diff --git a/.coveragerc b/.coveragerc index 506e51a63d8..b4f05ee01a3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -46,6 +46,9 @@ omit = homeassistant/components/isy994.py homeassistant/components/*/isy994.py + homeassistant/components/lutron.py + homeassistant/components/*/lutron.py + homeassistant/components/modbus.py homeassistant/components/*/modbus.py @@ -122,6 +125,9 @@ omit = homeassistant/components/mochad.py homeassistant/components/*/mochad.py + homeassistant/components/zabbix.py + homeassistant/components/*/zabbix.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/nx584.py @@ -130,6 +136,7 @@ omit = homeassistant/components/binary_sensor/concord232.py homeassistant/components/binary_sensor/flic.py homeassistant/components/binary_sensor/hikvision.py + homeassistant/components/binary_sensor/iss.py homeassistant/components/binary_sensor/rest.py homeassistant/components/browser.py homeassistant/components/camera/amcrest.py @@ -160,14 +167,17 @@ omit = homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/luci.py homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py homeassistant/components/device_tracker/ping.py + homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tomato.py + homeassistant/components/device_tracker/tado.py homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py @@ -185,7 +195,9 @@ omit = homeassistant/components/joaoapps_join.py homeassistant/components/keyboard.py homeassistant/components/keyboard_remote.py + homeassistant/components/light/avion.py homeassistant/components/light/blinksticklight.py + homeassistant/components/light/decora.py homeassistant/components/light/flux_led.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py @@ -195,8 +207,10 @@ omit = homeassistant/components/light/tikteck.py homeassistant/components/light/x10.py homeassistant/components/light/yeelight.py + homeassistant/components/light/piglow.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/media_player/anthemav.py homeassistant/components/media_player/aquostv.py homeassistant/components/media_player/braviatv.py homeassistant/components/media_player/cast.py @@ -208,6 +222,7 @@ omit = homeassistant/components/media_player/emby.py homeassistant/components/media_player/firetv.py homeassistant/components/media_player/gpmdp.py + homeassistant/components/media_player/hdmi_cec.py homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py @@ -231,6 +246,7 @@ omit = homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py + homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py @@ -256,12 +272,13 @@ omit = homeassistant/components/notify/telegram.py homeassistant/components/notify/telstra.py homeassistant/components/notify/twilio_sms.py + homeassistant/components/notify/twilio_call.py homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py homeassistant/components/nuimo_controller.py - homeassistant/components/openalpr.py homeassistant/components/remote/harmony.py homeassistant/components/scene/hunterdouglas_powerview.py + homeassistant/components/sensor/amcrest.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -293,7 +310,6 @@ omit = homeassistant/components/sensor/hddtemp.py homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/hydroquebec.py - homeassistant/components/sensor/iss.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/influxdb.py @@ -318,6 +334,7 @@ omit = homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial_pm.py + homeassistant/components/sensor/skybeacon.py homeassistant/components/sensor/sma.py homeassistant/components/sensor/snmp.py homeassistant/components/sensor/sonarr.py @@ -348,6 +365,7 @@ omit = homeassistant/components/switch/digitalloggers.py homeassistant/components/switch/dlink.py homeassistant/components/switch/edimax.py + homeassistant/components/switch/hdmi_cec.py homeassistant/components/switch/hikvisioncam.py homeassistant/components/switch/hook.py homeassistant/components/switch/kankun.py @@ -362,6 +380,7 @@ omit = homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py homeassistant/components/thingspeak.py + homeassistant/components/tts/amazon_polly.py homeassistant/components/tts/picotts.py homeassistant/components/upnp.py homeassistant/components/weather/bom.py diff --git a/.ignore b/.ignore new file mode 100644 index 00000000000..45c6dc5561f --- /dev/null +++ b/.ignore @@ -0,0 +1,6 @@ +# Patterns matched in this file will be ignored by supported search utilities + +# Ignore generated html and javascript files +/homeassistant/components/frontend/www_static/*.html +/homeassistant/components/frontend/www_static/*.js +/homeassistant/components/frontend/www_static/panels/*.html diff --git a/.travis.yml b/.travis.yml index f4c696a2236..2de101af24b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,15 +8,16 @@ matrix: env: TOXENV=requirements - python: "3.4.2" env: TOXENV=lint - - python: "3.5" - env: TOXENV=typing + # - python: "3.5" + # env: TOXENV=typing - python: "3.5" env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 - allow_failures: - - python: "3.5" - env: TOXENV=typing + # allow_failures: + # - python: "3.5" + # env: TOXENV=typing + cache: directories: - $HOME/.cache/pip diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000000..f8570cef551 --- /dev/null +++ b/CLA.md @@ -0,0 +1,39 @@ +# Contributor License Agreement + +``` +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the Apache 2.0 license; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the Apache 2.0 license; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it) is maintained indefinitely + and may be redistributed consistent with this project or the open + source license(s) involved. +``` + +## Attribution + +The text of this license is available under the [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). It is based on the Linux [Developer Certificate Of Origin](http://elinux.org/Developer_Certificate_Of_Origin), but is modified to explicitly use the Apache 2.0 license +and not mention sign-off. + +## Signing + +To sign this CLA you must first submit a pull request to a repository under the Home Assistant organization. + +## Adoption + +This Contributor License Agreement (CLA) was first announced on January 21st, 2017 in [this][cla-blog] blog post and adopted January 28th, 2017. + +[cla-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..5d2149dce05 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,80 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [safety@home-assistant.io][email]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available [here][version]. + +## Adoption + +This Code of Conduct was first adopted January 21st, 2017 and announced in [this][coc-blog] blog post. + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ +[email]: mailto:safety@home-assistant.io +[coc-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/ diff --git a/Dockerfile b/Dockerfile index 7522ca9cb64..ecdbbafba66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Copy build scripts -COPY script/setup_docker_prereqs script/build_python_openzwave script/build_libcec script/ +COPY script/setup_docker_prereqs script/build_python_openzwave script/build_libcec script/install_phantomjs script/ RUN script/setup_docker_prereqs # Install hass component dependencies diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 42a425b4118..00000000000 --- a/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2016 Paulus Schoutsen - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000000..b62a9b5ff78 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,194 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.rst b/README.rst index 1e25b6dcc90..bddbb9fd611 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,7 @@ components `__. If you run into issues while using Home Assistant or during development of a component, check the `Home Assistant help -section `__ how to reach us. +section `__ of our website for further help and information. .. |Build Status| image:: https://travis-ci.org/home-assistant/home-assistant.svg?branch=master :target: https://travis-ci.org/home-assistant/home-assistant diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index da7886ad1e8..0c8b0bc688e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -508,8 +508,10 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False, Async friendly. """ logging.basicConfig(level=logging.INFO) - fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) " - "[%(name)s] %(message)s%(reset)s") + fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " + "[%(name)s] %(message)s") + colorfmt = "%(log_color)s{}%(reset)s".format(fmt) + datefmt = '%y-%m-%d %H:%M:%S' # suppress overly verbose logs from libraries that aren't helpful logging.getLogger("requests").setLevel(logging.WARNING) @@ -519,8 +521,8 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False, try: from colorlog import ColoredFormatter logging.getLogger().handlers[0].setFormatter(ColoredFormatter( - fmt, - datefmt='%y-%m-%d %H:%M:%S', + colorfmt, + datefmt=datefmt, reset=True, log_colors={ 'DEBUG': 'cyan', @@ -554,9 +556,7 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False, err_log_path, mode='w', delay=True) err_handler.setLevel(logging.INFO if verbose else logging.WARNING) - err_handler.setFormatter( - logging.Formatter('%(asctime)s %(name)s: %(message)s', - datefmt='%y-%m-%d %H:%M:%S')) + err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) async_handler = AsyncHandler(hass.loop, err_handler) hass.data[core.DATA_ASYNCHANDLER] = async_handler diff --git a/homeassistant/components/alarm_control_panel/wink.py b/homeassistant/components/alarm_control_panel/wink.py new file mode 100644 index 00000000000..2a600fe70a9 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/wink.py @@ -0,0 +1,68 @@ +""" +Interfaces with Wink Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.wink/ +""" +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.const import (STATE_UNKNOWN, + STATE_ALARM_DISARMED, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY) +from homeassistant.components.wink import WinkDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['wink'] +STATE_ALARM_PRIVACY = 'Private' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Wink platform.""" + import pywink + + for camera in pywink.get_cameras(): + add_devices([WinkCameraDevice(camera, hass)]) + + +class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel): + """Representation a Wink camera alarm.""" + + def __init__(self, wink, hass): + """Initialize the Wink alarm.""" + WinkDevice.__init__(self, wink, hass) + + @property + def state(self): + """Return the state of the device.""" + wink_state = self.wink.state() + if wink_state == "away": + state = STATE_ALARM_ARMED_AWAY + elif wink_state == "home": + state = STATE_ALARM_DISARMED + elif wink_state == "night": + state = STATE_ALARM_ARMED_HOME + else: + state = STATE_UNKNOWN + return state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self.wink.set_mode("home") + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self.wink.set_mode("night") + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self.wink.set_mode("away") + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'private': self.wink.private() + } diff --git a/homeassistant/components/bbb_gpio.py b/homeassistant/components/bbb_gpio.py index d8acaaa184c..89692a1e1e1 100644 --- a/homeassistant/components/bbb_gpio.py +++ b/homeassistant/components/bbb_gpio.py @@ -63,7 +63,7 @@ def read_input(pin): """Read a value from a GPIO.""" # pylint: disable=import-error,undefined-variable import Adafruit_BBIO.GPIO as GPIO - return GPIO.input(pin) + return GPIO.input(pin) is GPIO.HIGH def edge_detect(pin, event_callback, bounce): diff --git a/homeassistant/components/binary_sensor/arest.py b/homeassistant/components/binary_sensor/arest.py index 1c7058cd1b0..834fb490049 100644 --- a/homeassistant/components/binary_sensor/arest.py +++ b/homeassistant/components/binary_sensor/arest.py @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the aREST binary sensor.""" + """Set up the aREST binary sensor.""" resource = config.get(CONF_RESOURCE) pin = config.get(CONF_PIN) sensor_class = config.get(CONF_SENSOR_CLASS) @@ -38,13 +38,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: response = requests.get(resource, timeout=10).json() except requests.exceptions.MissingSchema: - _LOGGER.error('Missing resource or schema in configuration. ' - 'Add http:// to your URL.') + _LOGGER.error("Missing resource or schema in configuration. " + "Add http:// to your URL") return False except requests.exceptions.ConnectionError: - _LOGGER.error('No route to device at %s. ' - 'Please check the IP address in the configuration file.', - resource) + _LOGGER.error("No route to device at %s", resource) return False arest = ArestData(resource, pin) @@ -67,10 +65,10 @@ class ArestBinarySensor(BinarySensorDevice): self.update() if self._pin is not None: - request = requests.get('{}/mode/{}/i'.format - (self._resource, self._pin), timeout=10) + request = requests.get( + '{}/mode/{}/i'.format(self._resource, self._pin), timeout=10) if request.status_code is not 200: - _LOGGER.error("Can't set mode. Is device offline?") + _LOGGER.error("Can't set mode of %s", self._resource) @property def name(self): @@ -109,5 +107,4 @@ class ArestData(object): self._resource, self._pin), timeout=10) self.data = {'state': response.json()['return_value']} except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device '%s'. Is device offline?", - self._resource) + _LOGGER.error("No route to device '%s'", self._resource) diff --git a/homeassistant/components/binary_sensor/bbb_gpio.py b/homeassistant/components/binary_sensor/bbb_gpio.py new file mode 100644 index 00000000000..dd960defaa8 --- /dev/null +++ b/homeassistant/components/binary_sensor/bbb_gpio.py @@ -0,0 +1,89 @@ +""" +Support for binary sensor using Beaglebone Black GPIO. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bbb_gpio/ +""" +import logging + +import voluptuous as vol + +import homeassistant.components.bbb_gpio as bbb_gpio +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bbb_gpio'] + +CONF_PINS = 'pins' +CONF_BOUNCETIME = 'bouncetime' +CONF_INVERT_LOGIC = 'invert_logic' +CONF_PULL_MODE = 'pull_mode' + +DEFAULT_BOUNCETIME = 50 +DEFAULT_INVERT_LOGIC = False +DEFAULT_PULL_MODE = 'UP' + +PIN_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int, + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, + vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): + vol.In(['UP', 'DOWN']) +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PINS, default={}): + vol.Schema({cv.string: PIN_SCHEMA}), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Beaglebone Black GPIO devices.""" + pins = config.get(CONF_PINS) + + binary_sensors = [] + + for pin, params in pins.items(): + binary_sensors.append(BBBGPIOBinarySensor(pin, params)) + add_devices(binary_sensors) + + +class BBBGPIOBinarySensor(BinarySensorDevice): + """Represent a binary sensor that uses Beaglebone Black GPIO.""" + + def __init__(self, pin, params): + """Initialize the Beaglebone Black binary sensor.""" + self._pin = pin + self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME + self._bouncetime = params.get(CONF_BOUNCETIME) + self._pull_mode = params.get(CONF_PULL_MODE) + self._invert_logic = params.get(CONF_INVERT_LOGIC) + + bbb_gpio.setup_input(self._pin, self._pull_mode) + self._state = bbb_gpio.read_input(self._pin) + + def read_gpio(pin): + """Read state from GPIO.""" + self._state = bbb_gpio.read_input(self._pin) + self.schedule_update_ha_state() + + bbb_gpio.edge_detect(self._pin, read_gpio, self._bouncetime) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state != self._invert_logic diff --git a/homeassistant/components/binary_sensor/digital_ocean.py b/homeassistant/components/binary_sensor/digital_ocean.py index 821acb2da95..4c5783cc220 100644 --- a/homeassistant/components/binary_sensor/digital_ocean.py +++ b/homeassistant/components/binary_sensor/digital_ocean.py @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Digital Ocean droplet sensor.""" + """Set up the Digital Ocean droplet sensor.""" digital_ocean = get_component('digital_ocean') droplets = config.get(CONF_DROPLETS) @@ -68,7 +68,7 @@ class DigitalOceanBinarySensor(BinarySensorDevice): return DEFAULT_SENSOR_CLASS @property - def state_attributes(self): + def device_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { ATTR_CREATED_AT: self.data.created_at, diff --git a/homeassistant/components/binary_sensor/ffmpeg.py b/homeassistant/components/binary_sensor/ffmpeg.py index 818a6b5b387..ea89ff7c743 100644 --- a/homeassistant/components/binary_sensor/ffmpeg.py +++ b/homeassistant/components/binary_sensor/ffmpeg.py @@ -4,8 +4,9 @@ Provides a binary sensor which is a collection of ffmpeg tools. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.ffmpeg/ """ +import asyncio import logging -from os import path +import os import voluptuous as vol @@ -13,17 +14,22 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA, DOMAIN) from homeassistant.components.ffmpeg import ( - get_binary, run_test, CONF_INPUT, CONF_OUTPUT, CONF_EXTRA_ARGUMENTS) + DATA_FFMPEG, CONF_INPUT, CONF_OUTPUT, CONF_EXTRA_ARGUMENTS) from homeassistant.config import load_yaml_config_file -from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_NAME, - ATTR_ENTITY_ID) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, CONF_NAME, + ATTR_ENTITY_ID) DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) +SERVICE_START = 'ffmpeg_start' +SERVICE_STOP = 'ffmpeg_stop' SERVICE_RESTART = 'ffmpeg_restart' +DATA_FFMPEG_DEVICE = 'ffmpeg_binary_sensor' + FFMPEG_SENSOR_NOISE = 'noise' FFMPEG_SENSOR_MOTION = 'motion' @@ -32,6 +38,7 @@ MAP_FFMPEG_BIN = [ FFMPEG_SENSOR_MOTION ] +CONF_INITIAL_STATE = 'initial_state' CONF_TOOL = 'tool' CONF_PEAK = 'peak' CONF_DURATION = 'duration' @@ -41,10 +48,12 @@ CONF_REPEAT = 'repeat' CONF_REPEAT_TIME = 'repeat_time' DEFAULT_NAME = 'FFmpeg' +DEFAULT_INIT_STATE = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOOL): vol.In(MAP_FFMPEG_BIN), vol.Required(CONF_INPUT): cv.string, + vol.Optional(CONF_INITIAL_STATE, default=DEFAULT_INIT_STATE): cv.boolean, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, vol.Optional(CONF_OUTPUT): cv.string, @@ -61,7 +70,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Range(min=0)), }) -SERVICE_RESTART_SCHEMA = vol.Schema({ +SERVICE_FFMPEG_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -72,86 +81,126 @@ def restart(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_RESTART, data) -# list of all ffmpeg sensors -DEVICES = [] - - -def setup_platform(hass, config, add_entities, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Create the binary sensor.""" from haffmpeg import SensorNoise, SensorMotion # check source - if not run_test(hass, config.get(CONF_INPUT)): + if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): return # generate sensor object if config.get(CONF_TOOL) == FFMPEG_SENSOR_NOISE: - entity = FFmpegNoise(SensorNoise, config) + entity = FFmpegNoise(hass, SensorNoise, config) else: - entity = FFmpegMotion(SensorMotion, config) + entity = FFmpegMotion(hass, SensorMotion, config) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, entity.shutdown_ffmpeg) + @asyncio.coroutine + def async_shutdown(event): + """Stop ffmpeg.""" + yield from entity.async_shutdown_ffmpeg() + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_shutdown) + + # start on startup + if config.get(CONF_INITIAL_STATE): + @asyncio.coroutine + def async_start(event): + """Start ffmpeg.""" + yield from entity.async_start_ffmpeg() + yield from entity.async_update_ha_state() + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_start) # add to system - add_entities([entity]) - DEVICES.append(entity) + yield from async_add_devices([entity]) # exists service? if hass.services.has_service(DOMAIN, SERVICE_RESTART): + hass.data[DATA_FFMPEG_DEVICE].append(entity) return + hass.data[DATA_FFMPEG_DEVICE] = [entity] - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) # register service - def _service_handle_restart(service): + @asyncio.coroutine + def async_service_handle(service): """Handle service binary_sensor.ffmpeg_restart.""" entity_ids = service.data.get('entity_id') if entity_ids: - _devices = [device for device in DEVICES + _devices = [device for device in hass.data[DATA_FFMPEG_DEVICE] if device.entity_id in entity_ids] else: - _devices = DEVICES + _devices = hass.data[DATA_FFMPEG_DEVICE] + tasks = [] for device in _devices: - device.restart_ffmpeg() + if service.service == SERVICE_START: + tasks.append(device.async_start_ffmpeg()) + elif service.service == SERVICE_STOP: + tasks.append(device.async_shutdown_ffmpeg()) + else: + tasks.append(device.async_restart_ffmpeg()) - hass.services.register(DOMAIN, SERVICE_RESTART, - _service_handle_restart, - descriptions.get(SERVICE_RESTART), - schema=SERVICE_RESTART_SCHEMA) + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_START, async_service_handle, + descriptions.get(SERVICE_START), schema=SERVICE_FFMPEG_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_STOP, async_service_handle, + descriptions.get(SERVICE_STOP), schema=SERVICE_FFMPEG_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_RESTART, async_service_handle, + descriptions.get(SERVICE_RESTART), schema=SERVICE_FFMPEG_SCHEMA) class FFmpegBinarySensor(BinarySensorDevice): """A binary sensor which use ffmpeg for noise detection.""" - def __init__(self, ffobj, config): + def __init__(self, hass, ffobj, config): """Constructor for binary sensor noise detection.""" + self._manager = hass.data[DATA_FFMPEG] self._state = False self._config = config self._name = config.get(CONF_NAME) - self._ffmpeg = ffobj(get_binary(), self._callback) + self._ffmpeg = ffobj( + self._manager.binary, hass.loop, self._async_callback) - self._start_ffmpeg(config) - - def _callback(self, state): + def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.schedule_update_ha_state() + self.hass.async_add_job(self.async_update_ha_state()) - def _start_ffmpeg(self, config): - """Start a FFmpeg instance.""" - raise NotImplementedError + def async_start_ffmpeg(self): + """Start a FFmpeg instance. - def shutdown_ffmpeg(self, event): - """For STOP event to shutdown ffmpeg.""" - self._ffmpeg.close() + This method must be run in the event loop and returns a coroutine. + """ + raise NotImplementedError() - def restart_ffmpeg(self): - """Restart ffmpeg with new config.""" - self._ffmpeg.close() - self._start_ffmpeg(self._config) + def async_shutdown_ffmpeg(self): + """For STOP event to shutdown ffmpeg. + + This method must be run in the event loop and returns a coroutine. + """ + return self._ffmpeg.close() + + @asyncio.coroutine + def async_restart_ffmpeg(self): + """Restart processing.""" + yield from self.async_shutdown_ffmpeg() + yield from self.async_start_ffmpeg() @property def is_on(self): @@ -177,20 +226,23 @@ class FFmpegBinarySensor(BinarySensorDevice): class FFmpegNoise(FFmpegBinarySensor): """A binary sensor which use ffmpeg for noise detection.""" - def _start_ffmpeg(self, config): - """Start a FFmpeg instance.""" + def async_start_ffmpeg(self): + """Start a FFmpeg instance. + + This method must be run in the event loop and returns a coroutine. + """ # init config self._ffmpeg.set_options( - time_duration=config.get(CONF_DURATION), - time_reset=config.get(CONF_RESET), - peak=config.get(CONF_PEAK), + time_duration=self._config.get(CONF_DURATION), + time_reset=self._config.get(CONF_RESET), + peak=self._config.get(CONF_PEAK), ) # run - self._ffmpeg.open_sensor( - input_source=config.get(CONF_INPUT), - output_dest=config.get(CONF_OUTPUT), - extra_cmd=config.get(CONF_EXTRA_ARGUMENTS), + return self._ffmpeg.open_sensor( + input_source=self._config.get(CONF_INPUT), + output_dest=self._config.get(CONF_OUTPUT), + extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) @property @@ -202,20 +254,23 @@ class FFmpegNoise(FFmpegBinarySensor): class FFmpegMotion(FFmpegBinarySensor): """A binary sensor which use ffmpeg for noise detection.""" - def _start_ffmpeg(self, config): - """Start a FFmpeg instance.""" + def async_start_ffmpeg(self): + """Start a FFmpeg instance. + + This method must be run in the event loop and returns a coroutine. + """ # init config self._ffmpeg.set_options( - time_reset=config.get(CONF_RESET), - time_repeat=config.get(CONF_REPEAT_TIME), - repeat=config.get(CONF_REPEAT), - changes=config.get(CONF_CHANGES), + time_reset=self._config.get(CONF_RESET), + time_repeat=self._config.get(CONF_REPEAT_TIME), + repeat=self._config.get(CONF_REPEAT), + changes=self._config.get(CONF_CHANGES), ) # run - self._ffmpeg.open_sensor( - input_source=config.get(CONF_INPUT), - extra_cmd=config.get(CONF_EXTRA_ARGUMENTS), + return self._ffmpeg.open_sensor( + input_source=self._config.get(CONF_INPUT), + extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) @property diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index 33eda1f2b1a..82b77eb11d4 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -17,6 +17,7 @@ DEPENDENCIES = ['homematic'] SENSOR_TYPES_CLASS = { "Remote": None, "ShutterContact": "opening", + "MaxShutterContact": "opening", "IPShutterContact": "opening", "Smoke": "smoke", "SmokeV2": "smoke", diff --git a/homeassistant/components/sensor/iss.py b/homeassistant/components/binary_sensor/iss.py similarity index 56% rename from homeassistant/components/sensor/iss.py rename to homeassistant/components/binary_sensor/iss.py index 6d9cf4b7106..b4182557878 100644 --- a/homeassistant/components/sensor/iss.py +++ b/homeassistant/components/binary_sensor/iss.py @@ -5,34 +5,39 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.iss/ """ import logging -from datetime import timedelta, datetime +from datetime import timedelta + import requests import voluptuous as vol -from homeassistant.util import Throttle -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME) -from homeassistant.helpers.entity import Entity + import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE) +from homeassistant.util import Throttle REQUIREMENTS = ['pyiss==1.0.1'] _LOGGER = logging.getLogger(__name__) -ATTR_ISS_VISIBLE = 'visible' ATTR_ISS_NEXT_RISE = 'next_rise' ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space' +CONF_SHOW_ON_MAP = 'show_on_map' + DEFAULT_NAME = 'ISS' +DEFAULT_SENSOR_CLASS = 'visible' + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the ISS sensor.""" - # Validate the configuration + """Set up the ISS sensor.""" if None in (hass.config.latitude, hass.config.longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False @@ -45,75 +50,74 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) - sensors = [] - sensors.append(IssSensor(iss_data, name)) - - add_devices(sensors, True) + add_devices([IssBinarySensor(iss_data, name, show_on_map)], True) -class IssSensor(Entity): - """Implementation of a ISS sensor.""" +class IssBinarySensor(BinarySensorDevice): + """Implementation of the ISS binary sensor.""" - def __init__(self, iss_data, name): + def __init__(self, iss_data, name, show): """Initialize the sensor.""" self.iss_data = iss_data self._state = None - self._attributes = {} - self._client_name = name - self._name = ATTR_ISS_VISIBLE - self._unit_of_measurement = None - self._icon = 'mdi:eye' + self._name = name + self._show_on_map = show + self.update() @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self._client_name, self._name) + return self._name @property - def state(self): - """Return the state of the sensor.""" - return self._state + def is_on(self): + """Return true if the binary sensor is on.""" + return self.iss_data.is_above if self.iss_data else False + + @property + def sensor_class(self): + """Return the class of this sensor.""" + return DEFAULT_SENSOR_CLASS @property def device_state_attributes(self): """Return the state attributes.""" - return self._attributes - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + if self.iss_data: + attrs = { + ATTR_ISS_NUMBER_PEOPLE_SPACE: + self.iss_data.number_of_people_in_space, + ATTR_ISS_NEXT_RISE: self.iss_data.next_rise, + } + if self._show_on_map: + attrs[ATTR_LONGITUDE] = self.iss_data.position.get('longitude') + attrs[ATTR_LATITUDE] = self.iss_data.position.get('latitude') + else: + attrs['long'] = self.iss_data.position.get('longitude') + attrs['lat'] = self.iss_data.position.get('latitude') + return attrs def update(self): """Get the latest data from ISS API and updates the states.""" - self._state = self.iss_data.is_above - - self._attributes[ATTR_ISS_NUMBER_PEOPLE_SPACE] = \ - self.iss_data.number_of_people_in_space - delta = self.iss_data.next_rise - datetime.utcnow() - self._attributes[ATTR_ISS_NEXT_RISE] = int(delta.total_seconds() / 60) + self.iss_data.update() class IssData(object): - """Get data from the ISS.""" + """Get data from the ISS API.""" def __init__(self, latitude, longitude): """Initialize the data object.""" self.is_above = None self.next_rise = None self.number_of_people_in_space = None + self.position = None self.latitude = latitude self.longitude = longitude @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Get the latest data from the ISS.""" + """Get the latest data from the ISS API.""" import pyiss try: @@ -121,7 +125,7 @@ class IssData(object): self.is_above = iss.is_ISS_above(self.latitude, self.longitude) self.next_rise = iss.next_rise(self.latitude, self.longitude) self.number_of_people_in_space = iss.number_of_people_in_space() - _LOGGER.error(self.next_rise.tzinfo) + self.position = iss.current_location() except requests.exceptions.HTTPError as error: _LOGGER.error(error) return False diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index c66373bc58a..4689bc59082 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -7,14 +7,10 @@ https://home-assistant.io/components/binary_sensor.nest/ from itertools import chain import logging -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.binary_sensor import (BinarySensorDevice) from homeassistant.components.sensor.nest import NestSensor -from homeassistant.const import (CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS) +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.components.nest import DATA_NEST -import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['nest'] @@ -42,17 +38,6 @@ _BINARY_TYPES_DEPRECATED = [ _VALID_BINARY_SENSOR_TYPES = BINARY_TYPES + CLIMATE_BINARY_TYPES \ + CAMERA_BINARY_TYPES -_VALID_BINARY_SENSOR_TYPES_WITH_DEPRECATED = _VALID_BINARY_SENSOR_TYPES \ - + _BINARY_TYPES_DEPRECATED - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SCAN_INTERVAL): - vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, - [vol.In(_VALID_BINARY_SENSOR_TYPES_WITH_DEPRECATED)]) -}) _LOGGER = logging.getLogger(__name__) @@ -63,15 +48,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return nest = hass.data[DATA_NEST] - conf = config.get(CONF_MONITORED_CONDITIONS, _VALID_BINARY_SENSOR_TYPES) - for variable in conf: + # Add all available binary sensors if no Nest binary sensor config is set + if discovery_info == {}: + conditions = _VALID_BINARY_SENSOR_TYPES + else: + conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) + + for variable in conditions: if variable in _BINARY_TYPES_DEPRECATED: wstr = (variable + " is no a longer supported " "monitored_conditions. See " "https://home-assistant.io/components/binary_sensor.nest/ " - "for valid options, or remove monitored_conditions " - "entirely to get a reasonable default") + "for valid options.") _LOGGER.error(wstr) sensors = [] @@ -80,16 +69,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): nest.cameras()) for structure, device in device_chain: sensors += [NestBinarySensor(structure, device, variable) - for variable in conf + for variable in conditions if variable in BINARY_TYPES] sensors += [NestBinarySensor(structure, device, variable) - for variable in conf + for variable in conditions if variable in CLIMATE_BINARY_TYPES and device.is_thermostat] if device.is_camera: sensors += [NestBinarySensor(structure, device, variable) - for variable in conf + for variable in conditions if variable in CAMERA_BINARY_TYPES] for activity_zone in device.activity_zones: sensors += [NestActivityZoneSensor(structure, diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index 94ef0faaad0..4ef29b9e5f5 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -1,19 +1,19 @@ """ Support for the Netatmo binary sensors. -The binary sensors based on events seen by the NetatmoCamera +The binary sensors based on events seen by the Netatmo cameras. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/binary_sensor.netatmo/ +https://home-assistant.io/components/binary_sensor.netatmo/. """ import logging import voluptuous as vol from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.components.netatmo import WelcomeData +from homeassistant.components.netatmo import CameraData from homeassistant.loader import get_component -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_TIMEOUT +from homeassistant.const import CONF_TIMEOUT, CONF_OFFSET from homeassistant.helpers import config_validation as cv DEPENDENCIES = ["netatmo"] @@ -22,24 +22,37 @@ _LOGGER = logging.getLogger(__name__) # These are the available sensors mapped to binary_sensor class -SENSOR_TYPES = { - "Someone known": 'occupancy', - "Someone unknown": 'motion', - "Motion": 'motion', +WELCOME_SENSOR_TYPES = { + "Someone known": "motion", + "Someone unknown": "motion", + "Motion": "motion", "Tag Vibration": 'vibration', - "Tag Open": 'opening', + "Tag Open": 'opening' +} +PRESENCE_SENSOR_TYPES = { + "Outdoor motion": "motion", + "Outdoor human": "motion", + "Outdoor animal": "motion", + "Outdoor vehicle": "motion" } CONF_HOME = 'home' CONF_CAMERAS = 'cameras' +CONF_WELCOME_SENSORS = 'welcome_sensors' +CONF_PRESENCE_SENSORS = 'presence_sensors' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOME): cv.string, vol.Optional(CONF_TIMEOUT): cv.positive_int, + vol.Optional(CONF_OFFSET): cv.positive_int, vol.Optional(CONF_CAMERAS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES.keys()): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional( + CONF_WELCOME_SENSORS, default=WELCOME_SENSOR_TYPES.keys()): + vol.All(cv.ensure_list, [vol.In(WELCOME_SENSOR_TYPES)]), + vol.Optional( + CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES.keys()): + vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]), }) @@ -49,48 +62,68 @@ def setup_platform(hass, config, add_devices, discovery_info=None): netatmo = get_component('netatmo') home = config.get(CONF_HOME, None) timeout = config.get(CONF_TIMEOUT, 15) + offset = config.get(CONF_OFFSET, 90) module_name = None import lnetatmo try: - data = WelcomeData(netatmo.NETATMO_AUTH, home) + data = CameraData(netatmo.NETATMO_AUTH, home) if data.get_camera_names() == []: return None except lnetatmo.NoDevice: return None - sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) + welcome_sensors = config.get( + CONF_WELCOME_SENSORS, WELCOME_SENSOR_TYPES) + presence_sensors = config.get( + CONF_PRESENCE_SENSORS, PRESENCE_SENSOR_TYPES) for camera_name in data.get_camera_names(): - if CONF_CAMERAS in config: - if config[CONF_CAMERAS] != [] and \ - camera_name not in config[CONF_CAMERAS]: - continue - for variable in sensors: - if variable in ('Tag Vibration', 'Tag Open'): - continue - add_devices([WelcomeBinarySensor(data, camera_name, module_name, - home, timeout, variable)]) + camera_type = data.get_camera_type(camera=camera_name, home=home) + if camera_type == "NACamera": + if CONF_CAMERAS in config: + if config[CONF_CAMERAS] != [] and \ + camera_name not in config[CONF_CAMERAS]: + continue + for variable in welcome_sensors: + add_devices([NetatmoBinarySensor(data, camera_name, + module_name, home, timeout, + offset, camera_type, + variable)]) + if camera_type == "NOC": + if CONF_CAMERAS in config: + if config[CONF_CAMERAS] != [] and \ + camera_name not in config[CONF_CAMERAS]: + continue + for variable in presence_sensors: + add_devices([NetatmoBinarySensor(data, camera_name, + module_name, home, timeout, + offset, camera_type, + variable)]) for module_name in data.get_module_names(camera_name): - for variable in sensors: + for variable in welcome_sensors: if variable in ('Tag Vibration', 'Tag Open'): - add_devices([WelcomeBinarySensor(data, camera_name, + add_devices([NetatmoBinarySensor(data, camera_name, module_name, home, - timeout, variable)]) + timeout, offset, + camera_type, + variable)]) -class WelcomeBinarySensor(BinarySensorDevice): - """Represent a single binary sensor in a Netatmo Welcome device.""" +class NetatmoBinarySensor(BinarySensorDevice): + """Represent a single binary sensor in a Netatmo Camera device.""" - def __init__(self, data, camera_name, module_name, home, timeout, sensor): + def __init__(self, data, camera_name, module_name, home, + timeout, offset, camera_type, sensor): """Setup for access to the Netatmo camera events.""" self._data = data self._camera_name = camera_name self._module_name = module_name self._home = home self._timeout = timeout + self._offset = offset if home: self._name = home + ' / ' + camera_name else: @@ -99,10 +132,11 @@ class WelcomeBinarySensor(BinarySensorDevice): self._name += ' / ' + module_name self._sensor_name = sensor self._name += ' ' + sensor - camera_id = data.welcomedata.cameraByName(camera=camera_name, + camera_id = data.camera_data.cameraByName(camera=camera_name, home=home)['id'] - self._unique_id = "Welcome_binary_sensor {0} - {1}".format(self._name, + self._unique_id = "Netatmo_binary_sensor {0} - {1}".format(self._name, camera_id) + self._cameratype = camera_type self.update() @property @@ -118,7 +152,12 @@ class WelcomeBinarySensor(BinarySensorDevice): @property def sensor_class(self): """Return the class of this sensor, from SENSOR_CLASSES.""" - return SENSOR_TYPES.get(self._sensor_name) + if self._cameratype == "NACamera": + return WELCOME_SENSOR_TYPES.get(self._sensor_name) + elif self._cameratype == "NOC": + return PRESENCE_SENSOR_TYPES.get(self._sensor_name) + else: + return None @property def is_on(self): @@ -130,30 +169,54 @@ class WelcomeBinarySensor(BinarySensorDevice): self._data.update() self._data.update_event() - if self._sensor_name == "Someone known": - self._state =\ - self._data.welcomedata.someoneKnownSeen(self._home, + if self._cameratype == "NACamera": + if self._sensor_name == "Someone known": + self._state =\ + self._data.camera_data.someoneKnownSeen(self._home, self._camera_name, self._timeout*60) - elif self._sensor_name == "Someone unknown": - self._state =\ - self._data.welcomedata.someoneUnknownSeen(self._home, + elif self._sensor_name == "Someone unknown": + self._state =\ + self._data.camera_data.someoneUnknownSeen( + self._home, self._camera_name, self._timeout*60) + elif self._sensor_name == "Motion": + self._state =\ + self._data.camera_data.motionDetected(self._home, self._camera_name, self._timeout*60) - elif self._sensor_name == "Motion": - self._state =\ - self._data.welcomedata.motionDetected(self._home, - self._camera_name, - self._timeout*60) + else: + return None + elif self._cameratype == "NOC": + if self._sensor_name == "Outdoor motion": + self._state =\ + self._data.camera_data.outdoormotionDetected( + self._home, self._camera_name, self._offset) + elif self._sensor_name == "Outdoor human": + self._state =\ + self._data.camera_data.humanDetected(self._home, + self._camera_name, + self._offset) + elif self._sensor_name == "Outdoor animal": + self._state =\ + self._data.camera_data.animalDetected(self._home, + self._camera_name, + self._offset) + elif self._sensor_name == "Outdoor vehicle": + self._state =\ + self._data.camera_data.carDetected(self._home, + self._camera_name, + self._offset) + else: + return None elif self._sensor_name == "Tag Vibration": self._state =\ - self._data.welcomedata.moduleMotionDetected(self._home, + self._data.camera_data.moduleMotionDetected(self._home, self._module_name, self._camera_name, self._timeout*60) elif self._sensor_name == "Tag Open": self._state =\ - self._data.welcomedata.moduleOpened(self._home, + self._data.camera_data.moduleOpened(self._home, self._module_name, self._camera_name) else: diff --git a/homeassistant/components/binary_sensor/rpi_gpio.py b/homeassistant/components/binary_sensor/rpi_gpio.py index 03978ac625b..eaf9ee737e5 100644 --- a/homeassistant/components/binary_sensor/rpi_gpio.py +++ b/homeassistant/components/binary_sensor/rpi_gpio.py @@ -51,7 +51,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for port_num, port_name in ports.items(): binary_sensors.append(RPiGPIOBinarySensor( port_name, port_num, pull_mode, bouncetime, invert_logic)) - add_devices(binary_sensors) + add_devices(binary_sensors, True) class RPiGPIOBinarySensor(BinarySensorDevice): @@ -65,9 +65,9 @@ class RPiGPIOBinarySensor(BinarySensorDevice): self._pull_mode = pull_mode self._bouncetime = bouncetime self._invert_logic = invert_logic + self._state = None rpi_gpio.setup_input(self._port, self._pull_mode) - self._state = rpi_gpio.read_input(self._port) def read_gpio(port): """Read state from GPIO.""" @@ -90,3 +90,7 @@ class RPiGPIOBinarySensor(BinarySensorDevice): def is_on(self): """Return the state of the entity.""" return self._state != self._invert_logic + + def update(self): + """Update the GPIO state.""" + self._state = rpi_gpio.read_input(self._port) diff --git a/homeassistant/components/binary_sensor/services.yaml b/homeassistant/components/binary_sensor/services.yaml index 9be9915e268..a1ac8cf8b5d 100644 --- a/homeassistant/components/binary_sensor/services.yaml +++ b/homeassistant/components/binary_sensor/services.yaml @@ -1,7 +1,23 @@ # Describes the format for available binary_sensor services +ffmpeg_start: + description: Send a start command to a ffmpeg based sensor. + + fields: + entity_id: + description: Name(s) of entites that will start. Platform dependent. + example: 'binary_sensor.ffmpeg_noise' + +ffmpeg_stop: + description: Send a stop command to a ffmpeg based sensor. + + fields: + entity_id: + description: Name(s) of entites that will stop. Platform dependent. + example: 'binary_sensor.ffmpeg_noise' + ffmpeg_restart: - description: Send a restart command to a ffmpeg based sensor (party mode). + description: Send a restart command to a ffmpeg based sensor. fields: entity_id: diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 4dc11a3c5c7..78338de64f7 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -110,7 +110,7 @@ class ThresholdSensor(BinarySensorDevice): return self._sensor_class @property - def state_attributes(self): + def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index b129b5f24d4..19ecb853536 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -8,7 +8,6 @@ at https://home-assistant.io/components/binary_sensor.wink/ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.sensor.wink import WinkDevice from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component DEPENDENCIES = ['wink'] @@ -43,6 +42,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for hub in pywink.get_hubs(): add_devices([WinkHub(hub, hass)]) + for remote in pywink.get_remotes(): + add_devices([WinkRemote(remote, hass)]) + + for button in pywink.get_buttons(): + add_devices([WinkButton(button, hass)]) + + for gang in pywink.get_gangs(): + add_devices([WinkGang(gang, hass)]) + class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): """Representation of a Wink binary sensor.""" @@ -50,33 +58,13 @@ class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): def __init__(self, wink, hass): """Initialize the Wink binary sensor.""" super().__init__(wink, hass) - wink = get_component('wink') - self._unit_of_measurement = self.wink.UNIT + self._unit_of_measurement = self.wink.unit() self.capability = self.wink.capability() @property def is_on(self): """Return true if the binary sensor is on.""" - if self.capability == "loudness": - state = self.wink.loudness_boolean() - elif self.capability == "vibration": - state = self.wink.vibration_boolean() - elif self.capability == "brightness": - state = self.wink.brightness_boolean() - elif self.capability == "liquid_detected": - state = self.wink.liquid_boolean() - elif self.capability == "motion": - state = self.wink.motion_boolean() - elif self.capability == "presence": - state = self.wink.presence_boolean() - elif self.capability == "co_detected": - state = self.wink.co_detected_boolean() - elif self.capability == "smoke_detected": - state = self.wink.smoke_detected_boolean() - else: - state = self.wink.state() - - return state + return self.wink.state() @property def sensor_class(self): @@ -91,6 +79,11 @@ class WinkHub(WinkDevice, BinarySensorDevice, Entity): """Initialize the hub sensor.""" WinkDevice.__init__(self, wink, hass) + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.wink.state() + @property def device_state_attributes(self): """Return the state attributes.""" @@ -99,7 +92,59 @@ class WinkHub(WinkDevice, BinarySensorDevice, Entity): 'firmware version': self.wink.firmware_version() } + +class WinkRemote(WinkDevice, BinarySensorDevice, Entity): + """Representation of a Wink Lutron Connected bulb remote.""" + + def __init(self, wink, hass): + """Initialize the hub sensor.""" + WinkDevice.__init__(self, wink, hass) + @property def is_on(self): """Return true if the binary sensor is on.""" return self.wink.state() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'button_on_pressed': self.wink.button_on_pressed(), + 'button_off_pressed': self.wink.button_off_pressed(), + 'button_up_pressed': self.wink.button_up_pressed(), + 'button_down_pressed': self.wink.button_down_pressed() + } + + +class WinkButton(WinkDevice, BinarySensorDevice, Entity): + """Representation of a Wink Relay button.""" + + def __init(self, wink, hass): + """Initialize the hub sensor.""" + WinkDevice.__init__(self, wink, hass) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.wink.state() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'pressed': self.wink.pressed(), + 'long_pressed': self.wink.long_pressed() + } + + +class WinkGang(WinkDevice, BinarySensorDevice, Entity): + """Representation of a Wink Relay gang.""" + + def __init(self, wink, hass): + """Initialize the gang sensor.""" + WinkDevice.__init__(self, wink, hass) + + @property + def is_on(self): + """Return true if the gang is connected.""" + return self.wink.state() diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index 32de0f653a7..b0054d7b00f 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -8,7 +8,6 @@ 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, @@ -65,21 +64,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ZWaveBinarySensor(value, None)]) -class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity, Entity): +class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): """Representation of a binary sensor within Z-Wave.""" def __init__(self, value, sensor_class): """Initialize the sensor.""" self._sensor_type = sensor_class - # pylint: disable=import-error - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher - zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) - dispatcher.connect( - self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) - @property def is_on(self): """Return True if the binary sensor is on.""" @@ -95,32 +87,25 @@ class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity, Entity): """No polling needed.""" return False - def value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - self.schedule_update_ha_state() - -class ZWaveTriggerSensor(ZWaveBinarySensor, Entity): +class ZWaveTriggerSensor(ZWaveBinarySensor): """Representation of a stateless sensor within Z-Wave.""" - def __init__(self, sensor_value, sensor_class, hass, re_arm_sec=60): + def __init__(self, value, sensor_class, hass, re_arm_sec=60): """Initialize the sensor.""" - super(ZWaveTriggerSensor, self).__init__(sensor_value, sensor_class) + super(ZWaveTriggerSensor, self).__init__(value, sensor_class) self._hass = hass self.re_arm_sec = re_arm_sec self.invalidate_after = dt_util.utcnow() + datetime.timedelta( seconds=self.re_arm_sec) # If it's active make sure that we set the timeout tracker - if sensor_value.data: + if value.data: track_point_in_time( self._hass, self.async_update_ha_state, self.invalidate_after) def value_changed(self, value): - """Called when a value has changed on the network.""" + """Called when a value for this entity's node has changed.""" if self._value.value_id == value.value_id: self.schedule_update_ha_state() if value.data: diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 89a2f6c5e46..b531a931a7a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -6,14 +6,17 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/camera/ """ import asyncio +import collections from datetime import timedelta import logging import hashlib +from random import SystemRandom import aiohttp from aiohttp import web import async_timeout +from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -21,6 +24,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -35,6 +39,9 @@ STATE_IDLE = 'idle' ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' +TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) +_RND = SystemRandom() + @asyncio.coroutine def async_get_image(hass, entity_id, timeout=10): @@ -80,6 +87,15 @@ def async_setup(hass, config): hass.http.register_view(CameraMjpegStream(component.entities)) yield from component.async_setup(config) + + @callback + def update_tokens(time): + """Update tokens of the entities.""" + for entity in component.entities.values(): + entity.async_update_token() + hass.async_add_job(entity.async_update_ha_state()) + + async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) return True @@ -89,13 +105,8 @@ class Camera(Entity): def __init__(self): """Initialize a camera.""" self.is_streaming = False - self._access_token = hashlib.sha256( - str.encode(str(id(self)))).hexdigest() - - @property - def access_token(self): - """Access token for this camera.""" - return self._access_token + self.access_tokens = collections.deque([], 2) + self.async_update_token() @property def should_poll(self): @@ -105,7 +116,7 @@ class Camera(Entity): @property def entity_picture(self): """Return a link to the camera feed as entity picture.""" - return ENTITY_IMAGE_URL.format(self.entity_id, self.access_token) + return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) @property def is_recording(self): @@ -174,7 +185,7 @@ class Camera(Entity): yield from asyncio.sleep(.5) - except asyncio.CancelledError: + except (asyncio.CancelledError, ConnectionResetError): _LOGGER.debug("Close stream by frontend.") response = None @@ -196,7 +207,7 @@ class Camera(Entity): def state_attributes(self): """Camera state attributes.""" attr = { - 'access_token': self.access_token, + 'access_token': self.access_tokens[-1], } if self.model: @@ -207,6 +218,13 @@ class Camera(Entity): return attr + @callback + def async_update_token(self): + """Update the used token.""" + self.access_tokens.append( + hashlib.sha256( + _RND.getrandbits(256).to_bytes(32, 'little')).hexdigest()) + class CameraView(HomeAssistantView): """Base CameraView.""" @@ -223,10 +241,11 @@ class CameraView(HomeAssistantView): camera = self.entities.get(entity_id) if camera is None: - return web.Response(status=404) + status = 404 if request[KEY_AUTHENTICATED] else 401 + return web.Response(status=status) authenticated = (request[KEY_AUTHENTICATED] or - request.GET.get('token') == camera.access_token) + request.GET.get('token') in camera.access_tokens) if not authenticated: return web.Response(status=401) diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index c6568677583..ecc93dfaaeb 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -4,8 +4,10 @@ This component provides basic support for Amcrest IP cameras. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.amcrest/ """ +import asyncio import logging +import aiohttp import voluptuous as vol import homeassistant.loader as loader @@ -13,16 +15,20 @@ from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_stream) -REQUIREMENTS = ['amcrest==1.0.0'] +REQUIREMENTS = ['amcrest==1.1.3'] _LOGGER = logging.getLogger(__name__) CONF_RESOLUTION = 'resolution' +CONF_STREAM_SOURCE = 'stream_source' DEFAULT_NAME = 'Amcrest Camera' DEFAULT_PORT = 80 DEFAULT_RESOLUTION = 'high' +DEFAULT_STREAM_SOURCE = 'mjpeg' NOTIFICATION_ID = 'amcrest_notification' NOTIFICATION_TITLE = 'Amcrest Camera Setup' @@ -32,6 +38,14 @@ RESOLUTION_LIST = { 'low': 1, } +STREAM_SOURCE_LIST = { + 'mjpeg': 0, + 'snapshot': 1 +} + +CONTENT_TYPE_HEADER = 'Content-Type' +TIMEOUT = 5 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, @@ -40,19 +54,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(vol.In(RESOLUTION_LIST)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): + vol.All(vol.In(STREAM_SOURCE_LIST)), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Amcrest IP Camera.""" from amcrest import AmcrestCamera - data = AmcrestCamera( + camera = AmcrestCamera( config.get(CONF_HOST), config.get(CONF_PORT), - config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + config.get(CONF_USERNAME), config.get(CONF_PASSWORD)).camera persistent_notification = loader.get_component('persistent_notification') try: - data.camera.current_time + camera.current_time # pylint: disable=broad-except except Exception as ex: _LOGGER.error("Unable to connect to Amcrest camera: %s", str(ex)) @@ -64,26 +80,53 @@ def setup_platform(hass, config, add_devices, discovery_info=None): notification_id=NOTIFICATION_ID) return False - add_devices([AmcrestCam(config, data)]) + add_devices([AmcrestCam(hass, config, camera)]) return True class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, device_info, data): + def __init__(self, hass, device_info, camera): """Initialize an Amcrest camera.""" super(AmcrestCam, self).__init__() - self._data = data + self._camera = camera + self._base_url = self._camera.get_base_url() + self._hass = hass self._name = device_info.get(CONF_NAME) self._resolution = RESOLUTION_LIST[device_info.get(CONF_RESOLUTION)] + self._stream_source = STREAM_SOURCE_LIST[ + device_info.get(CONF_STREAM_SOURCE) + ] + self._token = self._auth = aiohttp.BasicAuth( + device_info.get(CONF_USERNAME), + password=device_info.get(CONF_PASSWORD) + ) def camera_image(self): """Return a still image reponse from the camera.""" # Send the request to snap a picture and return raw jpg data - response = self._data.camera.snapshot(channel=self._resolution) + response = self._camera.snapshot(channel=self._resolution) return response.data + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Return an MJPEG stream.""" + # The snapshot implementation is handled by the parent class + if self._stream_source == STREAM_SOURCE_LIST['snapshot']: + yield from super().handle_async_mjpeg_stream(request) + return + + # Otherwise, stream an MJPEG image stream directly from the camera + websession = async_get_clientsession(self.hass) + streaming_url = '{0}mjpg/video.cgi?channel=0&subtype={1}'.format( + self._base_url, self._resolution) + + stream_coro = websession.get( + streaming_url, auth=self._token, timeout=TIMEOUT) + + yield from async_aiohttp_proxy_stream(self.hass, request, stream_coro) + @property def name(self): """Return the name of this camera.""" diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 0b8d60ab7f5..6b00ae240ed 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -12,10 +12,9 @@ from aiohttp import web from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import ( - async_run_test, get_binary, CONF_INPUT, CONF_EXTRA_ARGUMENTS) + DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_NAME -from homeassistant.util.async import run_coroutine_threadsafe DEPENDENCIES = ['ffmpeg'] @@ -33,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a FFmpeg Camera.""" - if not async_run_test(hass, config.get(CONF_INPUT)): + if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): return yield from async_add_devices([FFmpegCamera(hass, config)]) @@ -44,20 +43,17 @@ class FFmpegCamera(Camera): def __init__(self, hass, config): """Initialize a FFmpeg camera.""" super().__init__() + + self._manager = hass.data[DATA_FFMPEG] self._name = config.get(CONF_NAME) self._input = config.get(CONF_INPUT) self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) - def camera_image(self): - """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() - @asyncio.coroutine def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageSingleAsync, IMAGE_JPEG - ffmpeg = ImageSingleAsync(get_binary(), loop=self.hass.loop) + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) image = yield from ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, @@ -67,9 +63,9 @@ class FFmpegCamera(Camera): @asyncio.coroutine def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpegAsync + from haffmpeg import CameraMjpeg - stream = CameraMjpegAsync(get_binary(), loop=self.hass.loop) + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) yield from stream.open_camera( self._input, extra_cmd=self._extra_arguments) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index dd030099a45..8d52785557b 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -9,8 +9,6 @@ import logging from contextlib import closing import aiohttp -from aiohttp import web -from aiohttp.web_exceptions import HTTPGatewayTimeout import async_timeout import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth @@ -20,18 +18,21 @@ from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_stream) from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) CONF_MJPEG_URL = 'mjpeg_url' +CONF_STILL_IMAGE_URL = 'still_image_url' CONTENT_TYPE_HEADER = 'Content-Type' DEFAULT_NAME = 'Mjpeg Camera' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MJPEG_URL): cv.url, + vol.Optional(CONF_STILL_IMAGE_URL): cv.url, vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -70,6 +71,7 @@ class MjpegCamera(Camera): self._username = device_info.get(CONF_USERNAME) self._password = device_info.get(CONF_PASSWORD) self._mjpeg_url = device_info[CONF_MJPEG_URL] + self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL) self._auth = None if self._username and self._password: @@ -78,6 +80,37 @@ class MjpegCamera(Camera): self._username, password=self._password ) + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + # DigestAuth is not supported + if self._authentication == HTTP_DIGEST_AUTHENTICATION or \ + self._still_image_url is None: + image = yield from self.hass.loop.run_in_executor( + None, self.camera_image) + return image + + websession = async_get_clientsession(self.hass) + response = None + try: + with async_timeout.timeout(10, loop=self.hass.loop): + response = yield from websession.get( + self._still_image_url, auth=self._auth) + + image = yield from response.read() + return image + + except asyncio.TimeoutError: + _LOGGER.error('Timeout getting camera image') + + except (aiohttp.errors.ClientError, + aiohttp.errors.ClientDisconnectedError) as err: + _LOGGER.error('Error getting new camera image: %s', err) + + finally: + if response is not None: + yield from response.release() + def camera_image(self): """Return a still image response from the camera.""" if self._username and self._password: @@ -103,36 +136,9 @@ class MjpegCamera(Camera): # connect to stream websession = async_get_clientsession(self.hass) - stream = None - response = None - try: - with async_timeout.timeout(10, loop=self.hass.loop): - stream = yield from websession.get(self._mjpeg_url, - auth=self._auth) + stream_coro = websession.get(self._mjpeg_url, auth=self._auth) - response = web.StreamResponse() - response.content_type = stream.headers.get(CONTENT_TYPE_HEADER) - - yield from response.prepare(request) - - while True: - data = yield from stream.content.read(102400) - if not data: - break - response.write(data) - - except asyncio.TimeoutError: - raise HTTPGatewayTimeout() - - except asyncio.CancelledError: - _LOGGER.debug("Close stream by frontend.") - response = None - - finally: - if stream is not None: - stream.close() - if response is not None: - yield from response.write_eof() + yield from async_aiohttp_proxy_stream(self.hass, request, stream_coro) @property def name(self): diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index 47808de02b9..563de206dea 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -1,15 +1,15 @@ """ -Support for the Netatmo Welcome camera. +Support for the Netatmo cameras. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.netatmo/ +https://home-assistant.io/components/camera.netatmo/. """ import logging import requests import voluptuous as vol -from homeassistant.components.netatmo import WelcomeData +from homeassistant.components.netatmo import CameraData from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) from homeassistant.loader import get_component from homeassistant.helpers import config_validation as cv @@ -30,41 +30,43 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup access to Netatmo Welcome cameras.""" + """Setup access to Netatmo cameras.""" netatmo = get_component('netatmo') home = config.get(CONF_HOME) import lnetatmo try: - data = WelcomeData(netatmo.NETATMO_AUTH, home) + data = CameraData(netatmo.NETATMO_AUTH, home) for camera_name in data.get_camera_names(): + camera_type = data.get_camera_type(camera=camera_name, home=home) if CONF_CAMERAS in config: if config[CONF_CAMERAS] != [] and \ camera_name not in config[CONF_CAMERAS]: continue - add_devices([WelcomeCamera(data, camera_name, home)]) + add_devices([NetatmoCamera(data, camera_name, home, camera_type)]) except lnetatmo.NoDevice: return None -class WelcomeCamera(Camera): - """Representation of the images published from Welcome camera.""" +class NetatmoCamera(Camera): + """Representation of the images published from a Netatmo camera.""" - def __init__(self, data, camera_name, home): + def __init__(self, data, camera_name, home, camera_type): """Setup for access to the Netatmo camera images.""" - super(WelcomeCamera, self).__init__() + super(NetatmoCamera, self).__init__() self._data = data self._camera_name = camera_name if home: self._name = home + ' / ' + camera_name else: self._name = camera_name - camera_id = data.welcomedata.cameraByName(camera=camera_name, + camera_id = data.camera_data.cameraByName(camera=camera_name, home=home)['id'] self._unique_id = "Welcome_camera {0} - {1}".format(self._name, camera_id) - self._vpnurl, self._localurl = self._data.welcomedata.cameraUrls( + self._vpnurl, self._localurl = self._data.camera_data.cameraUrls( camera=camera_name ) + self._cameratype = camera_type def camera_image(self): """Return a still image response from the camera.""" @@ -79,15 +81,30 @@ class WelcomeCamera(Camera): _LOGGER.error('Welcome VPN url changed: %s', error) self._data.update() (self._vpnurl, self._localurl) = \ - self._data.welcomedata.cameraUrls(camera=self._camera_name) + self._data.camera_data.cameraUrls(camera=self._camera_name) return None return response.content @property def name(self): - """Return the name of this Netatmo Welcome device.""" + """Return the name of this Netatmo camera device.""" return self._name + @property + def brand(self): + """Camera brand.""" + return "Netatmo" + + @property + def model(self): + """Camera model.""" + if self._cameratype == "NOC": + return "Presence" + elif self._cameratype == "NACamera": + return "Welcome" + else: + return None + @property def unique_id(self): """Return the unique ID for this sensor.""" diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index 424e269c555..39939c73d0d 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -10,8 +10,6 @@ import logging import voluptuous as vol import aiohttp -from aiohttp import web -from aiohttp.web_exceptions import HTTPGatewayTimeout import async_timeout from homeassistant.const import ( @@ -20,7 +18,8 @@ from homeassistant.const import ( from homeassistant.components.camera import ( Camera, PLATFORM_SCHEMA) from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_create_clientsession) + async_get_clientsession, async_create_clientsession, + async_aiohttp_proxy_stream) import homeassistant.helpers.config_validation as cv from homeassistant.util.async import run_coroutine_threadsafe @@ -253,38 +252,10 @@ class SynologyCamera(Camera): 'cameraId': self._camera_id, 'format': 'mjpeg' } - stream = None - response = None - try: - with async_timeout.timeout(TIMEOUT, loop=self.hass.loop): - stream = yield from self._websession.get( - streaming_url, - params=streaming_payload - ) - response = web.StreamResponse() - response.content_type = stream.headers.get(CONTENT_TYPE_HEADER) + stream_coro = self._websession.get( + streaming_url, params=streaming_payload) - yield from response.prepare(request) - - while True: - data = yield from stream.content.read(102400) - if not data: - break - response.write(data) - - except (asyncio.TimeoutError, aiohttp.errors.ClientError): - _LOGGER.exception("Error on %s", streaming_url) - raise HTTPGatewayTimeout() - - except asyncio.CancelledError: - _LOGGER.debug("Close stream by frontend.") - response = None - - finally: - if stream is not None: - stream.close() - if response is not None: - yield from response.write_eof() + yield from async_aiohttp_proxy_stream(self.hass, request, stream_coro) @property def name(self): diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3058258c75a..0cd9bbe17d3 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -32,6 +32,7 @@ SERVICE_SET_AWAY_MODE = "set_away_mode" SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_TEMPERATURE = "set_temperature" SERVICE_SET_FAN_MODE = "set_fan_mode" +SERVICE_SET_HOLD_MODE = "set_hold_mode" SERVICE_SET_OPERATION_MODE = "set_operation_mode" SERVICE_SET_SWING_MODE = "set_swing_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -56,6 +57,7 @@ ATTR_CURRENT_HUMIDITY = "current_humidity" ATTR_HUMIDITY = "humidity" ATTR_MAX_HUMIDITY = "max_humidity" ATTR_MIN_HUMIDITY = "min_humidity" +ATTR_HOLD_MODE = "hold_mode" ATTR_OPERATION_MODE = "operation_mode" ATTR_OPERATION_LIST = "operation_list" ATTR_SWING_MODE = "swing_mode" @@ -93,6 +95,10 @@ SET_FAN_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_FAN_MODE): cv.string, }) +SET_HOLD_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_HOLD_MODE): cv.string, +}) SET_OPERATION_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_OPERATION_MODE): cv.string, @@ -116,9 +122,23 @@ def set_away_mode(hass, away_mode, entity_id=None): if entity_id: data[ATTR_ENTITY_ID] = entity_id + _LOGGER.warning( + 'This service has been deprecated; use climate.set_hold_mode') hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data) +def set_hold_mode(hass, hold_mode, entity_id=None): + """Set new hold mode.""" + data = { + ATTR_HOLD_MODE: hold_mode + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_HOLD_MODE, data) + + def set_aux_heat(hass, aux_heat, entity_id=None): """Turn all or specified climate devices auxillary heater on.""" data = { @@ -229,6 +249,8 @@ def async_setup(hass, config): SERVICE_SET_AWAY_MODE, ATTR_AWAY_MODE) return + _LOGGER.warning( + 'This service has been deprecated; use climate.set_hold_mode') for climate in target_climate: if away_mode: yield from climate.async_turn_away_mode_on() @@ -242,6 +264,23 @@ def async_setup(hass, config): descriptions.get(SERVICE_SET_AWAY_MODE), schema=SET_AWAY_MODE_SCHEMA) + @asyncio.coroutine + def async_hold_mode_set_service(service): + """Set hold mode on target climate devices.""" + target_climate = component.async_extract_from_service(service) + + hold_mode = service.data.get(ATTR_HOLD_MODE) + + for climate in target_climate: + yield from climate.async_set_hold_mode(hold_mode) + + yield from _async_update_climate(target_climate) + + hass.services.async_register( + DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, + descriptions.get(SERVICE_SET_HOLD_MODE), + schema=SET_HOLD_MODE_SCHEMA) + @asyncio.coroutine def async_aux_heat_set_service(service): """Set auxillary heater on target climate devices.""" @@ -446,6 +485,10 @@ class ClimateDevice(Entity): if self.operation_list: data[ATTR_OPERATION_LIST] = self.operation_list + is_hold = self.current_hold_mode + if is_hold is not None: + data[ATTR_HOLD_MODE] = is_hold + swing_mode = self.current_swing_mode if swing_mode is not None: data[ATTR_SWING_MODE] = swing_mode @@ -517,6 +560,11 @@ class ClimateDevice(Entity): """Return true if away mode is on.""" return None + @property + def current_hold_mode(self): + """Return the current hold mode, e.g., home, away, temp.""" + return None + @property def is_aux_heat_on(self): """Return true if aux heater.""" @@ -626,6 +674,18 @@ class ClimateDevice(Entity): return self.hass.loop.run_in_executor( None, self.turn_away_mode_off) + def set_hold_mode(self, hold_mode): + """Set new target hold mode.""" + raise NotImplementedError() + + def async_set_hold_mode(self, hold_mode): + """Set new target hold mode. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.loop.run_in_executor( + None, self.set_hold_mode, hold_mode) + def turn_aux_heat_on(self): """Turn auxillary heater on.""" raise NotImplementedError() diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 04053febf90..a66873cbc63 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -12,11 +12,11 @@ from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Demo climate devices.""" add_devices([ - DemoClimate("HeatPump", 68, TEMP_FAHRENHEIT, None, 77, "Auto Low", - None, None, "Auto", "heat", None, None, None), - DemoClimate("Hvac", 21, TEMP_CELSIUS, True, 22, "On High", + DemoClimate("HeatPump", 68, TEMP_FAHRENHEIT, None, None, 77, + "Auto Low", None, None, "Auto", "heat", None, None, None), + DemoClimate("Hvac", 21, TEMP_CELSIUS, True, None, 22, "On High", 67, 54, "Off", "cool", False, None, None), - DemoClimate("Ecobee", None, TEMP_CELSIUS, None, 23, "Auto Low", + DemoClimate("Ecobee", None, TEMP_CELSIUS, None, None, 23, "Auto Low", None, None, "Auto", "auto", None, 24, 21) ]) @@ -25,7 +25,7 @@ class DemoClimate(ClimateDevice): """Representation of a demo climate device.""" def __init__(self, name, target_temperature, unit_of_measurement, - away, current_temperature, current_fan_mode, + away, hold, current_temperature, current_fan_mode, target_humidity, current_humidity, current_swing_mode, current_operation, aux, target_temp_high, target_temp_low): """Initialize the climate device.""" @@ -34,6 +34,7 @@ class DemoClimate(ClimateDevice): self._target_humidity = target_humidity self._unit_of_measurement = unit_of_measurement self._away = away + self._hold = hold self._current_temperature = current_temperature self._current_humidity = current_humidity self._current_fan_mode = current_fan_mode @@ -106,6 +107,11 @@ class DemoClimate(ClimateDevice): """Return if away mode is on.""" return self._away + @property + def current_hold_mode(self): + """Return hold mode setting.""" + return self._hold + @property def is_aux_heat_on(self): """Return true if away mode is on.""" @@ -171,6 +177,11 @@ class DemoClimate(ClimateDevice): self._away = False self.update_ha_state() + def set_hold_mode(self, hold): + """Update hold mode on.""" + self._hold = hold + self.update_ha_state() + def turn_aux_heat_on(self): """Turn away auxillary heater on.""" self._aux = True diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index bfb11f703d1..dcee6d9ce31 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -11,10 +11,10 @@ import voluptuous as vol from homeassistant.components import ecobee from homeassistant.components.climate import ( - DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, + DOMAIN, STATE_COOL, STATE_HEAT, STATE_AUTO, STATE_IDLE, ClimateDevice, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH) from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, STATE_OFF, STATE_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT) from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv @@ -145,12 +145,30 @@ class Thermostat(ClimateDevice): @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" - return int(self.thermostat['runtime']['desiredHeat'] / 10) + if self.current_operation == STATE_AUTO: + return int(self.thermostat['runtime']['desiredHeat'] / 10) + else: + return None @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" - return int(self.thermostat['runtime']['desiredCool'] / 10) + if self.current_operation == STATE_AUTO: + return int(self.thermostat['runtime']['desiredCool'] / 10) + else: + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.current_operation == STATE_AUTO: + return None + if self.current_operation == STATE_HEAT: + return int(self.thermostat['runtime']['desiredHeat'] / 10) + elif self.current_operation == STATE_COOL: + return int(self.thermostat['runtime']['desiredCool'] / 10) + else: + return None @property def desired_fan_mode(self): @@ -165,6 +183,19 @@ class Thermostat(ClimateDevice): else: return STATE_OFF + @property + def current_hold_mode(self): + """Return current hold mode.""" + if self.is_away_mode_on: + hold = 'away' + elif self.is_home_mode_on: + hold = 'home' + elif self.is_temp_hold_on(): + hold = 'temp' + else: + hold = None + return hold + @property def current_operation(self): """Return current operation.""" @@ -218,54 +249,110 @@ class Thermostat(ClimateDevice): "fan_min_on_time": self.fan_min_on_time } + def is_vacation_on(self): + """Return true if vacation mode is on.""" + events = self.thermostat['events'] + return any(event['type'] == 'vacation' and event['running'] + for event in events) + + def is_temp_hold_on(self): + """Return true if temperature hold is on.""" + events = self.thermostat['events'] + return any(event['type'] == 'hold' and event['running'] + for event in events) + @property def is_away_mode_on(self): """Return true if away mode is on.""" - mode = self.mode events = self.thermostat['events'] - for event in events: - if event['holdClimateRef'] == 'away' or \ - event['type'] == 'autoAway': - mode = "away" - break - return 'away' in mode + return any(event['holdClimateRef'] == 'away' or + event['type'] == 'autoAway' + for event in events) def turn_away_mode_on(self): """Turn away on.""" - if self.hold_temp: - self.data.ecobee.set_climate_hold(self.thermostat_index, - "away", "indefinite") - else: - self.data.ecobee.set_climate_hold(self.thermostat_index, "away") + self.data.ecobee.set_climate_hold(self.thermostat_index, + "away", self.hold_preference()) self.update_without_throttle = True def turn_away_mode_off(self): """Turn away off.""" - self.data.ecobee.resume_program(self.thermostat_index) + self.set_hold_mode(None) + + @property + def is_home_mode_on(self): + """Return true if home mode is on.""" + events = self.thermostat['events'] + return any(event['holdClimateRef'] == 'home' or + event['type'] == 'autoHome' + for event in events) + + def turn_home_mode_on(self): + """Turn home on.""" + self.data.ecobee.set_climate_hold(self.thermostat_index, + "home", self.hold_preference()) + self.update_without_throttle = True + + def set_hold_mode(self, hold_mode): + """Set hold mode (away, home, temp).""" + hold = self.current_hold_mode + + if hold == hold_mode: + return + elif hold_mode == 'away': + self.turn_away_mode_on() + elif hold_mode == 'home': + self.turn_home_mode_on() + elif hold_mode == 'temp': + self.set_temp_hold(int(self.current_temperature)) + else: + self.data.ecobee.resume_program(self.thermostat_index) + self.update_without_throttle = True + + def set_auto_temp_hold(self, heat_temp, cool_temp): + """Set temperature hold in auto mode.""" + self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, + heat_temp, self.hold_preference()) + _LOGGER.debug("Setting ecobee hold_temp to: heat=%s, is=%s, " + "cool=%s, is=%s", heat_temp, isinstance( + heat_temp, (int, float)), cool_temp, + isinstance(cool_temp, (int, float))) + + self.update_without_throttle = True + + def set_temp_hold(self, temp): + """Set temperature hold in modes other than auto.""" + # Set arbitrary range when not in auto mode + if self.current_operation == STATE_HEAT: + heat_temp = temp + cool_temp = temp + 20 + elif self.current_operation == STATE_COOL: + heat_temp = temp - 20 + cool_temp = temp + + self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, + heat_temp, self.hold_preference()) + _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " + "cool=%s, is=%s", heat_temp, isinstance( + heat_temp, (int, float)), cool_temp, + isinstance(cool_temp, (int, float))) + self.update_without_throttle = True def set_temperature(self, **kwargs): """Set new target temperature.""" - if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None and \ - kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None: - high_temp = int(kwargs.get(ATTR_TARGET_TEMP_LOW)) - low_temp = int(kwargs.get(ATTR_TARGET_TEMP_HIGH)) + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) - if self.hold_temp: - self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, - high_temp, "indefinite") - _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " - "high=%s, is=%s", low_temp, isinstance( - low_temp, (int, float)), high_temp, - isinstance(high_temp, (int, float))) + if self.current_operation == STATE_AUTO and low_temp is not None \ + and high_temp is not None: + self.set_auto_temp_hold(int(low_temp), int(high_temp)) + elif temp is not None: + self.set_temp_hold(int(temp)) else: - self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, - high_temp) - _LOGGER.debug("Setting ecobee temp to: low=%s, is=%s, " - "high=%s, is=%s", low_temp, isinstance( - low_temp, (int, float)), high_temp, - isinstance(high_temp, (int, float))) - self.update_without_throttle = True + _LOGGER.error( + 'Missing valid arguments for set_temperature in %s', kwargs) def set_operation_mode(self, operation_mode): """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" @@ -284,15 +371,19 @@ class Thermostat(ClimateDevice): str(resume_all).lower()) self.update_without_throttle = True - # Home and Sleep mode aren't used in UI yet: + def hold_preference(self): + """Return user preference setting for hold time.""" + # Values returned from thermostat are 'useEndTime4hour', + # 'useEndTime2hour', 'nextTransition', 'indefinite', 'askMe' + default = self.thermostat['settings']['holdAction'] + if default == 'nextTransition': + return default + elif default == 'indefinite': + return default + else: + return 'nextTransition' - # def turn_home_mode_on(self): - # """ Turns home mode on. """ - # self.data.ecobee.set_climate_hold(self.thermostat_index, "home") - - # def turn_home_mode_off(self): - # """ Turns home mode off. """ - # self.data.ecobee.resume_program(self.thermostat_index) + # Sleep mode isn't used in UI yet: # def turn_sleep_mode_on(self): # """ Turns sleep mode on. """ diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index a8ab9bd30b2..6587ad86300 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -76,6 +76,11 @@ class EQ3BTSmartThermostat(ClimateDevice): self._name = _name self._thermostat = eq3.Thermostat(_mac) + @property + def available(self) -> bool: + """Return if thermostat is available.""" + return self.current_operation != STATE_UNKNOWN + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index a40795c37c5..562847567a3 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -87,6 +87,7 @@ class GenericThermostat(ClimateDevice): self._unit = hass.config.units.temperature_unit track_state_change(hass, sensor_entity_id, self._sensor_changed) + track_state_change(hass, heater_entity_id, self._switch_changed) sensor_state = hass.states.get(sensor_entity_id) if sensor_state: @@ -134,7 +135,7 @@ class GenericThermostat(ClimateDevice): return self._target_temp = temperature self._control_heating() - self.update_ha_state() + self.schedule_update_ha_state() @property def min_temp(self): @@ -165,6 +166,12 @@ class GenericThermostat(ClimateDevice): self._control_heating() self.schedule_update_ha_state() + def _switch_changed(self, entity_id, old_state, new_state): + """Called when heater switch changes state.""" + if new_state is None: + return + self.schedule_update_ha_state() + def _update_temp(self, state): """Update thermostat with latest state from sensor.""" unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 0d31cdd1387..3387baf76d8 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -16,7 +16,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['evohomeclient==0.2.5', - 'somecomfort==0.3.2'] + 'somecomfort==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 801052b31ff..899a3dcfe33 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -22,6 +22,18 @@ set_away_mode: description: New value of away mode example: true +set_hold_mode: + description: Turn hold mode for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + hold_mode: + description: New value of hold mode + example: 'away' + set_temperature: description: Set target temperature of climate device diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index f7bbe341cf7..fc2e8736ee9 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -52,8 +52,6 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): def __init__(self, value, temp_unit): """Initialize the Z-Wave climate device.""" - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher ZWaveDeviceEntity.__init__(self, value, DOMAIN) self._index = value.index self._node = value.node @@ -71,9 +69,6 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): _LOGGER.debug("temp_unit is %s", self._unit) self._zxt_120 = None self.update_properties() - # register listener - dispatcher.connect( - self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) # 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()): @@ -85,16 +80,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): " workaround") self._zxt_120 = 1 - def value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - self.update_properties() - self.schedule_update_ha_state() - def update_properties(self): - """Callback on data change for the registered node/value pair.""" + """Callback on data changes for node values.""" # Operation Mode for value in self._node.get_values( class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_MODE).values(): diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 89947e3e4fc..d9d33942e15 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -53,8 +53,6 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): def __init__(self, value): """Initialize the zwave rollershutter.""" import libopenzwave - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher ZWaveDeviceEntity.__init__(self, value, DOMAIN) # pylint: disable=no-member self._lozwmgr = libopenzwave.PyManager() @@ -62,8 +60,6 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): self._node = value.node self._current_position = None self._workaround = None - dispatcher.connect( - self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) if (value.node.manufacturer_id.strip() and value.node.product_id.strip()): specific_sensor_key = (int(value.node.manufacturer_id, 16), @@ -74,16 +70,8 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): _LOGGER.debug("Controller without positioning feedback") self._workaround = 1 - def value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - self.update_properties() - self.schedule_update_ha_state() - def update_properties(self): - """Callback on data change for the registered node/value pair.""" + """Callback on data changes for node values.""" # Position value for value in self._node.get_values( class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL).values(): @@ -160,24 +148,12 @@ class ZwaveGarageDoor(zwave.ZWaveDeviceEntity, CoverDevice): def __init__(self, value): """Initialize the zwave garage door.""" - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher ZWaveDeviceEntity.__init__(self, value, DOMAIN) - self._state = value.data - dispatcher.connect( - self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) - - def value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id: - _LOGGER.debug('Value changed for label %s', self._value.label) - self._state = value.data - self.schedule_update_ha_state() @property def is_closed(self): """Return the current position of Zwave garage door.""" - return not self._state + return not self._value.data def close_cover(self): """Close the garage door.""" diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index be530abc9e2..512ccba0b74 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -71,10 +71,11 @@ _ARP_REGEX = re.compile( _IP_NEIGH_CMD = 'ip neigh' _IP_NEIGH_REGEX = re.compile( - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + - r'\w+\s' + - r'\w+\s' + - r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' + + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3}|' + r'([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){1,7})\s' + r'\w+\s' + r'\w+\s' + r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' r'(?P(\w+))') _NVRAM_CMD = 'nvram get client_info_tmp' @@ -323,6 +324,8 @@ class AsusWrtDeviceScanner(DeviceScanner): else: for lease in result.leases: + if lease.startswith(b'duid '): + continue match = _LEASES_REGEX.search(lease.decode('utf-8')) if not match: diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 055c3bc85c0..c262a8fdf2a 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -15,9 +15,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.util import Throttle -REQUIREMENTS = ['https://github.com/deisi/fritzconnection/archive/' - 'b5c14515e1c8e2652b06b6316a7f3913df942841.zip' - '#fritzconnection==0.4.6'] +REQUIREMENTS = ['fritzconnection==0.6'] # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py new file mode 100644 index 00000000000..fc8f9f96a37 --- /dev/null +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -0,0 +1,107 @@ +""" +Support for Linksys Access Points. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.linksys_ap/ +""" +import base64 +import logging +import threading +from datetime import timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL) +from homeassistant.util import Throttle + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) +INTERFACES = 2 +DEFAULT_TIMEOUT = 10 + +REQUIREMENTS = ['beautifulsoup4==4.5.3'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}) + + +def get_scanner(hass, config): + """Validate the configuration and return a Linksys AP scanner.""" + try: + return LinksysAPDeviceScanner(config[DOMAIN]) + except ConnectionError: + return None + + +class LinksysAPDeviceScanner(object): + """This class queries a Linksys Access Point.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.verify_ssl = config[CONF_VERIFY_SSL] + + self.lock = threading.Lock() + self.last_results = [] + + # Check if the access point is accessible + response = self._make_request() + if not response.status_code == 200: + raise ConnectionError("Cannot connect to Linksys Access Point") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return self.last_results + + # pylint: disable=no-self-use + def get_device_name(self, mac): + """ + Return the name (if known) of the device. + + Linksys does not provide an API to get a name for a device, + so we just return None + """ + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """Check for connected devices.""" + from bs4 import BeautifulSoup as BS + + with self.lock: + _LOGGER.info("Checking Linksys AP") + + self.last_results = [] + for interface in range(INTERFACES): + request = self._make_request(interface) + self.last_results.extend( + [x.find_all('td')[1].text + for x in BS(request.content, "html.parser") + .find_all(class_='section-row')] + ) + + return True + + def _make_request(self, unit=0): + # No, the '&&' is not a typo - this is expected by the web interface. + login = base64.b64encode(bytes(self.username, 'utf8')).decode('ascii') + pwd = base64.b64encode(bytes(self.password, 'utf8')).decode('ascii') + return requests.get( + 'https://%s/StatusClients.htm&&unit=%s&vap=0' % (self.host, unit), + timeout=DEFAULT_TIMEOUT, + verify=self.verify_ssl, + cookies={'LoginName': login, + 'LoginPWD': pwd}) diff --git a/homeassistant/components/device_tracker/sky_hub.py b/homeassistant/components/device_tracker/sky_hub.py new file mode 100644 index 00000000000..647731d8485 --- /dev/null +++ b/homeassistant/components/device_tracker/sky_hub.py @@ -0,0 +1,126 @@ +""" +Support for Sky Hub. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.sky_hub/ +""" +import logging +import re +import threading +from datetime import timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) +_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string +}) + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """Return a Sky Hub 5 scanner if successful.""" + scanner = SkyHubDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class SkyHubDeviceScanner(DeviceScanner): + """This class queries a Sky Hub router.""" + + def __init__(self, config): + """Initialise the scanner.""" + _LOGGER.info("Initialising Sky Hub") + self.host = config.get(CONF_HOST, '192.168.1.254') + + self.lock = threading.Lock() + + self.last_results = {} + + self.url = 'http://{}/'.format(self.host) + + # Test the router is accessible + data = _get_skyhub_data(self.url) + self.success_init = data is not None + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return (device for device in self.last_results) + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + with self.lock: + # If not initialised and not already scanned and not found. + if device not in self.last_results: + self._update_info() + + if not self.last_results: + return None + + return self.last_results.get(device) + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """Ensure the information from the Sky Hub is up to date. + + Return boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + _LOGGER.info("Scanning") + + data = _get_skyhub_data(self.url) + + if not data: + _LOGGER.warning('Error scanning devices') + return False + + self.last_results = data + + return True + + +def _get_skyhub_data(url): + """Retrieve data from Sky Hub and return parsed result.""" + try: + response = requests.get(url, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.exception("Connection to the router timed out") + return + if response.status_code == 200: + return _parse_skyhub_response(response.text) + else: + _LOGGER.error("Invalid response from Sky Hub: %s", response) + + +def _parse_skyhub_response(data_str): + """Parse the Sky Hub data format.""" + pattmatch = re.search('attach_dev = \'(.*)\'', data_str) + patt = pattmatch.group(1) + + dev = [patt1.split(',') for patt1 in patt.split('')] + + devices = {} + for dvc in dev: + if _MAC_REGEX.match(dvc[1]): + devices[dvc[1]] = dvc[0] + else: + raise RuntimeError('Error: MAC address ' + dvc[1] + + ' not in correct format.') + + return devices diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py new file mode 100644 index 00000000000..9127ef4fad2 --- /dev/null +++ b/homeassistant/components/device_tracker/tado.py @@ -0,0 +1,129 @@ +""" +Support for Tado Smart Thermostat. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tado/ +""" +import logging +from datetime import timedelta +from collections import namedtuple + +import asyncio +import aiohttp +import async_timeout + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + + +def get_scanner(hass, config): + """Return a Tado scanner.""" + scanner = TadoDeviceScanner(hass, config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple("Device", ["mac", "name"]) + + +class TadoDeviceScanner(DeviceScanner): + """This class gets geofenced devices from Tado.""" + + def __init__(self, hass, config): + """Initialize the scanner.""" + self.last_results = [] + + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.tadoapiurl = 'https://my.tado.com/api/v2/me' \ + '?username={}&password={}' + + self.websession = async_create_clientsession( + hass, cookie_jar=aiohttp.CookieJar(unsafe=True, loop=hass.loop)) + + self.success_init = self._update_info() + _LOGGER.info("Tado scanner initialized") + + @asyncio.coroutine + def async_scan_devices(self): + """Scan for devices and return a list containing found device ids.""" + yield from self._update_info() + + return [device.mac for device in self.last_results] + + @asyncio.coroutine + def async_get_device_name(self, mac): + """Return the name of the given device or None if we don't know.""" + filter_named = [device.name for device in self.last_results + if device.mac == mac] + + if filter_named: + return filter_named[0] + else: + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Query Tado for device marked as at home. + + Returns boolean if scanning successful. + """ + _LOGGER.debug("Requesting Tado") + + last_results = [] + + response = None + tadojson = None + try: + # get first token + with async_timeout.timeout(10, loop=self.hass.loop): + url = self.tadoapiurl.format(self.username, self.password) + response = yield from self.websession.get( + url + ) + + # error on Tado webservice + if response.status != 200: + _LOGGER.warning( + "Error %d on %s.", response.status, self.tadoapiurl) + self.token = None + return + + tadojson = yield from response.json() + + except (asyncio.TimeoutError, aiohttp.errors.ClientError): + _LOGGER.error("Can not load Tado data") + return False + + finally: + if response is not None: + yield from response.release() + + # Find devices that have geofencing enabled, and are currently at home + for mobiledevice in tadojson['mobileDevices']: + if 'location' in mobiledevice: + if mobiledevice['location']['atHome']: + deviceid = mobiledevice['id'] + devicename = mobiledevice['name'] + last_results.append(Device(deviceid, devicename)) + + self.last_results = last_results + + _LOGGER.info("Tado presence query successful") + return True diff --git a/homeassistant/components/device_tracker/upc_connect.py b/homeassistant/components/device_tracker/upc_connect.py index 13336e939a5..a8d39baed57 100644 --- a/homeassistant/components/device_tracker/upc_connect.py +++ b/homeassistant/components/device_tracker/upc_connect.py @@ -12,6 +12,7 @@ import aiohttp import async_timeout import voluptuous as vol +from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) @@ -29,6 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) CMD_LOGIN = 15 +CMD_LOGOUT = 16 CMD_DEVICES = 123 @@ -62,7 +64,21 @@ class UPCDeviceScanner(DeviceScanner): } self.websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True, loop=hass.loop)) + hass, auto_cleanup=False, + cookie_jar=aiohttp.CookieJar(unsafe=True, loop=hass.loop) + ) + + @asyncio.coroutine + def async_logout(event): + """Logout from upc connect box.""" + try: + yield from self._async_ws_function(CMD_LOGOUT) + self.token = None + finally: + self.websession.detach() + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_logout) @asyncio.coroutine def async_scan_devices(self): @@ -74,12 +90,14 @@ class UPCDeviceScanner(DeviceScanner): return [] raw = yield from self._async_ws_function(CMD_DEVICES) - if raw is None: - _LOGGER.warning("Can't read device from %s", self.host) - return - xml_root = ET.fromstring(raw) - return [mac.text for mac in xml_root.iter('MACAddr')] + try: + xml_root = ET.fromstring(raw) + return [mac.text for mac in xml_root.iter('MACAddr')] + except (ET.ParseError, TypeError): + _LOGGER.warning("Can't read device from %s", self.host) + self.token = None + return [] @asyncio.coroutine def async_get_device_name(self, device): @@ -92,6 +110,7 @@ class UPCDeviceScanner(DeviceScanner): response = None try: # get first token + self.websession.cookie_jar.clear() with async_timeout.timeout(10, loop=self.hass.loop): response = yield from self.websession.get( "http://{}/common_page/login.html".format(self.host) @@ -107,7 +126,7 @@ class UPCDeviceScanner(DeviceScanner): }) # successfull? - if data.find("successful") != -1: + if data is not None: return True return False diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index ff53d1fe99f..7c5c415f054 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -31,12 +31,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_scanner(hass, config): """Validate the configuration and return a Xiaomi Device Scanner.""" - scanner = XioamiDeviceScanner(config[DOMAIN]) + scanner = XiaomiDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None -class XioamiDeviceScanner(DeviceScanner): +class XiaomiDeviceScanner(DeviceScanner): """This class queries a Xiaomi Mi router. Adapted from Luci scanner. @@ -44,15 +44,14 @@ class XioamiDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" - host = config[CONF_HOST] - username, password = config[CONF_USERNAME], config[CONF_PASSWORD] + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] self.lock = threading.Lock() self.last_results = {} - self.token = _get_token(host, username, password) - - self.host = host + self.token = _get_token(self.host, self.username, self.password) self.mac2name = None self.success_init = self.token is not None @@ -66,9 +65,7 @@ class XioamiDeviceScanner(DeviceScanner): """Return the name of the given device or None if we don't know.""" with self.lock: if self.mac2name is None: - url = "http://{}/cgi-bin/luci/;stok={}/api/misystem/devicelist" - url = url.format(self.host, self.token) - result = _get_device_list(url) + result = self._retrieve_list_with_retry() if result: hosts = [x for x in result if 'mac' in x and 'name' in x] @@ -76,7 +73,7 @@ class XioamiDeviceScanner(DeviceScanner): (x['mac'].upper(), x['name']) for x in hosts] self.mac2name = dict(mac2name_list) else: - # Error, handled in the _req_json_rpc + # Error, handled in the _retrieve_list_with_retry return return self.mac2name.get(device.upper(), None) @@ -90,29 +87,72 @@ class XioamiDeviceScanner(DeviceScanner): return False with self.lock: - _LOGGER.info('Refreshing device list') - url = "http://{}/cgi-bin/luci/;stok={}/api/misystem/devicelist" - url = url.format(self.host, self.token) - result = _get_device_list(url) + result = self._retrieve_list_with_retry() if result: - self.last_results = [] - for device_entry in result: - # Check if the device is marked as connected - if int(device_entry['online']) == 1: - self.last_results.append(device_entry['mac']) - + self._store_result(result) return True - return False + def _retrieve_list_with_retry(self): + """Retrieve the device list with a retry if token is invalid. -def _get_device_list(url, **kwargs): + Return the list if successful. + """ + _LOGGER.info('Refreshing device list') + result = _retrieve_list(self.host, self.token) + if result: + return result + else: + _LOGGER.info('Refreshing token and retrying device list refresh') + self.token = _get_token(self.host, self.username, self.password) + return _retrieve_list(self.host, self.token) + + def _store_result(self, result): + """Extract and store the device list in self.last_results.""" + self.last_results = [] + for device_entry in result: + # Check if the device is marked as connected + if int(device_entry['online']) == 1: + self.last_results.append(device_entry['mac']) + + +def _retrieve_list(host, token, **kwargs): + """"Get device list for the given host.""" + url = "http://{}/cgi-bin/luci/;stok={}/api/misystem/devicelist" + url = url.format(host, token) try: res = requests.get(url, timeout=5, **kwargs) except requests.exceptions.Timeout: - _LOGGER.exception('Connection to the router timed out') + _LOGGER.exception('Connection to the router timed out at URL [%s]', + url) + return + if res.status_code != 200: + _LOGGER.exception('Connection failed with http code [%s]', + res.status_code) + return + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.exception('Failed to parse response from mi router') + return + try: + xiaomi_code = result['code'] + except KeyError: + _LOGGER.exception('No field code in response from mi router. %s', + result) + return + if xiaomi_code == 0: + try: + return result['list'] + except KeyError: + _LOGGER.exception('No list in response from mi router. %s', result) + return + else: + _LOGGER.info( + 'Receive wrong Xiaomi code [%s], expected [0] in response [%s]', + xiaomi_code, result) return - return _extract_result(res, 'list') def _get_token(host, username, password): @@ -124,10 +164,6 @@ def _get_token(host, username, password): except requests.exceptions.Timeout: _LOGGER.exception('Connection to the router timed out') return - return _extract_result(res, 'token') - - -def _extract_result(res, key_name): if res.status_code == 200: try: result = res.json() @@ -136,10 +172,12 @@ def _extract_result(res, key_name): _LOGGER.exception('Failed to parse response from mi router') return try: - return result[key_name] + return result['token'] except KeyError: - _LOGGER.exception('No %s in response from mi router. %s', - key_name, result) + error_message = "Xiaomi token cannot be refreshed, response from "\ + + "url: [%s] \nwith parameter: [%s] \nwas: [%s]" + _LOGGER.exception(error_message, url, data, result) return else: - _LOGGER.error('Invalid response from mi router: %s', res) + _LOGGER.error('Invalid response: [%s] at url: [%s] with data [%s]', + res, url, data) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 2efce06528d..2412b283abe 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/emulated_hue/ """ import asyncio +import json import logging import voluptuous as vol @@ -13,6 +14,7 @@ from homeassistant import util from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) +from homeassistant.components.http import REQUIREMENTS # NOQA from homeassistant.components.http import HomeAssistantWSGI import homeassistant.helpers.config_validation as cv from .hue_api import ( @@ -24,8 +26,13 @@ DOMAIN = 'emulated_hue' _LOGGER = logging.getLogger(__name__) +NUMBERS_FILE = 'emulated_hue_ids.json' + CONF_HOST_IP = 'host_ip' CONF_LISTEN_PORT = 'listen_port' +CONF_ADVERTISE_IP = 'advertise_ip' +CONF_ADVERTISE_PORT = 'advertise_port' +CONF_UPNP_BIND_MULTICAST = 'upnp_bind_multicast' CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains' CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' CONF_EXPOSED_DOMAINS = 'exposed_domains' @@ -35,18 +42,23 @@ TYPE_ALEXA = 'alexa' TYPE_GOOGLE = 'google_home' DEFAULT_LISTEN_PORT = 8300 +DEFAULT_UPNP_BIND_MULTICAST = True DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene'] DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ 'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan' ] -DEFAULT_TYPE = TYPE_ALEXA +DEFAULT_TYPE = TYPE_GOOGLE CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST_IP): cv.string, vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + vol.Optional(CONF_ADVERTISE_IP): cv.string, + vol.Optional(CONF_ADVERTISE_PORT): + vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + vol.Optional(CONF_UPNP_BIND_MULTICAST): cv.boolean, vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list, vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, @@ -60,7 +72,7 @@ ATTR_EMULATED_HUE = 'emulated_hue' def setup(hass, yaml_config): """Activate the emulated_hue component.""" - config = Config(yaml_config.get(DOMAIN, {})) + config = Config(hass, yaml_config.get(DOMAIN, {})) server = HomeAssistantWSGI( hass, @@ -84,7 +96,9 @@ def setup(hass, yaml_config): server.register_view(HueOneLightChangeView(config)) upnp_listener = UPNPResponderThread( - config.host_ip_addr, config.listen_port) + config.host_ip_addr, config.listen_port, + config.upnp_bind_multicast, config.advertise_ip, + config.advertise_port) @asyncio.coroutine def stop_emulated_hue_bridge(event): @@ -108,12 +122,17 @@ def setup(hass, yaml_config): class Config(object): """Holds configuration variables for the emulated hue bridge.""" - def __init__(self, conf): + def __init__(self, hass, conf): """Initialize the instance.""" + self.hass = hass self.type = conf.get(CONF_TYPE) - self.numbers = {} + self.numbers = None self.cached_states = {} + if self.type == TYPE_ALEXA: + _LOGGER.warning('Alexa type is deprecated and will be removed in a' + ' future version') + # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) if self.host_ip_addr is None: @@ -134,6 +153,11 @@ class Config(object): _LOGGER.warning('When targetting Google Home, listening port has ' 'to be port 80') + # Get whether or not UPNP binds to multicast address (239.255.255.250) + # or to the unicast address (host_ip_addr) + self.upnp_bind_multicast = conf.get( + CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST) + # Get domains that cause both "on" and "off" commands to map to "on" # This is primarily useful for things like scenes or scripts, which # don't really have a concept of being off @@ -151,11 +175,21 @@ class Config(object): self.exposed_domains = conf.get( CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + # Calculated effective advertised IP and port for network isolation + self.advertise_ip = conf.get( + CONF_ADVERTISE_IP) or self.host_ip_addr + + self.advertise_port = conf.get( + CONF_ADVERTISE_PORT) or self.listen_port + def entity_id_to_number(self, entity_id): """Get a unique number for the entity id.""" if self.type == TYPE_ALEXA: return entity_id + if self.numbers is None: + self.numbers = self._load_numbers_json() + # Google Home for number, ent_id in self.numbers.items(): if entity_id == ent_id: @@ -163,6 +197,7 @@ class Config(object): number = str(len(self.numbers) + 1) self.numbers[number] = entity_id + self._save_numbers_json() return number def number_to_entity_id(self, number): @@ -170,6 +205,9 @@ class Config(object): if self.type == TYPE_ALEXA: return number + if self.numbers is None: + self.numbers = self._load_numbers_json() + # Google Home assert isinstance(number, str) return self.numbers.get(number) @@ -196,3 +234,26 @@ class Config(object): domain_exposed_by_default and explicit_expose is not False return is_default_exposed or explicit_expose + + def _load_numbers_json(self): + """Helper method to load numbers json.""" + try: + with open(self.hass.config.path(NUMBERS_FILE), + encoding='utf-8') as fil: + return json.loads(fil.read()) + except (OSError, ValueError) as err: + # OSError if file not found or unaccessible/no permissions + # ValueError if could not parse JSON + if not isinstance(err, FileNotFoundError): + _LOGGER.warning('Failed to open %s: %s', NUMBERS_FILE, err) + return {} + + def _save_numbers_json(self): + """Helper method to save numbers json.""" + try: + with open(self.hass.config.path(NUMBERS_FILE), 'w', + encoding='utf-8') as fil: + fil.write(json.dumps(self.numbers)) + except OSError as err: + # OSError if file write permissions + _LOGGER.warning('Failed to write %s: %s', NUMBERS_FILE, err) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 9b0a2828394..b56be3484fe 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -17,6 +17,10 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, ATTR_SUPPORTED_MEDIA_COMMANDS, SUPPORT_VOLUME_SET, ) +from homeassistant.components.fan import ( + ATTR_SPEED, SUPPORT_SET_SPEED, SPEED_OFF, SPEED_LOW, + SPEED_MEDIUM, SPEED_HIGH +) from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -174,7 +178,9 @@ class HueOneLightChangeView(HomeAssistantView): # Make sure the entity actually supports brightness entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS: + if (entity_features & + SUPPORT_BRIGHTNESS & + (entity.domain == "light")) == SUPPORT_BRIGHTNESS: if brightness is not None: data[ATTR_BRIGHTNESS] = brightness @@ -207,6 +213,23 @@ class HueOneLightChangeView(HomeAssistantView): else: service = SERVICE_CLOSE_COVER + # If the requested entity is a fan, convert to speed + elif entity.domain == "fan": + functions = entity.attributes.get( + ATTR_SUPPORTED_FEATURES, 0) + if (functions & SUPPORT_SET_SPEED) == SUPPORT_SET_SPEED: + if brightness is not None: + domain = entity.domain + # Convert 0-100 to a fan speed + if brightness == 0: + data[ATTR_SPEED] = SPEED_OFF + elif brightness <= 33.3 and brightness > 0: + data[ATTR_SPEED] = SPEED_LOW + elif brightness <= 66.6 and brightness > 33.3: + data[ATTR_SPEED] = SPEED_MEDIUM + elif brightness <= 100 and brightness > 66.6: + data[ATTR_SPEED] = SPEED_HIGH + if entity.domain in config.off_maps_to_on_domains: # Map the off command to on service = SERVICE_TURN_ON @@ -269,7 +292,9 @@ def parse_hue_api_put_light_body(request_json, entity): report_brightness = True result = (brightness > 0) - elif entity.domain == "script" or entity.domain == "media_player": + elif (entity.domain == "script" or + entity.domain == "media_player" or + entity.domain == "fan"): # Convert 0-255 to 0-100 level = brightness / 255 * 100 brightness = round(level) @@ -299,6 +324,16 @@ def get_entity_state(config, entity): ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0) # Convert 0.0-1.0 to 0-255 final_brightness = round(min(1.0, level) * 255) + elif entity.domain == "fan": + speed = entity.attributes.get(ATTR_SPEED, 0) + # Convert 0.0-1.0 to 0-255 + final_brightness = 0 + if speed == SPEED_LOW: + final_brightness = 85 + elif speed == SPEED_MEDIUM: + final_brightness = 170 + elif speed == SPEED_HIGH: + final_brightness = 255 else: final_state, final_brightness = cached_state # Make sure brightness is valid diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index fd880c40e6e..31d8ab60e30 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -50,7 +50,7 @@ class DescriptionXmlView(HomeAssistantView): """ resp_text = xml_template.format( - self.config.host_ip_addr, self.config.listen_port) + self.config.advertise_ip, self.config.advertise_port) return web.Response(text=resp_text, content_type='text/xml') @@ -60,12 +60,14 @@ class UPNPResponderThread(threading.Thread): _interrupted = False - def __init__(self, host_ip_addr, listen_port): + def __init__(self, host_ip_addr, listen_port, upnp_bind_multicast, + advertise_ip, advertise_port): """Initialize the class.""" threading.Thread.__init__(self) self.host_ip_addr = host_ip_addr self.listen_port = listen_port + self.upnp_bind_multicast = upnp_bind_multicast # Note that the double newline at the end of # this string is required per the SSDP spec @@ -80,9 +82,9 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 """ - self.upnp_response = resp_template.format(host_ip_addr, listen_port) \ - .replace("\n", "\r\n") \ - .encode('utf-8') + self.upnp_response = resp_template.format( + advertise_ip, advertise_port).replace("\n", "\r\n") \ + .encode('utf-8') # Set up a pipe for signaling to the receiver that it's time to # shutdown. Essentially, we place the SSDP socket into nonblocking @@ -116,7 +118,10 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 socket.inet_aton("239.255.255.250") + socket.inet_aton(self.host_ip_addr)) - ssdp_socket.bind(("239.255.255.250", 1900)) + if self.upnp_bind_multicast: + ssdp_socket.bind(("239.255.255.250", 1900)) + else: + ssdp_socket.bind((self.host_ip_addr, 1900)) while True: if self._interrupted: diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index efb7e0b1496..e6da2ff0fd7 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -41,7 +41,6 @@ SERVICE_SET_DIRECTION = 'set_direction' SPEED_OFF = 'off' SPEED_LOW = 'low' -SPEED_MED = 'med' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' @@ -230,6 +229,9 @@ class FanEntity(ToggleEntity): def set_speed(self: ToggleEntity, speed: str) -> None: """Set the speed of the fan.""" + if speed is SPEED_OFF: + self.turn_off() + return raise NotImplementedError() def set_direction(self: ToggleEntity, direction: str) -> None: @@ -238,6 +240,9 @@ class FanEntity(ToggleEntity): def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: """Turn on the fan.""" + if speed is SPEED_OFF: + self.turn_off() + return raise NotImplementedError() def turn_off(self: ToggleEntity, **kwargs) -> None: diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index 7ba6b4d67fb..6d24f8d3048 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_HIGH, +from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, SUPPORT_DIRECTION) from homeassistant.const import STATE_OFF @@ -54,9 +54,9 @@ class DemoFan(FanEntity): @property def speed_list(self) -> list: """Get the list of available speeds.""" - return [STATE_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] + return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=SPEED_MED) -> None: + def turn_on(self, speed: str=SPEED_MEDIUM) -> None: """Turn on the entity.""" self.set_speed(speed) diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index fd0690f4253..30c1d2ed2a3 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -8,7 +8,7 @@ import logging from typing import Callable from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF, - SPEED_LOW, SPEED_MED, + SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) import homeassistant.components.isy994 as isy from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF @@ -20,8 +20,8 @@ VALUE_TO_STATE = { 0: SPEED_OFF, 63: SPEED_LOW, 64: SPEED_LOW, - 190: SPEED_MED, - 191: SPEED_MED, + 190: SPEED_MEDIUM, + 191: SPEED_MEDIUM, 255: SPEED_HIGH, } @@ -29,7 +29,7 @@ STATE_TO_VALUE = {} for key in VALUE_TO_STATE: STATE_TO_VALUE[VALUE_TO_STATE[key]] = key -STATES = [SPEED_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] +STATES = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] # pylint: disable=unused-argument diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 08db5ead26b..4ca1fc8bae4 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) import homeassistant.helpers.config_validation as cv -from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_MEDIUM, +from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, SPEED_OFF, ATTR_SPEED) @@ -64,11 +64,11 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_OSCILLATION_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string, - vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MED): cv.string, + vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MEDIUM): cv.string, vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string, vol.Optional(CONF_SPEED_LIST, default=[SPEED_OFF, SPEED_LOW, - SPEED_MED, SPEED_HIGH]): cv.ensure_list, + SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, }) @@ -162,7 +162,7 @@ class MqttFan(FanEntity): if payload == self._payload[SPEED_LOW]: self._speed = SPEED_LOW elif payload == self._payload[SPEED_MEDIUM]: - self._speed = SPEED_MED + self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH self.update_ha_state() @@ -235,11 +235,12 @@ class MqttFan(FanEntity): """Return the oscillation state.""" return self._oscillation - def turn_on(self, speed: str=SPEED_MED) -> None: + def turn_on(self, speed: str=None) -> None: """Turn on the entity.""" mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC], self._payload[STATE_ON], self._qos, self._retain) - self.set_speed(speed) + if speed: + self.set_speed(speed) def turn_off(self) -> None: """Turn off the entity.""" @@ -252,7 +253,7 @@ class MqttFan(FanEntity): mqtt_payload = SPEED_OFF if speed == SPEED_LOW: mqtt_payload = self._payload[SPEED_LOW] - elif speed == SPEED_MED: + elif speed == SPEED_MEDIUM: mqtt_payload = self._payload[SPEED_MEDIUM] elif speed == SPEED_HIGH: mqtt_payload = self._payload[SPEED_HIGH] @@ -265,9 +266,12 @@ class MqttFan(FanEntity): def oscillate(self, oscillating: bool) -> None: """Set oscillation.""" - if self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + if self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None: self._oscillation = oscillating + payload = self._payload[OSCILLATE_ON_PAYLOAD] + if oscillating is False: + payload = self._payload[OSCILLATE_OFF_PAYLOAD] mqtt.publish(self._hass, self._topic[CONF_OSCILLATION_COMMAND_TOPIC], - self._oscillation, self._qos, self._retain) + payload, self._qos, self._retain) self.update_ha_state() diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index e729e7f7e89..7862aa9a7c3 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -50,4 +50,15 @@ toggle: fields: entity_id: description: Name(s) of the entities to toggle - exampl: 'fan.living_room' \ No newline at end of file + exampl: 'fan.living_room' + +set_direction: + description: Set the fan rotation direction + + fields: + entity_id: + description: Name(s) of the entities to toggle + exampl: 'fan.living_room' + direction: + description: The direction to rotate + example: 'left' diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index 066dbfcb561..74fd06e5516 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -32,7 +32,7 @@ class WinkFanDevice(WinkDevice, FanEntity): """Initialize the fan.""" WinkDevice.__init__(self, wink, hass) - def set_drection(self: ToggleEntity, direction: str) -> None: + def set_direction(self: ToggleEntity, direction: str) -> None: """Set the direction of the fan.""" self.wink.set_fan_direction(direction) diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index f345153e666..2a498198e3c 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -10,13 +10,14 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe DOMAIN = 'ffmpeg' -REQUIREMENTS = ["ha-ffmpeg==0.15"] +REQUIREMENTS = ["ha-ffmpeg==1.2"] _LOGGER = logging.getLogger(__name__) +DATA_FFMPEG = 'ffmpeg' + CONF_INPUT = 'input' CONF_FFMPEG_BIN = 'ffmpeg_bin' CONF_EXTRA_ARGUMENTS = 'extra_arguments' @@ -34,53 +35,54 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -FFMPEG_CONFIG = { - CONF_FFMPEG_BIN: DEFAULT_BINARY, - CONF_RUN_TEST: DEFAULT_RUN_TEST, -} -FFMPEG_TEST_CACHE = {} - - -def setup(hass, config): - """Setup the FFmpeg component.""" - if DOMAIN in config: - FFMPEG_CONFIG.update(config.get(DOMAIN)) - return True - - -def get_binary(): - """Return ffmpeg binary from config. - - Async friendly. - """ - return FFMPEG_CONFIG.get(CONF_FFMPEG_BIN) - - -def run_test(hass, input_source): - """Run test on this input. TRUE is deactivate or run correct.""" - return run_coroutine_threadsafe( - async_run_test(hass, input_source), hass.loop).result() - - @asyncio.coroutine -def async_run_test(hass, input_source): - """Run test on this input. TRUE is deactivate or run correct. +def async_setup(hass, config): + """Setup the FFmpeg component.""" + conf = config.get(DOMAIN, {}) - This method must be run in the event loop. - """ - from haffmpeg import TestAsync + hass.data[DATA_FFMPEG] = FFmpegManager( + hass, + conf.get(CONF_FFMPEG_BIN, DEFAULT_BINARY), + conf.get(CONF_RUN_TEST, DEFAULT_RUN_TEST) + ) - if FFMPEG_CONFIG.get(CONF_RUN_TEST): - # if in cache - if input_source in FFMPEG_TEST_CACHE: - return FFMPEG_TEST_CACHE[input_source] - - # run test - ffmpeg_test = TestAsync(get_binary(), loop=hass.loop) - success = yield from ffmpeg_test.run_test(input_source) - if not success: - _LOGGER.error("FFmpeg '%s' test fails!", input_source) - FFMPEG_TEST_CACHE[input_source] = False - return False - FFMPEG_TEST_CACHE[input_source] = True return True + + +class FFmpegManager(object): + """Helper for ha-ffmpeg.""" + + def __init__(self, hass, ffmpeg_bin, run_test): + """Initialize helper.""" + self.hass = hass + self._cache = {} + self._bin = ffmpeg_bin + self._run_test = run_test + + @property + def binary(self): + """Return ffmpeg binary from config.""" + return self._bin + + @asyncio.coroutine + def async_run_test(self, input_source): + """Run test on this input. TRUE is deactivate or run correct. + + This method must be run in the event loop. + """ + from haffmpeg import Test + + if self._run_test: + # if in cache + if input_source in self._cache: + return self._cache[input_source] + + # run test + ffmpeg_test = Test(self.binary, loop=self.hass.loop) + success = yield from ffmpeg_test.run_test(input_source) + if not success: + _LOGGER.error("FFmpeg '%s' test fails!", input_source) + self._cache[input_source] = False + return False + self._cache[input_source] = True + return True diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 3af14628008..12566881be8 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,18 +1,18 @@ """DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.""" FINGERPRINTS = { - "core.js": "22d39af274e1d824ca1302e10971f2d8", - "frontend.html": "61e57194179b27563a05282b58dd4f47", - "mdi.html": "5bb2f1717206bad0d187c2633062c575", + "core.js": "769f3fdd4e04b34bd66c7415743cf7b5", + "frontend.html": "d48d9a13f7d677e59b1d22c6db051207", + "mdi.html": "7a0f14bbf3822449f9060b9c53bd7376", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", "panels/ha-panel-dev-event.html": "f19840b9a6a46f57cb064b384e1353f5", "panels/ha-panel-dev-info.html": "3765a371478cc66d677cf6dcc35267c6", - "panels/ha-panel-dev-service.html": "e32bcd3afdf485417a3e20b4fc760776", + "panels/ha-panel-dev-service.html": "1d223225c1c75083738033895ea3e4b5", "panels/ha-panel-dev-state.html": "8257d99a38358a150eafdb23fa6727e0", "panels/ha-panel-dev-template.html": "cbb251acabd5e7431058ed507b70522b", - "panels/ha-panel-history.html": "7baeb4bd7d9ce0def4f95eab6f10812e", + "panels/ha-panel-history.html": "9f2c72574fb6135beb1b381a4b8b7703", "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", - "panels/ha-panel-logbook.html": "93de4cee3a2352a6813b5c218421d534", - "panels/ha-panel-map.html": "3b0ca63286cbe80f27bd36dbc2434e89", + "panels/ha-panel-logbook.html": "313f2ac57aaa5ad55933c9bbf8d8a1e5", + "panels/ha-panel-map.html": "13f120066c0b5faa2ce1db2c3f3cc486", "websocket_test.html": "575de64b431fe11c3785bf96d7813450" } diff --git a/homeassistant/components/frontend/www_static/core.js b/homeassistant/components/frontend/www_static/core.js index e3134a1ea79..f3679c981d7 100644 --- a/homeassistant/components/frontend/www_static/core.js +++ b/homeassistant/components/frontend/www_static/core.js @@ -1,4 +1,4 @@ -!(function(){"use strict";function t(t){return t&&t.__esModule?t.default:t}function e(t,e){return e={exports:{}},t(e,e.exports),e.exports}function n(t,e){var n=e.authToken,r=e.host;return xe({authToken:n,host:r,isValidating:!0,isInvalid:!1,errorMessage:""})}function r(){return Ve.getInitialState()}function i(t,e){var n=e.errorMessage;return t.withMutations((function(t){return t.set("isValidating",!1).set("isInvalid",!0).set("errorMessage",n)}))}function o(t,e){var n=e.authToken,r=e.host;return Fe({authToken:n,host:r})}function u(){return Ge.getInitialState()}function a(t,e){var n=e.rememberAuth;return n}function s(t){return t.withMutations((function(t){t.set("isStreaming",!0).set("hasError",!1)}))}function c(t){return t.withMutations((function(t){t.set("isStreaming",!1).set("hasError",!0)}))}function f(){return Xe.getInitialState()}function h(t){return{type:"auth",api_password:t}}function l(){return{type:"get_states"}}function p(){return{type:"get_config"}}function _(){return{type:"get_services"}}function d(){return{type:"get_panels"}}function v(t,e,n){var r={type:"call_service",domain:t,service:e};return n&&(r.service_data=n),r}function y(t){var e={type:"subscribe_events"};return t&&(e.event_type=t),e}function m(t){return{type:"unsubscribe_events",subscription:t}}function g(){return{type:"ping"}}function S(t,e){return{type:"result",success:!1,error:{code:t,message:e}}}function b(t){return t.result}function E(t,e){var n=new tn(t,e);return n.connect()}function I(t,e,n,r){void 0===r&&(r=null);var i=t.evaluate(Mo.authInfo),o=i.host+"/api/"+n;return new Promise(function(t,n){var u=new XMLHttpRequest;u.open(e,o,!0),u.setRequestHeader("X-HA-access",i.authToken),u.onload=function(){var e;try{e="application/json"===u.getResponseHeader("content-type")?JSON.parse(u.responseText):u.responseText}catch(t){e=u.responseText}u.status>199&&u.status<300?t(e):n(e)},u.onerror=function(){return n({})},r?(u.setRequestHeader("Content-Type","application/json;charset=UTF-8"),u.send(JSON.stringify(r))):u.send()})}function O(t,e){var n=e.model,r=e.result,i=e.params,o=n.entity;if(!r)return t;var u=i.replace?sn({}):t.get(o),a=Array.isArray(r)?r:[r],s=n.fromJSON||sn;return t.set(o,u.withMutations((function(t){for(var e=0;e6e4}function mt(t,e){var n=e.date;return n.toISOString()}function gt(){return Qr.getInitialState()}function St(t,e){var n=e.date,r=e.stateHistory;return 0===r.length?t.set(n,$r({})):t.withMutations((function(t){r.forEach((function(e){return t.setIn([n,e[0].entity_id],$r(e.map(In.fromJSON)))}))}))}function bt(){return ti.getInitialState()}function Et(t,e){var n=e.stateHistory;return t.withMutations((function(t){n.forEach((function(e){return t.set(e[0].entity_id,ii(e.map(In.fromJSON)))}))}))}function It(){return oi.getInitialState()}function Ot(t,e){var n=e.stateHistory,r=(new Date).getTime();return t.withMutations((function(t){n.forEach((function(e){return t.set(e[0].entity_id,r)})),history.length>1&&t.set(si,r)}))}function wt(){return ci.getInitialState()}function Tt(t,e){t.dispatch(Wr.ENTITY_HISTORY_DATE_SELECTED,{date:e})}function At(t,e){void 0===e&&(e=null),t.dispatch(Wr.RECENT_ENTITY_HISTORY_FETCH_START,{});var n="history/period";return null!==e&&(n+="?filter_entity_id="+e),on(t,"GET",n).then((function(e){return t.dispatch(Wr.RECENT_ENTITY_HISTORY_FETCH_SUCCESS,{stateHistory:e})}),(function(){return t.dispatch(Wr.RECENT_ENTITY_HISTORY_FETCH_ERROR,{})}))}function Dt(t,e){return t.dispatch(Wr.ENTITY_HISTORY_FETCH_START,{date:e}),on(t,"GET","history/period/"+e).then((function(n){return t.dispatch(Wr.ENTITY_HISTORY_FETCH_SUCCESS,{date:e,stateHistory:n})}),(function(){return t.dispatch(Wr.ENTITY_HISTORY_FETCH_ERROR,{})}))}function Ct(t){var e=t.evaluate(li);return Dt(t,e)}function zt(t){t.registerStores({currentEntityHistoryDate:Qr,entityHistory:ti,isLoadingEntityHistory:ni,recentEntityHistory:oi,recentEntityHistoryUpdated:ci})}function Rt(t){t.registerStores({moreInfoEntityId:Yr})}function Mt(t,e){var n=e.model,r=e.result,i=e.params;if(null===t||"entity"!==n.entity||!i.replace)return t;for(var o=0;o0?i=setTimeout(r,e-c):(i=null,n||(s=t.apply(u,o),i||(u=o=null)))}var i,o,u,a,s;null==e&&(e=100);var c=function(){u=this,o=arguments,a=(new Date).getTime();var c=n&&!i;return i||(i=setTimeout(r,e)),c&&(s=t.apply(u,o),u=o=null),s};return c.clear=function(){i&&(clearTimeout(i),i=null)},c}function Yt(t){var e=fo[t.hassId];e&&(e.scheduleHealthCheck.clear(),e.conn.close(),fo[t.hassId]=!1)}function Jt(t,e){void 0===e&&(e={});var n=e.syncOnInitialConnect;void 0===n&&(n=!0),Yt(t);var r=t.evaluate(Mo.authToken),i="https:"===document.location.protocol?"wss://":"ws://";i+=document.location.hostname,document.location.port&&(i+=":"+document.location.port),i+="/api/websocket",E(i,{authToken:r}).then((function(e){var r=Bt((function(){return e.ping()}),so);r(),e.socket.addEventListener("message",r),fo[t.hassId]={conn:e,scheduleHealthCheck:r},co.forEach((function(n){return e.subscribeEvents(ao.bind(null,t),n)})),t.batch((function(){t.dispatch(Ye.STREAM_START),n&&io.fetchAll(t)})),e.addEventListener("disconnected",(function(){t.dispatch(Ye.STREAM_ERROR)})),e.addEventListener("ready",(function(){t.batch((function(){t.dispatch(Ye.STREAM_START),io.fetchAll(t)}))}))}))}function Wt(t){t.registerStores({streamStatus:Xe})}function Xt(t,e,n){void 0===n&&(n={});var r=n.rememberAuth;void 0===r&&(r=!1);var i=n.host;void 0===i&&(i=""),t.dispatch(Ue.VALIDATING_AUTH_TOKEN,{authToken:e,host:i}),io.fetchAll(t).then((function(){t.dispatch(Ue.VALID_AUTH_TOKEN,{authToken:e,host:i,rememberAuth:r}),vo.start(t,{syncOnInitialConnect:!1})}),(function(e){void 0===e&&(e={});var n=e.message;void 0===n&&(n=go),t.dispatch(Ue.INVALID_AUTH_TOKEN,{errorMessage:n})}))}function Qt(t){t.dispatch(Ue.LOG_OUT,{})}function Zt(t){t.registerStores({authAttempt:Ve,authCurrent:Ge,rememberAuth:Be})}function $t(){if(!("localStorage"in window))return{};var t=window.localStorage,e="___test";try{return t.setItem(e,e),t.removeItem(e),t}catch(t){return{}}}function te(){var t=new Uo({debug:!1});return t.hassId=Ho++,t}function ee(t,e,n){Object.keys(n).forEach((function(r){var i=n[r];if("register"in i&&i.register(e),"getters"in i&&Object.defineProperty(t,r+"Getters",{value:i.getters,enumerable:!0}),"actions"in i){var o={};Object.getOwnPropertyNames(i.actions).forEach((function(t){"function"==typeof i.actions[t]&&Object.defineProperty(o,t,{value:i.actions[t].bind(null,e),enumerable:!0})})),Object.defineProperty(t,r+"Actions",{value:o,enumerable:!0})}}))}function ne(t,e){return xo(t.attributes.entity_id.map((function(t){return e.get(t)})).filter((function(t){return!!t})))}function re(t){return on(t,"GET","error_log")}function ie(t,e){var n=e.date;return n.toISOString()}function oe(){return Jo.getInitialState()}function ue(t,e){var n=e.date,r=e.entries;return t.set(n,eu(r.map($o.fromJSON)))}function ae(){return nu.getInitialState()}function se(t,e){var n=e.date;return t.set(n,(new Date).getTime())}function ce(){return ou.getInitialState()}function fe(t,e){t.dispatch(Bo.LOGBOOK_DATE_SELECTED,{date:e})}function he(t,e){t.dispatch(Bo.LOGBOOK_ENTRIES_FETCH_START,{date:e}),on(t,"GET","logbook/"+e).then((function(n){return t.dispatch(Bo.LOGBOOK_ENTRIES_FETCH_SUCCESS,{date:e,entries:n})}),(function(){return t.dispatch(Bo.LOGBOOK_ENTRIES_FETCH_ERROR,{})}))}function le(t){return!t||(new Date).getTime()-t>su}function pe(t){t.registerStores({currentLogbookDate:Jo,isLoadingLogbookEntries:Xo,logbookEntries:nu,logbookEntriesUpdated:ou})}function _e(t){return t.set("active",!0)}function de(t){return t.set("active",!1)}function ve(){return Su.getInitialState()}function ye(t){return navigator.serviceWorker.getRegistration().then((function(t){if(!t)throw new Error("No service worker registered.");return t.pushManager.subscribe({userVisibleOnly:!0})})).then((function(e){var n;return n=navigator.userAgent.toLowerCase().indexOf("firefox")>-1?"firefox":"chrome",on(t,"POST","notify.html5",{subscription:e,browser:n}).then((function(){return t.dispatch(yu.PUSH_NOTIFICATIONS_SUBSCRIBE,{})})).then((function(){return!0}))})).catch((function(e){var n;return n=e.message&&e.message.indexOf("gcm_sender_id")!==-1?"Please setup the notify.html5 platform.":"Notification registration failed.",console.error(e),Vn.createNotification(t,n),!1}))}function me(t){return navigator.serviceWorker.getRegistration().then((function(t){if(!t)throw new Error("No service worker registered");return t.pushManager.subscribe({userVisibleOnly:!0})})).then((function(e){return on(t,"DELETE","notify.html5",{subscription:e}).then((function(){return e.unsubscribe()})).then((function(){return t.dispatch(yu.PUSH_NOTIFICATIONS_UNSUBSCRIBE,{})})).then((function(){return!0}))})).catch((function(e){var n="Failed unsubscribing for push notifications.";return console.error(e),Vn.createNotification(t,n),!1}))}function ge(t){t.registerStores({pushNotifications:Su})}function Se(t,e){return on(t,"POST","template",{template:e})}function be(t){return t.set("isListening",!0)}function Ee(t,e){var n=e.interimTranscript,r=e.finalTranscript;return t.withMutations((function(t){return t.set("isListening",!0).set("isTransmitting",!1).set("interimTranscript",n).set("finalTranscript",r)}))}function Ie(t,e){var n=e.finalTranscript;return t.withMutations((function(t){return t.set("isListening",!1).set("isTransmitting",!0).set("interimTranscript","").set("finalTranscript",n)}))}function Oe(){return ku.getInitialState()}function we(){return ku.getInitialState()}function Te(){return ku.getInitialState()}function Ae(t){return Pu[t.hassId]}function De(t){var e=Ae(t);if(e){var n=e.finalTranscript||e.interimTranscript;t.dispatch(ju.VOICE_TRANSMITTING,{finalTranscript:n}),tr.callService(t,"conversation","process",{text:n}).then((function(){t.dispatch(ju.VOICE_DONE)}),(function(){t.dispatch(ju.VOICE_ERROR)}))}}function Ce(t){var e=Ae(t);e&&(e.recognition.stop(),Pu[t.hassId]=!1)}function ze(t){De(t),Ce(t)}function Re(t){var e=ze.bind(null,t);e();var n=new webkitSpeechRecognition;Pu[t.hassId]={recognition:n,interimTranscript:"",finalTranscript:""},n.interimResults=!0,n.onstart=function(){return t.dispatch(ju.VOICE_START)},n.onerror=function(){return t.dispatch(ju.VOICE_ERROR)},n.onend=e,n.onresult=function(e){var n=Ae(t);if(n){for(var r="",i="",o=e.resultIndex;o=n)}function c(t,e){return h(t,e,0)}function f(t,e){return h(t,e,e)}function h(t,e,n){return void 0===t?n:t<0?Math.max(0,e+t):void 0===e?t:Math.min(e,t)}function l(t){return v(t)?t:C(t)}function p(t){return y(t)?t:z(t)}function _(t){return m(t)?t:R(t)}function d(t){return v(t)&&!g(t)?t:M(t)}function v(t){return!(!t||!t[dn])}function y(t){return!(!t||!t[vn])}function m(t){return!(!t||!t[yn])}function g(t){return y(t)||m(t)}function S(t){return!(!t||!t[mn])}function b(t){this.next=t}function E(t,e,n,r){var i=0===t?e:1===t?n:[e,n];return r?r.value=i:r={value:i,done:!1},r}function I(){return{value:void 0,done:!0}}function O(t){return!!A(t)}function w(t){return t&&"function"==typeof t.next}function T(t){var e=A(t);return e&&e.call(t)}function A(t){var e=t&&(En&&t[En]||t[In]);if("function"==typeof e)return e}function D(t){return t&&"number"==typeof t.length}function C(t){return null===t||void 0===t?U():v(t)?t.toSeq():V(t)}function z(t){return null===t||void 0===t?U().toKeyedSeq():v(t)?y(t)?t.toSeq():t.fromEntrySeq():H(t)}function R(t){return null===t||void 0===t?U():v(t)?y(t)?t.entrySeq():t.toIndexedSeq():x(t)}function M(t){return(null===t||void 0===t?U():v(t)?y(t)?t.entrySeq():t:x(t)).toSetSeq()}function j(t){this._array=t,this.size=t.length}function L(t){var e=Object.keys(t);this._object=t,this._keys=e,this.size=e.length}function N(t){this._iterable=t,this.size=t.length||t.size}function k(t){this._iterator=t,this._iteratorCache=[]}function P(t){return!(!t||!t[wn])}function U(){return Tn||(Tn=new j([]))}function H(t){var e=Array.isArray(t)?new j(t).fromEntrySeq():w(t)?new k(t).fromEntrySeq():O(t)?new N(t).fromEntrySeq():"object"==typeof t?new L(t):void 0;if(!e)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+t);return e}function x(t){var e=q(t);if(!e)throw new TypeError("Expected Array or iterable object of values: "+t);return e}function V(t){var e=q(t)||"object"==typeof t&&new L(t);if(!e)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+t);return e}function q(t){return D(t)?new j(t):w(t)?new k(t):O(t)?new N(t):void 0}function F(t,e,n,r){var i=t._cache;if(i){for(var o=i.length-1,u=0;u<=o;u++){var a=i[n?o-u:u];if(e(a[1],r?a[0]:u,t)===!1)return u+1}return u}return t.__iterateUncached(e,n)}function G(t,e,n,r){var i=t._cache;if(i){var o=i.length-1,u=0;return new b(function(){var t=i[n?o-u:u];return u++>o?I():E(e,r?t[0]:u-1,t[1])})}return t.__iteratorUncached(e,n)}function K(){throw TypeError("Abstract")}function B(){}function Y(){}function J(){}function W(t,e){if(t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1;if("function"==typeof t.valueOf&&"function"==typeof e.valueOf){if(t=t.valueOf(),e=e.valueOf(),t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1}return!("function"!=typeof t.equals||"function"!=typeof e.equals||!t.equals(e))}function X(t,e){return e?Q(e,t,"",{"":t}):Z(t)}function Q(t,e,n,r){return Array.isArray(e)?t.call(r,n,R(e).map((function(n,r){return Q(t,n,r,e)}))):$(e)?t.call(r,n,z(e).map((function(n,r){return Q(t,n,r,e)}))):e}function Z(t){return Array.isArray(t)?R(t).map(Z).toList():$(t)?z(t).map(Z).toMap():t}function $(t){return t&&(t.constructor===Object||void 0===t.constructor)}function tt(t){return t>>>1&1073741824|3221225471&t}function et(t){if(t===!1||null===t||void 0===t)return 0;if("function"==typeof t.valueOf&&(t=t.valueOf(),t===!1||null===t||void 0===t))return 0;if(t===!0)return 1;var e=typeof t;if("number"===e){var n=0|t;for(n!==t&&(n^=4294967295*t);t>4294967295;)t/=4294967295,n^=t;return tt(n)}return"string"===e?t.length>Ln?nt(t):rt(t):"function"==typeof t.hashCode?t.hashCode():it(t)}function nt(t){var e=Pn[t];return void 0===e&&(e=rt(t),kn===Nn&&(kn=0,Pn={}),kn++,Pn[t]=e),e}function rt(t){for(var e=0,n=0;n0)switch(t.nodeType){case 1:return t.uniqueID;case 9:return t.documentElement&&t.documentElement.uniqueID}}function ut(t,e){if(!t)throw new Error(e)}function at(t){ut(t!==1/0,"Cannot perform this action with an infinite size.")}function st(t,e){this._iter=t,this._useKeys=e,this.size=t.size}function ct(t){this._iter=t,this.size=t.size}function ft(t){this._iter=t,this.size=t.size}function ht(t){this._iter=t,this.size=t.size}function lt(t){var e=jt(t);return e._iter=t,e.size=t.size,e.flip=function(){return t},e.reverse=function(){var e=t.reverse.apply(this);return e.flip=function(){return t.reverse()},e},e.has=function(e){return t.includes(e)},e.includes=function(e){return t.has(e)},e.cacheResult=Lt,e.__iterateUncached=function(e,n){var r=this;return t.__iterate((function(t,n){return e(n,t,r)!==!1}),n)},e.__iteratorUncached=function(e,n){if(e===bn){var r=t.__iterator(e,n);return new b(function(){var t=r.next();if(!t.done){var e=t.value[0];t.value[0]=t.value[1],t.value[1]=e}return t})}return t.__iterator(e===Sn?gn:Sn,n)},e}function pt(t,e,n){var r=jt(t);return r.size=t.size,r.has=function(e){return t.has(e)},r.get=function(r,i){var o=t.get(r,ln);return o===ln?i:e.call(n,o,r,t)},r.__iterateUncached=function(r,i){var o=this;return t.__iterate((function(t,i,u){return r(e.call(n,t,i,u),i,o)!==!1}),i)},r.__iteratorUncached=function(r,i){var o=t.__iterator(bn,i);return new b(function(){var i=o.next();if(i.done)return i;var u=i.value,a=u[0];return E(r,a,e.call(n,u[1],a,t),i)})},r}function _t(t,e){var n=jt(t);return n._iter=t,n.size=t.size,n.reverse=function(){return t},t.flip&&(n.flip=function(){var e=lt(t);return e.reverse=function(){return t.flip()},e}),n.get=function(n,r){return t.get(e?n:-1-n,r)},n.has=function(n){return t.has(e?n:-1-n)},n.includes=function(e){return t.includes(e)},n.cacheResult=Lt,n.__iterate=function(e,n){var r=this;return t.__iterate((function(t,n){return e(t,n,r)}),!n)},n.__iterator=function(e,n){return t.__iterator(e,!n)},n}function dt(t,e,n,r){var i=jt(t);return r&&(i.has=function(r){var i=t.get(r,ln);return i!==ln&&!!e.call(n,i,r,t)},i.get=function(r,i){var o=t.get(r,ln);return o!==ln&&e.call(n,o,r,t)?o:i}),i.__iterateUncached=function(i,o){var u=this,a=0;return t.__iterate((function(t,o,s){if(e.call(n,t,o,s))return a++,i(t,r?o:a-1,u)}),o),a},i.__iteratorUncached=function(i,o){var u=t.__iterator(bn,o),a=0;return new b(function(){for(;;){var o=u.next();if(o.done)return o;var s=o.value,c=s[0],f=s[1];if(e.call(n,f,c,t))return E(i,r?c:a++,f,o)}})},i}function vt(t,e,n){var r=Pt().asMutable();return t.__iterate((function(i,o){r.update(e.call(n,i,o,t),0,(function(t){return t+1}))})),r.asImmutable()}function yt(t,e,n){var r=y(t),i=(S(t)?Ie():Pt()).asMutable();t.__iterate((function(o,u){i.update(e.call(n,o,u,t),(function(t){return t=t||[],t.push(r?[u,o]:o),t}))}));var o=Mt(t);return i.map((function(e){return Ct(t,o(e))}))}function mt(t,e,n,r){var i=t.size;if(void 0!==e&&(e|=0),void 0!==n&&(n|=0),s(e,n,i))return t;var o=c(e,i),a=f(n,i);if(o!==o||a!==a)return mt(t.toSeq().cacheResult(),e,n,r);var h,l=a-o;l===l&&(h=l<0?0:l);var p=jt(t);return p.size=0===h?h:t.size&&h||void 0,!r&&P(t)&&h>=0&&(p.get=function(e,n){return e=u(this,e),e>=0&&eh)return I();var t=i.next();return r||e===Sn?t:e===gn?E(e,a-1,void 0,t):E(e,a-1,t.value[1],t)})},p}function gt(t,e,n){var r=jt(t);return r.__iterateUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterate(r,i);var u=0;return t.__iterate((function(t,i,a){return e.call(n,t,i,a)&&++u&&r(t,i,o)})),u},r.__iteratorUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterator(r,i);var u=t.__iterator(bn,i),a=!0;return new b(function(){if(!a)return I();var t=u.next();if(t.done)return t;var i=t.value,s=i[0],c=i[1];return e.call(n,c,s,o)?r===bn?t:E(r,s,c,t):(a=!1,I())})},r}function St(t,e,n,r){var i=jt(t);return i.__iterateUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterate(i,o);var a=!0,s=0;return t.__iterate((function(t,o,c){if(!a||!(a=e.call(n,t,o,c)))return s++,i(t,r?o:s-1,u)})),s},i.__iteratorUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterator(i,o);var a=t.__iterator(bn,o),s=!0,c=0;return new b(function(){var t,o,f;do{if(t=a.next(),t.done)return r||i===Sn?t:i===gn?E(i,c++,void 0,t):E(i,c++,t.value[1],t);var h=t.value;o=h[0],f=h[1],s&&(s=e.call(n,f,o,u))}while(s);return i===bn?t:E(i,o,f,t)})},i}function bt(t,e){var n=y(t),r=[t].concat(e).map((function(t){return v(t)?n&&(t=p(t)):t=n?H(t):x(Array.isArray(t)?t:[t]),t})).filter((function(t){return 0!==t.size}));if(0===r.length)return t;if(1===r.length){var i=r[0];if(i===t||n&&y(i)||m(t)&&m(i))return i}var o=new j(r);return n?o=o.toKeyedSeq():m(t)||(o=o.toSetSeq()),o=o.flatten(!0),o.size=r.reduce((function(t,e){if(void 0!==t){var n=e.size;if(void 0!==n)return t+n}}),0),o}function Et(t,e,n){var r=jt(t);return r.__iterateUncached=function(r,i){function o(t,s){var c=this;t.__iterate((function(t,i){return(!e||s0}function Dt(t,e,n){var r=jt(t);return r.size=new j(n).map((function(t){return t.size})).min(),r.__iterate=function(t,e){for(var n,r=this,i=this.__iterator(Sn,e),o=0;!(n=i.next()).done&&t(n.value,o++,r)!==!1;);return o},r.__iteratorUncached=function(t,r){var i=n.map((function(t){return t=l(t),T(r?t.reverse():t)})),o=0,u=!1; -return new b(function(){var n;return u||(n=i.map((function(t){return t.next()})),u=n.some((function(t){return t.done}))),u?I():E(t,o++,e.apply(null,n.map((function(t){return t.value}))))})},r}function Ct(t,e){return P(t)?e:t.constructor(e)}function zt(t){if(t!==Object(t))throw new TypeError("Expected [K, V] tuple: "+t)}function Rt(t){return at(t.size),o(t)}function Mt(t){return y(t)?p:m(t)?_:d}function jt(t){return Object.create((y(t)?z:m(t)?R:M).prototype)}function Lt(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):C.prototype.cacheResult.call(this)}function Nt(t,e){return t>e?1:t>>n)&hn,a=(0===n?r:r>>>n)&hn,s=u===a?[Zt(t,e,n+cn,r,i)]:(o=new Ft(e,r,i),u>>=1)u[a]=1&n?e[o++]:void 0;return u[r]=i,new Vt(t,o+1,u)}function ne(t,e,n){for(var r=[],i=0;i>1&1431655765,t=(858993459&t)+(t>>2&858993459),t=t+(t>>4)&252645135,t+=t>>8,t+=t>>16,127&t}function ae(t,e,n,r){var o=r?t:i(t);return o[e]=n,o}function se(t,e,n,r){var i=t.length+1;if(r&&e+1===i)return t[e]=n,t;for(var o=new Array(i),u=0,a=0;a0&&ro?0:o-n,c=u-n;return c>fn&&(c=fn),function(){if(i===c)return Yn;var t=e?--c:i++;return r&&r[t]}}function i(t,r,i){var a,s=t&&t.array,c=i>o?0:o-i>>r,f=(u-i>>r)+1;return f>fn&&(f=fn),function(){for(;;){if(a){var t=a();if(t!==Yn)return t;a=null}if(c===f)return Yn;var o=e?--f:c++;a=n(s&&s[o],r-cn,i+(o<=t.size||n<0)return t.withMutations((function(t){n<0?Se(t,n).set(0,r):Se(t,0,n+1).set(n,r)}));n+=t._origin;var i=t._tail,o=t._root,a=e(_n);return n>=Ee(t._capacity)?i=ye(i,t.__ownerID,0,n,r,a):o=ye(o,t.__ownerID,t._level,n,r,a),a.value?t.__ownerID?(t._root=o,t._tail=i,t.__hash=void 0,t.__altered=!0,t):_e(t._origin,t._capacity,t._level,o,i):t}function ye(t,e,r,i,o,u){var a=i>>>r&hn,s=t&&a0){var f=t&&t.array[a],h=ye(f,e,r-cn,i,o,u);return h===f?t:(c=me(t,e),c.array[a]=h,c)}return s&&t.array[a]===o?t:(n(u),c=me(t,e),void 0===o&&a===c.array.length-1?c.array.pop():c.array[a]=o,c)}function me(t,e){return e&&t&&e===t.ownerID?t:new le(t?t.array.slice():[],e)}function ge(t,e){if(e>=Ee(t._capacity))return t._tail;if(e<1<0;)n=n.array[e>>>r&hn],r-=cn;return n}}function Se(t,e,n){void 0!==e&&(e|=0),void 0!==n&&(n|=0);var i=t.__ownerID||new r,o=t._origin,u=t._capacity,a=o+e,s=void 0===n?u:n<0?u+n:o+n;if(a===o&&s===u)return t;if(a>=s)return t.clear();for(var c=t._level,f=t._root,h=0;a+h<0;)f=new le(f&&f.array.length?[void 0,f]:[],i),c+=cn,h+=1<=1<l?new le([],i):_;if(_&&p>l&&acn;y-=cn){var m=l>>>y&hn;v=v.array[m]=me(v.array[m],i)}v.array[l>>>cn&hn]=_}if(s=p)a-=p,s-=p,c=cn,f=null,d=d&&d.removeBefore(i,0,a);else if(a>o||p>>c&hn;if(g!==p>>>c&hn)break;g&&(h+=(1<o&&(f=f.removeBefore(i,c,a-h)),f&&pi&&(i=a.size),v(u)||(a=a.map((function(t){return X(t)}))),r.push(a)}return i>t.size&&(t=t.setSize(i)),ie(t,e,r)}function Ee(t){return t>>cn<=fn&&u.size>=2*o.size?(i=u.filter((function(t,e){return void 0!==t&&a!==e})),r=i.toKeyedSeq().map((function(t){return t[0]})).flip().toMap(),t.__ownerID&&(r.__ownerID=i.__ownerID=t.__ownerID)):(r=o.remove(e),i=a===u.size-1?u.pop():u.set(a,void 0))}else if(s){if(n===u.get(a)[1])return t;r=o,i=u.set(a,[e,n])}else r=o.set(e,u.size),i=u.set(u.size,[e,n]);return t.__ownerID?(t.size=r.size,t._map=r,t._list=i,t.__hash=void 0,t):we(r,i)}function De(t){return null===t||void 0===t?Re():Ce(t)?t:Re().unshiftAll(t)}function Ce(t){return!(!t||!t[Wn])}function ze(t,e,n,r){var i=Object.create(Xn);return i.size=t,i._head=e,i.__ownerID=n,i.__hash=r,i.__altered=!1,i}function Re(){return Qn||(Qn=ze(0))}function Me(t){return null===t||void 0===t?ke():je(t)&&!S(t)?t:ke().withMutations((function(e){var n=d(t);at(n.size),n.forEach((function(t){return e.add(t)}))}))}function je(t){return!(!t||!t[Zn])}function Le(t,e){return t.__ownerID?(t.size=e.size,t._map=e,t):e===t._map?t:0===e.size?t.__empty():t.__make(e)}function Ne(t,e){var n=Object.create($n);return n.size=t?t.size:0,n._map=t,n.__ownerID=e,n}function ke(){return tr||(tr=Ne(Jt()))}function Pe(t){return null===t||void 0===t?xe():Ue(t)?t:xe().withMutations((function(e){var n=d(t);at(n.size),n.forEach((function(t){return e.add(t)}))}))}function Ue(t){return je(t)&&S(t)}function He(t,e){var n=Object.create(er);return n.size=t?t.size:0,n._map=t,n.__ownerID=e,n}function xe(){return nr||(nr=He(Te()))}function Ve(t,e){var n,r=function(o){if(o instanceof r)return o;if(!(this instanceof r))return new r(o);if(!n){n=!0;var u=Object.keys(t);Ge(i,u),i.size=u.length,i._name=e,i._keys=u,i._defaultValues=t}this._map=Pt(o)},i=r.prototype=Object.create(rr);return i.constructor=r,r}function qe(t,e,n){var r=Object.create(Object.getPrototypeOf(t));return r._map=e,r.__ownerID=n,r}function Fe(t){return t._name||t.constructor.name||"Record"}function Ge(t,e){try{e.forEach(Ke.bind(void 0,t))}catch(t){}}function Ke(t,e){Object.defineProperty(t,e,{get:function(){return this.get(e)},set:function(t){ut(this.__ownerID,"Cannot set on an immutable record."),this.set(e,t)}})}function Be(t,e){if(t===e)return!0;if(!v(e)||void 0!==t.size&&void 0!==e.size&&t.size!==e.size||void 0!==t.__hash&&void 0!==e.__hash&&t.__hash!==e.__hash||y(t)!==y(e)||m(t)!==m(e)||S(t)!==S(e))return!1;if(0===t.size&&0===e.size)return!0;var n=!g(t);if(S(t)){var r=t.entries();return e.every((function(t,e){var i=r.next().value;return i&&W(i[1],t)&&(n||W(i[0],e))}))&&r.next().done}var i=!1;if(void 0===t.size)if(void 0===e.size)"function"==typeof t.cacheResult&&t.cacheResult();else{i=!0;var o=t;t=e,e=o}var u=!0,a=e.__iterate((function(e,r){if(n?!t.has(e):i?!W(e,t.get(r,ln)):!W(t.get(r,ln),e))return u=!1,!1}));return u&&t.size===a}function Ye(t,e,n){if(!(this instanceof Ye))return new Ye(t,e,n);if(ut(0!==n,"Cannot step a Range by 0"),t=t||0,void 0===e&&(e=1/0),n=void 0===n?1:Math.abs(n),ee?-1:0}function rn(t){if(t.size===1/0)return 0;var e=S(t),n=y(t),r=e?1:0,i=t.__iterate(n?e?function(t,e){r=31*r+un(et(t),et(e))|0}:function(t,e){r=r+un(et(t),et(e))|0}:e?function(t){r=31*r+et(t)|0}:function(t){r=r+et(t)|0});return on(i,r)}function on(t,e){return e=Dn(e,3432918353),e=Dn(e<<15|e>>>-15,461845907),e=Dn(e<<13|e>>>-13,5),e=(e+3864292196|0)^t,e=Dn(e^e>>>16,2246822507),e=Dn(e^e>>>13,3266489909),e=tt(e^e>>>16)}function un(t,e){return t^e+2654435769+(t<<6)+(t>>2)|0}var an=Array.prototype.slice,sn="delete",cn=5,fn=1<r?I():E(t,i,n[e?r-i++:i++])})},t(L,z),L.prototype.get=function(t,e){return void 0===e||this.has(t)?this._object[t]:e},L.prototype.has=function(t){return this._object.hasOwnProperty(t)},L.prototype.__iterate=function(t,e){for(var n=this,r=this._object,i=this._keys,o=i.length-1,u=0;u<=o;u++){var a=i[e?o-u:u];if(t(r[a],a,n)===!1)return u+1}return u},L.prototype.__iterator=function(t,e){var n=this._object,r=this._keys,i=r.length-1,o=0;return new b(function(){var u=r[e?i-o:o];return o++>i?I():E(t,u,n[u])})},L.prototype[mn]=!0,t(N,R),N.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);var r=this._iterable,i=T(r),o=0;if(w(i))for(var u;!(u=i.next()).done&&t(u.value,o++,n)!==!1;);return o},N.prototype.__iteratorUncached=function(t,e){if(e)return this.cacheResult().__iterator(t,e);var n=this._iterable,r=T(n);if(!w(r))return new b(I);var i=0;return new b(function(){var e=r.next();return e.done?e:E(t,i++,e.value)})},t(k,R),k.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);for(var r=this._iterator,i=this._iteratorCache,o=0;o=r.length){var e=n.next();if(e.done)return e;r[i]=e.value}return E(t,i,r[i++])})};var Tn;t(K,l),t(B,K),t(Y,K),t(J,K),K.Keyed=B,K.Indexed=Y,K.Set=J;var An,Dn="function"==typeof Math.imul&&Math.imul(4294967295,2)===-2?Math.imul:function(t,e){t|=0,e|=0;var n=65535&t,r=65535&e;return n*r+((t>>>16)*r+n*(e>>>16)<<16>>>0)|0},Cn=Object.isExtensible,zn=(function(){try{return Object.defineProperty({},"@",{}),!0}catch(t){return!1}})(),Rn="function"==typeof WeakMap;Rn&&(An=new WeakMap);var Mn=0,jn="__immutablehash__";"function"==typeof Symbol&&(jn=Symbol(jn));var Ln=16,Nn=255,kn=0,Pn={};t(st,z),st.prototype.get=function(t,e){return this._iter.get(t,e)},st.prototype.has=function(t){return this._iter.has(t)},st.prototype.valueSeq=function(){return this._iter.valueSeq()},st.prototype.reverse=function(){var t=this,e=_t(this,!0);return this._useKeys||(e.valueSeq=function(){return t._iter.toSeq().reverse()}),e},st.prototype.map=function(t,e){var n=this,r=pt(this,t,e);return this._useKeys||(r.valueSeq=function(){return n._iter.toSeq().map(t,e)}),r},st.prototype.__iterate=function(t,e){var n,r=this;return this._iter.__iterate(this._useKeys?function(e,n){return t(e,n,r)}:(n=e?Rt(this):0,function(i){return t(i,e?--n:n++,r)}),e)},st.prototype.__iterator=function(t,e){if(this._useKeys)return this._iter.__iterator(t,e);var n=this._iter.__iterator(Sn,e),r=e?Rt(this):0;return new b(function(){var i=n.next();return i.done?i:E(t,e?--r:r++,i.value,i)})},st.prototype[mn]=!0,t(ct,R),ct.prototype.includes=function(t){return this._iter.includes(t)},ct.prototype.__iterate=function(t,e){var n=this,r=0;return this._iter.__iterate((function(e){return t(e,r++,n)}),e)},ct.prototype.__iterator=function(t,e){var n=this._iter.__iterator(Sn,e),r=0;return new b(function(){var e=n.next();return e.done?e:E(t,r++,e.value,e)})},t(ft,M),ft.prototype.has=function(t){return this._iter.includes(t)},ft.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate((function(e){return t(e,e,n)}),e)},ft.prototype.__iterator=function(t,e){var n=this._iter.__iterator(Sn,e);return new b(function(){var e=n.next();return e.done?e:E(t,e.value,e.value,e)})},t(ht,z),ht.prototype.entrySeq=function(){return this._iter.toSeq()},ht.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate((function(e){if(e){zt(e);var r=v(e);return t(r?e.get(1):e[1],r?e.get(0):e[0],n)}}),e)},ht.prototype.__iterator=function(t,e){var n=this._iter.__iterator(Sn,e);return new b(function(){for(;;){var e=n.next();if(e.done)return e;var r=e.value;if(r){zt(r);var i=v(r);return E(t,i?r.get(0):r[0],i?r.get(1):r[1],e)}}})},ct.prototype.cacheResult=st.prototype.cacheResult=ft.prototype.cacheResult=ht.prototype.cacheResult=Lt,t(Pt,B),Pt.prototype.toString=function(){return this.__toString("Map {","}")},Pt.prototype.get=function(t,e){return this._root?this._root.get(0,void 0,t,e):e},Pt.prototype.set=function(t,e){return Wt(this,t,e)},Pt.prototype.setIn=function(t,e){return this.updateIn(t,ln,(function(){return e}))},Pt.prototype.remove=function(t){return Wt(this,t,ln)},Pt.prototype.deleteIn=function(t){return this.updateIn(t,(function(){return ln}))},Pt.prototype.update=function(t,e,n){return 1===arguments.length?t(this):this.updateIn([t],e,n)},Pt.prototype.updateIn=function(t,e,n){n||(n=e,e=void 0);var r=oe(this,kt(t),e,n);return r===ln?void 0:r},Pt.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):Jt()},Pt.prototype.merge=function(){return ne(this,void 0,arguments)},Pt.prototype.mergeWith=function(t){var e=an.call(arguments,1);return ne(this,t,e)},Pt.prototype.mergeIn=function(t){var e=an.call(arguments,1);return this.updateIn(t,Jt(),(function(t){return"function"==typeof t.merge?t.merge.apply(t,e):e[e.length-1]}))},Pt.prototype.mergeDeep=function(){return ne(this,re(void 0),arguments)},Pt.prototype.mergeDeepWith=function(t){var e=an.call(arguments,1);return ne(this,re(t),e)},Pt.prototype.mergeDeepIn=function(t){var e=an.call(arguments,1);return this.updateIn(t,Jt(),(function(t){return"function"==typeof t.mergeDeep?t.mergeDeep.apply(t,e):e[e.length-1]}))},Pt.prototype.sort=function(t){return Ie(wt(this,t))},Pt.prototype.sortBy=function(t,e){return Ie(wt(this,e,t))},Pt.prototype.withMutations=function(t){var e=this.asMutable();return t(e),e.wasAltered()?e.__ensureOwner(this.__ownerID):this},Pt.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new r)},Pt.prototype.asImmutable=function(){return this.__ensureOwner()},Pt.prototype.wasAltered=function(){return this.__altered},Pt.prototype.__iterator=function(t,e){return new Gt(this,t,e)},Pt.prototype.__iterate=function(t,e){var n=this,r=0;return this._root&&this._root.iterate((function(e){return r++,t(e[1],e[0],n)}),e),r},Pt.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Yt(this.size,this._root,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},Pt.isMap=Ut;var Un="@@__IMMUTABLE_MAP__@@",Hn=Pt.prototype;Hn[Un]=!0,Hn[sn]=Hn.remove,Hn.removeIn=Hn.deleteIn,Ht.prototype.get=function(t,e,n,r){for(var i=this.entries,o=0,u=i.length;o=Vn)return $t(t,f,o,u);var _=t&&t===this.ownerID,d=_?f:i(f);return p?c?h===l-1?d.pop():d[h]=d.pop():d[h]=[o,u]:d.push([o,u]),_?(this.entries=d,this):new Ht(t,d)}},xt.prototype.get=function(t,e,n,r){void 0===e&&(e=et(n));var i=1<<((0===t?e:e>>>t)&hn),o=this.bitmap;return 0===(o&i)?r:this.nodes[ue(o&i-1)].get(t+cn,e,n,r)},xt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=et(r));var a=(0===e?n:n>>>e)&hn,s=1<=qn)return ee(t,l,c,a,_);if(f&&!_&&2===l.length&&Qt(l[1^h]))return l[1^h];if(f&&_&&1===l.length&&Qt(_))return _;var d=t&&t===this.ownerID,v=f?_?c:c^s:c|s,y=f?_?ae(l,h,_,d):ce(l,h,d):se(l,h,_,d);return d?(this.bitmap=v,this.nodes=y,this):new xt(t,v,y)},Vt.prototype.get=function(t,e,n,r){void 0===e&&(e=et(n));var i=(0===t?e:e>>>t)&hn,o=this.nodes[i];return o?o.get(t+cn,e,n,r):r},Vt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=et(r));var a=(0===e?n:n>>>e)&hn,s=i===ln,c=this.nodes,f=c[a];if(s&&!f)return this;var h=Xt(f,t,e+cn,n,r,i,o,u);if(h===f)return this;var l=this.count;if(f){if(!h&&(l--,l=0&&t>>e&hn;if(r>=this.array.length)return new le([],t);var i,o=0===r;if(e>0){var u=this.array[r];if(i=u&&u.removeBefore(t,e-cn,n),i===u&&o)return this}if(o&&!i)return this;var a=me(this,t);if(!o)for(var s=0;s>>e&hn;if(r>=this.array.length)return this;var i;if(e>0){var o=this.array[r];if(i=o&&o.removeAfter(t,e-cn,n),i===o&&r===this.array.length-1)return this}var u=me(this,t);return u.array.splice(r+1),i&&(u.array[r]=i),u};var Bn,Yn={};t(Ie,Pt),Ie.of=function(){return this(arguments)},Ie.prototype.toString=function(){return this.__toString("OrderedMap {","}")},Ie.prototype.get=function(t,e){var n=this._map.get(t);return void 0!==n?this._list.get(n)[1]:e},Ie.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):Te()},Ie.prototype.set=function(t,e){return Ae(this,t,e)},Ie.prototype.remove=function(t){return Ae(this,t,ln)},Ie.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},Ie.prototype.__iterate=function(t,e){var n=this;return this._list.__iterate((function(e){return e&&t(e[1],e[0],n)}),e)},Ie.prototype.__iterator=function(t,e){return this._list.fromEntrySeq().__iterator(t,e)},Ie.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map.__ensureOwner(t),n=this._list.__ensureOwner(t);return t?we(e,n,t,this.__hash):(this.__ownerID=t,this._map=e,this._list=n,this)},Ie.isOrderedMap=Oe,Ie.prototype[mn]=!0,Ie.prototype[sn]=Ie.prototype.remove;var Jn;t(De,Y),De.of=function(){return this(arguments)},De.prototype.toString=function(){return this.__toString("Stack [","]")},De.prototype.get=function(t,e){var n=this._head;for(t=u(this,t);n&&t--;)n=n.next;return n?n.value:e},De.prototype.peek=function(){return this._head&&this._head.value},De.prototype.push=function(){var t=arguments;if(0===arguments.length)return this;for(var e=this.size+arguments.length,n=this._head,r=arguments.length-1;r>=0;r--)n={value:t[r],next:n};return this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):ze(e,n)},De.prototype.pushAll=function(t){if(t=_(t),0===t.size)return this;at(t.size);var e=this.size,n=this._head;return t.reverse().forEach((function(t){e++,n={value:t,next:n}})),this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):ze(e,n)},De.prototype.pop=function(){return this.slice(1)},De.prototype.unshift=function(){return this.push.apply(this,arguments)},De.prototype.unshiftAll=function(t){return this.pushAll(t)},De.prototype.shift=function(){return this.pop.apply(this,arguments)},De.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):Re()},De.prototype.slice=function(t,e){if(s(t,e,this.size))return this;var n=c(t,this.size),r=f(e,this.size);if(r!==this.size)return Y.prototype.slice.call(this,t,e);for(var i=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=i,this._head=o,this.__hash=void 0,this.__altered=!0,this):ze(i,o)},De.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?ze(this.size,this._head,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},De.prototype.__iterate=function(t,e){var n=this;if(e)return this.reverse().__iterate(t);for(var r=0,i=this._head;i&&t(i.value,r++,n)!==!1;)i=i.next;return r},De.prototype.__iterator=function(t,e){if(e)return this.reverse().__iterator(t);var n=0,r=this._head;return new b(function(){if(r){var e=r.value;return r=r.next,E(t,n++,e)}return I()})},De.isStack=Ce;var Wn="@@__IMMUTABLE_STACK__@@",Xn=De.prototype;Xn[Wn]=!0,Xn.withMutations=Hn.withMutations,Xn.asMutable=Hn.asMutable,Xn.asImmutable=Hn.asImmutable,Xn.wasAltered=Hn.wasAltered;var Qn;t(Me,J),Me.of=function(){return this(arguments)},Me.fromKeys=function(t){return this(p(t).keySeq())},Me.prototype.toString=function(){return this.__toString("Set {","}")},Me.prototype.has=function(t){return this._map.has(t)},Me.prototype.add=function(t){return Le(this,this._map.set(t,!0))},Me.prototype.remove=function(t){return Le(this,this._map.remove(t))},Me.prototype.clear=function(){return Le(this,this._map.clear())},Me.prototype.union=function(){var t=an.call(arguments,0);return t=t.filter((function(t){return 0!==t.size})),0===t.length?this:0!==this.size||this.__ownerID||1!==t.length?this.withMutations((function(e){for(var n=0;n1?" by "+this._step:"")+" ]"},Ye.prototype.get=function(t,e){return this.has(t)?this._start+u(this,t)*this._step:e},Ye.prototype.includes=function(t){var e=(t-this._start)/this._step;return e>=0&&e=0&&nn?I():E(t,o++,u)})},Ye.prototype.equals=function(t){return t instanceof Ye?this._start===t._start&&this._end===t._end&&this._step===t._step:Be(this,t)};var ir;t(Je,R),Je.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Je.prototype.get=function(t,e){return this.has(t)?this._value:e},Je.prototype.includes=function(t){return W(this._value,t)},Je.prototype.slice=function(t,e){var n=this.size;return s(t,e,n)?this:new Je(this._value,f(e,n)-c(t,n))},Je.prototype.reverse=function(){return this},Je.prototype.indexOf=function(t){return W(this._value,t)?0:-1},Je.prototype.lastIndexOf=function(t){return W(this._value,t)?this.size:-1},Je.prototype.__iterate=function(t,e){for(var n=this,r=0;rthis.size?e:this.find((function(e,n){return n===t}),void 0,e)},has:function(t){return t=u(this,t),t>=0&&(void 0!==this.size?this.size===1/0||t-1&&t%1===0&&t<=Number.MAX_VALUE}var i=Function.prototype.bind;e.isString=function(t){return"string"==typeof t||"[object String]"===n(t)},e.isArray=Array.isArray||function(t){return"[object Array]"===n(t)},"function"!=typeof/./&&"object"!=typeof Int8Array?e.isFunction=function(t){return"function"==typeof t||!1}:e.isFunction=function(t){return"[object Function]"===toString.call(t)},e.isObject=function(t){var e=typeof t;return"function"===e||"object"===e&&!!t},e.extend=function(t){var e=arguments,n=arguments.length;if(!t||n<2)return t||{};for(var r=1;r0)){var e=this.reactorState.get("dirtyStores");if(0!==e.size){var n=c.default.Set().withMutations((function(n){n.union(t.observerState.get("any")),e.forEach((function(e){var r=t.observerState.getIn(["stores",e]);r&&n.union(r)}))}));n.forEach((function(e){var n=t.observerState.getIn(["observersMap",e]);if(n){var r=n.get("getter"),i=n.get("handler"),o=p.evaluate(t.prevReactorState,r),u=p.evaluate(t.reactorState,r);t.prevReactorState=o.reactorState,t.reactorState=u.reactorState;var a=o.result,s=u.result;c.default.is(a,s)||i.call(null,s)}}));var r=p.resetDirtyStores(this.reactorState);this.prevReactorState=r,this.reactorState=r}}}},{key:"batchStart",value:function(){this.__batchDepth++}},{key:"batchEnd",value:function(){if(this.__batchDepth--,this.__batchDepth<=0){this.__isDispatching=!0;try{this.__notify()}catch(t){throw this.__isDispatching=!1,t}this.__isDispatching=!1}}}]),t})();e.default=(0,y.toFactory)(g),t.exports=e.default},function(t,e,n){function r(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i(t,e){var n={};return(0,o.each)(e,(function(e,r){n[r]=t.evaluate(e)})),n}Object.defineProperty(e,"__esModule",{value:!0});var o=n(4);e.default=function(t){return{getInitialState:function(){return i(t,this.getDataBindings())},componentDidMount:function(){var e=this;this.__unwatchFns=[],(0,o.each)(this.getDataBindings(),(function(n,i){var o=t.observe(n,(function(t){e.setState(r({},i,t))}));e.__unwatchFns.push(o)}))},componentWillUnmount:function(){for(var t=this;this.__unwatchFns.length;)t.__unwatchFns.shift()()}}},t.exports=e.default},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t,e){return new M({result:t,reactorState:e})}function o(t,e){return t.withMutations((function(t){(0,R.each)(e,(function(e,n){t.getIn(["stores",n])&&console.warn("Store already defined for id = "+n);var r=e.getInitialState();if(void 0===r&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store getInitialState() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,D.isImmutableValue)(r))throw new Error("Store getInitialState() must return an immutable value, did you forget to call toImmutable");t.update("stores",(function(t){return t.set(n,e)})).update("state",(function(t){return t.set(n,r)})).update("dirtyStores",(function(t){return t.add(n)})).update("storeStates",(function(t){return I(t,[n])}))})),E(t)}))}function u(t,e){return t.withMutations((function(t){(0,R.each)(e,(function(e,n){t.update("stores",(function(t){return t.set(n,e)}))}))}))}function a(t,e,n){if(void 0===e&&f(t,"throwOnUndefinedActionType"))throw new Error("`dispatch` cannot be called with an `undefined` action type.");var r=t.get("state"),i=t.get("dirtyStores"),o=r.withMutations((function(r){A.default.dispatchStart(t,e,n),t.get("stores").forEach((function(o,u){var a=r.get(u),s=void 0;try{s=o.handle(a,e,n)}catch(e){throw A.default.dispatchError(t,e.message),e}if(void 0===s&&f(t,"throwOnUndefinedStoreReturnValue")){var c="Store handler must return a value, did you forget a return statement";throw A.default.dispatchError(t,c),new Error(c)}r.set(u,s),a!==s&&(i=i.add(u))})),A.default.dispatchEnd(t,r,i)})),u=t.set("state",o).set("dirtyStores",i).update("storeStates",(function(t){return I(t,i)}));return E(u)}function s(t,e){var n=[],r=(0,D.toImmutable)({}).withMutations((function(r){(0,R.each)(e,(function(e,i){var o=t.getIn(["stores",i]);if(o){var u=o.deserialize(e);void 0!==u&&(r.set(i,u),n.push(i))}}))})),i=w.default.Set(n);return t.update("state",(function(t){return t.merge(r)})).update("dirtyStores",(function(t){return t.union(i)})).update("storeStates",(function(t){return I(t,n)}))}function c(t,e,n){var r=e;(0,z.isKeyPath)(e)&&(e=(0,C.fromKeyPath)(e));var i=t.get("nextId"),o=(0,C.getStoreDeps)(e),u=w.default.Map({id:i,storeDeps:o,getterKey:r,getter:e,handler:n}),a=void 0;return a=0===o.size?t.update("any",(function(t){return t.add(i)})):t.withMutations((function(t){o.forEach((function(e){var n=["stores",e];t.hasIn(n)||t.setIn(n,w.default.Set()),t.updateIn(["stores",e],(function(t){return t.add(i)}))}))})),a=a.set("nextId",i+1).setIn(["observersMap",i],u),{observerState:a,entry:u}}function f(t,e){var n=t.getIn(["options",e]);if(void 0===n)throw new Error("Invalid option: "+e);return n}function h(t,e,n){var r=t.get("observersMap").filter((function(t){var r=t.get("getterKey"),i=!n||t.get("handler")===n;return!!i&&((0,z.isKeyPath)(e)&&(0,z.isKeyPath)(r)?(0,z.isEqual)(e,r):e===r)}));return t.withMutations((function(t){r.forEach((function(e){return l(t,e)}))}))}function l(t,e){return t.withMutations((function(t){var n=e.get("id"),r=e.get("storeDeps");0===r.size?t.update("any",(function(t){return t.remove(n)})):r.forEach((function(e){t.updateIn(["stores",e],(function(t){return t?t.remove(n):t}))})),t.removeIn(["observersMap",n])}))}function p(t){var e=t.get("state");return t.withMutations((function(t){var n=t.get("stores"),r=n.keySeq().toJS();n.forEach((function(n,r){var i=e.get(r),o=n.handleReset(i);if(void 0===o&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store handleReset() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,D.isImmutableValue)(o))throw new Error("Store reset state must be an immutable value, did you forget to call toImmutable");t.setIn(["state",r],o)})),t.update("storeStates",(function(t){return I(t,r)})),v(t)}))}function _(t,e){var n=t.get("state");if((0,z.isKeyPath)(e))return i(n.getIn(e),t);if(!(0,C.isGetter)(e))throw new Error("evaluate must be passed a keyPath or Getter");if(g(t,e))return i(b(t,e),t);var r=(0,C.getDeps)(e).map((function(e){return _(t,e).result})),o=(0,C.getComputeFn)(e).apply(null,r);return i(o,S(t,e,o))}function d(t){var e={};return t.get("stores").forEach((function(n,r){var i=t.getIn(["state",r]),o=n.serialize(i);void 0!==o&&(e[r]=o)})),e}function v(t){return t.set("dirtyStores",w.default.Set())}function y(t){return t}function m(t,e){var n=y(e);return t.getIn(["cache",n])}function g(t,e){var n=m(t,e);if(!n)return!1;var r=n.get("storeStates");return 0!==r.size&&r.every((function(e,n){return t.getIn(["storeStates",n])===e}))}function S(t,e,n){var r=y(e),i=t.get("dispatchId"),o=(0,C.getStoreDeps)(e),u=(0,D.toImmutable)({}).withMutations((function(e){o.forEach((function(n){var r=t.getIn(["storeStates",n]);e.set(n,r)}))}));return t.setIn(["cache",r],w.default.Map({value:n,storeStates:u,dispatchId:i}))}function b(t,e){var n=y(e);return t.getIn(["cache",n,"value"])}function E(t){return t.update("dispatchId",(function(t){return t+1}))}function I(t,e){return t.withMutations((function(t){e.forEach((function(e){var n=t.has(e)?t.get(e)+1:1;t.set(e,n)}))}))}Object.defineProperty(e,"__esModule",{value:!0}),e.registerStores=o,e.replaceStores=u,e.dispatch=a,e.loadState=s,e.addObserver=c,e.getOption=f,e.removeObserver=h,e.removeObserverByEntry=l,e.reset=p,e.evaluate=_,e.serialize=d,e.resetDirtyStores=v;var O=n(3),w=r(O),T=n(9),A=r(T),D=n(5),C=n(10),z=n(11),R=n(4),M=w.default.Record({result:null,reactorState:null})},function(t,e,n){var r=n(8);e.dispatchStart=function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.groupCollapsed("Dispatch: %s",e),console.group("payload"),console.debug(n),console.groupEnd())},e.dispatchError=function(t,e){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.debug("Dispatch error: "+e),console.groupEnd())},e.dispatchEnd=function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&((0,r.getOption)(t,"logDirtyStores")&&console.log("Stores updated:",n.toList().toJS()),(0,r.getOption)(t,"logAppState")&&console.debug("Dispatch done, new state: ",e.toJS()),console.groupEnd())}},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,l.isArray)(t)&&(0,l.isFunction)(t[t.length-1])}function o(t){return t[t.length-1]}function u(t){return t.slice(0,t.length-1)}function a(t,e){e||(e=h.default.Set());var n=h.default.Set().withMutations((function(e){if(!i(t))throw new Error("getFlattenedDeps must be passed a Getter");u(t).forEach((function(t){if((0,p.isKeyPath)(t))e.add((0,f.List)(t));else{if(!i(t))throw new Error("Invalid getter, each dependency must be a KeyPath or Getter");e.union(a(t))}}))}));return e.union(n)}function s(t){if(!(0,p.isKeyPath)(t))throw new Error("Cannot create Getter from KeyPath: "+t);return[t,_]}function c(t){if(t.hasOwnProperty("__storeDeps"))return t.__storeDeps;var e=a(t).map((function(t){return t.first()})).filter((function(t){return!!t}));return Object.defineProperty(t,"__storeDeps",{enumerable:!1,configurable:!1,writable:!1,value:e}),e}Object.defineProperty(e,"__esModule",{value:!0});var f=n(3),h=r(f),l=n(4),p=n(11),_=function(t){return t};e.default={isGetter:i,getComputeFn:o,getFlattenedDeps:a,getStoreDeps:c,getDeps:u,fromKeyPath:s},t.exports=e.default},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,s.isArray)(t)&&!(0,s.isFunction)(t[t.length-1])}function o(t,e){var n=a.default.List(t),r=a.default.List(e);return a.default.is(n,r)}Object.defineProperty(e,"__esModule",{value:!0}),e.isKeyPath=i,e.isEqual=o;var u=n(3),a=r(u),s=n(4)},function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(3),i=(0,r.Map)({logDispatches:!1,logAppState:!1,logDirtyStores:!1,throwOnUndefinedActionType:!1,throwOnUndefinedStoreReturnValue:!1,throwOnNonImmutableStore:!1,throwOnDispatchInDispatch:!1});e.PROD_OPTIONS=i;var o=(0,r.Map)({logDispatches:!0,logAppState:!0,logDirtyStores:!0,throwOnUndefinedActionType:!0,throwOnUndefinedStoreReturnValue:!0,throwOnNonImmutableStore:!0,throwOnDispatchInDispatch:!0});e.DEBUG_OPTIONS=o;var u=(0,r.Record)({dispatchId:0,state:(0,r.Map)(),stores:(0,r.Map)(),cache:(0,r.Map)(),storeStates:(0,r.Map)(),dirtyStores:(0,r.Set)(),debug:!1,options:i});e.ReactorState=u;var a=(0,r.Record)({any:(0,r.Set)(),stores:(0,r.Map)({}),observersMap:(0,r.Map)({}),nextId:1});e.ObserverState=a}])}))})),Ne=t(Le),ke=function(t){var e,n={};if(!(t instanceof Object)||Array.isArray(t))throw new Error("keyMirror(...): Argument must be an object.");for(e in t)t.hasOwnProperty(e)&&(n[e]=e);return n},Pe=ke,Ue=Pe({VALIDATING_AUTH_TOKEN:null,VALID_AUTH_TOKEN:null,INVALID_AUTH_TOKEN:null,LOG_OUT:null}),He=Ne.Store,xe=Ne.toImmutable,Ve=new He({getInitialState:function(){return xe({isValidating:!1,authToken:!1,host:null,isInvalid:!1,errorMessage:""})},initialize:function(){this.on(Ue.VALIDATING_AUTH_TOKEN,n),this.on(Ue.VALID_AUTH_TOKEN,r),this.on(Ue.INVALID_AUTH_TOKEN,i)}}),qe=Ne.Store,Fe=Ne.toImmutable,Ge=new qe({getInitialState:function(){return Fe({authToken:null,host:""})},initialize:function(){this.on(Ue.VALID_AUTH_TOKEN,o),this.on(Ue.LOG_OUT,u)}}),Ke=Ne.Store,Be=new Ke({getInitialState:function(){return!0},initialize:function(){this.on(Ue.VALID_AUTH_TOKEN,a)}}),Ye=Pe({STREAM_START:null,STREAM_STOP:null,STREAM_ERROR:null}),Je=Ne.Store,We=Ne.toImmutable,Xe=new Je({getInitialState:function(){return We({isStreaming:!1,hasError:!1})},initialize:function(){this.on(Ye.STREAM_START,s),this.on(Ye.STREAM_ERROR,c),this.on(Ye.LOG_OUT,f)}}),Qe=1,Ze=2,$e=3,tn=function(t,e){this.url=t,this.options=e||{},this.commandId=1,this.commands={},this.connectionTries=0,this.eventListeners={},this.closeRequested=!1};tn.prototype.addEventListener=function(t,e){var n=this.eventListeners[t];n||(n=this.eventListeners[t]=[]),n.push(e)},tn.prototype.fireEvent=function(t){var e=this;(this.eventListeners[t]||[]).forEach((function(t){return t(e)}))},tn.prototype.connect=function(){var t=this;return new Promise(function(e,n){var r=t.commands;Object.keys(r).forEach((function(t){var e=r[t];e.reject&&e.reject(S($e,"Connection lost"))}));var i=!1;t.connectionTries+=1,t.socket=new WebSocket(t.url),t.socket.addEventListener("open",(function(){t.connectionTries=0})),t.socket.addEventListener("message",(function(o){var u=JSON.parse(o.data);switch(u.type){case"event":t.commands[u.id].eventCallback(u.event);break;case"result":u.success?t.commands[u.id].resolve(u):t.commands[u.id].reject(u.error),delete t.commands[u.id];break;case"pong":break;case"auth_required":t.sendMessage(h(t.options.authToken));break;case"auth_invalid":n(Ze),i=!0;break;case"auth_ok":e(t),t.fireEvent("ready"),t.commandId=1,t.commands={},Object.keys(r).forEach((function(e){var n=r[e];n.eventType&&t.subscribeEvents(n.eventCallback,n.eventType).then((function(t){n.unsubscribe=t}))}))}})),t.socket.addEventListener("close",(function(){if(!i&&!t.closeRequested){0===t.connectionTries?t.fireEvent("disconnected"):n(Qe);var e=1e3*Math.min(t.connectionTries,5);setTimeout((function(){return t.connect()}),e)}}))})},tn.prototype.close=function(){this.closeRequested=!0,this.socket.close()},tn.prototype.getStates=function(){return this.sendMessagePromise(l()).then(b)},tn.prototype.getServices=function(){return this.sendMessagePromise(_()).then(b)},tn.prototype.getPanels=function(){return this.sendMessagePromise(d()).then(b)},tn.prototype.getConfig=function(){return this.sendMessagePromise(p()).then(b)},tn.prototype.callService=function(t,e,n){return this.sendMessagePromise(v(t,e,n))},tn.prototype.subscribeEvents=function(t,e){var n=this;return this.sendMessagePromise(y(e)).then((function(r){var i={eventCallback:t,eventType:e,unsubscribe:function(){return n.sendMessagePromise(m(r.id)).then((function(){delete n.commands[r.id]}))}};return n.commands[r.id]=i,function(){return i.unsubscribe()}}))},tn.prototype.ping=function(){return this.sendMessagePromise(g())},tn.prototype.sendMessage=function(t){this.socket.send(JSON.stringify(t))},tn.prototype.sendMessagePromise=function(t){var e=this;return new Promise(function(n,r){e.commandId+=1;var i=e.commandId;t.id=i,e.commands[i]={resolve:n,reject:r},e.sendMessage(t)})};var en=Pe({API_FETCH_ALL_START:null,API_FETCH_ALL_SUCCESS:null,API_FETCH_ALL_FAIL:null,SYNC_SCHEDULED:null,SYNC_SCHEDULE_CANCELLED:null}),nn=Ne.Store,rn=new nn({getInitialState:function(){return!0},initialize:function(){this.on(en.API_FETCH_ALL_START,(function(){return!0})),this.on(en.API_FETCH_ALL_SUCCESS,(function(){return!1})),this.on(en.API_FETCH_ALL_FAIL,(function(){return!1})),this.on(en.LOG_OUT,(function(){return!1}))}}),on=I,un=Pe({API_FETCH_SUCCESS:null,API_FETCH_START:null,API_FETCH_FAIL:null,API_SAVE_SUCCESS:null,API_SAVE_START:null,API_SAVE_FAIL:null,API_DELETE_SUCCESS:null,API_DELETE_START:null,API_DELETE_FAIL:null,LOG_OUT:null}),an=Ne.Store,sn=Ne.toImmutable,cn=new an({getInitialState:function(){return sn({})},initialize:function(){var t=this;this.on(un.API_FETCH_SUCCESS,O),this.on(un.API_SAVE_SUCCESS,O),this.on(un.API_DELETE_SUCCESS,w),this.on(un.LOG_OUT,(function(){return t.getInitialState()}))}}),fn=Object.prototype.hasOwnProperty,hn=Object.prototype.propertyIsEnumerable,ln=A()?Object.assign:function(t,e){for(var n,r,i=arguments,o=T(t),u=1;u199&&u.status<300?t(e):n(e)},u.onerror=function(){return n({})},r?(u.setRequestHeader("Content-Type","application/json;charset=UTF-8"),u.send(JSON.stringify(r))):u.send()})}function O(t,e){var n=e.model,r=e.result,i=e.params,o=n.entity;if(!r)return t;var u=i.replace?sn({}):t.get(o),a=Array.isArray(r)?r:[r],s=n.fromJSON||sn;return t.set(o,u.withMutations((function(t){for(var e=0;e6e4}function gt(t,e){var n=e.date;return n.toISOString()}function mt(){return Zr.getInitialState()}function St(t,e){var n=e.date,r=e.stateHistory;return 0===r.length?t.set(n,ti({})):t.withMutations((function(t){r.forEach((function(e){return t.setIn([n,e[0].entity_id],ti(e.map(On.fromJSON)))}))}))}function Et(){return ei.getInitialState()}function bt(t,e){var n=e.stateHistory;return t.withMutations((function(t){n.forEach((function(e){return t.set(e[0].entity_id,oi(e.map(On.fromJSON)))}))}))}function It(){return ui.getInitialState()}function Ot(t,e){var n=e.stateHistory,r=(new Date).getTime();return t.withMutations((function(t){n.forEach((function(e){return t.set(e[0].entity_id,r)})),history.length>1&&t.set(ci,r)}))}function wt(){return fi.getInitialState()}function Tt(t,e){t.dispatch(Xr.ENTITY_HISTORY_DATE_SELECTED,{date:e})}function At(t,e){void 0===e&&(e=null),t.dispatch(Xr.RECENT_ENTITY_HISTORY_FETCH_START,{});var n="history/period";return null!==e&&(n+="?filter_entity_id="+e),on(t,"GET",n).then((function(e){return t.dispatch(Xr.RECENT_ENTITY_HISTORY_FETCH_SUCCESS,{stateHistory:e})}),(function(){return t.dispatch(Xr.RECENT_ENTITY_HISTORY_FETCH_ERROR,{})}))}function Ct(t,e){return t.dispatch(Xr.ENTITY_HISTORY_FETCH_START,{date:e}),on(t,"GET","history/period/"+e).then((function(n){return t.dispatch(Xr.ENTITY_HISTORY_FETCH_SUCCESS,{date:e,stateHistory:n})}),(function(){return t.dispatch(Xr.ENTITY_HISTORY_FETCH_ERROR,{})}))}function Dt(t){var e=t.evaluate(pi);return Ct(t,e)}function zt(t){t.registerStores({currentEntityHistoryDate:Zr,entityHistory:ei,isLoadingEntityHistory:ri,recentEntityHistory:ui,recentEntityHistoryUpdated:fi})}function Rt(t){t.registerStores({moreInfoEntityId:Jr})}function Mt(t,e){var n=e.model,r=e.result,i=e.params;if(null===t||"entity"!==n.entity||!i.replace)return t;for(var o=0;o0?i=setTimeout(r,e-c):(i=null,n||(s=t.apply(u,o),i||(u=o=null)))}var i,o,u,a,s;null==e&&(e=100);var c=function(){u=this,o=arguments,a=(new Date).getTime();var c=n&&!i;return i||(i=setTimeout(r,e)),c&&(s=t.apply(u,o),u=o=null),s};return c.clear=function(){i&&(clearTimeout(i),i=null)},c}function Yt(t){var e=ho[t.hassId];e&&(e.scheduleHealthCheck.clear(),e.conn.close(),ho[t.hassId]=!1)}function Jt(t,e){void 0===e&&(e={});var n=e.syncOnInitialConnect;void 0===n&&(n=!0),Yt(t);var r=t.evaluate(Lo.authToken),i="https:"===document.location.protocol?"wss://":"ws://";i+=document.location.hostname,document.location.port&&(i+=":"+document.location.port),i+="/api/websocket",b(i,{authToken:r}).then((function(e){var r=Bt((function(){return e.ping()}),co);r(),e.socket.addEventListener("message",r),ho[t.hassId]={conn:e,scheduleHealthCheck:r},fo.forEach((function(n){return e.subscribeEvents(so.bind(null,t),n)})),t.batch((function(){t.dispatch(Ye.STREAM_START),n&&oo.fetchAll(t)})),e.addEventListener("disconnected",(function(){t.dispatch(Ye.STREAM_ERROR)})),e.addEventListener("ready",(function(){t.batch((function(){t.dispatch(Ye.STREAM_START),oo.fetchAll(t)}))}))}))}function Wt(t){t.registerStores({streamStatus:Xe})}function Xt(t,e,n){void 0===n&&(n={});var r=n.rememberAuth;void 0===r&&(r=!1);var i=n.host;void 0===i&&(i=""),t.dispatch(Ue.VALIDATING_AUTH_TOKEN,{authToken:e,host:i}),oo.fetchAll(t).then((function(){t.dispatch(Ue.VALID_AUTH_TOKEN,{authToken:e,host:i,rememberAuth:r}),yo.start(t,{syncOnInitialConnect:!1})}),(function(e){void 0===e&&(e={});var n=e.message;void 0===n&&(n=So),t.dispatch(Ue.INVALID_AUTH_TOKEN,{errorMessage:n})}))}function Qt(t){t.dispatch(Ue.LOG_OUT,{})}function Zt(t){t.registerStores({authAttempt:Ve,authCurrent:Ge,rememberAuth:Be})}function $t(){if(!("localStorage"in window))return{};var t=window.localStorage,e="___test";try{return t.setItem(e,e),t.removeItem(e),t}catch(t){return{}}}function te(){var t=new Ho({debug:!1});return t.hassId=xo++,t}function ee(t,e,n){Object.keys(n).forEach((function(r){var i=n[r];if("register"in i&&i.register(e),"getters"in i&&Object.defineProperty(t,r+"Getters",{value:i.getters,enumerable:!0}),"actions"in i){var o={};Object.getOwnPropertyNames(i.actions).forEach((function(t){"function"==typeof i.actions[t]&&Object.defineProperty(o,t,{value:i.actions[t].bind(null,e),enumerable:!0})})),Object.defineProperty(t,r+"Actions",{value:o,enumerable:!0})}}))}function ne(t,e){return Vo(t.attributes.entity_id.map((function(t){return e.get(t)})).filter((function(t){return!!t})))}function re(t){return on(t,"GET","error_log")}function ie(t,e){var n=e.date;return n.toISOString()}function oe(){return Wo.getInitialState()}function ue(t,e){var n=e.date,r=e.entries;return t.set(n,nu(r.map(tu.fromJSON)))}function ae(){return ru.getInitialState()}function se(t,e){var n=e.date;return t.set(n,(new Date).getTime())}function ce(){return uu.getInitialState()}function fe(t,e){t.dispatch(Yo.LOGBOOK_DATE_SELECTED,{date:e})}function he(t,e){t.dispatch(Yo.LOGBOOK_ENTRIES_FETCH_START,{date:e}),on(t,"GET","logbook/"+e).then((function(n){return t.dispatch(Yo.LOGBOOK_ENTRIES_FETCH_SUCCESS,{date:e,entries:n})}),(function(){return t.dispatch(Yo.LOGBOOK_ENTRIES_FETCH_ERROR,{})}))}function le(t){return!t||(new Date).getTime()-t>cu}function pe(t){t.registerStores({currentLogbookDate:Wo,isLoadingLogbookEntries:Qo,logbookEntries:ru,logbookEntriesUpdated:uu})}function _e(t){return t.set("active",!0)}function de(t){return t.set("active",!1)}function ve(){return Eu.getInitialState()}function ye(t){return navigator.serviceWorker.getRegistration().then((function(t){if(!t)throw new Error("No service worker registered.");return t.pushManager.subscribe({userVisibleOnly:!0})})).then((function(e){var n;return n=navigator.userAgent.toLowerCase().indexOf("firefox")>-1?"firefox":"chrome",on(t,"POST","notify.html5",{subscription:e,browser:n}).then((function(){return t.dispatch(gu.PUSH_NOTIFICATIONS_SUBSCRIBE,{})})).then((function(){return!0}))})).catch((function(e){var n;return n=e.message&&e.message.indexOf("gcm_sender_id")!==-1?"Please setup the notify.html5 platform.":"Notification registration failed.",console.error(e),qn.createNotification(t,n),!1}))}function ge(t){return navigator.serviceWorker.getRegistration().then((function(t){if(!t)throw new Error("No service worker registered");return t.pushManager.subscribe({userVisibleOnly:!0})})).then((function(e){return on(t,"DELETE","notify.html5",{subscription:e}).then((function(){return e.unsubscribe()})).then((function(){return t.dispatch(gu.PUSH_NOTIFICATIONS_UNSUBSCRIBE,{})})).then((function(){return!0}))})).catch((function(e){var n="Failed unsubscribing for push notifications.";return console.error(e),qn.createNotification(t,n),!1}))}function me(t){t.registerStores({pushNotifications:Eu})}function Se(t,e){return on(t,"POST","template",{template:e})}function Ee(t){return t.set("isListening",!0)}function be(t,e){var n=e.interimTranscript,r=e.finalTranscript;return t.withMutations((function(t){return t.set("isListening",!0).set("isTransmitting",!1).set("interimTranscript",n).set("finalTranscript",r)}))}function Ie(t,e){var n=e.finalTranscript;return t.withMutations((function(t){return t.set("isListening",!1).set("isTransmitting",!0).set("interimTranscript","").set("finalTranscript",n)}))}function Oe(){return Pu.getInitialState()}function we(){return Pu.getInitialState()}function Te(){return Pu.getInitialState()}function Ae(t){return Uu[t.hassId]}function Ce(t){var e=Ae(t);if(e){var n=e.finalTranscript||e.interimTranscript;t.dispatch(ju.VOICE_TRANSMITTING,{finalTranscript:n}),er.callService(t,"conversation","process",{text:n}).then((function(){t.dispatch(ju.VOICE_DONE)}),(function(){t.dispatch(ju.VOICE_ERROR)}))}}function De(t){var e=Ae(t);e&&(e.recognition.stop(),Uu[t.hassId]=!1)}function ze(t){Ce(t),De(t)}function Re(t){var e=ze.bind(null,t);e();var n=new webkitSpeechRecognition;Uu[t.hassId]={recognition:n,interimTranscript:"",finalTranscript:""},n.interimResults=!0,n.onstart=function(){return t.dispatch(ju.VOICE_START)},n.onerror=function(){return t.dispatch(ju.VOICE_ERROR)},n.onend=e,n.onresult=function(e){var n=Ae(t);if(n){for(var r="",i="",o=e.resultIndex;o>>0;if(""+n!==e||4294967295===n)return NaN;e=n}return e<0?_(t)+e:e}function v(){return!0}function y(t,e,n){return(0===t||void 0!==n&&t<=-n)&&(void 0===e||void 0!==n&&e>=n)}function g(t,e){return S(t,e,0)}function m(t,e){return S(t,e,e)}function S(t,e,n){return void 0===t?n:t<0?Math.max(0,e+t):void 0===e?t:Math.min(e,t)}function E(t){this.next=t}function b(t,e,n,r){var i=0===t?e:1===t?n:[e,n];return r?r.value=i:r={value:i,done:!1},r}function I(){return{value:void 0,done:!0}}function O(t){return!!A(t)}function w(t){return t&&"function"==typeof t.next}function T(t){var e=A(t);return e&&e.call(t)}function A(t){var e=t&&(In&&t[In]||t[On]);if("function"==typeof e)return e}function C(t){return t&&"number"==typeof t.length}function D(t){return null===t||void 0===t?U():o(t)?t.toSeq():V(t)}function z(t){return null===t||void 0===t?U().toKeyedSeq():o(t)?u(t)?t.toSeq():t.fromEntrySeq():H(t)}function R(t){return null===t||void 0===t?U():o(t)?u(t)?t.entrySeq():t.toIndexedSeq():x(t)}function M(t){return(null===t||void 0===t?U():o(t)?u(t)?t.entrySeq():t:x(t)).toSetSeq()}function L(t){this._array=t,this.size=t.length}function j(t){var e=Object.keys(t);this._object=t,this._keys=e,this.size=e.length}function k(t){this._iterable=t,this.size=t.length||t.size}function N(t){this._iterator=t,this._iteratorCache=[]}function P(t){return!(!t||!t[Tn])}function U(){return An||(An=new L([]))}function H(t){var e=Array.isArray(t)?new L(t).fromEntrySeq():w(t)?new N(t).fromEntrySeq():O(t)?new k(t).fromEntrySeq():"object"==typeof t?new j(t):void 0;if(!e)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+t);return e}function x(t){var e=q(t);if(!e)throw new TypeError("Expected Array or iterable object of values: "+t);return e}function V(t){var e=q(t)||"object"==typeof t&&new j(t);if(!e)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+t);return e}function q(t){return C(t)?new L(t):w(t)?new N(t):O(t)?new k(t):void 0}function F(t,e,n,r){var i=t._cache;if(i){for(var o=i.length-1,u=0;u<=o;u++){var a=i[n?o-u:u];if(e(a[1],r?a[0]:u,t)===!1)return u+1}return u}return t.__iterateUncached(e,n)}function G(t,e,n,r){var i=t._cache;if(i){var o=i.length-1,u=0;return new E(function(){var t=i[n?o-u:u];return u++>o?I():b(e,r?t[0]:u-1,t[1])})}return t.__iteratorUncached(e,n)}function K(t,e){return e?B(e,t,"",{"":t}):Y(t)}function B(t,e,n,r){return Array.isArray(e)?t.call(r,n,R(e).map((function(n,r){return B(t,n,r,e)}))):J(e)?t.call(r,n,z(e).map((function(n,r){return B(t,n,r,e)}))):e}function Y(t){return Array.isArray(t)?R(t).map(Y).toList():J(t)?z(t).map(Y).toMap():t}function J(t){return t&&(t.constructor===Object||void 0===t.constructor)}function W(t,e){if(t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1;if("function"==typeof t.valueOf&&"function"==typeof e.valueOf){if(t=t.valueOf(),e=e.valueOf(),t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1}return!("function"!=typeof t.equals||"function"!=typeof e.equals||!t.equals(e))}function X(t,e){if(t===e)return!0;if(!o(e)||void 0!==t.size&&void 0!==e.size&&t.size!==e.size||void 0!==t.__hash&&void 0!==e.__hash&&t.__hash!==e.__hash||u(t)!==u(e)||a(t)!==a(e)||c(t)!==c(e))return!1;if(0===t.size&&0===e.size)return!0;var n=!s(t);if(c(t)){var r=t.entries();return e.every((function(t,e){var i=r.next().value;return i&&W(i[1],t)&&(n||W(i[0],e))}))&&r.next().done}var i=!1;if(void 0===t.size)if(void 0===e.size)"function"==typeof t.cacheResult&&t.cacheResult();else{i=!0;var f=t;t=e,e=f}var h=!0,l=e.__iterate((function(e,r){if(n?!t.has(e):i?!W(e,t.get(r,yn)):!W(t.get(r,yn),e))return h=!1,!1}));return h&&t.size===l}function Q(t,e){if(!(this instanceof Q))return new Q(t,e);if(this._value=t,this.size=void 0===e?1/0:Math.max(0,e),0===this.size){if(Cn)return Cn;Cn=this}}function Z(t,e){if(!t)throw new Error(e)}function $(t,e,n){if(!(this instanceof $))return new $(t,e,n);if(Z(0!==n,"Cannot step a Range by 0"),t=t||0,void 0===e&&(e=1/0),n=void 0===n?1:Math.abs(n),e>>1&1073741824|3221225471&t}function ot(t){if(t===!1||null===t||void 0===t)return 0;if("function"==typeof t.valueOf&&(t=t.valueOf(),t===!1||null===t||void 0===t))return 0;if(t===!0)return 1;var e=typeof t;if("number"===e){if(t!==t||t===1/0)return 0;var n=0|t;for(n!==t&&(n^=4294967295*t);t>4294967295;)t/=4294967295,n^=t;return it(n)}if("string"===e)return t.length>Pn?ut(t):at(t);if("function"==typeof t.hashCode)return t.hashCode();if("object"===e)return st(t);if("function"==typeof t.toString)return at(t.toString());throw new Error("Value type "+e+" cannot be hashed.")}function ut(t){var e=xn[t];return void 0===e&&(e=at(t),Hn===Un&&(Hn=0,xn={}),Hn++,xn[t]=e),e}function at(t){for(var e=0,n=0;n0)switch(t.nodeType){case 1:return t.uniqueID;case 9:return t.documentElement&&t.documentElement.uniqueID}}function ft(t){Z(t!==1/0,"Cannot perform this action with an infinite size.")}function ht(t){return null===t||void 0===t?bt():lt(t)&&!c(t)?t:bt().withMutations((function(e){var r=n(t);ft(r.size),r.forEach((function(t,n){return e.set(n,t)}))}))}function lt(t){return!(!t||!t[Vn])}function pt(t,e){this.ownerID=t,this.entries=e}function _t(t,e,n){this.ownerID=t,this.bitmap=e,this.nodes=n}function dt(t,e,n){this.ownerID=t,this.count=e,this.nodes=n}function vt(t,e,n){this.ownerID=t,this.keyHash=e,this.entries=n}function yt(t,e,n){this.ownerID=t,this.keyHash=e,this.entry=n}function gt(t,e,n){this._type=e,this._reverse=n,this._stack=t._root&&St(t._root)}function mt(t,e){return b(t,e[0],e[1])}function St(t,e){return{node:t,index:0,__prev:e}}function Et(t,e,n,r){var i=Object.create(qn);return i.size=t,i._root=e,i.__ownerID=n,i.__hash=r,i.__altered=!1,i}function bt(){return Fn||(Fn=Et(0))}function It(t,e,n){var r,i;if(t._root){var o=f(gn),u=f(mn);if(r=Ot(t._root,t.__ownerID,0,void 0,e,n,o,u),!u.value)return t;i=t.size+(o.value?n===yn?-1:1:0)}else{if(n===yn)return t;i=1,r=new pt(t.__ownerID,[[e,n]])}return t.__ownerID?(t.size=i,t._root=r,t.__hash=void 0,t.__altered=!0,t):r?Et(i,r):bt()}function Ot(t,e,n,r,i,o,u,a){return t?t.update(e,n,r,i,o,u,a):o===yn?t:(h(a),h(u),new yt(e,r,[i,o]))}function wt(t){return t.constructor===yt||t.constructor===vt}function Tt(t,e,n,r,i){if(t.keyHash===r)return new vt(e,r,[t.entry,i]);var o,u=(0===n?t.keyHash:t.keyHash>>>n)&vn,a=(0===n?r:r>>>n)&vn,s=u===a?[Tt(t,e,n+_n,r,i)]:(o=new yt(e,r,i),u>>=1)u[a]=1&n?e[o++]:void 0;return u[r]=i,new dt(t,o+1,u)}function zt(t,e,r){for(var i=[],u=0;u>1&1431655765,t=(858993459&t)+(t>>2&858993459),t=t+(t>>4)&252645135,t+=t>>8,t+=t>>16,127&t}function Nt(t,e,n,r){var i=r?t:p(t);return i[e]=n,i}function Pt(t,e,n,r){var i=t.length+1;if(r&&e+1===i)return t[e]=n,t;for(var o=new Array(i),u=0,a=0;a0&&io?0:o-n,c=u-n;return c>dn&&(c=dn),function(){if(i===c)return Xn;var t=e?--c:i++;return r&&r[t]}}function i(t,r,i){var a,s=t&&t.array,c=i>o?0:o-i>>r,f=(u-i>>r)+1;return f>dn&&(f=dn),function(){for(;;){if(a){var t=a();if(t!==Xn)return t;a=null}if(c===f)return Xn;var o=e?--f:c++;a=n(s&&s[o],r-_n,i+(o<=t.size||e<0)return t.withMutations((function(t){e<0?Wt(t,e).set(0,n):Wt(t,0,e+1).set(e,n)}));e+=t._origin;var r=t._tail,i=t._root,o=f(mn);return e>=Qt(t._capacity)?r=Bt(r,t.__ownerID,0,e,n,o):i=Bt(i,t.__ownerID,t._level,e,n,o),o.value?t.__ownerID?(t._root=i,t._tail=r,t.__hash=void 0,t.__altered=!0,t):Ft(t._origin,t._capacity,t._level,i,r):t}function Bt(t,e,n,r,i,o){var u=r>>>n&vn,a=t&&u0){var c=t&&t.array[u],f=Bt(c,e,n-_n,r,i,o);return f===c?t:(s=Yt(t,e),s.array[u]=f,s)}return a&&t.array[u]===i?t:(h(o),s=Yt(t,e),void 0===i&&u===s.array.length-1?s.array.pop():s.array[u]=i,s)}function Yt(t,e){return e&&t&&e===t.ownerID?t:new Vt(t?t.array.slice():[],e)}function Jt(t,e){if(e>=Qt(t._capacity))return t._tail;if(e<1<0;)n=n.array[e>>>r&vn],r-=_n;return n}}function Wt(t,e,n){void 0!==e&&(e|=0),void 0!==n&&(n|=0);var r=t.__ownerID||new l,i=t._origin,o=t._capacity,u=i+e,a=void 0===n?o:n<0?o+n:i+n;if(u===i&&a===o)return t;if(u>=a)return t.clear();for(var s=t._level,c=t._root,f=0;u+f<0;)c=new Vt(c&&c.array.length?[void 0,c]:[],r),s+=_n,f+=1<=1<h?new Vt([],r):_;if(_&&p>h&&u_n;y-=_n){var g=h>>>y&vn;v=v.array[g]=Yt(v.array[g],r)}v.array[h>>>_n&vn]=_}if(a=p)u-=p,a-=p,s=_n,c=null,d=d&&d.removeBefore(r,0,u);else if(u>i||p>>s&vn;if(m!==p>>>s&vn)break;m&&(f+=(1<i&&(c=c.removeBefore(r,s,u-f)),c&&pu&&(u=c.size),o(s)||(c=c.map((function(t){return K(t)}))),i.push(c)}return u>t.size&&(t=t.setSize(u)),Lt(t,e,i)}function Qt(t){return t>>_n<<_n}function Zt(t){return null===t||void 0===t?ee():$t(t)?t:ee().withMutations((function(e){var r=n(t);ft(r.size),r.forEach((function(t,n){return e.set(n,t)}))}))}function $t(t){return lt(t)&&c(t)}function te(t,e,n,r){var i=Object.create(Zt.prototype);return i.size=t?t.size:0,i._map=t,i._list=e,i.__ownerID=n,i.__hash=r,i}function ee(){return Qn||(Qn=te(bt(),Gt()))}function ne(t,e,n){var r,i,o=t._map,u=t._list,a=o.get(e),s=void 0!==a;if(n===yn){if(!s)return t;u.size>=dn&&u.size>=2*o.size?(i=u.filter((function(t,e){return void 0!==t&&a!==e})),r=i.toKeyedSeq().map((function(t){return t[0]})).flip().toMap(),t.__ownerID&&(r.__ownerID=i.__ownerID=t.__ownerID)):(r=o.remove(e),i=a===u.size-1?u.pop():u.set(a,void 0))}else if(s){if(n===u.get(a)[1])return t;r=o,i=u.set(a,[e,n])}else r=o.set(e,u.size),i=u.set(u.size,[e,n]);return t.__ownerID?(t.size=r.size,t._map=r,t._list=i,t.__hash=void 0,t):te(r,i)}function re(t,e){this._iter=t,this._useKeys=e,this.size=t.size}function ie(t){this._iter=t,this.size=t.size}function oe(t){this._iter=t,this.size=t.size}function ue(t){this._iter=t,this.size=t.size}function ae(t){var e=Ce(t);return e._iter=t,e.size=t.size,e.flip=function(){return t},e.reverse=function(){var e=t.reverse.apply(this);return e.flip=function(){return t.reverse()},e},e.has=function(e){return t.includes(e)},e.includes=function(e){return t.has(e)},e.cacheResult=De,e.__iterateUncached=function(e,n){var r=this;return t.__iterate((function(t,n){return e(n,t,r)!==!1}),n)},e.__iteratorUncached=function(e,n){if(e===bn){var r=t.__iterator(e,n);return new E(function(){var t=r.next();if(!t.done){var e=t.value[0];t.value[0]=t.value[1],t.value[1]=e}return t})}return t.__iterator(e===En?Sn:En,n)},e}function se(t,e,n){var r=Ce(t);return r.size=t.size,r.has=function(e){return t.has(e)},r.get=function(r,i){var o=t.get(r,yn);return o===yn?i:e.call(n,o,r,t)},r.__iterateUncached=function(r,i){var o=this;return t.__iterate((function(t,i,u){return r(e.call(n,t,i,u),i,o)!==!1}),i)},r.__iteratorUncached=function(r,i){var o=t.__iterator(bn,i);return new E(function(){var i=o.next();if(i.done)return i;var u=i.value,a=u[0];return b(r,a,e.call(n,u[1],a,t),i)})},r}function ce(t,e){var n=Ce(t);return n._iter=t,n.size=t.size,n.reverse=function(){return t},t.flip&&(n.flip=function(){var e=ae(t);return e.reverse=function(){return t.flip()},e}),n.get=function(n,r){return t.get(e?n:-1-n,r)},n.has=function(n){return t.has(e?n:-1-n)},n.includes=function(e){return t.includes(e)},n.cacheResult=De,n.__iterate=function(e,n){var r=this;return t.__iterate((function(t,n){return e(t,n,r)}),!n)},n.__iterator=function(e,n){return t.__iterator(e,!n)},n}function fe(t,e,n,r){var i=Ce(t);return r&&(i.has=function(r){var i=t.get(r,yn);return i!==yn&&!!e.call(n,i,r,t)},i.get=function(r,i){var o=t.get(r,yn);return o!==yn&&e.call(n,o,r,t)?o:i}),i.__iterateUncached=function(i,o){var u=this,a=0;return t.__iterate((function(t,o,s){if(e.call(n,t,o,s))return a++,i(t,r?o:a-1,u)}),o),a},i.__iteratorUncached=function(i,o){var u=t.__iterator(bn,o),a=0;return new E(function(){for(;;){var o=u.next();if(o.done)return o;var s=o.value,c=s[0],f=s[1];if(e.call(n,f,c,t))return b(i,r?c:a++,f,o)}})},i}function he(t,e,n){var r=ht().asMutable();return t.__iterate((function(i,o){r.update(e.call(n,i,o,t),0,(function(t){return t+1}))})),r.asImmutable()}function le(t,e,n){var r=u(t),i=(c(t)?Zt():ht()).asMutable();t.__iterate((function(o,u){i.update(e.call(n,o,u,t),(function(t){return t=t||[],t.push(r?[u,o]:o),t}))}));var o=Ae(t);return i.map((function(e){return Oe(t,o(e))}))}function pe(t,e,n,r){var i=t.size;if(void 0!==e&&(e|=0),void 0!==n&&(n===1/0?n=i:n|=0),y(e,n,i))return t;var o=g(e,i),u=m(n,i);if(o!==o||u!==u)return pe(t.toSeq().cacheResult(),e,n,r);var a,s=u-o;s===s&&(a=s<0?0:s);var c=Ce(t);return c.size=0===a?a:t.size&&a||void 0,!r&&P(t)&&a>=0&&(c.get=function(e,n){return e=d(this,e),e>=0&&ea)return I();var t=i.next();return r||e===En?t:e===Sn?b(e,s-1,void 0,t):b(e,s-1,t.value[1],t)})},c}function _e(t,e,n){var r=Ce(t);return r.__iterateUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterate(r,i);var u=0;return t.__iterate((function(t,i,a){return e.call(n,t,i,a)&&++u&&r(t,i,o)})),u},r.__iteratorUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterator(r,i);var u=t.__iterator(bn,i),a=!0;return new E(function(){if(!a)return I();var t=u.next();if(t.done)return t;var i=t.value,s=i[0],c=i[1];return e.call(n,c,s,o)?r===bn?t:b(r,s,c,t):(a=!1,I())})},r}function de(t,e,n,r){var i=Ce(t);return i.__iterateUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterate(i,o);var a=!0,s=0;return t.__iterate((function(t,o,c){if(!a||!(a=e.call(n,t,o,c)))return s++,i(t,r?o:s-1,u)})),s},i.__iteratorUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterator(i,o);var a=t.__iterator(bn,o),s=!0,c=0;return new E(function(){var t,o,f;do{if(t=a.next(),t.done)return r||i===En?t:i===Sn?b(i,c++,void 0,t):b(i,c++,t.value[1],t);var h=t.value;o=h[0],f=h[1],s&&(s=e.call(n,f,o,u))}while(s);return i===bn?t:b(i,o,f,t)})},i}function ve(t,e){var r=u(t),i=[t].concat(e).map((function(t){return o(t)?r&&(t=n(t)):t=r?H(t):x(Array.isArray(t)?t:[t]),t})).filter((function(t){return 0!==t.size}));if(0===i.length)return t;if(1===i.length){var s=i[0];if(s===t||r&&u(s)||a(t)&&a(s))return s}var c=new L(i);return r?c=c.toKeyedSeq():a(t)||(c=c.toSetSeq()),c=c.flatten(!0),c.size=i.reduce((function(t,e){if(void 0!==t){var n=e.size;if(void 0!==n)return t+n}}),0),c}function ye(t,e,n){var r=Ce(t);return r.__iterateUncached=function(r,i){function u(t,c){var f=this;t.__iterate((function(t,i){return(!e||c0}function Ie(t,n,r){var i=Ce(t);return i.size=new L(r).map((function(t){return t.size})).min(),i.__iterate=function(t,e){for(var n,r=this,i=this.__iterator(En,e),o=0;!(n=i.next()).done&&t(n.value,o++,r)!==!1;);return o},i.__iteratorUncached=function(t,i){var o=r.map((function(t){return t=e(t),T(i?t.reverse():t)})),u=0,a=!1;return new E(function(){var e;return a||(e=o.map((function(t){return t.next()})),a=e.some((function(t){return t.done}))),a?I():b(t,u++,n.apply(null,e.map((function(t){return t.value}))))})},i}function Oe(t,e){return P(t)?e:t.constructor(e)}function we(t){if(t!==Object(t))throw new TypeError("Expected [K, V] tuple: "+t)}function Te(t){return ft(t.size),_(t)}function Ae(t){return u(t)?n:a(t)?r:i}function Ce(t){return Object.create((u(t)?z:a(t)?R:M).prototype)}function De(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):D.prototype.cacheResult.call(this)}function ze(t,e){return t>e?1:te?-1:0}function on(t){if(t.size===1/0)return 0;var e=c(t),n=u(t),r=e?1:0,i=t.__iterate(n?e?function(t,e){r=31*r+an(ot(t),ot(e))|0}:function(t,e){r=r+an(ot(t),ot(e))|0}:e?function(t){r=31*r+ot(t)|0}:function(t){r=r+ot(t)|0});return un(i,r)}function un(t,e){return e=Rn(e,3432918353),e=Rn(e<<15|e>>>-15,461845907),e=Rn(e<<13|e>>>-13,5),e=(e+3864292196|0)^t,e=Rn(e^e>>>16,2246822507),e=Rn(e^e>>>13,3266489909),e=it(e^e>>>16)}function an(t,e){return t^e+2654435769+(t<<6)+(t>>2)|0}var sn=Array.prototype.slice;t(n,e),t(r,e),t(i,e),e.isIterable=o,e.isKeyed=u,e.isIndexed=a,e.isAssociative=s,e.isOrdered=c,e.Keyed=n,e.Indexed=r,e.Set=i;var cn="@@__IMMUTABLE_ITERABLE__@@",fn="@@__IMMUTABLE_KEYED__@@",hn="@@__IMMUTABLE_INDEXED__@@",ln="@@__IMMUTABLE_ORDERED__@@",pn="delete",_n=5,dn=1<<_n,vn=dn-1,yn={},gn={value:!1},mn={value:!1},Sn=0,En=1,bn=2,In="function"==typeof Symbol&&Symbol.iterator,On="@@iterator",wn=In||On;E.prototype.toString=function(){return"[Iterator]"},E.KEYS=Sn,E.VALUES=En,E.ENTRIES=bn,E.prototype.inspect=E.prototype.toSource=function(){return this.toString()},E.prototype[wn]=function(){return this},t(D,e),D.of=function(){return D(arguments)},D.prototype.toSeq=function(){return this},D.prototype.toString=function(){return this.__toString("Seq {","}")},D.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},D.prototype.__iterate=function(t,e){return F(this,t,e,!0)},D.prototype.__iterator=function(t,e){return G(this,t,e,!0)},t(z,D),z.prototype.toKeyedSeq=function(){return this},t(R,D),R.of=function(){return R(arguments)},R.prototype.toIndexedSeq=function(){return this},R.prototype.toString=function(){return this.__toString("Seq [","]")},R.prototype.__iterate=function(t,e){return F(this,t,e,!1)},R.prototype.__iterator=function(t,e){return G(this,t,e,!1)},t(M,D),M.of=function(){return M(arguments)},M.prototype.toSetSeq=function(){return this},D.isSeq=P,D.Keyed=z,D.Set=M,D.Indexed=R;var Tn="@@__IMMUTABLE_SEQ__@@";D.prototype[Tn]=!0,t(L,R),L.prototype.get=function(t,e){return this.has(t)?this._array[d(this,t)]:e},L.prototype.__iterate=function(t,e){for(var n=this,r=this._array,i=r.length-1,o=0;o<=i;o++)if(t(r[e?i-o:o],o,n)===!1)return o+1;return o},L.prototype.__iterator=function(t,e){var n=this._array,r=n.length-1,i=0;return new E(function(){return i>r?I():b(t,i,n[e?r-i++:i++])})},t(j,z),j.prototype.get=function(t,e){return void 0===e||this.has(t)?this._object[t]:e},j.prototype.has=function(t){return this._object.hasOwnProperty(t)},j.prototype.__iterate=function(t,e){for(var n=this,r=this._object,i=this._keys,o=i.length-1,u=0;u<=o;u++){var a=i[e?o-u:u];if(t(r[a],a,n)===!1)return u+1}return u},j.prototype.__iterator=function(t,e){var n=this._object,r=this._keys,i=r.length-1,o=0;return new E(function(){var u=r[e?i-o:o];return o++>i?I():b(t,u,n[u])})},j.prototype[ln]=!0,t(k,R),k.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);var r=this._iterable,i=T(r),o=0;if(w(i))for(var u;!(u=i.next()).done&&t(u.value,o++,n)!==!1;);return o},k.prototype.__iteratorUncached=function(t,e){if(e)return this.cacheResult().__iterator(t,e);var n=this._iterable,r=T(n);if(!w(r))return new E(I);var i=0;return new E(function(){var e=r.next();return e.done?e:b(t,i++,e.value)})},t(N,R),N.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);for(var r=this._iterator,i=this._iteratorCache,o=0;o=r.length){var e=n.next();if(e.done)return e;r[i]=e.value}return b(t,i,r[i++])})};var An;t(Q,R),Q.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Q.prototype.get=function(t,e){return this.has(t)?this._value:e},Q.prototype.includes=function(t){return W(this._value,t)},Q.prototype.slice=function(t,e){var n=this.size;return y(t,e,n)?this:new Q(this._value,m(e,n)-g(t,n))},Q.prototype.reverse=function(){return this},Q.prototype.indexOf=function(t){return W(this._value,t)?0:-1},Q.prototype.lastIndexOf=function(t){return W(this._value,t)?this.size:-1},Q.prototype.__iterate=function(t,e){for(var n=this,r=0;r=0&&e=0&&nn?I():b(t,o++,u)})},$.prototype.equals=function(t){return t instanceof $?this._start===t._start&&this._end===t._end&&this._step===t._step:X(this,t)};var Dn;t(tt,e),t(et,tt),t(nt,tt),t(rt,tt),tt.Keyed=et,tt.Indexed=nt,tt.Set=rt;var zn,Rn="function"==typeof Math.imul&&Math.imul(4294967295,2)===-2?Math.imul:function(t,e){t|=0,e|=0;var n=65535&t,r=65535&e;return n*r+((t>>>16)*r+n*(e>>>16)<<16>>>0)|0},Mn=Object.isExtensible,Ln=(function(){try{return Object.defineProperty({},"@",{}),!0}catch(t){return!1}})(),jn="function"==typeof WeakMap;jn&&(zn=new WeakMap);var kn=0,Nn="__immutablehash__";"function"==typeof Symbol&&(Nn=Symbol(Nn));var Pn=16,Un=255,Hn=0,xn={};t(ht,et),ht.of=function(){var t=sn.call(arguments,0);return bt().withMutations((function(e){for(var n=0;n=t.length)throw new Error("Missing value for key: "+t[n]);e.set(t[n],t[n+1])}}))},ht.prototype.toString=function(){return this.__toString("Map {","}")},ht.prototype.get=function(t,e){return this._root?this._root.get(0,void 0,t,e):e},ht.prototype.set=function(t,e){return It(this,t,e)},ht.prototype.setIn=function(t,e){return this.updateIn(t,yn,(function(){return e}))},ht.prototype.remove=function(t){return It(this,t,yn)},ht.prototype.deleteIn=function(t){return this.updateIn(t,(function(){return yn}))},ht.prototype.update=function(t,e,n){return 1===arguments.length?t(this):this.updateIn([t],e,n)},ht.prototype.updateIn=function(t,e,n){n||(n=e,e=void 0);var r=jt(this,Re(t),e,n);return r===yn?void 0:r},ht.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):bt()},ht.prototype.merge=function(){return zt(this,void 0,arguments)},ht.prototype.mergeWith=function(t){var e=sn.call(arguments,1);return zt(this,t,e)},ht.prototype.mergeIn=function(t){var e=sn.call(arguments,1);return this.updateIn(t,bt(),(function(t){return"function"==typeof t.merge?t.merge.apply(t,e):e[e.length-1]}))},ht.prototype.mergeDeep=function(){return zt(this,Rt,arguments)},ht.prototype.mergeDeepWith=function(t){var e=sn.call(arguments,1);return zt(this,Mt(t),e)},ht.prototype.mergeDeepIn=function(t){var e=sn.call(arguments,1);return this.updateIn(t,bt(),(function(t){return"function"==typeof t.mergeDeep?t.mergeDeep.apply(t,e):e[e.length-1]}))},ht.prototype.sort=function(t){return Zt(Se(this,t))},ht.prototype.sortBy=function(t,e){return Zt(Se(this,e,t))},ht.prototype.withMutations=function(t){var e=this.asMutable();return t(e),e.wasAltered()?e.__ensureOwner(this.__ownerID):this},ht.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new l)},ht.prototype.asImmutable=function(){return this.__ensureOwner()},ht.prototype.wasAltered=function(){return this.__altered},ht.prototype.__iterator=function(t,e){return new gt(this,t,e)},ht.prototype.__iterate=function(t,e){var n=this,r=0;return this._root&&this._root.iterate((function(e){return r++,t(e[1],e[0],n)}),e),r},ht.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Et(this.size,this._root,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},ht.isMap=lt;var Vn="@@__IMMUTABLE_MAP__@@",qn=ht.prototype;qn[Vn]=!0,qn[pn]=qn.remove,qn.removeIn=qn.deleteIn,pt.prototype.get=function(t,e,n,r){for(var i=this.entries,o=0,u=i.length;o=Gn)return At(t,s,r,i);var _=t&&t===this.ownerID,d=_?s:p(s);return l?a?c===f-1?d.pop():d[c]=d.pop():d[c]=[r,i]:d.push([r,i]),_?(this.entries=d,this):new pt(t,d)}},_t.prototype.get=function(t,e,n,r){void 0===e&&(e=ot(n));var i=1<<((0===t?e:e>>>t)&vn),o=this.bitmap;return 0===(o&i)?r:this.nodes[kt(o&i-1)].get(t+_n,e,n,r)},_t.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=ot(r));var a=(0===e?n:n>>>e)&vn,s=1<=Kn)return Dt(t,l,c,a,_);if(f&&!_&&2===l.length&&wt(l[1^h]))return l[1^h];if(f&&_&&1===l.length&&wt(_))return _;var d=t&&t===this.ownerID,v=f?_?c:c^s:c|s,y=f?_?Nt(l,h,_,d):Ut(l,h,d):Pt(l,h,_,d);return d?(this.bitmap=v,this.nodes=y,this):new _t(t,v,y)},dt.prototype.get=function(t,e,n,r){void 0===e&&(e=ot(n));var i=(0===t?e:e>>>t)&vn,o=this.nodes[i];return o?o.get(t+_n,e,n,r):r},dt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=ot(r));var a=(0===e?n:n>>>e)&vn,s=i===yn,c=this.nodes,f=c[a];if(s&&!f)return this;var h=Ot(f,t,e+_n,n,r,i,o,u);if(h===f)return this;var l=this.count;if(f){if(!h&&(l--,l=0&&t>>e&vn;if(r>=this.array.length)return new Vt([],t);var i,o=0===r;if(e>0){var u=this.array[r];if(i=u&&u.removeBefore(t,e-_n,n),i===u&&o)return this}if(o&&!i)return this;var a=Yt(this,t);if(!o)for(var s=0;s>>e&vn;if(r>=this.array.length)return this;var i;if(e>0){var o=this.array[r];if(i=o&&o.removeAfter(t,e-_n,n),i===o&&r===this.array.length-1)return this}var u=Yt(this,t);return u.array.splice(r+1),i&&(u.array[r]=i),u};var Wn,Xn={};t(Zt,ht),Zt.of=function(){return this(arguments)},Zt.prototype.toString=function(){return this.__toString("OrderedMap {","}")},Zt.prototype.get=function(t,e){var n=this._map.get(t);return void 0!==n?this._list.get(n)[1]:e},Zt.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):ee()},Zt.prototype.set=function(t,e){return ne(this,t,e)},Zt.prototype.remove=function(t){return ne(this,t,yn)},Zt.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},Zt.prototype.__iterate=function(t,e){var n=this;return this._list.__iterate((function(e){return e&&t(e[1],e[0],n)}),e)},Zt.prototype.__iterator=function(t,e){return this._list.fromEntrySeq().__iterator(t,e)},Zt.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map.__ensureOwner(t),n=this._list.__ensureOwner(t);return t?te(e,n,t,this.__hash):(this.__ownerID=t,this._map=e,this._list=n,this)},Zt.isOrderedMap=$t,Zt.prototype[ln]=!0,Zt.prototype[pn]=Zt.prototype.remove;var Qn;t(re,z),re.prototype.get=function(t,e){return this._iter.get(t,e)},re.prototype.has=function(t){return this._iter.has(t)},re.prototype.valueSeq=function(){return this._iter.valueSeq()},re.prototype.reverse=function(){var t=this,e=ce(this,!0);return this._useKeys||(e.valueSeq=function(){return t._iter.toSeq().reverse()}),e},re.prototype.map=function(t,e){var n=this,r=se(this,t,e);return this._useKeys||(r.valueSeq=function(){return n._iter.toSeq().map(t,e)}),r},re.prototype.__iterate=function(t,e){var n,r=this;return this._iter.__iterate(this._useKeys?function(e,n){return t(e,n,r)}:(n=e?Te(this):0,function(i){return t(i,e?--n:n++,r)}),e)},re.prototype.__iterator=function(t,e){if(this._useKeys)return this._iter.__iterator(t,e);var n=this._iter.__iterator(En,e),r=e?Te(this):0;return new E(function(){var i=n.next();return i.done?i:b(t,e?--r:r++,i.value,i)})},re.prototype[ln]=!0,t(ie,R),ie.prototype.includes=function(t){return this._iter.includes(t)},ie.prototype.__iterate=function(t,e){var n=this,r=0;return this._iter.__iterate((function(e){return t(e,r++,n)}),e)},ie.prototype.__iterator=function(t,e){var n=this._iter.__iterator(En,e),r=0;return new E(function(){var e=n.next();return e.done?e:b(t,r++,e.value,e)})},t(oe,M),oe.prototype.has=function(t){return this._iter.includes(t)},oe.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate((function(e){return t(e,e,n)}),e)},oe.prototype.__iterator=function(t,e){var n=this._iter.__iterator(En,e);return new E(function(){var e=n.next();return e.done?e:b(t,e.value,e.value,e)})},t(ue,z),ue.prototype.entrySeq=function(){return this._iter.toSeq()},ue.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate((function(e){if(e){we(e);var r=o(e);return t(r?e.get(1):e[1],r?e.get(0):e[0],n)}}),e)},ue.prototype.__iterator=function(t,e){var n=this._iter.__iterator(En,e);return new E(function(){for(;;){var e=n.next();if(e.done)return e;var r=e.value;if(r){we(r);var i=o(r);return b(t,i?r.get(0):r[0],i?r.get(1):r[1],e)}}})},ie.prototype.cacheResult=re.prototype.cacheResult=oe.prototype.cacheResult=ue.prototype.cacheResult=De,t(Me,et),Me.prototype.toString=function(){return this.__toString(je(this)+" {","}")},Me.prototype.has=function(t){return this._defaultValues.hasOwnProperty(t)},Me.prototype.get=function(t,e){if(!this.has(t))return e;var n=this._defaultValues[t];return this._map?this._map.get(t,n):n},Me.prototype.clear=function(){if(this.__ownerID)return this._map&&this._map.clear(),this;var t=this.constructor;return t._empty||(t._empty=Le(this,bt()))},Me.prototype.set=function(t,e){if(!this.has(t))throw new Error('Cannot set unknown key "'+t+'" on '+je(this));if(this._map&&!this._map.has(t)){var n=this._defaultValues[t];if(e===n)return this}var r=this._map&&this._map.set(t,e);return this.__ownerID||r===this._map?this:Le(this,r)},Me.prototype.remove=function(t){if(!this.has(t))return this;var e=this._map&&this._map.remove(t);return this.__ownerID||e===this._map?this:Le(this,e)},Me.prototype.wasAltered=function(){return this._map.wasAltered()},Me.prototype.__iterator=function(t,e){var r=this;return n(this._defaultValues).map((function(t,e){return r.get(e)})).__iterator(t,e)},Me.prototype.__iterate=function(t,e){var r=this;return n(this._defaultValues).map((function(t,e){return r.get(e)})).__iterate(t,e)},Me.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map&&this._map.__ensureOwner(t);return t?Le(this,e,t):(this.__ownerID=t,this._map=e,this)};var Zn=Me.prototype;Zn[pn]=Zn.remove,Zn.deleteIn=Zn.removeIn=qn.removeIn,Zn.merge=qn.merge,Zn.mergeWith=qn.mergeWith,Zn.mergeIn=qn.mergeIn,Zn.mergeDeep=qn.mergeDeep,Zn.mergeDeepWith=qn.mergeDeepWith,Zn.mergeDeepIn=qn.mergeDeepIn,Zn.setIn=qn.setIn,Zn.update=qn.update,Zn.updateIn=qn.updateIn,Zn.withMutations=qn.withMutations,Zn.asMutable=qn.asMutable,Zn.asImmutable=qn.asImmutable,t(Pe,rt),Pe.of=function(){return this(arguments)},Pe.fromKeys=function(t){return this(n(t).keySeq())},Pe.prototype.toString=function(){return this.__toString("Set {","}")},Pe.prototype.has=function(t){return this._map.has(t)},Pe.prototype.add=function(t){return He(this,this._map.set(t,!0))},Pe.prototype.remove=function(t){return He(this,this._map.remove(t)); +},Pe.prototype.clear=function(){return He(this,this._map.clear())},Pe.prototype.union=function(){var t=sn.call(arguments,0);return t=t.filter((function(t){return 0!==t.size})),0===t.length?this:0!==this.size||this.__ownerID||1!==t.length?this.withMutations((function(e){for(var n=0;n=0;r--)n={value:t[r],next:n};return this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):Je(e,n)},Be.prototype.pushAll=function(t){if(t=r(t),0===t.size)return this;ft(t.size);var e=this.size,n=this._head;return t.reverse().forEach((function(t){e++,n={value:t,next:n}})),this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):Je(e,n)},Be.prototype.pop=function(){return this.slice(1)},Be.prototype.unshift=function(){return this.push.apply(this,arguments)},Be.prototype.unshiftAll=function(t){return this.pushAll(t)},Be.prototype.shift=function(){return this.pop.apply(this,arguments)},Be.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):We()},Be.prototype.slice=function(t,e){if(y(t,e,this.size))return this;var n=g(t,this.size),r=m(e,this.size);if(r!==this.size)return nt.prototype.slice.call(this,t,e);for(var i=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=i,this._head=o,this.__hash=void 0,this.__altered=!0,this):Je(i,o)},Be.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Je(this.size,this._head,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},Be.prototype.__iterate=function(t,e){var n=this;if(e)return this.reverse().__iterate(t);for(var r=0,i=this._head;i&&t(i.value,r++,n)!==!1;)i=i.next;return r},Be.prototype.__iterator=function(t,e){if(e)return this.reverse().__iterator(t);var n=0,r=this._head;return new E(function(){if(r){var e=r.value;return r=r.next,b(t,n++,e)}return I()})},Be.isStack=Ye;var ir="@@__IMMUTABLE_STACK__@@",or=Be.prototype;or[ir]=!0,or.withMutations=qn.withMutations,or.asMutable=qn.asMutable,or.asImmutable=qn.asImmutable,or.wasAltered=qn.wasAltered;var ur;e.Iterator=E,Xe(e,{toArray:function(){ft(this.size);var t=new Array(this.size||0);return this.valueSeq().__iterate((function(e,n){t[n]=e})),t},toIndexedSeq:function(){return new ie(this)},toJS:function(){return this.toSeq().map((function(t){return t&&"function"==typeof t.toJS?t.toJS():t})).__toJS()},toJSON:function(){return this.toSeq().map((function(t){return t&&"function"==typeof t.toJSON?t.toJSON():t})).__toJS()},toKeyedSeq:function(){return new re(this,!0)},toMap:function(){return ht(this.toKeyedSeq())},toObject:function(){ft(this.size);var t={};return this.__iterate((function(e,n){t[n]=e})),t},toOrderedMap:function(){return Zt(this.toKeyedSeq())},toOrderedSet:function(){return qe(u(this)?this.valueSeq():this)},toSet:function(){return Pe(u(this)?this.valueSeq():this)},toSetSeq:function(){return new oe(this)},toSeq:function(){return a(this)?this.toIndexedSeq():u(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Be(u(this)?this.valueSeq():this)},toList:function(){return Ht(u(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(t,e){return 0===this.size?t+e:t+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+e},concat:function(){var t=sn.call(arguments,0);return Oe(this,ve(this,t))},includes:function(t){return this.some((function(e){return W(e,t)}))},entries:function(){return this.__iterator(bn)},every:function(t,e){ft(this.size);var n=!0;return this.__iterate((function(r,i,o){if(!t.call(e,r,i,o))return n=!1,!1})),n},filter:function(t,e){return Oe(this,fe(this,t,e,!0))},find:function(t,e,n){var r=this.findEntry(t,e);return r?r[1]:n},forEach:function(t,e){return ft(this.size),this.__iterate(e?t.bind(e):t)},join:function(t){ft(this.size),t=void 0!==t?""+t:",";var e="",n=!0;return this.__iterate((function(r){n?n=!1:e+=t,e+=null!==r&&void 0!==r?r.toString():""})),e},keys:function(){return this.__iterator(Sn)},map:function(t,e){return Oe(this,se(this,t,e))},reduce:function(t,e,n){ft(this.size);var r,i;return arguments.length<2?i=!0:r=e,this.__iterate((function(e,o,u){i?(i=!1,r=e):r=t.call(n,r,e,o,u)})),r},reduceRight:function(t,e,n){var r=this.toKeyedSeq().reverse();return r.reduce.apply(r,arguments)},reverse:function(){return Oe(this,ce(this,!0))},slice:function(t,e){return Oe(this,pe(this,t,e,!0))},some:function(t,e){return!this.every($e(t),e)},sort:function(t){return Oe(this,Se(this,t))},values:function(){return this.__iterator(En)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(t,e){return _(t?this.toSeq().filter(t,e):this)},countBy:function(t,e){return he(this,t,e)},equals:function(t){return X(this,t)},entrySeq:function(){var t=this;if(t._cache)return new L(t._cache);var e=t.toSeq().map(Ze).toIndexedSeq();return e.fromEntrySeq=function(){return t.toSeq()},e},filterNot:function(t,e){return this.filter($e(t),e)},findEntry:function(t,e,n){var r=n;return this.__iterate((function(n,i,o){if(t.call(e,n,i,o))return r=[i,n],!1})),r},findKey:function(t,e){var n=this.findEntry(t,e);return n&&n[0]},findLast:function(t,e,n){return this.toKeyedSeq().reverse().find(t,e,n)},findLastEntry:function(t,e,n){return this.toKeyedSeq().reverse().findEntry(t,e,n)},findLastKey:function(t,e){return this.toKeyedSeq().reverse().findKey(t,e)},first:function(){return this.find(v)},flatMap:function(t,e){return Oe(this,ge(this,t,e))},flatten:function(t){return Oe(this,ye(this,t,!0))},fromEntrySeq:function(){return new ue(this)},get:function(t,e){return this.find((function(e,n){return W(n,t)}),void 0,e)},getIn:function(t,e){for(var n,r=this,i=Re(t);!(n=i.next()).done;){var o=n.value;if(r=r&&r.get?r.get(o,yn):yn,r===yn)return e}return r},groupBy:function(t,e){return le(this,t,e)},has:function(t){return this.get(t,yn)!==yn},hasIn:function(t){return this.getIn(t,yn)!==yn},isSubset:function(t){return t="function"==typeof t.includes?t:e(t),this.every((function(e){return t.includes(e)}))},isSuperset:function(t){return t="function"==typeof t.isSubset?t:e(t),t.isSubset(this)},keyOf:function(t){return this.findKey((function(e){return W(e,t)}))},keySeq:function(){return this.toSeq().map(Qe).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(t){return this.toKeyedSeq().reverse().keyOf(t)},max:function(t){return Ee(this,t)},maxBy:function(t,e){return Ee(this,e,t)},min:function(t){return Ee(this,t?tn(t):rn)},minBy:function(t,e){return Ee(this,e?tn(e):rn,t)},rest:function(){return this.slice(1)},skip:function(t){return this.slice(Math.max(0,t))},skipLast:function(t){return Oe(this,this.toSeq().reverse().skip(t).reverse())},skipWhile:function(t,e){return Oe(this,de(this,t,e,!0))},skipUntil:function(t,e){return this.skipWhile($e(t),e)},sortBy:function(t,e){return Oe(this,Se(this,e,t))},take:function(t){return this.slice(0,Math.max(0,t))},takeLast:function(t){return Oe(this,this.toSeq().reverse().take(t).reverse())},takeWhile:function(t,e){return Oe(this,_e(this,t,e))},takeUntil:function(t,e){return this.takeWhile($e(t),e)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=on(this))}});var ar=e.prototype;ar[cn]=!0,ar[wn]=ar.values,ar.__toJS=ar.toArray,ar.__toStringMapper=en,ar.inspect=ar.toSource=function(){return this.toString()},ar.chain=ar.flatMap,ar.contains=ar.includes,Xe(n,{flip:function(){return Oe(this,ae(this))},mapEntries:function(t,e){var n=this,r=0;return Oe(this,this.toSeq().map((function(i,o){return t.call(e,[o,i],r++,n)})).fromEntrySeq())},mapKeys:function(t,e){var n=this;return Oe(this,this.toSeq().flip().map((function(r,i){return t.call(e,r,i,n)})).flip())}});var sr=n.prototype;sr[fn]=!0,sr[wn]=ar.entries,sr.__toJS=ar.toObject,sr.__toStringMapper=function(t,e){return JSON.stringify(e)+": "+en(t)},Xe(r,{toKeyedSeq:function(){return new re(this,!1)},filter:function(t,e){return Oe(this,fe(this,t,e,!1))},findIndex:function(t,e){var n=this.findEntry(t,e);return n?n[0]:-1},indexOf:function(t){var e=this.keyOf(t);return void 0===e?-1:e},lastIndexOf:function(t){var e=this.lastKeyOf(t);return void 0===e?-1:e},reverse:function(){return Oe(this,ce(this,!1))},slice:function(t,e){return Oe(this,pe(this,t,e,!1))},splice:function(t,e){var n=arguments.length;if(e=Math.max(0|e,0),0===n||2===n&&!e)return this;t=g(t,t<0?this.count():this.size);var r=this.slice(0,t);return Oe(this,1===n?r:r.concat(p(arguments,2),this.slice(t+e)))},findLastIndex:function(t,e){var n=this.findLastEntry(t,e);return n?n[0]:-1},first:function(){return this.get(0)},flatten:function(t){return Oe(this,ye(this,t,!1))},get:function(t,e){return t=d(this,t),t<0||this.size===1/0||void 0!==this.size&&t>this.size?e:this.find((function(e,n){return n===t}),void 0,e)},has:function(t){return t=d(this,t),t>=0&&(void 0!==this.size?this.size===1/0||t-1&&t%1===0&&t<=Number.MAX_VALUE}var i=Function.prototype.bind;e.isString=function(t){return"string"==typeof t||"[object String]"===n(t)},e.isArray=Array.isArray||function(t){return"[object Array]"===n(t)},"function"!=typeof/./&&"object"!=typeof Int8Array?e.isFunction=function(t){return"function"==typeof t||!1}:e.isFunction=function(t){return"[object Function]"===toString.call(t)},e.isObject=function(t){var e=typeof t;return"function"===e||"object"===e&&!!t},e.extend=function(t){var e=arguments,n=arguments.length;if(!t||n<2)return t||{};for(var r=1;r0)){var e=this.reactorState.get("dirtyStores");if(0!==e.size){var n=c.default.Set().withMutations((function(n){n.union(t.observerState.get("any")),e.forEach((function(e){var r=t.observerState.getIn(["stores",e]);r&&n.union(r)}))}));n.forEach((function(e){var n=t.observerState.getIn(["observersMap",e]);if(n){var r=n.get("getter"),i=n.get("handler"),o=p.evaluate(t.prevReactorState,r),u=p.evaluate(t.reactorState,r);t.prevReactorState=o.reactorState,t.reactorState=u.reactorState;var a=o.result,s=u.result;c.default.is(a,s)||i.call(null,s)}}));var r=p.resetDirtyStores(this.reactorState);this.prevReactorState=r,this.reactorState=r}}}},{key:"batchStart",value:function(){this.__batchDepth++}},{key:"batchEnd",value:function(){if(this.__batchDepth--,this.__batchDepth<=0){this.__isDispatching=!0;try{this.__notify()}catch(t){throw this.__isDispatching=!1,t}this.__isDispatching=!1}}}]),t})();e.default=(0,m.toFactory)(E),t.exports=e.default}),(function(t,e,n){function r(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i(t,e){var n={};return(0,o.each)(e,(function(e,r){n[r]=t.evaluate(e)})),n}Object.defineProperty(e,"__esModule",{value:!0});var o=n(4);e.default=function(t){return{getInitialState:function(){return i(t,this.getDataBindings())},componentDidMount:function(){var e=this;this.__unwatchFns=[],(0,o.each)(this.getDataBindings(),(function(n,i){var o=t.observe(n,(function(t){e.setState(r({},i,t))}));e.__unwatchFns.push(o)}))},componentWillUnmount:function(){for(var t=this;this.__unwatchFns.length;)t.__unwatchFns.shift()()}}},t.exports=e.default}),(function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t,e){return new C({result:t,reactorState:e})}function o(t,e){return t.withMutations((function(t){(0,A.each)(e,(function(e,n){t.getIn(["stores",n])&&console.warn("Store already defined for id = "+n);var r=e.getInitialState();if(void 0===r&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store getInitialState() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,O.isImmutableValue)(r))throw new Error("Store getInitialState() must return an immutable value, did you forget to call toImmutable");t.update("stores",(function(t){return t.set(n,e)})).update("state",(function(t){return t.set(n,r)})).update("dirtyStores",(function(t){return t.add(n)})).update("storeStates",(function(t){return S(t,[n])}))})),m(t)}))}function u(t,e){return t.withMutations((function(t){(0,A.each)(e,(function(e,n){t.update("stores",(function(t){return t.set(n,e)}))}))}))}function a(t,e,n){var r=t.get("logger");if(void 0===e&&f(t,"throwOnUndefinedActionType"))throw new Error("`dispatch` cannot be called with an `undefined` action type.");var i=t.get("state"),o=t.get("dirtyStores"),u=i.withMutations((function(u){r.dispatchStart(t,e,n),t.get("stores").forEach((function(i,a){var s=u.get(a),c=void 0;try{c=i.handle(s,e,n)}catch(e){throw r.dispatchError(t,e.message),e}if(void 0===c&&f(t,"throwOnUndefinedStoreReturnValue")){var h="Store handler must return a value, did you forget a return statement";throw r.dispatchError(t,h),new Error(h)}u.set(a,c),s!==c&&(o=o.add(a))})),r.dispatchEnd(t,u,o,i)})),a=t.set("state",u).set("dirtyStores",o).update("storeStates",(function(t){return S(t,o)}));return m(a)}function s(t,e){var n=[],r=(0,O.toImmutable)({}).withMutations((function(r){(0,A.each)(e,(function(e,i){var o=t.getIn(["stores",i]);if(o){var u=o.deserialize(e);void 0!==u&&(r.set(i,u),n.push(i))}}))})),i=b.default.Set(n);return t.update("state",(function(t){return t.merge(r)})).update("dirtyStores",(function(t){return t.union(i)})).update("storeStates",(function(t){return S(t,n)}))}function c(t,e,n){var r=e;(0,T.isKeyPath)(e)&&(e=(0,w.fromKeyPath)(e));var i=t.get("nextId"),o=(0,w.getStoreDeps)(e),u=b.default.Map({id:i,storeDeps:o,getterKey:r,getter:e,handler:n}),a=void 0;return a=0===o.size?t.update("any",(function(t){return t.add(i)})):t.withMutations((function(t){o.forEach((function(e){var n=["stores",e];t.hasIn(n)||t.setIn(n,b.default.Set()),t.updateIn(["stores",e],(function(t){return t.add(i)}))}))})),a=a.set("nextId",i+1).setIn(["observersMap",i],u),{observerState:a,entry:u}}function f(t,e){var n=t.getIn(["options",e]);if(void 0===n)throw new Error("Invalid option: "+e);return n}function h(t,e,n){var r=t.get("observersMap").filter((function(t){var r=t.get("getterKey"),i=!n||t.get("handler")===n;return!!i&&((0,T.isKeyPath)(e)&&(0,T.isKeyPath)(r)?(0,T.isEqual)(e,r):e===r)}));return t.withMutations((function(t){r.forEach((function(e){return l(t,e)}))}))}function l(t,e){return t.withMutations((function(t){var n=e.get("id"),r=e.get("storeDeps");0===r.size?t.update("any",(function(t){return t.remove(n)})):r.forEach((function(e){t.updateIn(["stores",e],(function(t){return t?t.remove(n):t}))})),t.removeIn(["observersMap",n])}))}function p(t){var e=t.get("state");return t.withMutations((function(t){var n=t.get("stores"),r=n.keySeq().toJS();n.forEach((function(n,r){var i=e.get(r),o=n.handleReset(i);if(void 0===o&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store handleReset() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,O.isImmutableValue)(o))throw new Error("Store reset state must be an immutable value, did you forget to call toImmutable");t.setIn(["state",r],o)})),t.update("storeStates",(function(t){return S(t,r)})),v(t)}))}function _(t,e){var n=t.get("state");if((0,T.isKeyPath)(e))return i(n.getIn(e),t);if(!(0,w.isGetter)(e))throw new Error("evaluate must be passed a keyPath or Getter");var r=t.get("cache"),o=r.lookup(e),u=!o||y(t,o);return u&&(o=g(t,e)),i(o.get("value"),t.update("cache",(function(t){return u?t.miss(e,o):t.hit(e)})))}function d(t){var e={};return t.get("stores").forEach((function(n,r){var i=t.getIn(["state",r]),o=n.serialize(i);void 0!==o&&(e[r]=o)})),e}function v(t){return t.set("dirtyStores",b.default.Set())}function y(t,e){var n=e.get("storeStates");return!n.size||n.some((function(e,n){return t.getIn(["storeStates",n])!==e}))}function g(t,e){var n=(0,w.getDeps)(e).map((function(e){return _(t,e).result})),r=(0,w.getComputeFn)(e).apply(null,n),i=(0,w.getStoreDeps)(e),o=(0,O.toImmutable)({}).withMutations((function(e){i.forEach((function(n){var r=t.getIn(["storeStates",n]);e.set(n,r)}))}));return(0,I.CacheEntry)({value:r,storeStates:o,dispatchId:t.get("dispatchId")})}function m(t){return t.update("dispatchId",(function(t){return t+1}))}function S(t,e){return t.withMutations((function(t){e.forEach((function(e){var n=t.has(e)?t.get(e)+1:1;t.set(e,n)}))}))}Object.defineProperty(e,"__esModule",{value:!0}),e.registerStores=o,e.replaceStores=u,e.dispatch=a,e.loadState=s,e.addObserver=c,e.getOption=f,e.removeObserver=h,e.removeObserverByEntry=l,e.reset=p,e.evaluate=_,e.serialize=d,e.resetDirtyStores=v;var E=n(3),b=r(E),I=n(9),O=n(5),w=n(10),T=n(11),A=n(4),C=b.default.Record({result:null,reactorState:null})}),(function(t,e,n){function r(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function i(){return new s}Object.defineProperty(e,"__esModule",{value:!0});var o=(function(){function t(t,e){for(var n=0;nn.dispatchId)throw new Error("Refusing to cache older value");return n})))}},{key:"evict",value:function(e){return new t(this.cache.remove(e))}}]),t})();e.BasicCache=s;var c=1e3,f=1,h=(function(){function t(){var e=arguments.length<=0||void 0===arguments[0]?c:arguments[0],n=arguments.length<=1||void 0===arguments[1]?f:arguments[1],i=arguments.length<=2||void 0===arguments[2]?new s:arguments[2],o=arguments.length<=3||void 0===arguments[3]?(0,u.OrderedSet)():arguments[3];r(this,t),console.log("using LRU"),this.limit=e,this.evictCount=n,this.cache=i,this.lru=o}return o(t,[{key:"lookup",value:function(t,e){return this.cache.lookup(t,e)}},{key:"has",value:function(t){return this.cache.has(t)}},{key:"asMap",value:function(){return this.cache.asMap()}},{key:"hit",value:function(e){return this.cache.has(e)?new t(this.limit,this.evictCount,this.cache,this.lru.remove(e).add(e)):this}},{key:"miss",value:function(e,n){var r;if(this.lru.size>=this.limit){if(this.has(e))return new t(this.limit,this.evictCount,this.cache.miss(e,n),this.lru.remove(e).add(e));var i=this.lru.take(this.evictCount).reduce((function(t,e){return t.evict(e)}),this.cache).miss(e,n);r=new t(this.limit,this.evictCount,i,this.lru.skip(this.evictCount).add(e))}else r=new t(this.limit,this.evictCount,this.cache.miss(e,n),this.lru.add(e));return r}},{key:"evict",value:function(e){return this.cache.has(e)?new t(this.limit,this.evictCount,this.cache.evict(e),this.lru.remove(e)):this}}]),t})();e.LRUCache=h}),(function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,l.isArray)(t)&&(0,l.isFunction)(t[t.length-1])}function o(t){return t[t.length-1]}function u(t){return t.slice(0,t.length-1)}function a(t,e){e||(e=h.default.Set());var n=h.default.Set().withMutations((function(e){if(!i(t))throw new Error("getFlattenedDeps must be passed a Getter");u(t).forEach((function(t){if((0,p.isKeyPath)(t))e.add((0,f.List)(t));else{if(!i(t))throw new Error("Invalid getter, each dependency must be a KeyPath or Getter");e.union(a(t))}}))}));return e.union(n)}function s(t){if(!(0,p.isKeyPath)(t))throw new Error("Cannot create Getter from KeyPath: "+t);return[t,_]}function c(t){if(t.hasOwnProperty("__storeDeps"))return t.__storeDeps;var e=a(t).map((function(t){return t.first()})).filter((function(t){return!!t}));return Object.defineProperty(t,"__storeDeps",{enumerable:!1,configurable:!1,writable:!1,value:e}),e}Object.defineProperty(e,"__esModule",{value:!0});var f=n(3),h=r(f),l=n(4),p=n(11),_=function(t){return t};e.default={isGetter:i,getComputeFn:o,getFlattenedDeps:a,getStoreDeps:c,getDeps:u,fromKeyPath:s},t.exports=e.default}),(function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,s.isArray)(t)&&!(0,s.isFunction)(t[t.length-1])}function o(t,e){var n=a.default.List(t),r=a.default.List(e);return a.default.is(n,r)}Object.defineProperty(e,"__esModule",{value:!0}),e.isKeyPath=i,e.isEqual=o;var u=n(3),a=r(u),s=n(4)}),(function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(8),i={dispatchStart:function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.groupCollapsed("Dispatch: %s",e),console.group("payload"),console.debug(n),console.groupEnd())},dispatchError:function(t,e){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.debug("Dispatch error: "+e),console.groupEnd())},dispatchEnd:function(t,e,n,i){(0,r.getOption)(t,"logDispatches")&&console.group&&((0,r.getOption)(t,"logDirtyStores")&&console.log("Stores updated:",n.toList().toJS()),(0,r.getOption)(t,"logAppState")&&console.debug("Dispatch done, new state: ",e.toJS()),console.groupEnd())}};e.ConsoleGroupLogger=i;var o={dispatchStart:function(t,e,n){},dispatchError:function(t,e){},dispatchEnd:function(t,e,n){}};e.NoopLogger=o}),(function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(3),i=n(9),o=n(12),u=(0,r.Map)({logDispatches:!1,logAppState:!1,logDirtyStores:!1,throwOnUndefinedActionType:!1,throwOnUndefinedStoreReturnValue:!1,throwOnNonImmutableStore:!1,throwOnDispatchInDispatch:!1});e.PROD_OPTIONS=u;var a=(0,r.Map)({logDispatches:!0,logAppState:!0,logDirtyStores:!0,throwOnUndefinedActionType:!0,throwOnUndefinedStoreReturnValue:!0,throwOnNonImmutableStore:!0,throwOnDispatchInDispatch:!0});e.DEBUG_OPTIONS=a;var s=(0,r.Record)({dispatchId:0,state:(0,r.Map)(),stores:(0,r.Map)(),cache:(0,i.DefaultCache)(),logger:o.NoopLogger,storeStates:(0,r.Map)(),dirtyStores:(0,r.Set)(),debug:!1,options:u});e.ReactorState=s;var c=(0,r.Record)({any:(0,r.Set)(),stores:(0,r.Map)({}),observersMap:(0,r.Map)({}),nextId:1});e.ObserverState=c})])}))})),ke=t(je),Ne=function(t){var e,n={};if(!(t instanceof Object)||Array.isArray(t))throw new Error("keyMirror(...): Argument must be an object.");for(e in t)t.hasOwnProperty(e)&&(n[e]=e);return n},Pe=Ne,Ue=Pe({VALIDATING_AUTH_TOKEN:null,VALID_AUTH_TOKEN:null,INVALID_AUTH_TOKEN:null,LOG_OUT:null}),He=ke.Store,xe=ke.toImmutable,Ve=new He({getInitialState:function(){return xe({isValidating:!1,authToken:!1,host:null,isInvalid:!1,errorMessage:""})},initialize:function(){this.on(Ue.VALIDATING_AUTH_TOKEN,n),this.on(Ue.VALID_AUTH_TOKEN,r),this.on(Ue.INVALID_AUTH_TOKEN,i)}}),qe=ke.Store,Fe=ke.toImmutable,Ge=new qe({getInitialState:function(){return Fe({authToken:null,host:""})},initialize:function(){this.on(Ue.VALID_AUTH_TOKEN,o),this.on(Ue.LOG_OUT,u)}}),Ke=ke.Store,Be=new Ke({getInitialState:function(){return!0},initialize:function(){this.on(Ue.VALID_AUTH_TOKEN,a)}}),Ye=Pe({STREAM_START:null,STREAM_STOP:null,STREAM_ERROR:null}),Je=ke.Store,We=ke.toImmutable,Xe=new Je({getInitialState:function(){return We({isStreaming:!1,hasError:!1})},initialize:function(){this.on(Ye.STREAM_START,s),this.on(Ye.STREAM_ERROR,c),this.on(Ye.LOG_OUT,f)}}),Qe=1,Ze=2,$e=3,tn=function(t,e){this.url=t,this.options=e||{},this.commandId=1,this.commands={},this.connectionTries=0,this.eventListeners={},this.closeRequested=!1};tn.prototype.addEventListener=function(t,e){var n=this.eventListeners[t];n||(n=this.eventListeners[t]=[]),n.push(e)},tn.prototype.fireEvent=function(t){var e=this;(this.eventListeners[t]||[]).forEach((function(t){return t(e)}))},tn.prototype.connect=function(){var t=this;return new Promise(function(e,n){var r=t.commands;Object.keys(r).forEach((function(t){var e=r[t];e.reject&&e.reject(S($e,"Connection lost"))}));var i=!1;t.connectionTries+=1,t.socket=new WebSocket(t.url),t.socket.addEventListener("open",(function(){t.connectionTries=0})),t.socket.addEventListener("message",(function(o){var u=JSON.parse(o.data);switch(u.type){case"event":t.commands[u.id].eventCallback(u.event);break;case"result":u.success?t.commands[u.id].resolve(u):t.commands[u.id].reject(u.error),delete t.commands[u.id];break;case"pong":break; +case"auth_required":t.sendMessage(h(t.options.authToken));break;case"auth_invalid":n(Ze),i=!0;break;case"auth_ok":e(t),t.fireEvent("ready"),t.commandId=1,t.commands={},Object.keys(r).forEach((function(e){var n=r[e];n.eventType&&t.subscribeEvents(n.eventCallback,n.eventType).then((function(t){n.unsubscribe=t}))}))}})),t.socket.addEventListener("close",(function(){if(!i&&!t.closeRequested){0===t.connectionTries?t.fireEvent("disconnected"):n(Qe);var e=1e3*Math.min(t.connectionTries,5);setTimeout((function(){return t.connect()}),e)}}))})},tn.prototype.close=function(){this.closeRequested=!0,this.socket.close()},tn.prototype.getStates=function(){return this.sendMessagePromise(l()).then(E)},tn.prototype.getServices=function(){return this.sendMessagePromise(_()).then(E)},tn.prototype.getPanels=function(){return this.sendMessagePromise(d()).then(E)},tn.prototype.getConfig=function(){return this.sendMessagePromise(p()).then(E)},tn.prototype.callService=function(t,e,n){return this.sendMessagePromise(v(t,e,n))},tn.prototype.subscribeEvents=function(t,e){var n=this;return this.sendMessagePromise(y(e)).then((function(r){var i={eventCallback:t,eventType:e,unsubscribe:function(){return n.sendMessagePromise(g(r.id)).then((function(){delete n.commands[r.id]}))}};return n.commands[r.id]=i,function(){return i.unsubscribe()}}))},tn.prototype.ping=function(){return this.sendMessagePromise(m())},tn.prototype.sendMessage=function(t){this.socket.send(JSON.stringify(t))},tn.prototype.sendMessagePromise=function(t){var e=this;return new Promise(function(n,r){e.commandId+=1;var i=e.commandId;t.id=i,e.commands[i]={resolve:n,reject:r},e.sendMessage(t)})};var en=Pe({API_FETCH_ALL_START:null,API_FETCH_ALL_SUCCESS:null,API_FETCH_ALL_FAIL:null,SYNC_SCHEDULED:null,SYNC_SCHEDULE_CANCELLED:null}),nn=ke.Store,rn=new nn({getInitialState:function(){return!0},initialize:function(){this.on(en.API_FETCH_ALL_START,(function(){return!0})),this.on(en.API_FETCH_ALL_SUCCESS,(function(){return!1})),this.on(en.API_FETCH_ALL_FAIL,(function(){return!1})),this.on(en.LOG_OUT,(function(){return!1}))}}),on=I,un=Pe({API_FETCH_SUCCESS:null,API_FETCH_START:null,API_FETCH_FAIL:null,API_SAVE_SUCCESS:null,API_SAVE_START:null,API_SAVE_FAIL:null,API_DELETE_SUCCESS:null,API_DELETE_START:null,API_DELETE_FAIL:null,LOG_OUT:null}),an=ke.Store,sn=ke.toImmutable,cn=new an({getInitialState:function(){return sn({})},initialize:function(){var t=this;this.on(un.API_FETCH_SUCCESS,O),this.on(un.API_SAVE_SUCCESS,O),this.on(un.API_DELETE_SUCCESS,w),this.on(un.LOG_OUT,(function(){return t.getInitialState()}))}}),fn=Object.getOwnPropertySymbols,hn=Object.prototype.hasOwnProperty,ln=Object.prototype.propertyIsEnumerable,pn=A()?Object.assign:function(t,e){for(var n,r,i=arguments,o=T(t),u=1;u \ No newline at end of file +n&&e.updateNativeStyleProperties(document.documentElement,this.customStyle)}};return r}(),function(){"use strict";var e=Polymer.Base.serializeValueToAttribute,t=Polymer.StyleProperties,n=Polymer.StyleTransformer,r=Polymer.StyleDefaults,s=Polymer.Settings.useNativeShadow,i=Polymer.Settings.useNativeCSSProperties;Polymer.Base._addFeature({_prepStyleProperties:function(){i||(this._ownStylePropertyNames=this._styles&&this._styles.length?t.decorateStyles(this._styles,this):null)},customStyle:null,getComputedStyleValue:function(e){return i||this._styleProperties||this._computeStyleProperties(),!i&&this._styleProperties&&this._styleProperties[e]||getComputedStyle(this).getPropertyValue(e)},_setupStyleProperties:function(){this.customStyle={},this._styleCache=null,this._styleProperties=null,this._scopeSelector=null,this._ownStyleProperties=null,this._customStyle=null},_needsStyleProperties:function(){return Boolean(!i&&this._ownStylePropertyNames&&this._ownStylePropertyNames.length)},_validateApplyShim:function(){if(this.__applyShimInvalid){Polymer.ApplyShim.transform(this._styles,this.__proto__);var e=n.elementStyles(this);if(s){var t=this._template.content.querySelector("style");t&&(t.textContent=e)}else{var r=this._scopeStyle&&this._scopeStyle.nextSibling;r&&(r.textContent=e)}}},_beforeAttached:function(){this._scopeSelector&&!this.__stylePropertiesInvalid||!this._needsStyleProperties()||(this.__stylePropertiesInvalid=!1,this._updateStyleProperties())},_findStyleHost:function(){for(var e,t=this;e=Polymer.dom(t).getOwnerRoot();){if(Polymer.isInstance(e.host))return e.host;t=e.host}return r},_updateStyleProperties:function(){var e,n=this._findStyleHost();n._styleProperties||n._computeStyleProperties(),n._styleCache||(n._styleCache=new Polymer.StyleCache);var r=t.propertyDataFromStyles(n._styles,this),i=!this.__notStyleScopeCacheable;i&&(r.key.customStyle=this.customStyle,e=n._styleCache.retrieve(this.is,r.key,this._styles));var a=Boolean(e);a?this._styleProperties=e._styleProperties:this._computeStyleProperties(r.properties),this._computeOwnStyleProperties(),a||(e=o.retrieve(this.is,this._ownStyleProperties,this._styles));var l=Boolean(e)&&!a,c=this._applyStyleProperties(e);a||(c=c&&s?c.cloneNode(!0):c,e={style:c,_scopeSelector:this._scopeSelector,_styleProperties:this._styleProperties},i&&(r.key.customStyle={},this.mixin(r.key.customStyle,this.customStyle),n._styleCache.store(this.is,e,r.key,this._styles)),l||o.store(this.is,Object.create(e),this._ownStyleProperties,this._styles))},_computeStyleProperties:function(e){var n=this._findStyleHost();n._styleProperties||n._computeStyleProperties();var r=Object.create(n._styleProperties),s=t.hostAndRootPropertiesForScope(this);this.mixin(r,s.hostProps),e=e||t.propertyDataFromStyles(n._styles,this).properties,this.mixin(r,e),this.mixin(r,s.rootProps),t.mixinCustomStyle(r,this.customStyle),t.reify(r),this._styleProperties=r},_computeOwnStyleProperties:function(){for(var e,t={},n=0;n0&&l.push(t);return[{removed:a,added:l}]}},Polymer.Collection.get=function(e){return Polymer._collections.get(e)||new Polymer.Collection(e)},Polymer.Collection.applySplices=function(e,t){var n=Polymer._collections.get(e);return n?n._applySplices(t):null},Polymer({is:"dom-repeat",extends:"template",_template:null,properties:{items:{type:Array},as:{type:String,value:"item"},indexAs:{type:String,value:"index"},sort:{type:Function,observer:"_sortChanged"},filter:{type:Function,observer:"_filterChanged"},observe:{type:String,observer:"_observeChanged"},delay:Number,renderedItemCount:{type:Number,notify:!0,readOnly:!0},initialCount:{type:Number,observer:"_initializeChunking"},targetFramerate:{type:Number,value:20},_targetFrameTime:{type:Number,computed:"_computeFrameTime(targetFramerate)"}},behaviors:[Polymer.Templatizer],observers:["_itemsChanged(items.*)"],created:function(){this._instances=[],this._pool=[],this._limit=1/0;var e=this;this._boundRenderChunk=function(){e._renderChunk()}},detached:function(){this.__isDetached=!0;for(var e=0;e=0;t--){var n=this._instances[t];n.isPlaceholder&&t=this._limit&&(n=this._downgradeInstance(t,n.__key__)),e[n.__key__]=t,n.isPlaceholder||n.__setProperty(this.indexAs,t,!0)}this._pool.length=0,this._setRenderedItemCount(this._instances.length),this.fire("dom-change"),this._tryRenderChunk()},_applyFullRefresh:function(){var e,t=this.collection;if(this._sortFn)e=t?t.getKeys():[];else{e=[];var n=this.items;if(n)for(var r=0;r=r;a--)this._detachAndRemoveInstance(a)},_numericSort:function(e,t){return e-t},_applySplicesUserSort:function(e){for(var t,n,r=this.collection,s={},i=0;i=0;i--){var c=a[i];void 0!==c&&this._detachAndRemoveInstance(c)}var h=this;if(l.length){this._filterFn&&(l=l.filter(function(e){return h._filterFn(r.getItem(e))})),l.sort(function(e,t){return h._sortFn(r.getItem(e),r.getItem(t))});var u=0;for(i=0;i>1,a=this._instances[o].__key__,l=this._sortFn(n.getItem(a),r);if(l<0)e=o+1;else{if(!(l>0)){i=o;break}s=o-1}}return i<0&&(i=s+1),this._insertPlaceholder(i,t),i},_applySplicesArrayOrder:function(e){for(var t,n=0;n=0?(e=this.as+"."+e.substring(n+1),i._notifyPath(e,t,!0)):i.__setProperty(this.as,t,!0))}},itemForElement:function(e){var t=this.modelForElement(e);return t&&t[this.as]},keyForElement:function(e){var t=this.modelForElement(e);return t&&t.__key__},indexForElement:function(e){var t=this.modelForElement(e);return t&&t[this.indexAs]}}),Polymer({is:"array-selector",_template:null,properties:{items:{type:Array,observer:"clearSelection"},multi:{type:Boolean,value:!1,observer:"clearSelection"},selected:{type:Object,notify:!0},selectedItem:{type:Object,notify:!0},toggle:{type:Boolean,value:!1}},clearSelection:function(){if(Array.isArray(this.selected))for(var e=0;e \ 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 a8fa716d1ea..6c318038174 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 988ac002816..5159326a7b3 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 988ac0028163cfc970e781718bc9459ed486ea61 +Subproject commit 5159326a7b3d1ba29ae17a7861fa2eaa8c2c95f6 diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html index ce1d5d24574..44dfedbfb39 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 774ce87fa36..b540bf33f7d 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/panels/ha-panel-dev-service.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html index 8364b2e9991..c3068300cd2 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz index 0215ae8c97c..5bfed6cbac9 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html index 840f82cfd48..ac1979c3cb1 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html @@ -1,4 +1,4 @@ - \ 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,.is-outside-current-month .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/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz index 8d03ffbcfe4..589cf52692c 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html index 8a13ca837f7..c3fd949967a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html @@ -1,4 +1,4 @@ - \ 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,.is-outside-current-month .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/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index ebe5f5edee4..2844f1ce8e0 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html index 38dd2b6e961..42097a123a2 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -75,8 +75,15 @@ Polymer({ }, fitMap: function () { - var bounds = new window.L.latLngBounds( - this._mapItems.map(function (item) { return item.getLatLng(); })); + var bounds; + + if (this._mapItems.length === 0) { + bounds = new window.L.latLngBounds( + [window.L.latLng(this.locationGPS.latitude, this.locationGPS.longitude)]); + } else { + bounds = new window.L.latLngBounds( + this._mapItems.map(function (item) { return item.getLatLng(); })); + } this._map.fitBounds(bounds.pad(0.5)); }, diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 56a98ed3431..f542d52730d 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-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 e8c6a4989d8..3c0d0ab8b0d 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","8fc0e185070f60174a8d0bad69226022"],["/frontend/panels/dev-event-f19840b9a6a46f57cb064b384e1353f5.html","21cf247351b95fdd451c304e308a726c"],["/frontend/panels/dev-info-3765a371478cc66d677cf6dcc35267c6.html","dd614f2ee5e09a9dfd7f98822a55893d"],["/frontend/panels/dev-service-e32bcd3afdf485417a3e20b4fc760776.html","d7b70007dfb97e8ccbaa79bc2b41a51d"],["/frontend/panels/dev-state-8257d99a38358a150eafdb23fa6727e0.html","3cf24bb7e92c759b35a74cf641ed80cb"],["/frontend/panels/dev-template-cbb251acabd5e7431058ed507b70522b.html","edd6ef67f4ab763f9d3dd7d3aa6f4007"],["/frontend/panels/map-3b0ca63286cbe80f27bd36dbc2434e89.html","d22eee1c33886ce901851ccd35cb43ed"],["/static/core-22d39af274e1d824ca1302e10971f2d8.js","c6305fc1dee07b5bf94de95ffaccacc4"],["/static/frontend-61e57194179b27563a05282b58dd4f47.html","0a0bbfc6567f7ba6a4dabd7fef6a0ee7"],["/static/mdi-48fcee544a61b668451faf2b7295df70.html","08069e54df1fd92bbff70299605d8585"],["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/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","89313f9f2126ddea722150f8154aca03"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e.delete(a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;a confidence: + confidence = i_co + face_name = i_name + return face_name + + @property + def state_attributes(self): + """Return device specific state attributes.""" + attr = { + ATTR_KNOWN_FACES: self.known_faces, + ATTR_TOTAL_FACES: self.total_faces, + } + + return attr + + def process_faces(self, known, total): + """Send event with detected faces and store data.""" + run_callback_threadsafe( + self.hass.loop, self.async_process_faces, known, total + ).result() + + @callback + def async_process_faces(self, known, total): + """Send event with detected faces and store data. + + known are a dict in follow format: + { 'name': confidence } + + This method must be run in the event loop. + """ + detect = {name: confidence for name, confidence in known.items() + if confidence >= self.confidence} + + # send events + for name, confidence in detect.items(): + self.hass.async_add_job( + self.hass.bus.async_fire, EVENT_IDENTIFY_FACE, { + ATTR_NAME: name, + ATTR_ENTITY_ID: self.entity_id, + ATTR_CONFIDENCE: confidence, + } + ) + + # update entity store + self.known_faces = detect + self.total_faces = total + + +class MicrosoftFaceIdentifyEntity(ImageProcessingFaceIdentifyEntity): + """Microsoft face api entity for identify.""" + + def __init__(self, camera_entity, api, face_group, confidence, name=None): + """Initialize openalpr local api.""" + super().__init__() + + self._api = api + self._camera = camera_entity + self._confidence = confidence + self._face_group = face_group + + if name: + self._name = name + else: + self._name = "MicrosoftFace {0}".format( + split_entity_id(camera_entity)[1]) + + @property + def confidence(self): + """Return minimum confidence for send events.""" + return self._confidence + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @asyncio.coroutine + def async_process_image(self, image): + """Process image. + + This method is a coroutine. + """ + detect = None + try: + face_data = yield from self._api.call_api( + 'post', 'detect', image, binary=True) + + if face_data is None or len(face_data) < 1: + return + + face_ids = [data['faceId'] for data in face_data] + detect = yield from self._api.call_api( + 'post', 'identify', + {'faceIds': face_ids, 'personGroupId': self._face_group}) + + except HomeAssistantError as err: + _LOGGER.error("Can't process image on microsoft face: %s", err) + return + + # parse data + knwon_faces = {} + total = 0 + for face in detect: + total += 1 + if len(face['candidates']) == 0: + continue + + data = face['candidates'][0] + name = '' + for s_name, s_id in self._api.store[self._face_group].items(): + if data['personId'] == s_id: + name = s_name + break + + knwon_faces[name] = data['confidence'] * 100 + + # process data + self.async_process_faces(knwon_faces, total) diff --git a/homeassistant/components/image_processing/openalpr_cloud.py b/homeassistant/components/image_processing/openalpr_cloud.py index 61b3442856a..7c7d26ce724 100644 --- a/homeassistant/components/image_processing/openalpr_cloud.py +++ b/homeassistant/components/image_processing/openalpr_cloud.py @@ -26,25 +26,26 @@ _LOGGER = logging.getLogger(__name__) OPENALPR_API_URL = "https://api.openalpr.com/v1/recognize" OPENALPR_REGIONS = [ - 'us', - 'eu', 'au', 'auwide', + 'br', + 'eu', + 'fr', 'gb', 'kr', + 'kr2', 'mx', 'sg', + 'us', + 'vn2' ] CONF_REGION = 'region' -DEFAULT_CONFIDENCE = 80 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), - vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) }) diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index a1736c00ffc..319f14c1f3d 100644 --- a/homeassistant/components/image_processing/openalpr_local.py +++ b/homeassistant/components/image_processing/openalpr_local.py @@ -31,28 +31,30 @@ ATTR_PLATES = 'plates' ATTR_VEHICLES = 'vehicles' OPENALPR_REGIONS = [ - 'us', - 'eu', 'au', 'auwide', + 'br', + 'eu', + 'fr', 'gb', 'kr', + 'kr2', 'mx', 'sg', + 'us', + 'vn2' ] + CONF_REGION = 'region' CONF_ALPR_BIN = 'alp_bin' DEFAULT_BINARY = 'alpr' -DEFAULT_CONFIDENCE = 80 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)), vol.Optional(CONF_ALPR_BIN, default=DEFAULT_BINARY): cv.string, - vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) }) @@ -79,11 +81,6 @@ class ImageProcessingAlprEntity(ImageProcessingEntity): self.plates = {} # last scan data self.vehicles = 0 # vehicles count - @property - def confidence(self): - """Return minimum confidence for send events.""" - return None - @property def state(self): """Return the state of the entity.""" diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index d83bffabc91..22f8b832b3d 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -244,9 +244,7 @@ def setup(hass, config): if CONFIG_FILE == {}: CONFIG_FILE[ATTR_DEVICES] = {} - # Notify needs to have discovery - # notify_config = {"notify": {CONF_PLATFORM: "ios"}} - # bootstrap.setup_component(hass, "notify", notify_config) + discovery.load_platform(hass, "notify", DOMAIN, {}, config) discovery.load_platform(hass, "sensor", DOMAIN, {}, config) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7451b3286f7..cbe7c7166e7 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -228,10 +228,6 @@ class ISYDevice(Entity): self._change_handler = self._node.status.subscribe('changed', self.on_update) - def __del__(self) -> None: - """Cleanup the subscriptions.""" - self._change_handler.unsubscribe() - # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" @@ -272,7 +268,7 @@ class ISYDevice(Entity): return self._node.status._val @property - def state_attributes(self) -> Dict: + def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" attr = {} if hasattr(self._node, 'aux_properties'): diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index de7eacf96dd..69151043276 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -1,32 +1,8 @@ """ Receive signals from a keyboard and use it as a remote control. -This component allows to use a keyboard as remote control. It will -fire ´keyboard_remote_command_received´ events witch can then be used -in automation rules. - -The `evdev` package is used to interface with the keyboard and thus this -is Linux only. It also means you can't use your normal keyboard for this, -because `evdev` will block it. - -Example: - keyboard_remote: - device_descriptor: '/dev/input/by-id/foo' - type: 'key_up' # optional alternaive 'key_down' and 'key_hold' - # be carefull, 'key_hold' fires a lot of events - - and an automation rule to bring breath live into it. - - automation: - alias: Keyboard All light on - trigger: - platform: event - event_type: keyboard_remote_command_received - event_data: - key_code: 107 # inspect log to obtain desired keycode - action: - service: light.turn_on - entity_id: light.all +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/keyboard_remote/ """ # pylint: disable=import-error @@ -48,14 +24,20 @@ REQUIREMENTS = ['evdev==0.6.1'] _LOGGER = logging.getLogger(__name__) ICON = 'mdi:remote' KEYBOARD_REMOTE_COMMAND_RECEIVED = 'keyboard_remote_command_received' +KEYBOARD_REMOTE_CONNECTED = 'keyboard_remote_connected' +KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected' KEY_CODE = 'key_code' KEY_VALUE = {'key_up': 0, 'key_down': 1, 'key_hold': 2} TYPE = 'type' DEVICE_DESCRIPTOR = 'device_descriptor' +DEVICE_NAME = 'device_name' +DEVICE_ID_GROUP = 'Device descriptor or name' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(DEVICE_DESCRIPTOR): cv.string, + vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, + vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, vol.Optional(TYPE, default='key_up'): vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')), }), @@ -65,22 +47,15 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Setup keyboard_remote.""" config = config.get(DOMAIN) - device_descriptor = config.get(DEVICE_DESCRIPTOR) - if not device_descriptor: - id_folder = '/dev/input/' - _LOGGER.error( - 'A device_descriptor must be defined. ' - 'Possible descriptors are %s:\n%s', - id_folder, os.listdir(id_folder) - ) - return - key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) + if not config.get(DEVICE_DESCRIPTOR) and\ + not config.get(DEVICE_NAME): + _LOGGER.error('No device_descriptor or device_name found.') + return keyboard_remote = KeyboardRemote( hass, - device_descriptor, - key_value + config ) def _start_keyboard_remote(_event): @@ -104,60 +79,93 @@ def setup(hass, config): class KeyboardRemote(threading.Thread): """This interfaces with the inputdevice using evdev.""" - def __init__(self, hass, device_descriptor, key_value): + def __init__(self, hass, config): """Construct a KeyboardRemote interface object.""" - from evdev import InputDevice + from evdev import InputDevice, list_devices - self.device_descriptor = device_descriptor - try: - self.dev = InputDevice(device_descriptor) - except OSError: # Keyboard not present - _LOGGER.debug( - 'KeyboardRemote: keyboard not connected, %s', - self.device_descriptor) - self.keyboard_connected = False + self.device_descriptor = config.get(DEVICE_DESCRIPTOR) + self.device_name = config.get(DEVICE_NAME) + if self.device_descriptor: + self.device_id = self.device_descriptor else: - self.keyboard_connected = True + self.device_id = self.device_name + self.dev = self._get_keyboard_device() + if self.dev is not None: _LOGGER.debug( - 'KeyboardRemote: keyboard connected, %s', - self.dev) + 'Keyboard connected, %s', + self.device_id + ) + else: + id_folder = '/dev/input/by-id/' + device_names = [InputDevice(file_name).name + for file_name in list_devices()] + _LOGGER.debug( + 'Keyboard not connected, %s.\n\ + Check /dev/input/event* permissions.\ + Possible device names are:\n %s.\n \ + Possible device descriptors are %s:\n %s', + self.device_id, + device_names, + id_folder, + os.listdir(id_folder) + ) threading.Thread.__init__(self) self.stopped = threading.Event() self.hass = hass - self.key_value = key_value + self.key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) + + def _get_keyboard_device(self): + from evdev import InputDevice, list_devices + if self.device_name: + devices = [InputDevice(file_name) for file_name in list_devices()] + for device in devices: + if self.device_name == device.name: + return device + elif self.device_descriptor: + try: + device = InputDevice(self.device_descriptor) + except OSError: + pass + else: + return device + return None def run(self): """Main loop of the KeyboardRemote.""" - from evdev import categorize, ecodes, InputDevice + from evdev import categorize, ecodes - if self.keyboard_connected: + if self.dev is not None: self.dev.grab() _LOGGER.debug( - 'KeyboardRemote interface started for %s', + 'Interface started for %s', self.dev) while not self.stopped.isSet(): # Sleeps to ease load on processor time.sleep(.1) - if not self.keyboard_connected: - try: - self.dev = InputDevice(self.device_descriptor) - except OSError: # still disconnected - continue - else: + if self.dev is None: + self.dev = self._get_keyboard_device() + if self.dev is not None: self.dev.grab() - self.keyboard_connected = True - _LOGGER.debug('KeyboardRemote: keyboard re-connected, %s', - self.device_descriptor) + self.hass.bus.fire( + KEYBOARD_REMOTE_CONNECTED + ) + _LOGGER.debug('Keyboard re-connected, %s', + self.device_id) + else: + continue try: event = self.dev.read_one() except IOError: # Keyboard Disconnected - self.keyboard_connected = False - _LOGGER.debug('KeyboardRemote: keyard disconnected, %s', - self.device_descriptor) + self.dev = None + self.hass.bus.fire( + KEYBOARD_REMOTE_DISCONNECTED + ) + _LOGGER.debug('Keyboard disconnected, %s', + self.device_id) continue if not event: diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py new file mode 100644 index 00000000000..929b2bc33ac --- /dev/null +++ b/homeassistant/components/light/avion.py @@ -0,0 +1,118 @@ +""" +Support for Avion dimmers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.avion/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, + PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['avion==0.5'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_AVION_LED = (SUPPORT_BRIGHTNESS) + +DEVICE_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up an Avion switch.""" + lights = [] + for address, device_config in config[CONF_DEVICES].items(): + device = {} + device['name'] = device_config[CONF_NAME] + device['key'] = device_config[CONF_API_KEY] + device['address'] = address + light = AvionLight(device) + if light.is_valid: + lights.append(light) + + add_devices(lights) + + +class AvionLight(Light): + """Representation of an Avion light.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import avion + + self._name = device['name'] + self._address = device['address'] + self._key = device['key'] + self._brightness = 255 + self._state = False + self._switch = avion.avion(self._address, self._key) + self._switch.connect() + self.is_valid = True + + @property + def unique_id(self): + """Return the ID of this light.""" + return "{}.{}".format(self.__class__, self._address) + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_AVION_LED + + @property + def should_poll(self): + """Don't poll.""" + return False + + @property + def assumed_state(self): + """We can't read the actual state, so assume it matches.""" + return True + + def set_state(self, brightness): + """Set the state of this lamp to the provided brightness.""" + self._switch.set_brightness(brightness) + return True + + def turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + + if brightness is not None: + self._brightness = brightness + + self.set_state(self.brightness) + self._state = True + + def turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + self.set_state(0) + self._state = False diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py new file mode 100644 index 00000000000..eaae90f486e --- /dev/null +++ b/homeassistant/components/light/decora.py @@ -0,0 +1,124 @@ +""" +Support for Decora dimmers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.decora/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, + PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['decora==0.3'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_DECORA_LED = (SUPPORT_BRIGHTNESS) + +DEVICE_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up an Decora switch.""" + lights = [] + for address, device_config in config[CONF_DEVICES].items(): + device = {} + device['name'] = device_config[CONF_NAME] + device['key'] = device_config[CONF_API_KEY] + device['address'] = address + light = DecoraLight(device) + if light.is_valid: + lights.append(light) + + add_devices(lights) + + +class DecoraLight(Light): + """Representation of an Decora light.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import decora + + self._name = device['name'] + self._address = device['address'] + self._key = device["key"] + self._switch = decora.decora(self._address, self._key) + self._switch.connect() + self._state = self._switch.get_on() + self._brightness = self._switch.get_brightness() + self.is_valid = True + + @property + def unique_id(self): + """Return the ID of this light.""" + return "{}.{}".format(self.__class__, self._address) + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_DECORA_LED + + @property + def should_poll(self): + """We can read the device state, so poll.""" + return True + + @property + def assumed_state(self): + """We can read the actual state.""" + return False + + def set_state(self, brightness): + """Set the state of this lamp to the provided brightness.""" + self._switch.set_brightness(brightness) + self._brightness = brightness + return True + + def turn_on(self, **kwargs): + """Turn the specified or all lights on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + + self._switch.on() + if brightness is not None: + self.set_state(brightness) + + self._state = True + + def turn_off(self, **kwargs): + """Turn the specified or all lights off.""" + self._switch.off() + self._state = False + + def update(self): + """Synchronise internal state with the actual light state.""" + self._brightness = self._switch.get_brightness() + self._state = self._switch.get_on() diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 259553cc620..3e1e81b05ea 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -25,6 +25,7 @@ from homeassistant.components.light import ( from homeassistant.config import load_yaml_config_file from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) from homeassistant.loader import get_component +from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['phue==0.9'] @@ -50,10 +51,21 @@ SUPPORT_HUE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION | SUPPORT_XY_COLOR) +CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" +DEFAULT_ALLOW_IN_EMULATED_HUE = True + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ALLOW_UNREACHABLE): cv.boolean, - vol.Optional(CONF_FILENAME): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_ALLOW_UNREACHABLE, + default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, + vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, + vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, + default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, + vol.Optional(CONF_ALLOW_HUE_GROUPS, + default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, }) ATTR_GROUP_NAME = "group_name" @@ -63,6 +75,8 @@ SCENE_SCHEMA = vol.Schema({ vol.Required(ATTR_SCENE_NAME): cv.string, }) +ATTR_IS_HUE_GROUP = "is_hue_group" + def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): """Attempt to detect host based on existing configuration.""" @@ -84,9 +98,10 @@ def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Hue lights.""" # Default needed in case of discovery - filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE) - allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE) + filename = config.get(CONF_FILENAME) + allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE) + allow_in_emulated_hue = config.get(CONF_ALLOW_IN_EMULATED_HUE) + allow_hue_groups = config.get(CONF_ALLOW_HUE_GROUPS) if discovery_info is not None: host = urlparse(discovery_info[1]).hostname @@ -109,10 +124,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): socket.gethostbyname(host) in _CONFIGURED_BRIDGES: return - setup_bridge(host, hass, add_devices, filename, allow_unreachable) + setup_bridge(host, hass, add_devices, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) -def setup_bridge(host, hass, add_devices, filename, allow_unreachable): +def setup_bridge(host, hass, add_devices, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups): """Setup a phue bridge based on host parameter.""" import phue @@ -129,7 +146,8 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable): _LOGGER.warning("Connected to Hue at %s but not registered.", host) request_configuration(host, hass, add_devices, filename, - allow_unreachable) + allow_unreachable, allow_in_emulated_hue, + allow_hue_groups) return @@ -143,7 +161,7 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable): lights = {} lightgroups = {} - skip_groups = False + skip_groups = not allow_hue_groups @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_lights(): @@ -184,7 +202,8 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable): if light_id not in lights: lights[light_id] = HueLight(int(light_id), info, bridge, update_lights, - bridge_type, allow_unreachable) + bridge_type, allow_unreachable, + allow_in_emulated_hue) new_lights.append(lights[light_id]) else: lights[light_id].info = info @@ -200,7 +219,8 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable): if lightgroup_id not in lightgroups: lightgroups[lightgroup_id] = HueLight( int(lightgroup_id), info, bridge, update_lights, - bridge_type, allow_unreachable, True) + bridge_type, allow_unreachable, allow_in_emulated_hue, + True) new_lights.append(lightgroups[lightgroup_id]) else: lightgroups[lightgroup_id].info = info @@ -229,7 +249,8 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable): def request_configuration(host, hass, add_devices, filename, - allow_unreachable): + allow_unreachable, allow_in_emulated_hue, + allow_hue_groups): """Request configuration steps from the user.""" configurator = get_component('configurator') @@ -243,7 +264,8 @@ def request_configuration(host, hass, add_devices, filename, # pylint: disable=unused-argument def hue_configuration_callback(data): """The actions to do when our configuration callback is called.""" - setup_bridge(host, hass, add_devices, filename, allow_unreachable) + setup_bridge(host, hass, add_devices, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) _CONFIGURING[host] = configurator.request_config( hass, "Philips Hue", hue_configuration_callback, @@ -259,7 +281,8 @@ class HueLight(Light): """Representation of a Hue light.""" def __init__(self, light_id, info, bridge, update_lights, - bridge_type, allow_unreachable, is_group=False): + bridge_type, allow_unreachable, allow_in_emulated_hue, + is_group=False): """Initialize the light.""" self.light_id = light_id self.info = info @@ -268,6 +291,7 @@ class HueLight(Light): self.bridge_type = bridge_type self.allow_unreachable = allow_unreachable self.is_group = is_group + self.allow_in_emulated_hue = allow_in_emulated_hue if is_group: self._command_func = self.bridge.set_group @@ -395,3 +419,13 @@ class HueLight(Light): def update(self): """Synchronize state with bridge.""" self.update_lights(no_throttle=True) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + if not self.allow_in_emulated_hue: + attributes[ATTR_EMULATED_HUE] = self.allow_in_emulated_hue + if self.is_group: + attributes[ATTR_IS_HUE_GROUP] = self.is_group + return attributes diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index c6a52be2842..c51c7d9d839 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -38,15 +38,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): setup_light(device_id, conf_lights[device_id], insteonhub, hass, add_devices) - linked = insteonhub.get_linked() + else: + linked = insteonhub.get_linked() - for device_id in linked: - if (linked[device_id]['cat_type'] == 'dimmer' and - device_id not in conf_lights): - request_configuration(device_id, - insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], hass, add_devices) + for device_id in linked: + if (linked[device_id]['cat_type'] == 'dimmer' and + device_id not in conf_lights): + request_configuration(device_id, + insteonhub, + linked[device_id]['model_name'] + ' ' + + linked[device_id]['sku'], + hass, add_devices) def request_configuration(device_id, insteonhub, model, hass, diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 952c52b2809..1cde50de820 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -8,18 +8,13 @@ import logging from typing import Callable from homeassistant.components.light import ( - Light, SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS) + Light, SUPPORT_BRIGHTNESS) import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN +from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - UOM = ['2', '51', '78'] STATES = [STATE_OFF, STATE_ON, 'true', 'false', '%'] @@ -52,12 +47,12 @@ class ISYLightDevice(isy.ISYDevice, Light): @property def is_on(self) -> bool: """Get whether the ISY994 light is on.""" - return self.state == STATE_ON + return self.value > 0 @property - def state(self) -> str: - """Get the state of the ISY994 light.""" - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + def brightness(self) -> float: + """Get the brightness of the ISY994 light.""" + return self.value def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 light device.""" @@ -69,11 +64,6 @@ class ISYLightDevice(isy.ISYDevice, Light): if not self._node.on(val=brightness): _LOGGER.debug('Unable to turn on light.') - @property - def state_attributes(self): - """Flag supported attributes.""" - return {ATTR_BRIGHTNESS: self.value} - @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/light/lutron.py b/homeassistant/components/light/lutron.py new file mode 100644 index 00000000000..7bc6fb50571 --- /dev/null +++ b/homeassistant/components/light/lutron.py @@ -0,0 +1,98 @@ +"""Support for Lutron lights.""" +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, DOMAIN, SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.lutron import ( + LutronDevice, LUTRON_DEVICES, LUTRON_GROUPS, LUTRON_CONTROLLER) + +DEPENDENCIES = ['lutron'] + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Lutron lights.""" + area_devs = {} + devs = [] + for (area_name, device) in hass.data[LUTRON_DEVICES]['light']: + dev = LutronLight(hass, area_name, device, + hass.data[LUTRON_CONTROLLER]) + area_devs.setdefault(area_name, []).append(dev) + devs.append(dev) + add_devices(devs, True) + + for area in area_devs: + if area not in hass.data[LUTRON_GROUPS]: + continue + grp = hass.data[LUTRON_GROUPS][area] + ids = list(grp.tracking) + [dev.entity_id for dev in area_devs[area]] + grp.update_tracked_entity_ids(ids) + + return True + + +def to_lutron_level(level): + """Convert the given HASS light level (0-255) to Lutron (0.0-100.0).""" + return float((level * 100) / 255) + + +def to_hass_level(level): + """Convert the given Lutron (0.0-100.0) light level to HASS (0-255).""" + return int((level * 255) / 100) + + +class LutronLight(LutronDevice, Light): + """Representation of a Lutron Light, including dimmable.""" + + def __init__(self, hass, area_name, lutron_device, controller): + """Initialize the light.""" + self._prev_brightness = None + LutronDevice.__init__(self, hass, DOMAIN, area_name, lutron_device, + controller) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def brightness(self): + """Return the brightness of the light.""" + new_brightness = to_hass_level(self._lutron_device.last_level()) + if new_brightness != 0: + self._prev_brightness = new_brightness + return new_brightness + + def turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: + brightness = kwargs[ATTR_BRIGHTNESS] + elif self._prev_brightness == 0: + brightness = 255 / 2 + else: + brightness = self._prev_brightness + self._prev_brightness = brightness + self._lutron_device.level = to_lutron_level(brightness) + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._lutron_device.level = 0 + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + attr['Lutron Integration ID'] = self._lutron_device.id + return attr + + @property + def is_on(self): + """Return true if device is on.""" + return self._lutron_device.last_level() > 0 + + def update(self): + """Called when forcing a refresh of the device.""" + if self._prev_brightness is None: + self._prev_brightness = to_hass_level(self._lutron_device.level) diff --git a/homeassistant/components/light/piglow.py b/homeassistant/components/light/piglow.py new file mode 100644 index 00000000000..d4e9c9ed106 --- /dev/null +++ b/homeassistant/components/light/piglow.py @@ -0,0 +1,104 @@ +""" +Support for Piglow LED's. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.piglow/ +""" +import logging +import subprocess + +import voluptuous as vol + +# Import the device class from the component that you want to support +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, + ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, + Light, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +# Home Assistant depends on 3rd party packages for API specific code. +REQUIREMENTS = ['piglow==1.2.4'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_PIGLOW = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) + +DEFAULT_NAME = 'Piglow' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Piglow Light platform.""" + import piglow + + if subprocess.getoutput("i2cdetect -q -y 1 | grep -o 54") != '54': + _LOGGER.error("A Piglow device was not found") + return False + + name = config.get(CONF_NAME) + + # Add devices + add_devices([PiglowLight(piglow, name)]) + + +class PiglowLight(Light): + """Representation of an Piglow Light.""" + + def __init__(self, piglow, name): + """Initialize an PiglowLight.""" + self._piglow = piglow + self._name = name + self._is_on = False + self._brightness = 255 + self._rgb_color = [255, 255, 255] + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Brightness of the light (an integer in the range 1-255).""" + return self._brightness + + @property + def rgb_color(self): + """Read back the color of the light.""" + return self._rgb_color + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_PIGLOW + + @property + def is_on(self): + """Return true if light is on.""" + return self._is_on + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + self._piglow.clear() + self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + percent_bright = (self._brightness / 255) + + if ATTR_RGB_COLOR in kwargs: + self._rgb_color = kwargs[ATTR_RGB_COLOR] + self._piglow.red(int(self._rgb_color[0] * percent_bright)) + self._piglow.green(int(self._rgb_color[1] * percent_bright)) + self._piglow.blue(int(self._rgb_color[2] * percent_bright)) + else: + self._piglow.all(self._brightness) + self._piglow.show() + self._is_on = True + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._piglow.clear() + self._piglow.show() + self._is_on = False diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index 5612f41c942..b963f14cfb4 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -5,8 +5,11 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.qwikswitch/ """ import logging + import homeassistant.components.qwikswitch as qwikswitch +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['qwikswitch'] @@ -14,7 +17,7 @@ DEPENDENCIES = ['qwikswitch'] def setup_platform(hass, config, add_devices, discovery_info=None): """Add lights from the main Qwikswitch component.""" if discovery_info is None: - logging.getLogger(__name__).error('Configure Qwikswitch Component.') + _LOGGER.error("Configure Qwikswitch component") return False add_devices(qwikswitch.QSUSB['light']) diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index ea908fda02f..9002731e44e 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -83,7 +83,9 @@ class TellstickLight(TellstickDevice, Light): def _send_device_command(self, requested_state, requested_data): """Let tellcore update the actual device to the requested state.""" if requested_state: - brightness = requested_data - self._tellcore_device.dim(brightness) + if requested_data is not None: + self._brightness = int(requested_data) + + self._tellcore_device.dim(self._brightness) else: self._tellcore_device.turn_off() diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 1a4556ee46b..dcff4b31a5c 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Wink lights.""" import pywink - add_devices(WinkLight(light, hass) for light in pywink.get_bulbs()) + add_devices(WinkLight(light, hass) for light in pywink.get_light_bulbs()) class WinkLight(WinkDevice, Light): diff --git a/homeassistant/components/light/x10.py b/homeassistant/components/light/x10.py index 30ede3eac18..48df8368294 100644 --- a/homeassistant/components/light/x10.py +++ b/homeassistant/components/light/x10.py @@ -33,16 +33,10 @@ def x10_command(command): return check_output(['heyu'] + command.split(' '), stderr=STDOUT) -def get_status(): - """Get on/off status for all x10 units in default housecode.""" - output = check_output('heyu info | grep monitored', shell=True) - return output.decode('utf-8').split(' ')[-1].strip('\n()') - - def get_unit_status(code): """Get on/off status for given unit.""" - unit = int(code[1:]) - return get_status()[16 - int(unit)] == '1' + output = check_output('heyu onstate ' + code, shell=True) + return int(output.decode('utf-8')[0]) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -63,8 +57,8 @@ class X10Light(Light): """Initialize an X10 Light.""" self._name = light['name'] self._id = light['id'] - self._is_on = False self._brightness = 0 + self._state = False @property def name(self): @@ -79,7 +73,7 @@ class X10Light(Light): @property def is_on(self): """Return true if light is on.""" - return self._is_on + return self._state @property def supported_features(self): @@ -90,13 +84,13 @@ class X10Light(Light): """Instruct the light to turn on.""" x10_command('on ' + self._id) self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - self._is_on = True + self._state = True def turn_off(self, **kwargs): """Instruct the light to turn off.""" x10_command('off ' + self._id) - self._is_on = False + self._state = False def update(self): - """Fetch new state data for this light.""" - self._is_on = get_unit_status(self._id) + """Fetch update state.""" + self._state = bool(get_unit_status(self._id)) diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index d973f8d8dd2..5bab9ace4c6 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -17,6 +17,7 @@ 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_rgb_to_rgbw, color_rgbw_to_rgb +from homeassistant.helpers import customize _LOGGER = logging.getLogger(__name__) @@ -42,7 +43,10 @@ TEMP_MID_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 2 + HASS_COLOR_MIN TEMP_WARM_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 3 * 2 + HASS_COLOR_MIN TEMP_COLD_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 3 + HASS_COLOR_MIN -SUPPORT_ZWAVE = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR +SUPPORT_ZWAVE_DIMMER = SUPPORT_BRIGHTNESS +SUPPORT_ZWAVE_COLOR = SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR +SUPPORT_ZWAVE_COLORTEMP = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + | SUPPORT_COLOR_TEMP) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -51,13 +55,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] - customize = hass.data['zwave_customize'] name = '{}.{}'.format(DOMAIN, zwave.object_id(value)) - node_config = customize.get(name, {}) + node_config = customize.get_overrides(hass, zwave.DOMAIN, name) refresh = node_config.get(zwave.CONF_REFRESH_VALUE) delay = node_config.get(zwave.CONF_REFRESH_DELAY) - _LOGGER.debug('customize=%s name=%s node_config=%s CONF_REFRESH_VALUE=%s' - ' CONF_REFRESH_DELAY=%s', customize, name, node_config, + _LOGGER.debug('name=%s node_config=%s CONF_REFRESH_VALUE=%s' + ' CONF_REFRESH_DELAY=%s', name, node_config, refresh, delay) if value.command_class != zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL: return @@ -87,9 +90,6 @@ class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): def __init__(self, value, refresh, delay): """Initialize the light.""" - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher - zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) self._brightness = None self._state = None @@ -115,38 +115,33 @@ class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): self._timer = None _LOGGER.debug('self._refreshing=%s self.delay=%s', self._refresh_value, self._delay) - dispatcher.connect( - self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) def update_properties(self): """Update internal properties based on zwave values.""" # Brightness self._brightness, self._state = brightness_state(self._value) - def _value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - if self._refresh_value: - if self._refreshing: - self._refreshing = False - self.update_properties() - else: - def _refresh_value(): - """Used timer callback for delayed value refresh.""" - self._refreshing = True - self._value.refresh() - - if self._timer is not None and self._timer.isAlive(): - self._timer.cancel() - - self._timer = Timer(self._delay, _refresh_value) - self._timer.start() - self.schedule_update_ha_state() - else: + def value_changed(self, value): + """Called when a value for this entity's node has changed.""" + if self._refresh_value: + if self._refreshing: + self._refreshing = False self.update_properties() - self.schedule_update_ha_state() + else: + def _refresh_value(): + """Used timer callback for delayed value refresh.""" + self._refreshing = True + self._value.refresh() + + if self._timer is not None and self._timer.isAlive(): + self._timer.cancel() + + self._timer = Timer(self._delay, _refresh_value) + self._timer.start() + self.schedule_update_ha_state() + else: + self.update_properties() + self.schedule_update_ha_state() @property def brightness(self): @@ -161,7 +156,7 @@ class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_ZWAVE + return SUPPORT_ZWAVE_DIMMER def turn_on(self, **kwargs): """Turn the device on.""" @@ -351,3 +346,11 @@ class ZwaveColorLight(ZwaveDimmer): self._value_color.node.set_rgbw(self._value_color.value_id, rgbw) super().turn_on(**kwargs) + + @property + def supported_features(self): + """Flag supported features.""" + if self._zw098: + return SUPPORT_ZWAVE_COLORTEMP + else: + return SUPPORT_ZWAVE_COLOR diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 40a7c3ffe38..6b12d49302d 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,21 +1,57 @@ -lock: - description: Lock all or specified locks - - fields: - entity_id: - description: Name of lock to lock - example: 'lock.front_door' - code: - description: An optional code to lock the lock with - example: 1234 - -unlock: - description: Unlock all or specified locks - - fields: - entity_id: - description: Name of lock to unlock - example: 'lock.front_door' - code: - description: An optional code to unlock the lock with - example: 1234 +clear_usercode: + description: Clear a usercode from lock + + fields: + node_id: + description: Node id of the lock + example: 18 + code_slot: + description: Code slot to clear code from + example: 1 + +get_usercode: + description: Retrieve a usercode from lock + + fields: + node_id: + description: Node id of the lock + example: 18 + code_slot: + description: Code slot to retrive a code from + example: 1 + +lock: + description: Lock all or specified locks + + fields: + entity_id: + description: Name of lock to lock + example: 'lock.front_door' + code: + description: An optional code to lock the lock with + example: 1234 + +set_usercode: + description: Set a usercode to lock + + fields: + node_id: + description: Node id of the lock + example: 18 + code_slot: + description: Code slot to set the code + example: 1 + usercode: + description: Code to set + example: 1234 + +unlock: + description: Unlock all or specified locks + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + code: + description: An optional code to unlock the lock with + example: 1234 diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 17fc30e93cf..6ff628f158f 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -7,13 +7,24 @@ https://home-assistant.io/components/lock.zwave/ # Because we do not compile openzwave on CI # pylint: disable=import-error import logging +from os import path + +import voluptuous as vol from homeassistant.components.lock import DOMAIN, LockDevice from homeassistant.components import zwave +from homeassistant.config import load_yaml_config_file _LOGGER = logging.getLogger(__name__) ATTR_NOTIFICATION = 'notification' +ATTR_LOCK_STATUS = 'lock_status' +ATTR_CODE_SLOT = 'code_slot' +ATTR_USERCODE = 'usercode' + +SERVICE_SET_USERCODE = 'set_usercode' +SERVICE_GET_USERCODE = 'get_usercode' +SERVICE_CLEAR_USERCODE = 'clear_usercode' LOCK_NOTIFICATION = { 1: 'Manual Lock', @@ -22,18 +33,80 @@ LOCK_NOTIFICATION = { 4: 'RF Unlock', 5: 'Keypad Lock', 6: 'Keypad Unlock', + 11: 'Lock Jammed', 254: 'Unknown Event' } +LOCK_ALARM_TYPE = { + 9: 'Deadbolt Jammed', + 18: 'Locked with Keypad by user', + 19: 'Unlocked with Keypad by user ', + 21: 'Manually Locked by', + 22: 'Manually Unlocked by Key or Inside thumb turn', + 24: 'Locked by RF', + 25: 'Unlocked by RF', + 27: 'Auto re-lock', + 33: 'User deleted: ', + 112: 'Master code changed or User added: ', + 113: 'Duplicate Pin-code: ', + 130: 'RF module, power restored', + 161: 'Tamper Alarm: ', + 167: 'Low Battery', + 168: 'Critical Battery Level', + 169: 'Battery too low to operate' +} + +MANUAL_LOCK_ALARM_LEVEL = { + 1: 'Key Cylinder or Inside thumb turn', + 2: 'Touch function (lock and leave)' +} + +TAMPER_ALARM_LEVEL = { + 1: 'Too many keypresses', + 2: 'Cover removed' +} + LOCK_STATUS = { 1: True, 2: False, 3: True, 4: False, 5: True, - 6: False + 6: False, + 9: False, + 18: True, + 19: False, + 21: True, + 22: False, + 24: True, + 25: False, + 27: True } +ALARM_TYPE_STD = [ + 18, + 19, + 33, + 112, + 113 +] + +SET_USERCODE_SCHEMA = vol.Schema({ + vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), + vol.Required(ATTR_USERCODE): vol.Coerce(int), +}) + +GET_USERCODE_SCHEMA = vol.Schema({ + vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), +}) + +CLEAR_USERCODE_SCHEMA = vol.Schema({ + vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -44,13 +117,81 @@ def setup_platform(hass, config, add_devices, discovery_info=None): node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + def set_usercode(service): + """Set the usercode to index X on the lock.""" + node_id = service.data.get(zwave.const.ATTR_NODE_ID) + lock_node = zwave.NETWORK.nodes[node_id] + code_slot = service.data.get(ATTR_CODE_SLOT) + usercode = service.data.get(ATTR_USERCODE) + + for value in lock_node.get_values( + class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + if value.index != code_slot: + continue + if len(str(usercode)) > 4: + _LOGGER.error('Invalid code provided: (%s)' + ' usercode must %s or less digits', + usercode, len(value.data)) + value.data = str(usercode) + break + + def get_usercode(service): + """Get a usercode at index X on the lock.""" + node_id = service.data.get(zwave.const.ATTR_NODE_ID) + lock_node = zwave.NETWORK.nodes[node_id] + code_slot = service.data.get(ATTR_CODE_SLOT) + + for value in lock_node.get_values( + class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + if value.index != code_slot: + continue + _LOGGER.info('Usercode at slot %s is: %s', value.index, value.data) + break + + def clear_usercode(service): + """Set usercode to slot X on the lock.""" + node_id = service.data.get(zwave.const.ATTR_NODE_ID) + lock_node = zwave.NETWORK.nodes[node_id] + code_slot = service.data.get(ATTR_CODE_SLOT) + data = '' + + for value in lock_node.get_values( + class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + if value.index != code_slot: + continue + for i in range(len(value.data)): + data += '\0' + i += 1 + _LOGGER.debug('Data to clear lock: %s', data) + value.data = data + _LOGGER.info('Usercode at slot %s is cleared', value.index) + break + if value.command_class != zwave.const.COMMAND_CLASS_DOOR_LOCK: return if value.type != zwave.const.TYPE_BOOL: return if value.genre != zwave.const.GENRE_USER: return - + if node.has_command_class(zwave.const.COMMAND_CLASS_USER_CODE): + hass.services.register(DOMAIN, + SERVICE_SET_USERCODE, + set_usercode, + descriptions.get(SERVICE_SET_USERCODE), + schema=SET_USERCODE_SCHEMA) + hass.services.register(DOMAIN, + SERVICE_GET_USERCODE, + get_usercode, + descriptions.get(SERVICE_GET_USERCODE), + schema=GET_USERCODE_SCHEMA) + hass.services.register(DOMAIN, + SERVICE_CLEAR_USERCODE, + clear_usercode, + descriptions.get(SERVICE_CLEAR_USERCODE), + schema=CLEAR_USERCODE_SCHEMA) value.set_change_verified(False) add_devices([ZwaveLock(value)]) @@ -60,28 +201,16 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): def __init__(self, value): """Initialize the Z-Wave switch device.""" - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher - zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) self._node = value.node self._state = None self._notification = None - dispatcher.connect( - self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + self._lock_status = None self.update_properties() - def _value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - self.update_properties() - self.schedule_update_ha_state() - def update_properties(self): - """Callback on data change for the registered node/value pair.""" + """Callback on data changes for node values.""" for value in self._node.get_values( class_id=zwave.const.COMMAND_CLASS_ALARM).values(): if value.label != "Access Control": @@ -89,9 +218,55 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): self._notification = LOCK_NOTIFICATION.get(value.data) if self._notification: self._state = LOCK_STATUS.get(value.data) + _LOGGER.debug('Lock state set from Access Control value and' + ' is %s', value.data) break - if not self._notification: - self._state = self._value.data + + for value in self._node.get_values( + class_id=zwave.const.COMMAND_CLASS_ALARM).values(): + if value.label != "Alarm Type": + continue + alarm_type = LOCK_ALARM_TYPE.get(value.data) + if alarm_type: + self._state = LOCK_STATUS.get(value.data) + _LOGGER.debug('Lock state set from Alarm Type value and' + ' is %s', value.data) + break + + for value in self._node.get_values( + class_id=zwave.const.COMMAND_CLASS_ALARM).values(): + if value.label != "Alarm Level": + continue + alarm_level = value.data + _LOGGER.debug('Lock alarm_level is %s', alarm_level) + if alarm_type is 21: + self._lock_status = '{}{}'.format( + LOCK_ALARM_TYPE.get(alarm_type), + MANUAL_LOCK_ALARM_LEVEL.get(alarm_level)) + if alarm_type in ALARM_TYPE_STD: + self._lock_status = '{}{}'.format( + LOCK_ALARM_TYPE.get(alarm_type), alarm_level) + break + if alarm_type is 161: + self._lock_status = '{}{}'.format( + LOCK_ALARM_TYPE.get(alarm_type), + TAMPER_ALARM_LEVEL.get(alarm_level)) + break + if alarm_type != 0: + self._lock_status = LOCK_ALARM_TYPE.get(alarm_type) + break + + if not self._notification and not self._lock_status: + for value in self._node.get_values( + class_id=zwave.const.COMMAND_CLASS_DOOR_LOCK).values(): + if value.type != zwave.const.TYPE_BOOL: + continue + if value.genre != zwave.const.GENRE_USER: + continue + self._state = value.data + _LOGGER.debug('Lock state set from Bool value and' + ' is %s', value.data) + break @property def is_locked(self): @@ -112,4 +287,6 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): data = super().device_state_attributes if self._notification: data[ATTR_NOTIFICATION] = self._notification + if self._lock_status: + data[ATTR_LOCK_STATUS] = self._lock_status return data diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 94445935093..b69289db989 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -307,6 +307,10 @@ def _exclude_events(events, config): if event.event_type == EVENT_STATE_CHANGED: to_state = State.from_dict(event.data.get('new_state')) # Do not report on new entities + if event.data.get('old_state') is None: + continue + + # Do not report on entity removal if not to_state: continue diff --git a/homeassistant/components/lutron.py b/homeassistant/components/lutron.py new file mode 100644 index 00000000000..d5512e9e5b6 --- /dev/null +++ b/homeassistant/components/lutron.py @@ -0,0 +1,86 @@ +""" +Component for interacting with a Lutron RadioRA 2 system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/lutron/ +""" +import logging + +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import (Entity, generate_entity_id) +from homeassistant.loader import get_component + +REQUIREMENTS = ['https://github.com/thecynic/pylutron/archive/v0.1.0.zip#' + 'pylutron==0.1.0'] + +DOMAIN = 'lutron' + +_LOGGER = logging.getLogger(__name__) + +LUTRON_CONTROLLER = 'lutron_controller' +LUTRON_DEVICES = 'lutron_devices' +LUTRON_GROUPS = 'lutron_groups' + + +def setup(hass, base_config): + """Setup the Lutron component.""" + from pylutron import Lutron + + hass.data[LUTRON_CONTROLLER] = None + hass.data[LUTRON_DEVICES] = {'light': []} + hass.data[LUTRON_GROUPS] = {} + + config = base_config.get(DOMAIN) + hass.data[LUTRON_CONTROLLER] = Lutron( + config['lutron_host'], + config['lutron_user'], + config['lutron_password'] + ) + hass.data[LUTRON_CONTROLLER].load_xml_db() + hass.data[LUTRON_CONTROLLER].connect() + _LOGGER.info("Connected to Main Repeater at %s", config['lutron_host']) + + group = get_component('group') + + # Sort our devices into types + for area in hass.data[LUTRON_CONTROLLER].areas: + if area.name not in hass.data[LUTRON_GROUPS]: + grp = group.Group.create_group(hass, area.name, []) + hass.data[LUTRON_GROUPS][area.name] = grp + for output in area.outputs: + hass.data[LUTRON_DEVICES]['light'].append((area.name, output)) + + for component in ('light',): + discovery.load_platform(hass, component, DOMAIN, None, base_config) + return True + + +class LutronDevice(Entity): + """Representation of a Lutron device entity.""" + + def __init__(self, hass, domain, area_name, lutron_device, controller): + """Initialize the device.""" + self._lutron_device = lutron_device + self._controller = controller + self._area_name = area_name + + self.hass = hass + object_id = '{} {}'.format(area_name, lutron_device.name) + self.entity_id = generate_entity_id(domain + '.{}', object_id, + hass=hass) + + self._controller.subscribe(self._lutron_device, self._update_callback) + + def _update_callback(self, _device): + """Callback invoked by pylutron when the device state changes.""" + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of the device.""" + return self._lutron_device.name + + @property + def should_poll(self): + """No polling needed.""" + return False diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f97b169e1bc..71901b6256a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -10,6 +10,7 @@ import functools as ft import hashlib import logging import os +from random import SystemRandom from aiohttp import web import async_timeout @@ -32,6 +33,7 @@ from homeassistant.const import ( SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK) _LOGGER = logging.getLogger(__name__) +_RND = SystemRandom() DOMAIN = 'media_player' DEPENDENCIES = ['http'] @@ -389,6 +391,8 @@ def async_setup(hass, config): class MediaPlayerDevice(Entity): """ABC for media player devices.""" + _access_token = None + # pylint: disable=no-self-use # Implement these for your media player @property @@ -399,7 +403,10 @@ class MediaPlayerDevice(Entity): @property def access_token(self): """Access token for this media player.""" - return str(id(self)) + if self._access_token is None: + self._access_token = hashlib.sha256( + _RND.getrandbits(256).to_bytes(32, 'little')).hexdigest() + return self._access_token @property def volume_level(self): @@ -757,6 +764,7 @@ class MediaPlayerDevice(Entity): if hasattr(self, 'volume_up'): # pylint: disable=no-member yield from self.hass.loop.run_in_executor(None, self.volume_up) + return if self.volume_level < 1: yield from self.async_set_volume_level( @@ -771,6 +779,7 @@ class MediaPlayerDevice(Entity): if hasattr(self, 'volume_down'): # pylint: disable=no-member yield from self.hass.loop.run_in_executor(None, self.volume_down) + return if self.volume_level > 0: yield from self.async_set_volume_level( @@ -893,7 +902,8 @@ class MediaPlayerImageView(HomeAssistantView): """Start a get request.""" player = self.entities.get(entity_id) if player is None: - return web.Response(status=404) + status = 404 if request[KEY_AUTHENTICATED] else 401 + return web.Response(status=status) authenticated = (request[KEY_AUTHENTICATED] or request.GET.get('token') == player.access_token) diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py new file mode 100644 index 00000000000..f53f5cf9264 --- /dev/null +++ b/homeassistant/components/media_player/anthemav.py @@ -0,0 +1,175 @@ +""" +Support for Anthem Network Receivers and Processors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.anthemav/ +""" +import logging +import asyncio + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_SELECT_SOURCE, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON, STATE_UNKNOWN, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['anthemav==1.1.8'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'anthemav' + +DEFAULT_PORT = 14999 + +SUPPORT_ANTHEMAV = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + }) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up our socket to the AVR.""" + import anthemav + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + device = None + + _LOGGER.info('Provisioning Anthem AVR device at %s:%d', host, port) + + def async_anthemav_update_callback(message): + """Receive notification from transport that new data exists.""" + _LOGGER.info('Received update calback from AVR: %s', message) + hass.async_add_job(device.async_update_ha_state()) + + avr = yield from anthemav.Connection.create( + host=host, port=port, loop=hass.loop, + update_callback=async_anthemav_update_callback) + + device = AnthemAVR(avr, name) + + _LOGGER.debug('dump_devicedata: '+device.dump_avrdata) + _LOGGER.debug('dump_conndata: '+avr.dump_conndata) + _LOGGER.debug('dump_rawdata: '+avr.protocol.dump_rawdata) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.avr.close) + yield from async_add_devices([device]) + + +class AnthemAVR(MediaPlayerDevice): + """Entity reading values from Anthem AVR protocol.""" + + def __init__(self, avr, name): + """"Initialize entity with transport.""" + super().__init__() + self.avr = avr + self._name = name + + def _lookup(self, propname, dval=None): + return getattr(self.avr.protocol, propname, dval) + + @property + def supported_media_commands(self): + """Return flag of media commands that are supported.""" + return SUPPORT_ANTHEMAV + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return name of device.""" + return self._name or self._lookup('model') + + @property + def state(self): + """Return state of power on/off.""" + pwrstate = self._lookup('power') + + if pwrstate is True: + return STATE_ON + elif pwrstate is False: + return STATE_OFF + else: + return STATE_UNKNOWN + + @property + def is_volume_muted(self): + """Return boolean reflecting mute state on device.""" + return self._lookup('mute', False) + + @property + def volume_level(self): + """Return volume level from 0 to 1.""" + return self._lookup('volume_as_percentage', 0.0) + + @property + def media_title(self): + """Return current input name (closest we have to media title).""" + return self._lookup('input_name', 'No Source') + + @property + def app_name(self): + """Return details about current video and audio stream.""" + return self._lookup('video_input_resolution_text', '') + ' ' \ + + self._lookup('audio_input_name', '') + + @property + def source(self): + """Return currently selected input.""" + return self._lookup('input_name', "Unknown") + + @property + def source_list(self): + """Return all active, configured inputs.""" + return self._lookup('input_list', ["Unknown"]) + + @asyncio.coroutine + def async_select_source(self, source): + """Change AVR to the designated source (by name).""" + self._update_avr('input_name', source) + + @asyncio.coroutine + def async_turn_off(self): + """Turn AVR power off.""" + self._update_avr('power', False) + + @asyncio.coroutine + def async_turn_on(self): + """Turn AVR power on.""" + self._update_avr('power', True) + + @asyncio.coroutine + def async_set_volume_level(self, volume): + """Set AVR volume (0 to 1).""" + self._update_avr('volume_as_percentage', volume) + + @asyncio.coroutine + def async_mute_volume(self, mute): + """Engage AVR mute.""" + self._update_avr('mute', mute) + + def _update_avr(self, propname, value): + """Update a property in the AVR.""" + _LOGGER.info('Sending command to AVR: set '+propname+' to '+str(value)) + setattr(self.avr.protocol, propname, value) + + @property + def dump_avrdata(self): + """Return state of avr object for debugging forensics.""" + attrs = vars(self) + return( + 'dump_avrdata: ' + + ', '.join('%s: %s' % item for item in attrs.items())) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index faa204e675e..202c877c2b1 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -37,6 +37,7 @@ KNOWN_HOSTS = [] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_IGNORE_CEC): [cv.string], }) @@ -46,11 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import pychromecast # import CEC IGNORE attributes - ignore_cec = config.get(CONF_IGNORE_CEC, []) - if isinstance(ignore_cec, list): - pychromecast.IGNORE_CEC += ignore_cec - else: - _LOGGER.error('CEC config "%s" must be a list.', CONF_IGNORE_CEC) + pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) hosts = [] diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index 1feee79635d..22ccd2f0d56 100755 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -150,7 +150,7 @@ class DenonDevice(MediaPlayerDevice): answer_codes = ["NSE0", "NSE1X", "NSE2X", "NSE3X", "NSE4", "NSE5", "NSE6", "NSE7", "NSE8"] for line in self.telnet_request(telnet, 'NSE', all_lines=True): - self._mediainfo += line[len(answer_codes.pop()):] + '\n' + self._mediainfo += line[len(answer_codes.pop(0)):] + '\n' else: self._mediainfo = self.source diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 50b16afc811..e6f0bf99d42 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.3.0'] +REQUIREMENTS = ['denonavr==0.3.1'] _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,9 @@ KEY_DENON_CACHE = 'denonavr_hosts' SUPPORT_DENON = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA | \ + SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET + +SUPPORT_MEDIA_MODES = SUPPORT_PLAY_MEDIA | \ SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET | SUPPORT_PLAY @@ -167,7 +169,10 @@ class DenonDevice(MediaPlayerDevice): @property def supported_media_commands(self): """Flag of media commands that are supported.""" - return SUPPORT_DENON + if self._current_source in self._receiver.netaudio_func_list: + return SUPPORT_DENON | SUPPORT_MEDIA_MODES + else: + return SUPPORT_DENON @property def media_content_id(self): @@ -190,7 +195,7 @@ class DenonDevice(MediaPlayerDevice): @property def media_image_url(self): """Image url of current playing media.""" - if self._power == "ON": + if self._current_source in self._receiver.playing_func_list: return self._media_image_url else: return None @@ -198,7 +203,9 @@ class DenonDevice(MediaPlayerDevice): @property def media_title(self): """Title of current playing media.""" - if self._title is not None: + if self._current_source not in self._receiver.playing_func_list: + return self._current_source + elif self._title is not None: return self._title else: return self._frequency diff --git a/homeassistant/components/media_player/hdmi_cec.py b/homeassistant/components/media_player/hdmi_cec.py new file mode 100644 index 00000000000..c7e9be562cc --- /dev/null +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -0,0 +1,175 @@ +""" +Support for HDMI CEC devices as media players. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hdmi_cec/ +""" +import logging + +from homeassistant.components.hdmi_cec import ATTR_NEW, CecDevice +from homeassistant.components.media_player import MediaPlayerDevice, DOMAIN, \ + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY_MEDIA, SUPPORT_PAUSE, \ + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, SUPPORT_STOP, \ + SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_MUTE +from homeassistant.const import STATE_ON, STATE_OFF, STATE_PLAYING, \ + STATE_IDLE, STATE_PAUSED +from homeassistant.core import HomeAssistant + +DEPENDENCIES = ['hdmi_cec'] + +_LOGGER = logging.getLogger(__name__) + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Find and return HDMI devices as +switches.""" + if ATTR_NEW in discovery_info: + _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) + add_devices(CecPlayerDevice(hass, hass.data.get(device), + hass.data.get(device).logical_address) for + device in discovery_info[ATTR_NEW]) + + +class CecPlayerDevice(CecDevice, MediaPlayerDevice): + """Representation of a HDMI device as a Media palyer.""" + + def __init__(self, hass: HomeAssistant, device, logical): + """Initialize the HDMI device.""" + CecDevice.__init__(self, hass, device, logical) + self.entity_id = "%s.%s_%s" % ( + DOMAIN, 'hdmi', hex(self._logical_address)[2:]) + self.update() + + def send_keypress(self, key): + """Send keypress to CEC adapter.""" + from pycec.commands import KeyPressCommand, KeyReleaseCommand + _LOGGER.debug("Sending keypress %s to device %s", hex(key), + hex(self._logical_address)) + self._device.send_command( + KeyPressCommand(key, dst=self._logical_address)) + self._device.send_command( + KeyReleaseCommand(dst=self._logical_address)) + + def send_playback(self, key): + """Send playback status to CEC adapter.""" + from pycec.commands import CecCommand + self._device.async_send_command( + CecCommand(key, dst=self._logical_address)) + + def mute_volume(self, mute): + """Mute volume.""" + from pycec.const import KEY_MUTE_TOGGLE + self.send_keypress(KEY_MUTE_TOGGLE) + + def media_previous_track(self): + """Go to previous track.""" + from pycec.const import KEY_BACKWARD + self.send_keypress(KEY_BACKWARD) + + def turn_on(self): + """Turn device on.""" + self._device.turn_on() + self._state = STATE_ON + + def clear_playlist(self): + """Clear players playlist.""" + raise NotImplementedError() + + def turn_off(self): + """Turn device off.""" + self._device.turn_off() + self._state = STATE_OFF + + def media_stop(self): + """Stop playback.""" + from pycec.const import KEY_STOP + self.send_keypress(KEY_STOP) + self._state = STATE_IDLE + + def play_media(self, media_type, media_id): + """Not supported.""" + raise NotImplementedError() + + def media_next_track(self): + """Skip to next track.""" + from pycec.const import KEY_FORWARD + self.send_keypress(KEY_FORWARD) + + def media_seek(self, position): + """Not supported.""" + raise NotImplementedError() + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + raise NotImplementedError() + + def media_pause(self): + """Pause playback.""" + from pycec.const import KEY_PAUSE + self.send_keypress(KEY_PAUSE) + self._state = STATE_PAUSED + + def select_source(self, source): + """Not supported.""" + raise NotImplementedError() + + def media_play(self): + """Start playback.""" + from pycec.const import KEY_PLAY + self.send_keypress(KEY_PLAY) + self._state = STATE_PLAYING + + def volume_up(self): + """Increase volume.""" + from pycec.const import KEY_VOLUME_UP + _LOGGER.debug("%s: volume up", self._logical_address) + self.send_keypress(KEY_VOLUME_UP) + + def volume_down(self): + """Decrease volume.""" + from pycec.const import KEY_VOLUME_DOWN + _LOGGER.debug("%s: volume down", self._logical_address) + self.send_keypress(KEY_VOLUME_DOWN) + + @property + def state(self) -> str: + """Cached state of device.""" + return self._state + + def _update(self, device=None): + """Update device status.""" + if device: + from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ + POWER_OFF, POWER_ON + if device.power_status == POWER_OFF: + self._state = STATE_OFF + elif not self.support_pause: + if device.power_status == POWER_ON: + self._state = STATE_ON + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + else: + _LOGGER.warning("Unknown state: %s", device.status) + self.schedule_update_ha_state() + + @property + def supported_media_commands(self): + """Flag media commands that are supported.""" + from pycec.const import TYPE_RECORDER, TYPE_PLAYBACK, TYPE_TUNER, \ + TYPE_AUDIO + if self.type_id == TYPE_RECORDER or self.type == TYPE_PLAYBACK: + return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | + SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_PREVIOUS_TRACK | + SUPPORT_NEXT_TRACK) + if self.type == TYPE_TUNER: + return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | + SUPPORT_PAUSE | SUPPORT_STOP) + if self.type_id == TYPE_AUDIO: + return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | + SUPPORT_VOLUME_MUTE) + return SUPPORT_TURN_ON | SUPPORT_TURN_OFF diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 8cfa7a587fb..acb6a6f45db 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -14,10 +14,11 @@ import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_TURN_OFF, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) + SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, MediaPlayerDevice, + PLATFORM_SCHEMA) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, - CONF_PORT, CONF_USERNAME, CONF_PASSWORD) + CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -30,17 +31,19 @@ CONF_TURN_OFF_ACTION = 'turn_off_action' DEFAULT_NAME = 'Kodi' DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 5 +DEFAULT_SSL = False TURN_OFF_ACTION = [None, 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown'] SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_VOLUME_STEP PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION), vol.Inclusive(CONF_USERNAME, 'auth'): cv.string, vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string, @@ -53,6 +56,7 @@ def async_setup_platform(hass, config, async_add_entities, """Setup the Kodi platform.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) + use_encryption = config.get(CONF_SSL) if host.startswith('http://') or host.startswith('https://'): host = host.lstrip('http://').lstrip('https://') @@ -64,7 +68,7 @@ def async_setup_platform(hass, config, async_add_entities, entity = KodiDevice( hass, name=config.get(CONF_NAME), - host=host, port=port, + host=host, port=port, encryption=use_encryption, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), turn_off_action=config.get(CONF_TURN_OFF_ACTION)) @@ -75,8 +79,8 @@ def async_setup_platform(hass, config, async_add_entities, class KodiDevice(MediaPlayerDevice): """Representation of a XBMC/Kodi device.""" - def __init__(self, hass, name, host, port, username=None, password=None, - turn_off_action=None): + def __init__(self, hass, name, host, port, encryption=False, username=None, + password=None, turn_off_action=None): """Initialize the Kodi device.""" import jsonrpc_async self.hass = hass @@ -93,9 +97,11 @@ class KodiDevice(MediaPlayerDevice): else: image_auth_string = "" - self._http_url = 'http://{}:{}/jsonrpc'.format(host, port) - self._image_url = 'http://{}{}:{}/image'.format( - image_auth_string, host, port) + protocol = 'https' if encryption else 'http' + + self._http_url = '{}://{}:{}/jsonrpc'.format(protocol, host, port) + self._image_url = '{}://{}{}:{}/image'.format( + protocol, image_auth_string, host, port) self._server = jsonrpc_async.Server(self._http_url, **kwargs) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 015ba2fd0ac..2f16410e783 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.mpd/ """ import logging import socket +from datetime import timedelta import voluptuous as vol @@ -13,11 +14,12 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_PLAY, MEDIA_TYPE_PLAYLIST, - MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, MediaPlayerDevice) from homeassistant.const import ( STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, CONF_HOST) import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle REQUIREMENTS = ['python-mpd2==0.5.5'] @@ -28,9 +30,11 @@ CONF_LOCATION = 'location' DEFAULT_LOCATION = 'MPD' DEFAULT_PORT = 6600 +PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120) + SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -88,6 +92,8 @@ class MpdDevice(MediaPlayerDevice): self.password = password self.status = None self.currentsong = None + self.playlists = [] + self.currentplaylist = None self.client = mpd.MPDClient() self.client.timeout = 10 @@ -100,6 +106,7 @@ class MpdDevice(MediaPlayerDevice): try: self.status = self.client.status() self.currentsong = self.client.currentsong() + self._update_playlists() except (mpd.ConnectionError, OSError, BrokenPipeError, ValueError): # Cleanly disconnect in case connection is not in valid state try: @@ -181,6 +188,20 @@ class MpdDevice(MediaPlayerDevice): """Flag of media commands that are supported.""" return SUPPORT_MPD + @property + def source(self): + """Name of the current input source.""" + return self.currentplaylist + + @property + def source_list(self): + """List of available input sources.""" + return self.playlists + + def select_source(self, source): + """Choose a different available playlist and play it.""" + self.play_media(MEDIA_TYPE_PLAYLIST, source) + def turn_off(self): """Service to send the MPD the command to stop playing.""" self.client.stop() @@ -188,6 +209,14 @@ class MpdDevice(MediaPlayerDevice): def turn_on(self): """Service to send the MPD the command to start playing.""" self.client.play() + self._update_playlists(no_throttle=True) + + @Throttle(PLAYLIST_UPDATE_INTERVAL) + def _update_playlists(self, **kwargs): + """Update available MPD playlists.""" + self.playlists = [] + for playlist_data in self.client.listplaylists(): + self.playlists.append(playlist_data['playlist']) def set_volume_level(self, volume): """Set volume of media player.""" @@ -227,6 +256,12 @@ class MpdDevice(MediaPlayerDevice): """Send the media player the command for playing a playlist.""" _LOGGER.info(str.format("Playing playlist: {0}", media_id)) if media_type == MEDIA_TYPE_PLAYLIST: + if media_id in self.playlists: + self.currentplaylist = media_id + else: + self.currentplaylist = None + _LOGGER.warning(str.format("Unknown playlist name %s.", + media_id)) self.client.clear() self.client.load(media_id) self.client.play() diff --git a/homeassistant/components/media_player/nad.py b/homeassistant/components/media_player/nad.py index 0b8efda0e44..27122fcfc93 100644 --- a/homeassistant/components/media_player/nad.py +++ b/homeassistant/components/media_player/nad.py @@ -18,7 +18,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/joopert/nad_receiver/archive/' - '0.0.2.zip#nad_receiver==0.0.2'] + '0.0.3.zip#nad_receiver==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 5a4e993aee5..26649d0be96 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -17,8 +17,8 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ - 'https://github.com/bah2830/python-roku/archive/3.1.2.zip' - '#roku==3.1.2'] + 'https://github.com/bah2830/python-roku/archive/3.1.3.zip' + '#roku==3.1.3'] KNOWN_HOSTS = [] DEFAULT_PORT = 8060 @@ -69,10 +69,10 @@ 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.device_info = {} self.update() @@ -81,7 +81,7 @@ class RokuDevice(MediaPlayerDevice): import requests.exceptions try: - self.roku_name = "roku_" + self.roku.device_info.sernum + self.device_info = self.roku.device_info self.ip_address = self.roku.host self.channels = self.get_source_list() @@ -106,7 +106,7 @@ class RokuDevice(MediaPlayerDevice): @property def name(self): """Return the name of the device.""" - return self.roku_name + return self.device_info.userdevicename @property def state(self): @@ -114,7 +114,8 @@ class RokuDevice(MediaPlayerDevice): if self.current_app is None: return STATE_UNKNOWN - if self.current_app.name in ["Power Saver", "Default screensaver"]: + if (self.current_app.name == "Power Saver" or + self.current_app.is_screensaver): return STATE_IDLE elif self.current_app.name == "Roku": return STATE_HOME diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index ca3aaba7a9e..fa26e1613dc 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -154,10 +154,14 @@ clear_playlist: 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). +sonos_join: + description: Group player together. fields: + master: + description: Entity ID of the player that should become the coordinator of the group. + example: 'media_player.living_room_sonos' + entity_id: description: Name(s) of entites that will coordinate the grouping. Platform dependent. example: 'media_player.living_room_sonos' @@ -178,6 +182,10 @@ sonos_snapshot: description: Name(s) of entites that will be snapshot. Platform dependent. example: 'media_player.living_room_sonos' + with_group: + description: True (default) or False. Snapshot with all group attributes. + example: 'true' + sonos_restore: description: Restore a snapshot of the media player. @@ -186,6 +194,10 @@ sonos_restore: description: Name(s) of entites that will be restored. Platform dependent. example: 'media_player.living_room_sonos' + with_group: + description: True (default) or False. Restore with all group attributes. + example: 'true' + sonos_set_sleep_timer: description: Set a Sonos timer diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 7e9a553fd97..38408914448 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -42,13 +42,15 @@ SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY -SERVICE_GROUP_PLAYERS = 'sonos_group_players' +SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' SERVICE_SNAPSHOT = 'sonos_snapshot' SERVICE_RESTORE = 'sonos_restore' SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' +DATA_SONOS = 'sonos' + SUPPORT_SOURCE_LINEIN = 'Line-in' SUPPORT_SOURCE_TV = 'TV' @@ -57,6 +59,10 @@ CONF_INTERFACE_ADDR = 'interface_addr' # Service call validation schemas ATTR_SLEEP_TIME = 'sleep_time' +ATTR_MASTER = 'master' +ATTR_WITH_GROUP = 'with_group' + +ATTR_IS_COORDINATOR = 'is_coordinator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ADVERTISE_ADDR): cv.string, @@ -65,22 +71,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) SONOS_SCHEMA = vol.Schema({ - ATTR_ENTITY_ID: cv.entity_ids, + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SONOS_JOIN_SCHEMA = SONOS_SCHEMA.extend({ + vol.Required(ATTR_MASTER): cv.entity_id, +}) + +SONOS_STATES_SCHEMA = SONOS_SCHEMA.extend({ + vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean, }) SONOS_SET_TIMER_SCHEMA = SONOS_SCHEMA.extend({ - vol.Required(ATTR_SLEEP_TIME): vol.All(vol.Coerce(int), - vol.Range(min=0, max=86399)) + vol.Required(ATTR_SLEEP_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0, max=86399)) }) -# List of devices that have been registered -DEVICES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Sonos platform.""" import soco - global DEVICES + + if DATA_SONOS not in hass.data: + hass.data[DATA_SONOS] = [] advertise_addr = config.get(CONF_ADVERTISE_ADDR, None) if advertise_addr: @@ -90,154 +103,92 @@ def setup_platform(hass, config, add_devices, discovery_info=None): player = soco.SoCo(discovery_info) # if device allready exists by config - if player.uid in [x.unique_id for x in DEVICES]: - return True + if player.uid in [x.unique_id for x in hass.data[DATA_SONOS]]: + return if player.is_visible: device = SonosDevice(hass, player) add_devices([device], True) - if not DEVICES: - register_services(hass) - DEVICES.append(device) - return True - return False + hass.data[DATA_SONOS].append(device) + if len(hass.data[DATA_SONOS]) > 1: + return + else: + players = None + hosts = config.get(CONF_HOSTS, None) + if hosts: + # Support retro compatibility with comma separated list of hosts + # from config + hosts = hosts[0] if len(hosts) == 1 else hosts + hosts = hosts.split(',') if isinstance(hosts, str) else hosts + players = [] + for host in hosts: + players.append(soco.SoCo(socket.gethostbyname(host))) - players = None - hosts = config.get(CONF_HOSTS, None) - if hosts: - # Support retro compatibility with comma separated list of hosts - # from config - hosts = hosts[0] if len(hosts) == 1 else hosts - hosts = hosts.split(',') if isinstance(hosts, str) else hosts - players = [] - for host in hosts: - players.append(soco.SoCo(socket.gethostbyname(host))) + if not players: + players = soco.discover( + interface_addr=config.get(CONF_INTERFACE_ADDR)) - if not players: - players = soco.discover(interface_addr=config.get(CONF_INTERFACE_ADDR)) + if not players: + _LOGGER.warning('No Sonos speakers found.') + return - if not players: - _LOGGER.warning('No Sonos speakers found.') - return False + hass.data[DATA_SONOS] = [SonosDevice(hass, p) for p in players] + add_devices(hass.data[DATA_SONOS], True) + _LOGGER.info('Added %s Sonos speakers', len(players)) - DEVICES = [SonosDevice(hass, p) for p in players] - add_devices(DEVICES, True) - register_services(hass) - _LOGGER.info('Added %s Sonos speakers', len(players)) - return True - - -def register_services(hass): - """Register all services for sonos devices.""" descriptions = load_yaml_config_file( path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_GROUP_PLAYERS, - _group_players_service, - descriptions.get(SERVICE_GROUP_PLAYERS), - schema=SONOS_SCHEMA) + def service_handle(service): + """Internal func for applying a service.""" + entity_ids = service.data.get('entity_id') - hass.services.register(DOMAIN, SERVICE_UNJOIN, - _unjoin_service, - descriptions.get(SERVICE_UNJOIN), - schema=SONOS_SCHEMA) - - hass.services.register(DOMAIN, SERVICE_SNAPSHOT, - _snapshot_service, - descriptions.get(SERVICE_SNAPSHOT), - schema=SONOS_SCHEMA) - - hass.services.register(DOMAIN, SERVICE_RESTORE, - _restore_service, - descriptions.get(SERVICE_RESTORE), - schema=SONOS_SCHEMA) - - hass.services.register(DOMAIN, SERVICE_SET_TIMER, - _set_sleep_timer_service, - descriptions.get(SERVICE_SET_TIMER), - schema=SONOS_SET_TIMER_SCHEMA) - - hass.services.register(DOMAIN, SERVICE_CLEAR_TIMER, - _clear_sleep_timer_service, - descriptions.get(SERVICE_CLEAR_TIMER), - schema=SONOS_SCHEMA) - - -def _apply_service(service, service_func, *service_func_args): - """Internal func for applying a service.""" - entity_ids = service.data.get('entity_id') - - if entity_ids: - _devices = [device for device in DEVICES - if device.entity_id in entity_ids] - else: - _devices = DEVICES - - for device in _devices: - service_func(device, *service_func_args) - device.update_ha_state(True) - - -def _group_players_service(service): - """Group media players, use player as coordinator.""" - _apply_service(service, SonosDevice.group_players) - - -def _unjoin_service(service): - """Unjoin the player from a group.""" - _apply_service(service, SonosDevice.unjoin) - - -def _snapshot_service(service): - """Take a snapshot.""" - _apply_service(service, SonosDevice.snapshot) - - -def _restore_service(service): - """Restore a snapshot.""" - _apply_service(service, SonosDevice.restore) - - -def _set_sleep_timer_service(service): - """Set a timer.""" - _apply_service(service, - SonosDevice.set_sleep_timer, - service.data[ATTR_SLEEP_TIME]) - - -def _clear_sleep_timer_service(service): - """Set a timer.""" - _apply_service(service, - SonosDevice.clear_sleep_timer) - - -def only_if_coordinator(func): - """Decorator for coordinator. - - If used as decorator, avoid calling the decorated method if player is not - a coordinator. If not, a grouped speaker (not in coordinator role) will - throw soco.exceptions.SoCoSlaveException. - - Also, partially catch exceptions like: - - soco.exceptions.SoCoUPnPException: UPnP Error 701 received: - Transition not available from - """ - def wrapper(*args, **kwargs): - """Decorator wrapper.""" - if args[0].is_coordinator: - from soco.exceptions import SoCoUPnPException - try: - func(*args, **kwargs) - except SoCoUPnPException: - _LOGGER.error('command "%s" for Sonos device "%s" ' - 'not available in this mode', - func.__name__, args[0].name) + if entity_ids: + devices = [device for device in hass.data[DATA_SONOS] + if device.entity_id in entity_ids] else: - _LOGGER.debug('Ignore command "%s" for Sonos device "%s" (%s)', - func.__name__, args[0].name, 'not coordinator') + devices = hass.data[DATA_SONOS] - return wrapper + for device in devices: + if service.service == SERVICE_JOIN: + if device.entity_id != service.data[ATTR_MASTER]: + device.join(service.data[ATTR_MASTER]) + elif service.service == SERVICE_UNJOIN: + device.unjoin() + elif service.service == SERVICE_SNAPSHOT: + device.snapshot(service.data[ATTR_WITH_GROUP]) + elif service.service == SERVICE_RESTORE: + device.restore(service.data[ATTR_WITH_GROUP]) + elif service.service == SERVICE_SET_TIMER: + device.set_timer(service.data[ATTR_SLEEP_TIME]) + elif service.service == SERVICE_CLEAR_TIMER: + device.clear_timer() + + device.schedule_update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_JOIN, service_handle, + descriptions.get(SERVICE_JOIN), schema=SONOS_JOIN_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_UNJOIN, service_handle, + descriptions.get(SERVICE_UNJOIN), schema=SONOS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SNAPSHOT, service_handle, + descriptions.get(SERVICE_SNAPSHOT), schema=SONOS_STATES_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_RESTORE, service_handle, + descriptions.get(SERVICE_RESTORE), schema=SONOS_STATES_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SET_TIMER, service_handle, + descriptions.get(SERVICE_SET_TIMER), schema=SONOS_SET_TIMER_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CLEAR_TIMER, service_handle, + descriptions.get(SERVICE_CLEAR_TIMER), schema=SONOS_SCHEMA) def _parse_timespan(timespan): @@ -262,6 +213,14 @@ class _ProcessSonosEventQueue(): self._sonos_device.process_sonos_event(item) +def _get_entity_from_soco(hass, soco): + """Return SonosDevice from SoCo.""" + for device in hass.data[DATA_SONOS]: + if soco == device.soco_device: + return device + raise ValueError("No entity for SoCo device!") + + class SonosDevice(MediaPlayerDevice): """Representation of a Sonos device.""" @@ -302,6 +261,7 @@ class SonosDevice(MediaPlayerDevice): self._favorite_sources = None self._source_name = None self.soco_snapshot = Snapshot(self._player) + self._snapshot_group = None @property def should_poll(self): @@ -336,6 +296,16 @@ class SonosDevice(MediaPlayerDevice): """Return true if player is a coordinator.""" return self._coordinator is None + @property + def soco_device(self): + """Return soco device.""" + return self._player + + @property + def coordinator(self): + """Return coordinator of this player.""" + return self._coordinator + def _is_available(self): try: sock = socket.create_connection( @@ -372,6 +342,19 @@ class SonosDevice(MediaPlayerDevice): if is_available: + if self._player.group.coordinator != self._player: + try: + self._coordinator = _get_entity_from_soco( + self.hass, self._player.group.coordinator) + except ValueError: + self._coordinator = None + else: + self._coordinator = None + + if self._coordinator == self: + _LOGGER.warning("Coordinator loop on: %s", self.unique_id) + self._coordinator = None + track_info = None if self._last_avtransport_event: variables = self._last_avtransport_event.variables @@ -402,16 +385,6 @@ class SonosDevice(MediaPlayerDevice): if not track_info: track_info = self._player.get_current_track_info() - if track_info['uri'].startswith('x-rincon:'): - # this speaker is a slave, find the coordinator - # the uri of the track is 'x-rincon:{coordinator-id}' - coordinator_id = track_info['uri'][9:] - coordinators = [device for device in DEVICES - if device.unique_id == coordinator_id] - self._coordinator = coordinators[0] if coordinators else None - else: - self._coordinator = None - if not self._coordinator: is_playing_tv = self._player.is_playing_tv @@ -548,6 +521,10 @@ class SonosDevice(MediaPlayerDevice): update_media_position |= rel_time is not None and \ self._media_position is None + # used only if a media is playing + if self.state != STATE_PLAYING: + update_media_position = None + # position changed? if rel_time is not None and \ self._media_position is not None: @@ -614,10 +591,10 @@ class SonosDevice(MediaPlayerDevice): self._source_name = source_name # update state of the whole group - # pylint: disable=protected-access - for device in [x for x in DEVICES if x._coordinator == self]: + for device in [x for x in self.hass.data[DATA_SONOS] + if x.coordinator == self]: if device.entity_id is not self.entity_id: - self.hass.add_job(device.async_update_ha_state) + self.schedule_update_ha_state() if self._queue is None and self.entity_id is not None: self._subscribe_to_player_events() @@ -653,6 +630,8 @@ class SonosDevice(MediaPlayerDevice): def _format_media_image_url(self, url, fallback_uri): if url in ('', 'NOT_IMPLEMENTED', None): + if fallback_uri in ('', 'NOT_IMPLEMENTED', None): + return None return 'http://{host}:{port}/getaa?s=1&u={uri}'.format( host=self._player.ip_address, port=1400, @@ -703,7 +682,7 @@ class SonosDevice(MediaPlayerDevice): self._player_volume_muted = \ event.variables['mute'].get('Master') == '1' - self.update_ha_state(True) + self.schedule_update_ha_state(True) if next_track_image_url: self.preload_media_image_url(next_track_image_url) @@ -942,34 +921,100 @@ class SonosDevice(MediaPlayerDevice): else: self._player.play_uri(media_id) - def group_players(self): - """Group all players under this coordinator.""" - if self._coordinator: - self._coordinator.group_players() - else: - self._player.partymode() + def join(self, master): + """Join the player to a group.""" + coord = [device for device in self.hass.data[DATA_SONOS] + if device.entity_id == master] + + if coord and master != self.entity_id: + coord = coord[0] + if coord.soco_device.group.coordinator != coord.soco_device: + coord.soco_device.unjoin() + self._player.join(coord.soco_device) + self._coordinator = coord + else: + _LOGGER.error("Master not found %s", master) - @only_if_coordinator def unjoin(self): """Unjoin the player from a group.""" self._player.unjoin() + self._coordinator = None - @only_if_coordinator - def snapshot(self): + def snapshot(self, with_group=True): """Snapshot the player.""" self.soco_snapshot.snapshot() - @only_if_coordinator - def restore(self): - """Restore snapshot for the player.""" - self.soco_snapshot.restore(True) + if with_group: + self._snapshot_group = self._player.group + if self._coordinator: + self._coordinator.snapshot(False) + else: + self._snapshot_group = None + + def restore(self, with_group=True): + """Restore snapshot for the player.""" + from soco.exceptions import SoCoException + try: + # need catch exception if a coordinator is going to slave. + # this state will recover with group part. + self.soco_snapshot.restore(True) + except (TypeError, SoCoException): + _LOGGER.debug("Error on restore %s", self.entity_id) + + # restore groups + if with_group and self._snapshot_group: + old = self._snapshot_group + actual = self._player.group + + ## + # Master have not change, update group + if old.coordinator == actual.coordinator: + if self._player is not old.coordinator: + # restore state of the groups + self._coordinator.restore(False) + remove = actual.members - old.members + add = old.members - actual.members + + # remove new members + for soco_dev in list(remove): + soco_dev.unjoin() + + # add old members + for soco_dev in list(add): + soco_dev.join(old.coordinator) + return + + ## + # old is allready master, rejoin + if old.coordinator.group.coordinator == old.coordinator: + self._player.join(old.coordinator) + return + + ## + # restore old master, update group + old.coordinator.unjoin() + coordinator = _get_entity_from_soco(self.hass, old.coordinator) + coordinator.restore(False) + + for s_dev in list(old.members): + if s_dev != old.coordinator: + s_dev.join(old.coordinator) - @only_if_coordinator def set_sleep_timer(self, sleep_time): """Set the timer on the player.""" - self._player.set_sleep_timer(sleep_time) + if self._coordinator: + self._coordinator.set_sleep_timer(sleep_time) + else: + self._player.set_sleep_timer(sleep_time) - @only_if_coordinator def clear_sleep_timer(self): """Clear the timer on the player.""" - self._player.set_sleep_timer(None) + if self._coordinator: + self._coordinator.set_sleep_timer(None) + else: + self._player.set_sleep_timer(None) + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return {ATTR_IS_COORDINATOR: self.is_coordinator} diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index c338c6dffd8..852ce522559 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -182,7 +182,7 @@ class SqueezeBoxDevice(MediaPlayerDevice): @property def state(self): """Return the state of the device.""" - if 'power' in self._status and self._status['power'] == '0': + if 'power' in self._status and self._status['power'] == 0: return STATE_OFF if 'mode' in self._status: if self._status['mode'] == 'pause': @@ -213,8 +213,16 @@ class SqueezeBoxDevice(MediaPlayerDevice): "status", "-", "1", "tags:{tags}" .format(tags=tags)) + if response is False: + return + + self._status = response.copy() + + try: + self._status.update(response["playlist_loop"][0]) + except KeyError: + pass try: - self._status = response.copy() self._status.update(response["remoteMeta"]) except KeyError: pass diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 45c30b979a6..e01717f5693 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -190,6 +190,10 @@ class UniversalMediaPlayer(MediaPlayerDevice): return active_child = self._child_state + if active_child is None: + # No child to call service on + return + service_data[ATTR_ENTITY_ID] = active_child.entity_id self.hass.services.call(DOMAIN, service_name, service_data, diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index 2596e7a4ca9..84778cef2d5 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -103,6 +103,7 @@ class YamahaDevice(MediaPlayerDevice): self._source_ignore = source_ignore or [] self._source_names = source_names or {} self._reverse_mapping = None + self._playback_support = None self._is_playback_supported = False self._play_status = None self.update() @@ -131,6 +132,7 @@ class YamahaDevice(MediaPlayerDevice): current_source = self._receiver.input self._current_source = self._source_names.get( current_source, current_source) + self._playback_support = self._receiver.get_playback_support() self._is_playback_supported = self._receiver.is_playback_supported( self._current_source) @@ -183,7 +185,7 @@ class YamahaDevice(MediaPlayerDevice): """Flag of media commands that are supported.""" supported_commands = SUPPORT_YAMAHA - supports = self._receiver.get_playback_support() + supports = self._playback_support mapping = {'play': (SUPPORT_PLAY | SUPPORT_PLAY_MEDIA), 'pause': SUPPORT_PAUSE, 'stop': SUPPORT_STOP, diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py new file mode 100644 index 00000000000..dc0e9001b24 --- /dev/null +++ b/homeassistant/components/microsoft_face.py @@ -0,0 +1,388 @@ +""" +Support for microsoft face recognition. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/microsoft_face/ +""" +import asyncio +import json +import logging +import os + +import aiohttp +from aiohttp.hdrs import CONTENT_TYPE +import async_timeout +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT +from homeassistant.config import load_yaml_config_file +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.loader import get_component +from homeassistant.util import slugify + +DOMAIN = 'microsoft_face' +DEPENDENCIES = ['camera'] + +_LOGGER = logging.getLogger(__name__) + +FACE_API_URL = "https://westus.api.cognitive.microsoft.com/face/v1.0/{0}" + +DATA_MICROSOFT_FACE = 'microsoft_face' + +SERVICE_CREATE_GROUP = 'create_group' +SERVICE_DELETE_GROUP = 'delete_group' +SERVICE_TRAIN_GROUP = 'train_group' +SERVICE_CREATE_PERSON = 'create_person' +SERVICE_DELETE_PERSON = 'delete_person' +SERVICE_FACE_PERSON = 'face_person' + +ATTR_GROUP = 'group' +ATTR_PERSON = 'person' +ATTR_CAMERA_ENTITY = 'camera_entity' +ATTR_NAME = 'name' + +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +SCHEMA_GROUP_SERVICE = vol.Schema({ + vol.Required(ATTR_NAME): cv.string, +}) + +SCHEMA_PERSON_SERVICE = SCHEMA_GROUP_SERVICE.extend({ + vol.Required(ATTR_GROUP): cv.slugify, +}) + +SCHEMA_FACE_SERVICE = vol.Schema({ + vol.Required(ATTR_PERSON): cv.string, + vol.Required(ATTR_GROUP): cv.slugify, + vol.Required(ATTR_CAMERA_ENTITY): cv.entity_id, +}) + +SCHEMA_TRAIN_SERVICE = vol.Schema({ + vol.Required(ATTR_GROUP): cv.slugify, +}) + + +def create_group(hass, name): + """Create a new person group.""" + data = {ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_CREATE_GROUP, data) + + +def delete_group(hass, name): + """Delete a person group.""" + data = {ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_DELETE_GROUP, data) + + +def train_group(hass, group): + """Train a person group.""" + data = {ATTR_GROUP: group} + hass.services.call(DOMAIN, SERVICE_TRAIN_GROUP, data) + + +def create_person(hass, group, name): + """Create a person in a group.""" + data = {ATTR_GROUP: group, ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_CREATE_PERSON, data) + + +def delete_person(hass, group, name): + """Delete a person in a group.""" + data = {ATTR_GROUP: group, ATTR_NAME: name} + hass.services.call(DOMAIN, SERVICE_DELETE_PERSON, data) + + +def face_person(hass, group, person, camera_entity): + """Add a new face picture to a person.""" + data = {ATTR_GROUP: group, ATTR_PERSON: person, + ATTR_CAMERA_ENTITY: camera_entity} + hass.services.call(DOMAIN, SERVICE_FACE_PERSON, data) + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup microsoft face.""" + entities = {} + face = MicrosoftFace( + hass, + config[DOMAIN].get(CONF_API_KEY), + config[DOMAIN].get(CONF_TIMEOUT), + entities + ) + + try: + # read exists group/person from cloud and create entities + yield from face.update_store() + except HomeAssistantError as err: + _LOGGER.error("Can't load data from face api: %s", err) + return False + + hass.data[DATA_MICROSOFT_FACE] = face + + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + @asyncio.coroutine + def async_create_group(service): + """Create a new person group.""" + name = service.data[ATTR_NAME] + g_id = slugify(name) + + try: + yield from face.call_api( + 'put', "persongroups/{0}".format(g_id), {'name': name}) + face.store[g_id] = {} + + entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) + yield from entities[g_id].async_update_ha_state() + except HomeAssistantError as err: + _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_CREATE_GROUP, async_create_group, + descriptions[DOMAIN].get(SERVICE_CREATE_GROUP), + schema=SCHEMA_GROUP_SERVICE) + + @asyncio.coroutine + def async_delete_group(service): + """Delete a person group.""" + g_id = slugify(service.data[ATTR_NAME]) + + try: + yield from face.call_api('delete', "persongroups/{0}".format(g_id)) + face.store.pop(g_id) + + entity = entities.pop(g_id) + yield from entity.async_remove() + except HomeAssistantError as err: + _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_DELETE_GROUP, async_delete_group, + descriptions[DOMAIN].get(SERVICE_DELETE_GROUP), + schema=SCHEMA_GROUP_SERVICE) + + @asyncio.coroutine + def async_train_group(service): + """Train a person group.""" + g_id = service.data[ATTR_GROUP] + + try: + yield from face.call_api( + 'post', "persongroups/{0}/train".format(g_id)) + except HomeAssistantError as err: + _LOGGER.error("Can't train group '%s' with error: %s", g_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_TRAIN_GROUP, async_train_group, + descriptions[DOMAIN].get(SERVICE_TRAIN_GROUP), + schema=SCHEMA_TRAIN_SERVICE) + + @asyncio.coroutine + def async_create_person(service): + """Create a person in a group.""" + name = service.data[ATTR_NAME] + g_id = service.data[ATTR_GROUP] + + try: + user_data = yield from face.call_api( + 'post', "persongroups/{0}/persons".format(g_id), {'name': name} + ) + + face.store[g_id][name] = user_data['personId'] + yield from entities[g_id].async_update_ha_state() + except HomeAssistantError as err: + _LOGGER.error("Can't create person '%s' with error: %s", name, err) + + hass.services.async_register( + DOMAIN, SERVICE_CREATE_PERSON, async_create_person, + descriptions[DOMAIN].get(SERVICE_CREATE_PERSON), + schema=SCHEMA_PERSON_SERVICE) + + @asyncio.coroutine + def async_delete_person(service): + """Delete a person in a group.""" + name = service.data[ATTR_NAME] + g_id = service.data[ATTR_GROUP] + p_id = face.store[g_id].get(name) + + try: + yield from face.call_api( + 'delete', "persongroups/{0}/persons/{1}".format(g_id, p_id)) + + face.store[g_id].pop(name) + yield from entities[g_id].async_update_ha_state() + except HomeAssistantError as err: + _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_DELETE_PERSON, async_delete_person, + descriptions[DOMAIN].get(SERVICE_DELETE_PERSON), + schema=SCHEMA_PERSON_SERVICE) + + @asyncio.coroutine + def async_face_person(service): + """Add a new face picture to a person.""" + g_id = service.data[ATTR_GROUP] + p_id = face.store[g_id].get(service.data[ATTR_PERSON]) + + camera_entity = service.data[ATTR_CAMERA_ENTITY] + camera = get_component('camera') + + try: + image = yield from camera.async_get_image(hass, camera_entity) + + yield from face.call_api( + 'post', + "persongroups/{0}/persons/{1}/persistedFaces".format( + g_id, p_id), + image, + binary=True + ) + except HomeAssistantError as err: + _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err) + + hass.services.async_register( + DOMAIN, SERVICE_FACE_PERSON, async_face_person, + descriptions[DOMAIN].get(SERVICE_FACE_PERSON), + schema=SCHEMA_FACE_SERVICE) + + return True + + +class MicrosoftFaceGroupEntity(Entity): + """Person-Group state/data Entity.""" + + def __init__(self, hass, api, g_id, name): + """Initialize person/group entity.""" + self.hass = hass + self._api = api + self._id = g_id + self._name = name + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def entity_id(self): + """Return entity id.""" + return "{0}.{1}".format(DOMAIN, self._id) + + @property + def state(self): + """Return the state of the entity.""" + return len(self._api.store[self._id]) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attr = {} + for name, p_id in self._api.store[self._id].items(): + attr[name] = p_id + + return attr + + +class MicrosoftFace(object): + """Microsoft Face api for HomeAssistant.""" + + def __init__(self, hass, api_key, timeout, entities): + """Initialize Microsoft Face api.""" + self.hass = hass + self.websession = async_get_clientsession(hass) + self.timeout = timeout + self._api_key = api_key + self._store = {} + self._entities = entities + + @property + def store(self): + """Store group/person data and IDs.""" + return self._store + + @asyncio.coroutine + def update_store(self): + """Load all group/person data into local store.""" + groups = yield from self.call_api('get', 'persongroups') + + tasks = [] + for group in groups: + g_id = group['personGroupId'] + self._store[g_id] = {} + self._entities[g_id] = MicrosoftFaceGroupEntity( + self.hass, self, g_id, group['name']) + + persons = yield from self.call_api( + 'get', "persongroups/{0}/persons".format(g_id)) + + for person in persons: + self._store[g_id][person['name']] = person['personId'] + + tasks.append(self._entities[g_id].async_update_ha_state()) + + if tasks: + yield from asyncio.wait(tasks, loop=self.hass.loop) + + @asyncio.coroutine + def call_api(self, method, function, data=None, binary=False, + params=None): + """Make a api call.""" + headers = {"Ocp-Apim-Subscription-Key": self._api_key} + url = FACE_API_URL.format(function) + + payload = None + if binary: + headers[CONTENT_TYPE] = "application/octet-stream" + payload = data + else: + headers[CONTENT_TYPE] = "application/json" + if data is not None: + payload = json.dumps(data).encode() + else: + payload = None + + response = None + try: + with async_timeout.timeout(self.timeout, loop=self.hass.loop): + response = yield from getattr(self.websession, method)( + url, data=payload, headers=headers, params=params) + + answer = yield from response.json() + _LOGGER.debug("Read from microsoft face api: %s", answer) + if response.status == 200 or response.status == 202: + return answer + + _LOGGER.warning("Error %d microsoft face api %s", + response.status, response.url) + raise HomeAssistantError(answer['error']['message']) + + except (aiohttp.errors.ClientError, + aiohttp.errors.ClientDisconnectedError): + _LOGGER.warning("Can't connect to microsoft face api") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from microsoft face api %s", response.url) + + finally: + if response is not None: + yield from response.release() + + raise HomeAssistantError("Network error on microsoft face api.") diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 79e572defeb..52b851d1983 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -5,12 +5,15 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ import logging +import os import socket import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.bootstrap import setup_component +from homeassistant.components.mqtt import (valid_publish_topic, + valid_subscribe_topic) from homeassistant.const import (ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) @@ -44,22 +47,61 @@ REQUIREMENTS = [ 'https://github.com/theolind/pymysensors/archive/' '0b705119389be58332f17753c53167f551254b6c.zip#pymysensors==0.8'] + +def is_socket_address(value): + """Validate that value is a valid address.""" + try: + socket.getaddrinfo(value, None) + return value + except OSError: + raise vol.Invalid('Device is not a valid domain name or ip address') + + +def has_parent_dir(value): + """Validate that value is in an existing directory which is writetable.""" + parent = os.path.dirname(os.path.realpath(value)) + is_dir_writable = os.path.isdir(parent) and os.access(parent, os.W_OK) + if not is_dir_writable: + raise vol.Invalid( + '{} directory does not exist or is not writetable'.format(parent)) + return value + + +def has_all_unique_files(value): + """Validate that all persistence files are unique and set if any is set.""" + persistence_files = [ + gateway.get(CONF_PERSISTENCE_FILE) for gateway in value] + if None in persistence_files and any( + name is not None for name in persistence_files): + raise vol.Invalid( + 'persistence file name of all devices must be set if any is set') + if not all(name is None for name in persistence_files): + schema = vol.Schema(vol.Unique()) + schema(persistence_files) + return value + + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [ - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_PERSISTENCE_FILE): cv.string, + vol.Required(CONF_GATEWAYS): vol.All( + cv.ensure_list, has_all_unique_files, + [{ + vol.Required(CONF_DEVICE): + vol.Any(cv.isdevice, MQTT_COMPONENT, is_socket_address), + vol.Optional(CONF_PERSISTENCE_FILE): + vol.All(cv.string, has_parent_dir), vol.Optional( CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, vol.Optional( CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX, default=''): cv.string, - vol.Optional(CONF_TOPIC_OUT_PREFIX, default=''): cv.string, - }, - ]), + vol.Optional( + CONF_TOPIC_IN_PREFIX, default=''): valid_subscribe_topic, + vol.Optional( + CONF_TOPIC_OUT_PREFIX, default=''): valid_publish_topic, + }] + ), vol.Optional(CONF_DEBUG, default=False): cv.boolean, vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, @@ -100,7 +142,7 @@ def setup(hass, config): out_prefix=out_prefix, retain=retain) else: try: - socket.inet_aton(device) + socket.getaddrinfo(device, None) # valid ip address gateway = mysensors.TCPGateway( device, event_callback=None, persistence=persistence, diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 337cc8f9160..13c2ddc7bed 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -11,7 +11,9 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery -from homeassistant.const import (CONF_STRUCTURE, CONF_FILENAME) +from homeassistant.const import (CONF_STRUCTURE, CONF_FILENAME, + CONF_BINARY_SENSORS, CONF_SENSORS, + CONF_MONITORED_CONDITIONS) from homeassistant.loader import get_component _CONFIGURING = {} @@ -30,11 +32,17 @@ NEST_CONFIG_FILE = 'nest.conf' CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list) +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string) + vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string), + vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, + vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA }) }, extra=vol.ALLOW_EXTRA) @@ -88,9 +96,15 @@ def setup_nest(hass, nest, config, pin=None): _LOGGER.debug("proceeding with discovery") discovery.load_platform(hass, 'climate', DOMAIN, {}, config) - discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) discovery.load_platform(hass, 'camera', DOMAIN, {}, config) + + sensor_config = conf.get(CONF_SENSORS, {}) + discovery.load_platform(hass, 'sensor', DOMAIN, sensor_config, config) + + binary_sensor_config = conf.get(CONF_BINARY_SENSORS, {}) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, + binary_sensor_config, config) + _LOGGER.debug("setup done") return True diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index b4ebbc1d460..2bd450a2917 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -18,7 +18,7 @@ from homeassistant.util import Throttle REQUIREMENTS = [ 'https://github.com/jabesq/netatmo-api-python/archive/' - 'v0.8.1.zip#lnetatmo==0.8.1'] + 'v0.9.1.zip#lnetatmo==0.9.1'] _LOGGER = logging.getLogger(__name__) @@ -53,7 +53,8 @@ def setup(hass, config): config[DOMAIN][CONF_API_KEY], config[DOMAIN][CONF_SECRET_KEY], config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], 'read_station read_camera access_camera ' - 'read_thermostat write_thermostat') + 'read_thermostat write_thermostat ' + 'read_presence access_presence') except HTTPError: _LOGGER.error("Unable to connect to Netatmo API") return False @@ -65,27 +66,28 @@ def setup(hass, config): return True -class WelcomeData(object): +class CameraData(object): """Get the latest data from Netatmo.""" def __init__(self, auth, home=None): """Initialize the data object.""" self.auth = auth - self.welcomedata = None + self.camera_data = None self.camera_names = [] self.module_names = [] self.home = home + self.camera_type = None def get_camera_names(self): """Return all camera available on the API as a list.""" self.camera_names = [] self.update() if not self.home: - for home in self.welcomedata.cameras: - for camera in self.welcomedata.cameras[home].values(): + for home in self.camera_data.cameras: + for camera in self.camera_data.cameras[home].values(): self.camera_names.append(camera['name']) else: - for camera in self.welcomedata.cameras[self.home].values(): + for camera in self.camera_data.cameras[self.home].values(): self.camera_names.append(camera['name']) return self.camera_names @@ -93,20 +95,27 @@ class WelcomeData(object): """Return all module available on the API as a list.""" self.module_names = [] self.update() - cam_id = self.welcomedata.cameraByName(camera=camera_name, + cam_id = self.camera_data.cameraByName(camera=camera_name, home=self.home)['id'] - for module in self.welcomedata.modules.values(): + for module in self.camera_data.modules.values(): if cam_id == module['cam_id']: self.module_names.append(module['name']) return self.module_names + def get_camera_type(self, camera=None, home=None, cid=None): + """Return all module available on the API as a list.""" + for camera_name in self.camera_names: + self.camera_type = self.camera_data.cameraType(camera_name) + return self.camera_type + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Call the Netatmo API to update the data.""" import lnetatmo - self.welcomedata = lnetatmo.WelcomeData(self.auth, size=100) + self.camera_data = lnetatmo.CameraData(self.auth, size=100) @Throttle(MIN_TIME_BETWEEN_EVENT_UPDATES) def update_event(self): - """Call the Netatmo API to update the list of events.""" - self.welcomedata.updateEvent(home=self.home) + """Call the Netatmo API to update the events.""" + self.camera_data.updateEvent( + home=self.home, cameratype=self.camera_type) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index a5c1e53ef03..d1d35e07054 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -4,13 +4,15 @@ Provides functionality to notify people. For more details about this component, please refer to the documentation at https://home-assistant.io/components/notify/ """ +import asyncio import logging import os from functools import partial import voluptuous as vol -import homeassistant.bootstrap as bootstrap +from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_NAME, CONF_PLATFORM @@ -64,91 +66,110 @@ def send_message(hass, message, title=None, data=None): hass.services.call(DOMAIN, SERVICE_NOTIFY, info) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Setup the notify services.""" - descriptions = load_yaml_config_file( + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) targets = {} - def setup_notify_platform(platform, p_config=None, discovery_info=None): + @asyncio.coroutine + def async_setup_platform(p_type, p_config=None, discovery_info=None): """Set up a notify platform.""" if p_config is None: p_config = {} if discovery_info is None: discovery_info = {} - notify_implementation = bootstrap.prepare_setup_platform( - hass, config, DOMAIN, platform) + platform = yield from async_prepare_setup_platform( + hass, config, DOMAIN, p_type) - if notify_implementation is None: + if platform is None: _LOGGER.error("Unknown notification service specified") - return False + return - notify_service = notify_implementation.get_service( - hass, p_config, discovery_info) + _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) + notify_service = None + try: + if hasattr(platform, 'async_get_service'): + notify_service = yield from \ + platform.async_get_service(hass, p_config, discovery_info) + elif hasattr(platform, 'get_service'): + notify_service = yield from hass.loop.run_in_executor( + None, platform.get_service, hass, p_config, discovery_info) + else: + raise HomeAssistantError("Invalid notify platform.") - if notify_service is None: - _LOGGER.error("Failed to initialize notification service %s", - platform) - return False + if notify_service is None: + _LOGGER.error( + "Failed to initialize notification service %s", p_type) + return - def notify_message(notify_service, call): + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up platform %s', p_type) + return + + notify_service.hass = hass + + @asyncio.coroutine + def async_notify_message(service): """Handle sending notification message service calls.""" kwargs = {} - message = call.data[ATTR_MESSAGE] - title = call.data.get(ATTR_TITLE) + message = service.data[ATTR_MESSAGE] + title = service.data.get(ATTR_TITLE) if title: title.hass = hass - kwargs[ATTR_TITLE] = title.render() + kwargs[ATTR_TITLE] = title.async_render() - if targets.get(call.service) is not None: - kwargs[ATTR_TARGET] = [targets[call.service]] - elif call.data.get(ATTR_TARGET) is not None: - kwargs[ATTR_TARGET] = call.data.get(ATTR_TARGET) + if targets.get(service.service) is not None: + kwargs[ATTR_TARGET] = [targets[service.service]] + elif service.data.get(ATTR_TARGET) is not None: + kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) message.hass = hass - kwargs[ATTR_MESSAGE] = message.render() - kwargs[ATTR_DATA] = call.data.get(ATTR_DATA) + kwargs[ATTR_MESSAGE] = message.async_render() + kwargs[ATTR_DATA] = service.data.get(ATTR_DATA) - notify_service.send_message(**kwargs) - - service_call_handler = partial(notify_message, notify_service) + yield from notify_service.async_send_message(**kwargs) if hasattr(notify_service, 'targets'): platform_name = ( p_config.get(CONF_NAME) or discovery_info.get(CONF_NAME) or - platform) + p_type) for name, target in notify_service.targets.items(): target_name = slugify('{}_{}'.format(platform_name, name)) targets[target_name] = target - hass.services.register(DOMAIN, target_name, - service_call_handler, - descriptions.get(SERVICE_NOTIFY), - schema=NOTIFY_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, target_name, async_notify_message, + descriptions.get(SERVICE_NOTIFY), + schema=NOTIFY_SERVICE_SCHEMA) platform_name = ( p_config.get(CONF_NAME) or discovery_info.get(CONF_NAME) or SERVICE_NOTIFY) platform_name_slug = slugify(platform_name) - hass.services.register( - DOMAIN, platform_name_slug, service_call_handler, + hass.services.async_register( + DOMAIN, platform_name_slug, async_notify_message, descriptions.get(SERVICE_NOTIFY), schema=NOTIFY_SERVICE_SCHEMA) return True - for platform, p_config in config_per_platform(config, DOMAIN): - if not setup_notify_platform(platform, p_config): - _LOGGER.error("Failed to set up platform %s", platform) - continue + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config + in config_per_platform(config, DOMAIN)] - def platform_discovered(platform, info): + if setup_tasks: + yield from asyncio.wait(setup_tasks, loop=hass.loop) + + @asyncio.coroutine + def async_platform_discovered(platform, info): """Callback to load a platform.""" - setup_notify_platform(platform, discovery_info=info) + yield from async_setup_platform(platform, discovery_info=info) - discovery.listen_platform(hass, DOMAIN, platform_discovered) + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) return True @@ -156,9 +177,20 @@ def setup(hass, config): class BaseNotificationService(object): """An abstract class for notification services.""" + hass = None + def send_message(self, message, **kwargs): """Send a message. kwargs can contain ATTR_TITLE to specify a title. """ - raise NotImplementedError + raise NotImplementedError() + + def async_send_message(self, message, **kwargs): + """Send a message. + + kwargs can contain ATTR_TITLE to specify a title. + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.loop.run_in_executor( + None, partial(self.send_message, message, **kwargs)) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index d18da5ae2f0..7bb83db7f82 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.3.1"] +REQUIREMENTS = ["boto3==1.4.3"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index f02b6b75a84..9b95c486b4d 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.3.1"] +REQUIREMENTS = ["boto3==1.4.3"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index ecbadac46ce..76a137734d3 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.3.1"] +REQUIREMENTS = ["boto3==1.4.3"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py new file mode 100644 index 00000000000..e6c4b3bad96 --- /dev/null +++ b/homeassistant/components/notify/discord.py @@ -0,0 +1,52 @@ +""" +Discord platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.discord/ +""" +import logging +import asyncio +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService, ATTR_TARGET) + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['discord.py==0.16.0'] + +CONF_TOKEN = 'token' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Discord notification service.""" + token = config.get(CONF_TOKEN) + return DiscordNotificationService(hass, token) + + +class DiscordNotificationService(BaseNotificationService): + """Implement the notification service for Discord.""" + + def __init__(self, hass, token): + """Initialize the service.""" + self.token = token + self.hass = hass + + @asyncio.coroutine + def async_send_message(self, message, **kwargs): + """Login to Discord, send message to channel(s) and log out.""" + import discord + discord_bot = discord.Client(loop=self.hass.loop) + + yield from discord_bot.login(self.token) + + for channelid in kwargs[ATTR_TARGET]: + channel = discord.Object(id=channelid) + yield from discord_bot.send_message(channel, message) + + yield from discord_bot.logout() + yield from discord_bot.close() diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index 2acabcf02c0..e598b0e818b 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def get_service(hass, config): +def get_service(hass, config, discovery_info=None): """Get the Facebook notification service.""" return FacebookNotificationService(config[CONF_PAGE_ACCESS_TOKEN]) diff --git a/homeassistant/components/notify/group.py b/homeassistant/components/notify/group.py index 3de79f5a7be..07cc7b1146a 100644 --- a/homeassistant/components/notify/group.py +++ b/homeassistant/components/notify/group.py @@ -4,15 +4,15 @@ Group platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.group/ """ +import asyncio import collections from copy import deepcopy import logging import voluptuous as vol from homeassistant.const import ATTR_SERVICE -from homeassistant.components.notify import (DOMAIN, ATTR_MESSAGE, ATTR_DATA, - PLATFORM_SCHEMA, - BaseNotificationService) +from homeassistant.components.notify import ( + DOMAIN, ATTR_MESSAGE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def update(input_dict, update_source): - """Deep update a dictionary.""" + """Deep update a dictionary. + + Async friendly. + """ for key, val in update_source.items(): if isinstance(val, collections.Mapping): recurse = update(input_dict.get(key, {}), val) @@ -38,7 +41,8 @@ def update(input_dict, update_source): return input_dict -def get_service(hass, config, discovery_info=None): +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): """Get the Group notification service.""" return GroupNotifyPlatform(hass, config.get(CONF_SERVICES)) @@ -51,14 +55,19 @@ class GroupNotifyPlatform(BaseNotificationService): self.hass = hass self.entities = entities - def send_message(self, message="", **kwargs): + @asyncio.coroutine + def async_send_message(self, message="", **kwargs): """Send message to all entities in the group.""" payload = {ATTR_MESSAGE: message} payload.update({key: val for key, val in kwargs.items() if val}) + tasks = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) if entity.get(ATTR_DATA) is not None: update(sending_payload, entity.get(ATTR_DATA)) - self.hass.services.call(DOMAIN, entity.get(ATTR_SERVICE), - sending_payload) + tasks.append(self.hass.services.async_call( + DOMAIN, entity.get(ATTR_SERVICE), sending_payload)) + + if tasks: + yield from asyncio.wait(tasks, loop=self.hass.loop) diff --git a/homeassistant/components/notify/twilio_call.py b/homeassistant/components/notify/twilio_call.py new file mode 100644 index 00000000000..374e77b9507 --- /dev/null +++ b/homeassistant/components/notify/twilio_call.py @@ -0,0 +1,74 @@ +""" +Twilio Call platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.twilio_call/ +""" +import logging +import urllib + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["twilio==5.7.0"] + + +CONF_ACCOUNT_SID = "account_sid" +CONF_AUTH_TOKEN = "auth_token" +CONF_FROM_NUMBER = "from_number" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCOUNT_SID): cv.string, + vol.Required(CONF_AUTH_TOKEN): cv.string, + vol.Required(CONF_FROM_NUMBER): + vol.All(cv.string, vol.Match(r"^\+?[1-9]\d{1,14}$")), +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Twilio Call notification service.""" + # pylint: disable=import-error + from twilio.rest import TwilioRestClient + + twilio_client = TwilioRestClient(config[CONF_ACCOUNT_SID], + config[CONF_AUTH_TOKEN]) + + return TwilioCallNotificationService(twilio_client, + config[CONF_FROM_NUMBER]) + + +class TwilioCallNotificationService(BaseNotificationService): + """Implement the notification service for the Twilio Call service.""" + + def __init__(self, twilio_client, from_number): + """Initialize the service.""" + self.client = twilio_client + self.from_number = from_number + + def send_message(self, message="", **kwargs): + """Call to specified target users.""" + from twilio import TwilioRestException + + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if message.startswith(("http://", "https://")): + twimlet_url = message + else: + twimlet_url = "http://twimlets.com/message?Message=" + twimlet_url += urllib.parse.quote(message, safe="") + + for target in targets: + try: + self.client.calls.create(to=target, + url=twimlet_url, + from_=self.from_number) + except TwilioRestException as exc: + _LOGGER.error(exc) diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py index 950e0eed221..ab3ac89e6b2 100644 --- a/homeassistant/components/notify/twilio_sms.py +++ b/homeassistant/components/notify/twilio_sms.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["twilio==5.4.0"] +REQUIREMENTS = ["twilio==5.7.0"] CONF_ACCOUNT_SID = "account_sid" diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 24128edd880..5672429b9c6 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME REQUIREMENTS = ['TwitterAPI==2.4.3'] @@ -22,10 +22,11 @@ CONF_CONSUMER_SECRET = 'consumer_secret' CONF_ACCESS_TOKEN_SECRET = 'access_token_secret' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_CONSUMER_KEY): cv.string, - vol.Required(CONF_CONSUMER_SECRET): cv.string, vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Required(CONF_ACCESS_TOKEN_SECRET): cv.string, + vol.Required(CONF_CONSUMER_KEY): cv.string, + vol.Required(CONF_CONSUMER_SECRET): cv.string, + vol.Optional(CONF_USERNAME): cv.string, }) @@ -33,7 +34,8 @@ def get_service(hass, config, discovery_info=None): """Get the Twitter notification service.""" return TwitterNotificationService( config[CONF_CONSUMER_KEY], config[CONF_CONSUMER_SECRET], - config[CONF_ACCESS_TOKEN], config[CONF_ACCESS_TOKEN_SECRET] + config[CONF_ACCESS_TOKEN], config[CONF_ACCESS_TOKEN_SECRET], + config.get(CONF_USERNAME) ) @@ -41,15 +43,21 @@ class TwitterNotificationService(BaseNotificationService): """Implementation of a notification service for the Twitter service.""" def __init__(self, consumer_key, consumer_secret, access_token_key, - access_token_secret): + access_token_secret, username): """Initialize the service.""" from TwitterAPI import TwitterAPI + self.user = username self.api = TwitterAPI(consumer_key, consumer_secret, access_token_key, access_token_secret) def send_message(self, message="", **kwargs): - """Tweet some message.""" - resp = self.api.request('statuses/update', {'status': message}) + """Tweet a message.""" + if self.user: + resp = self.api.request( + 'direct_messages/new', {'text': message, 'user': self.user}) + else: + resp = self.api.request('statuses/update', {'status': message}) + if resp.status_code != 200: import json obj = json.loads(resp.text) diff --git a/homeassistant/components/openalpr.py b/homeassistant/components/openalpr.py deleted file mode 100644 index eaaba5f8af8..00000000000 --- a/homeassistant/components/openalpr.py +++ /dev/null @@ -1,472 +0,0 @@ -""" -Component that will help set the openalpr for video streams. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/openalpr/ -""" -from base64 import b64encode -import logging -import os -from time import time - -import requests -import voluptuous as vol - -from homeassistant.config import load_yaml_config_file -from homeassistant.const import ( - CONF_API_KEY, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, ATTR_ENTITY_ID, - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) -from homeassistant.components.ffmpeg import ( - get_binary, run_test, CONF_INPUT, CONF_EXTRA_ARGUMENTS) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent - -DOMAIN = 'openalpr' -DEPENDENCIES = ['ffmpeg'] -REQUIREMENTS = [ - 'https://github.com/pvizeli/cloudapi/releases/download/1.0.2/' - 'python-1.0.2.zip#openalpr_api==1.0.2', - 'ha-alpr==0.3'] - -_LOGGER = logging.getLogger(__name__) - -SERVICE_SCAN = 'scan' -SERVICE_RESTART = 'restart' - -EVENT_FOUND = 'openalpr.found' - -ATTR_PLATE = 'plate' - - -ENGINE_LOCAL = 'local' -ENGINE_CLOUD = 'cloud' - -RENDER_IMAGE = 'image' -RENDER_FFMPEG = 'ffmpeg' - -OPENALPR_REGIONS = [ - 'us', - 'eu', - 'au', - 'auwide', - 'gb', - 'kr', - 'mx', - 'sg', -] - -CONF_RENDER = 'render' -CONF_ENGINE = 'engine' -CONF_REGION = 'region' -CONF_INTERVAL = 'interval' -CONF_ENTITIES = 'entities' -CONF_CONFIDENCE = 'confidence' -CONF_ALPR_BINARY = 'alpr_binary' - -DEFAULT_NAME = 'OpenAlpr' -DEFAULT_ENGINE = ENGINE_LOCAL -DEFAULT_RENDER = RENDER_FFMPEG -DEFAULT_BINARY = 'alpr' -DEFAULT_INTERVAL = 10 -DEFAULT_CONFIDENCE = 80.0 - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(CONF_INPUT): cv.string, - vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_RENDER, default=DEFAULT_RENDER): - vol.In([RENDER_IMAGE, RENDER_FFMPEG]), - vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_ENGINE): vol.In([ENGINE_LOCAL, ENGINE_CLOUD]), - vol.Required(CONF_REGION): vol.In(OPENALPR_REGIONS), - vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.Coerce(float), - vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_ALPR_BINARY, default=DEFAULT_BINARY): cv.string, - vol.Required(CONF_ENTITIES): - vol.All(cv.ensure_list, [DEVICE_SCHEMA]), - }) -}, extra=vol.ALLOW_EXTRA) - - -SERVICE_RESTART_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - -SERVICE_SCAN_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, -}) - - -def scan(hass, entity_id=None): - """Scan a image immediately.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_SCAN, data) - - -def restart(hass, entity_id=None): - """Restart a ffmpeg process.""" - data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_RESTART, data) - - -def setup(hass, config): - """Setup the OpenAlpr component.""" - engine = config[DOMAIN].get(CONF_ENGINE) - region = config[DOMAIN].get(CONF_REGION) - confidence = config[DOMAIN].get(CONF_CONFIDENCE) - api_key = config[DOMAIN].get(CONF_API_KEY) - binary = config[DOMAIN].get(CONF_ALPR_BINARY) - use_render_fffmpeg = False - - _LOGGER.warning("This platform is replaced by 'image_processing' and will " - "be removed in a future version!") - - component = EntityComponent(_LOGGER, DOMAIN, hass) - openalpr_device = [] - - for device in config[DOMAIN].get(CONF_ENTITIES): - input_source = device.get(CONF_INPUT) - render = device.get(CONF_RENDER) - - ## - # create api - if engine == ENGINE_LOCAL: - alpr_api = OpenalprApiLocal( - confidence=confidence, - region=region, - binary=binary, - ) - else: - alpr_api = OpenalprApiCloud( - confidence=confidence, - region=region, - api_key=api_key, - ) - - ## - # Create Alpr device / render engine - if render == RENDER_FFMPEG: - use_render_fffmpeg = True - if not run_test(hass, input_source): - _LOGGER.error("'%s' is not valid ffmpeg input", input_source) - continue - - alpr_dev = OpenalprDeviceFFmpeg( - name=device.get(CONF_NAME), - interval=device.get(CONF_INTERVAL), - api=alpr_api, - input_source=input_source, - extra_arguments=device.get(CONF_EXTRA_ARGUMENTS), - ) - else: - alpr_dev = OpenalprDeviceImage( - name=device.get(CONF_NAME), - interval=device.get(CONF_INTERVAL), - api=alpr_api, - input_source=input_source, - username=device.get(CONF_USERNAME), - password=device.get(CONF_PASSWORD), - ) - - # register shutdown event - openalpr_device.append(alpr_dev) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, alpr_dev.shutdown) - - component.add_entities(openalpr_device) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - def _handle_service_scan(service): - """Handle service for immediately scan.""" - device_list = component.extract_from_service(service) - - for device in device_list: - device.scan() - - hass.services.register(DOMAIN, SERVICE_SCAN, - _handle_service_scan, - descriptions[DOMAIN][SERVICE_SCAN], - schema=SERVICE_SCAN_SCHEMA) - - # Add restart service only if a device use ffmpeg as render - if not use_render_fffmpeg: - return True - - def _handle_service_restart(service): - """Handle service for restart ffmpeg process.""" - device_list = component.extract_from_service(service) - - for device in device_list: - device.restart() - - hass.services.register(DOMAIN, SERVICE_RESTART, - _handle_service_restart, - descriptions[DOMAIN][SERVICE_RESTART], - schema=SERVICE_RESTART_SCHEMA) - - return True - - -class OpenalprDevice(Entity): - """Represent a openalpr device object for processing stream/images.""" - - def __init__(self, name, interval, api): - """Init image processing.""" - self._name = name - self._interval = interval - self._api = api - self._last = {} - - @property - def state(self): - """Return the state of the entity.""" - confidence = 0 - plate = STATE_UNKNOWN - - # search high plate - for i_pl, i_co in self._last.items(): - if i_co > confidence: - confidence = i_co - plate = i_pl - return plate - - def shutdown(self, event): - """Close stream.""" - if hasattr(self._api, "shutdown"): - self._api.shutdown(event) - - def restart(self): - """Restart stream.""" - raise NotImplementedError() - - def _process_image(self, image): - """Callback for processing image.""" - self._api.process_image(image, self._process_event) - - def _process_event(self, plates): - """Send event with new plates.""" - state_change = False - plates_set = set(plates) - last_set = set(self._last) - new_plates = plates_set - last_set - - # send events - for i_plate in new_plates: - self.hass.bus.fire(EVENT_FOUND, { - ATTR_PLATE: i_plate, - ATTR_ENTITY_ID: self.entity_id - }) - - # update entity store - if last_set <= plates_set: - state_change = True - self._last = plates - - # update HA state - if state_change: - self.update_ha_state() - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return {'plates': self._last} - - def scan(self): - """Immediately scan a image.""" - raise NotImplementedError() - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - -class OpenalprDeviceFFmpeg(OpenalprDevice): - """Represent a openalpr device object for processing stream/images.""" - - def __init__(self, name, interval, api, input_source, - extra_arguments=None): - """Init image processing.""" - from haffmpeg import ImageStream, ImageSingle - - super().__init__(name, interval, api) - self._input_source = input_source - self._extra_arguments = extra_arguments - - if self._interval > 0: - self._ffmpeg = ImageStream(get_binary(), self._process_image) - else: - self._ffmpeg = ImageSingle(get_binary()) - - self._start_ffmpeg() - - def shutdown(self, event): - """Close ffmpeg stream.""" - if self._interval > 0: - self._ffmpeg.close() - - def restart(self): - """Restart ffmpeg stream.""" - if self._interval > 0: - self._ffmpeg.close() - self._start_ffmpeg() - - def scan(self): - """Immediately scan a image.""" - from haffmpeg import IMAGE_PNG - - # process single image - if self._interval == 0: - image = self._ffmpeg.get_image( - self._input_source, - output_format=IMAGE_PNG, - extra_cmd=self._extra_arguments - ) - return self._process_image(image) - - # stream - self._ffmpeg.push_image() - - def _start_ffmpeg(self): - """Start a ffmpeg image stream.""" - from haffmpeg import IMAGE_PNG - if self._interval == 0: - return - - self._ffmpeg.open_stream( - input_source=self._input_source, - interval=self._interval, - output_format=IMAGE_PNG, - extra_cmd=self._extra_arguments, - ) - - @property - def should_poll(self): - """Return True if render is be 'image' or False if 'ffmpeg'.""" - return False - - @property - def available(self): - """Return True if entity is available.""" - return self._interval == 0 or self._ffmpeg.is_running - - -class OpenalprDeviceImage(OpenalprDevice): - """Represent a openalpr device object for processing stream/images.""" - - def __init__(self, name, interval, api, input_source, - username=None, password=None): - """Init image processing.""" - super().__init__(name, interval, api) - - self._next = time() - self._username = username - self._password = password - self._url = input_source - - def restart(self): - """Fake restart with scan a picture.""" - self.scan() - - def scan(self): - """Immediately scan a image.""" - # send request - if self._username is not None and self._password is not None: - req = requests.get( - self._url, auth=(self._username, self._password), timeout=15) - else: - req = requests.get(self._url, timeout=15) - - # process image - image = req.content - self._process_image(image) - - @property - def should_poll(self): - """Return True if render is be 'image' or False if 'ffmpeg'.""" - return self._interval > 0 - - def update(self): - """Retrieve latest state.""" - if self._next > time(): - return - self.scan() - self._next = time() + self._interval - - -class OpenalprApi(object): - """OpenAlpr api class.""" - - def __init__(self, region, confidence): - """Init basic api processing.""" - self._region = region - self._confidence = confidence - - def process_image(self, image, event_callback): - """Callback for processing image.""" - raise NotImplementedError() - - -class OpenalprApiCloud(OpenalprApi): - """Use the cloud openalpr api to parse licences plate.""" - - def __init__(self, region, confidence, api_key): - """Init cloud api processing.""" - import openalpr_api - - super().__init__(region=region, confidence=confidence) - self._api = openalpr_api.DefaultApi() - self._api_key = api_key - - def process_image(self, image, event_callback): - """Callback for processing image.""" - result = self._api.recognize_post( - self._api_key, - 'plate', - image="", - image_bytes=str(b64encode(image), 'utf-8'), - country=self._region - ) - - # process result - f_plates = {} - # pylint: disable=no-member - for object_plate in result.plate.results: - plate = object_plate.plate - confidence = object_plate.confidence - if confidence >= self._confidence: - f_plates[plate] = confidence - event_callback(f_plates) - - -class OpenalprApiLocal(OpenalprApi): - """Use local openalpr library to parse licences plate.""" - - def __init__(self, region, confidence, binary): - """Init local api processing.""" - # pylint: disable=import-error - from haalpr import HAAlpr - - super().__init__(region=region, confidence=confidence) - self._api = HAAlpr(binary=binary, country=region) - - def process_image(self, image, event_callback): - """Callback for processing image.""" - result = self._api.recognize_byte(image) - - # process result - f_plates = {} - for found in result: - for plate, confidence in found.items(): - if confidence >= self._confidence: - f_plates[plate] = confidence - event_callback(f_plates) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 2e01d91f50f..3c0e66679bc 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -5,26 +5,32 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/qwikswitch/ """ import logging + import voluptuous as vol -from homeassistant.const import (EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_URL) from homeassistant.helpers.discovery import load_platform -from homeassistant.components.light import (ATTR_BRIGHTNESS, - SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.switch import SwitchDevice -DOMAIN = 'qwikswitch' REQUIREMENTS = ['pyqwikswitch==0.4'] _LOGGER = logging.getLogger(__name__) +DOMAIN = 'qwikswitch' + +CONF_DIMMER_ADJUST = 'dimmer_adjust' +CONF_BUTTON_EVENTS = 'button_events' CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3)) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required('url', default='http://127.0.0.1:2020'): vol.Coerce(str), - vol.Optional('dimmer_adjust', default=1): CV_DIM_VALUE, - vol.Optional('button_events'): vol.Coerce(str) + vol.Required(CONF_URL, default='http://127.0.0.1:2020'): + vol.Coerce(str), + vol.Optional(CONF_DIMMER_ADJUST, default=1): CV_DIM_VALUE, + vol.Optional(CONF_BUTTON_EVENTS): vol.Coerce(str) })}, extra=vol.ALLOW_EXTRA) QSUSB = {} @@ -118,16 +124,17 @@ class QSLight(QSToggleEntity, Light): def setup(hass, config): """Setup the QSUSB component.""" - from pyqwikswitch import (QSUsb, CMD_BUTTONS, QS_NAME, QS_ID, QS_CMD, - PQS_VALUE, PQS_TYPE, QSType) + from pyqwikswitch import ( + QSUsb, CMD_BUTTONS, QS_NAME, QS_ID, QS_CMD, PQS_VALUE, PQS_TYPE, + QSType) # Override which cmd's in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] - cmd_buttons = config[DOMAIN].get('button_events', ','.join(CMD_BUTTONS)) + cmd_buttons = config[DOMAIN].get(CONF_BUTTON_EVENTS, ','.join(CMD_BUTTONS)) cmd_buttons = cmd_buttons.split(',') - url = config[DOMAIN]['url'] - dimmer_adjust = config[DOMAIN]['dimmer_adjust'] + url = config[DOMAIN][CONF_URL] + dimmer_adjust = config[DOMAIN][CONF_DIMMER_ADJUST] qsusb = QSUsb(url, _LOGGER, dimmer_adjust) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 4f02fe2873d..3d8d1357b0f 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,7 @@ import homeassistant.util.dt as dt_util DOMAIN = 'recorder' -REQUIREMENTS = ['sqlalchemy==1.1.4'] +REQUIREMENTS = ['sqlalchemy==1.1.5'] DEFAULT_URL = 'sqlite:///{hass_config_path}' DEFAULT_DB_FILE = 'home-assistant_v2.db' diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 56026168383..6918a596988 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.14.0'] +REQUIREMENTS = ['pyRFXtrx==0.15.0'] DOMAIN = "rfxtrx" @@ -34,6 +34,7 @@ EVENT_BUTTON_PRESSED = 'button_pressed' DATA_TYPES = OrderedDict([ ('Temperature', TEMP_CELSIUS), + ('Temperature2', TEMP_CELSIUS), ('Humidity', '%'), ('Barometer', ''), ('Wind direction', ''), diff --git a/homeassistant/components/sensor/amcrest.py b/homeassistant/components/sensor/amcrest.py new file mode 100644 index 00000000000..44fdeca54f1 --- /dev/null +++ b/homeassistant/components/sensor/amcrest.py @@ -0,0 +1,142 @@ +""" +This component provides HA sensor support for Amcrest IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.amcrest/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, + CONF_PORT, STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity +import homeassistant.loader as loader + +from requests.exceptions import HTTPError, ConnectTimeout + +REQUIREMENTS = ['amcrest==1.1.3'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'amcrest_notification' +NOTIFICATION_TITLE = 'Amcrest Sensor Setup' + +DEFAULT_NAME = 'Amcrest' +DEFAULT_PORT = 80 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +# Sensor types are defined like: Name, units, icon +SENSOR_TYPES = { + 'motion_detector': ['Motion Detected', None, 'run'], + 'sdcard': ['SD Used', '%', 'sd'], + 'ptz_preset': ['PTZ Preset', None, 'camera-iris'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for an Amcrest IP Camera.""" + from amcrest import AmcrestCamera + + camera = AmcrestCamera( + config.get(CONF_HOST), config.get(CONF_PORT), + config.get(CONF_USERNAME), config.get(CONF_PASSWORD)).camera + + persistent_notification = loader.get_component('persistent_notification') + try: + camera.current_time + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Amcrest camera: %s", str(ex)) + persistent_notification.create( + hass, 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + sensors.append(AmcrestSensor(config, camera, sensor_type)) + + add_devices(sensors, True) + + return True + + +class AmcrestSensor(Entity): + """A sensor implementation for Amcrest IP camera.""" + + def __init__(self, device_info, camera, sensor_type): + """Initialize a sensor for Amcrest camera.""" + super(AmcrestSensor, self).__init__() + self._attrs = {} + self._camera = camera + self._sensor_type = sensor_type + self._name = '{0}_{1}'.format(device_info.get(CONF_NAME), + SENSOR_TYPES.get(self._sensor_type)[0]) + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) + self._state = STATE_UNKNOWN + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + def update(self): + """Get the latest data and updates the state.""" + version, build_date = self._camera.software_information + self._attrs['Build Date'] = build_date.split('=')[-1] + self._attrs['Serial Number'] = self._camera.serial_number + self._attrs['Version'] = version.split('=')[-1] + + if self._sensor_type == 'motion_detector': + self._state = self._camera.is_motion_detected + self._attrs['Record Mode'] = self._camera.record_mode + + elif self._sensor_type == 'ptz_preset': + self._state = self._camera.ptz_presets_count + + elif self._sensor_type == 'sdcard': + sd_used = self._camera.storage_used + sd_total = self._camera.storage_total + self._attrs['Total'] = '{0} {1}'.format(*sd_total) + self._attrs['Used'] = '{0} {1}'.format(*sd_used) + self._state = self._camera.percent(sd_used[0], sd_total[0]) diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py index 30caa80bc53..d99240cf0d2 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -45,7 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the aREST sensor.""" + """Set up the aREST sensor.""" resource = config.get(CONF_RESOURCE) var_conf = config.get(CONF_MONITORED_VARIABLES) pins = config.get(CONF_PINS) @@ -54,12 +54,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): response = requests.get(resource, timeout=10).json() except requests.exceptions.MissingSchema: _LOGGER.error("Missing resource or schema in configuration. " - "Add http:// to your URL.") + "Add http:// to your URL") return False except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device at %s. " - "Please check the IP address in the configuration file.", - resource) + _LOGGER.error("No route to device at %s", resource) return False arest = ArestData(resource) @@ -75,7 +73,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: return value_template.async_render({'value': value}) except TemplateError: - _LOGGER.exception('Error parsing value') + _LOGGER.exception("Error parsing value") return value return _render @@ -91,7 +89,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): renderer = make_renderer(var_data.get(CONF_VALUE_TEMPLATE)) dev.append(ArestSensor( arest, resource, config.get(CONF_NAME, response[CONF_NAME]), - variable, variable=variable, + var_data.get(CONF_NAME, variable), variable=variable, unit_of_measurement=var_data.get(CONF_UNIT_OF_MEASUREMENT), renderer=renderer)) @@ -127,7 +125,7 @@ class ArestSensor(Entity): request = requests.get( '{}/mode/{}/i'.format(self._resource, self._pin), timeout=10) if request.status_code is not 200: - _LOGGER.error("Can't set mode. Is device offline?") + _LOGGER.error("Can't set mode of %s", self._resource) @property def name(self): @@ -184,15 +182,11 @@ class ArestData(object): response = requests.get('{}/analog/{}'.format( self._resource, self._pin[1:]), timeout=10) self.data = {'value': response.json()['return_value']} - else: - _LOGGER.error("Wrong pin naming. " - "Please check your configuration file.") except TypeError: response = requests.get('{}/digital/{}'.format( self._resource, self._pin), timeout=10) self.data = {'value': response.json()['return_value']} self.available = True except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device %s. Is device offline?", - self._resource) + _LOGGER.error("No route to device %s", self._resource) self.available = False diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 173990a6a2f..bf3ff0587a3 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -25,58 +25,76 @@ _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Powered by Dark Sky" CONF_UNITS = 'units' CONF_UPDATE_INTERVAL = 'update_interval' +CONF_FORECAST = 'forecast' DEFAULT_NAME = 'Dark Sky' # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { - 'summary': ['Summary', None, None, None, None, None, None], + 'summary': ['Summary', None, None, None, None, None, None, []], 'minutely_summary': ['Minutely Summary', - None, None, None, None, None, None], - 'hourly_summary': ['Hourly Summary', None, None, None, None, None, None], - 'daily_summary': ['Daily Summary', None, None, None, None, None, None], - 'icon': ['Icon', None, None, None, None, None, None], + None, None, None, None, None, None, []], + 'hourly_summary': ['Hourly Summary', None, None, None, None, None, None, + []], + 'daily_summary': ['Daily Summary', None, None, None, None, None, None, []], + 'icon': ['Icon', None, None, None, None, None, None, + ['currently', 'hourly', 'daily']], 'nearest_storm_distance': ['Nearest Storm Distance', 'km', 'm', 'km', 'km', 'm', - 'mdi:weather-lightning'], + 'mdi:weather-lightning', ['currently']], 'nearest_storm_bearing': ['Nearest Storm Bearing', '°', '°', '°', '°', '°', - 'mdi:weather-lightning'], + 'mdi:weather-lightning', ['currently']], 'precip_type': ['Precip', None, None, None, None, None, - 'mdi:weather-pouring'], + 'mdi:weather-pouring', + ['currently', 'minutely', 'hourly', 'daily']], 'precip_intensity': ['Precip Intensity', - 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:weather-rainy'], + 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:weather-rainy', + ['currently', 'minutely', 'hourly', 'daily']], 'precip_probability': ['Precip Probability', - '%', '%', '%', '%', '%', 'mdi:water-percent'], + '%', '%', '%', '%', '%', 'mdi:water-percent', + ['currently', 'minutely', 'hourly', 'daily']], 'temperature': ['Temperature', - '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer'], + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['currently', 'hourly']], 'apparent_temperature': ['Apparent Temperature', - '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer'], + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['currently', 'hourly']], 'dew_point': ['Dew point', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer'], + 'mdi:thermometer', ['currently', 'hourly', 'daily']], 'wind_speed': ['Wind Speed', 'm/s', 'mph', 'km/h', 'mph', 'mph', - 'mdi:weather-windy'], - 'wind_bearing': ['Wind Bearing', '°', '°', '°', '°', '°', 'mdi:compass'], + 'mdi:weather-windy', ['currently', 'hourly', 'daily']], + 'wind_bearing': ['Wind Bearing', '°', '°', '°', '°', '°', 'mdi:compass', + ['currently', 'hourly', 'daily']], 'cloud_cover': ['Cloud Coverage', '%', '%', '%', '%', '%', - 'mdi:weather-partlycloudy'], - 'humidity': ['Humidity', '%', '%', '%', '%', '%', 'mdi:water-percent'], + 'mdi:weather-partlycloudy', + ['currently', 'hourly', 'daily']], + 'humidity': ['Humidity', '%', '%', '%', '%', '%', 'mdi:water-percent', + ['currently', 'hourly', 'daily']], 'pressure': ['Pressure', 'mbar', 'mbar', 'mbar', 'mbar', 'mbar', - 'mdi:gauge'], - 'visibility': ['Visibility', 'km', 'm', 'km', 'km', 'm', 'mdi:eye'], - 'ozone': ['Ozone', 'DU', 'DU', 'DU', 'DU', 'DU', 'mdi:eye'], + 'mdi:gauge', ['currently', 'hourly', 'daily']], + 'visibility': ['Visibility', 'km', 'm', 'km', 'km', 'm', 'mdi:eye', + ['currently', 'hourly', 'daily']], + 'ozone': ['Ozone', 'DU', 'DU', 'DU', 'DU', 'DU', 'mdi:eye', + ['currently', 'hourly', 'daily']], 'apparent_temperature_max': ['Daily High Apparent Temperature', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer'], + 'mdi:thermometer', + ['currently', 'hourly', 'daily']], 'apparent_temperature_min': ['Daily Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer'], + 'mdi:thermometer', + ['currently', 'hourly', 'daily']], 'temperature_max': ['Daily High Temperature', - '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer'], + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['currently', 'hourly', 'daily']], 'temperature_min': ['Daily Low Temperature', - '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer'], + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['currently', 'hourly', 'daily']], 'precip_intensity_max': ['Daily Max Precip Intensity', - 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:thermometer'], + 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:thermometer', + ['currently', 'hourly', 'daily']], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -87,6 +105,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']), vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=120)): ( vol.All(cv.time_period, cv.positive_timedelta)), + vol.Optional(CONF_FORECAST): + vol.All(cv.ensure_list, [vol.Range(min=1, max=7)]), }) @@ -119,9 +139,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) + forecast = config.get(CONF_FORECAST) sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(DarkSkySensor(forecast_data, variable, name)) + if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]: + for forecast_day in forecast: + sensors.append(DarkSkySensor(forecast_data, + variable, name, forecast_day)) add_devices(sensors, True) @@ -129,19 +154,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DarkSkySensor(Entity): """Implementation of a Dark Sky sensor.""" - def __init__(self, forecast_data, sensor_type, name): + def __init__(self, forecast_data, sensor_type, name, forecast_day=0): """Initialize the sensor.""" self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] self.forecast_data = forecast_data self.type = sensor_type + self.forecast_day = forecast_day self._state = None self._unit_of_measurement = None @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + if self.forecast_day == 0: + return '{} {}'.format(self.client_name, self._name) + else: + return '{} {} {}'.format(self.client_name, self._name, + self.forecast_day) @property def state(self): @@ -198,19 +228,21 @@ class DarkSkySensor(Entity): self.forecast_data.update_hourly() hourly = self.forecast_data.data_hourly self._state = getattr(hourly, 'summary', '') - elif self.type in ['daily_summary', - 'temperature_min', - 'temperature_max', - 'apparent_temperature_min', - 'apparent_temperature_max', - 'precip_intensity_max']: + elif self.forecast_day > 0 or ( + self.type in ['daily_summary', + 'temperature_min', + 'temperature_max', + 'apparent_temperature_min', + 'apparent_temperature_max', + 'precip_intensity_max']): self.forecast_data.update_daily() daily = self.forecast_data.data_daily if self.type == 'daily_summary': self._state = getattr(daily, 'summary', '') else: if hasattr(daily, 'data'): - self._state = self.get_state(daily.data[0]) + self._state = self.get_state( + daily.data[self.forecast_day]) else: self._state = 0 else: diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 0c42033006c..729b435edbc 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -1,5 +1,4 @@ -""" -Support for Dutch Smart Meter Requirements. +"""Support for Dutch Smart Meter Requirements. Also known as: Smartmeter or P1 port. @@ -24,23 +23,27 @@ DSMR version the Entities for this component are create during bootstrap. Another loop (DSMR class) is setup which reads the telegram queue, stores/caches the latest telegram and notifies the Entities that the telegram has been updated. + """ import asyncio from datetime import timedelta +from functools import partial import logging from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) + CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) +from homeassistant.core import CoreState import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import voluptuous as vol _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['dsmr_parser==0.4'] +REQUIREMENTS = ['dsmr_parser==0.6'] CONF_DSMR_VERSION = 'dsmr_version' +CONF_RECONNECT_INTERVAL = 'reconnect_interval' DEFAULT_DSMR_VERSION = '2.2' DEFAULT_PORT = '/dev/ttyUSB0' @@ -51,11 +54,14 @@ ICON_POWER = 'mdi:flash' # Smart meter sends telegram every 10 seconds MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +RECONNECT_INTERVAL = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_HOST, default=None): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( cv.string, vol.In(['4', '2.2'])), + vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, }) @@ -66,7 +72,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): logging.getLogger('dsmr_parser').setLevel(logging.ERROR) from dsmr_parser import obis_references as obis_ref - from dsmr_parser.protocol import create_dsmr_reader + from dsmr_parser.protocol import create_dsmr_reader, create_tcp_dsmr_reader + import serial dsmr_version = config[CONF_DSMR_VERSION] @@ -105,15 +112,55 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device.telegram = telegram hass.async_add_job(device.async_update_ha_state) - # Creates a asyncio.Protocol for reading DSMR telegrams from serial + # Creates a asyncio.Protocol factory for reading DSMR telegrams from serial # and calls update_entities_telegram to update entities on arrival - dsmr = create_dsmr_reader(config[CONF_PORT], config[CONF_DSMR_VERSION], - update_entities_telegram, loop=hass.loop) + if config[CONF_HOST]: + reader_factory = partial(create_tcp_dsmr_reader, + config[CONF_HOST], + config[CONF_PORT], + config[CONF_DSMR_VERSION], + update_entities_telegram, + loop=hass.loop) + else: + reader_factory = partial(create_dsmr_reader, + config[CONF_PORT], + config[CONF_DSMR_VERSION], + update_entities_telegram, + loop=hass.loop) - # Start DSMR asycnio.Protocol reader - transport, _ = yield from hass.loop.create_task(dsmr) + @asyncio.coroutine + def connect_and_reconnect(): + """Connect to DSMR and keep reconnecting until HA stops.""" + while hass.state != CoreState.stopping: + # Start DSMR asycnio.Protocol reader + try: + transport, protocol = yield from hass.loop.create_task( + reader_factory()) + except (serial.serialutil.SerialException, ConnectionRefusedError, + TimeoutError): + # log any error while establishing connection and drop to retry + # connection wait + _LOGGER.exception('error connecting to DSMR') + transport = None - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, transport.close) + if transport: + # register listener to close transport on HA shutdown + stop_listerer = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, transport.close) + + # wait for reader to close + yield from protocol.wait_closed() + + if hass.state != CoreState.stopping: + if transport: + # remove listerer + stop_listerer() + + # throttle reconnect attempts + yield from asyncio.sleep(config[CONF_RECONNECT_INTERVAL], + loop=hass.loop) + + hass.loop.create_task(connect_and_reconnect()) class DSMREntity(Entity): @@ -187,6 +234,7 @@ class DerivativeDSMREntity(DSMREntity): Gas readings are only reported per hour and don't offer a rate only the current meter reading. This entity converts subsequents readings into a hourly rate. + """ _previous_reading = None @@ -202,10 +250,11 @@ class DerivativeDSMREntity(DSMREntity): def async_update(self): """Recalculate hourly rate if timestamp has changed. - DSMR updates gas meter reading every hour. Along with the - new value a timestamp is provided for the reading. Test - if the last known timestamp differs from the current one - then calculate a new rate for the previous hour. + DSMR updates gas meter reading every hour. Along with the new + value a timestamp is provided for the reading. Test if the last + known timestamp differs from the current one then calculate a + new rate for the previous hour. + """ # check if the timestamp for the object differs from the previous one timestamp = self.get_dsmr_object_attr('datetime') diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index e252e2d30c6..2d7e374c46d 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -41,6 +41,7 @@ HM_UNIT_HA_CAST = { "SUNSHINEDURATION": "#", "AIR_PRESSURE": "hPa", "FREQUENCY": "Hz", + "VALUE": "#" } diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index c7fbac6b56a..c0f4e091c45 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['beautifulsoup4==4.5.1'] +REQUIREMENTS = ['beautifulsoup4==4.5.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 5d660f20217..5da512b205d 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pylast==1.6.0'] +REQUIREMENTS = ['pylast==1.7.0'] CONF_USERS = 'users' diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index a519d97a855..1922d4832ee 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -4,7 +4,6 @@ Support for Xiaomi Mi Flora BLE plant sensor. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.miflora/ """ -from datetime import timedelta import logging import voluptuous as vol @@ -12,7 +11,6 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC) @@ -31,9 +29,7 @@ DEFAULT_MEDIAN = 3 DEFAULT_NAME = 'Mi Flora' DEFAULT_RETRIES = 2 DEFAULT_TIMEOUT = 10 - -UPDATE_INTERVAL = 1200 -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=UPDATE_INTERVAL) +DEFAULT_UPDATE_INTERVAL = 1200 # Sensor types are defined like: Name, units SENSOR_TYPES = { @@ -53,7 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_RETRIES, default=DEFAULT_RETRIES): cv.positive_int, - vol.Optional(CONF_CACHE, default=UPDATE_INTERVAL): cv.positive_int, + vol.Optional(CONF_CACHE, default=DEFAULT_UPDATE_INTERVAL): cv.positive_int, }) @@ -122,7 +118,6 @@ class MiFloraSensor(Entity): """Force update.""" return self._force_update - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """ Update current conditions. diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index a074dcc310d..6305f5265b0 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -7,15 +7,10 @@ https://home-assistant.io/components/sensor.nest/ from itertools import chain import logging -import voluptuous as vol - -from homeassistant.components.nest import ( - DATA_NEST, DOMAIN) +from homeassistant.components.nest import DATA_NEST from homeassistant.helpers.entity import Entity -from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_PLATFORM, - CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS -) +from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_MONITORED_CONDITIONS) DEPENDENCIES = ['nest'] SENSOR_TYPES = ['humidity', @@ -26,23 +21,15 @@ SENSOR_TYPES_DEPRECATED = ['last_ip', 'local_ip', 'last_connection'] -SENSOR_TYPES_DEPRECATED = ['last_ip', - 'local_ip'] - -WEATHER_VARS = {} - DEPRECATED_WEATHER_VARS = {'weather_humidity': 'humidity', 'weather_temperature': 'temperature', 'weather_condition': 'condition', 'wind_speed': 'kph', 'wind_direction': 'direction'} -SENSOR_UNITS = {'humidity': '%', - 'temperature': '°C'} +SENSOR_UNITS = {'humidity': '%', 'temperature': '°C'} -PROTECT_VARS = ['co_status', - 'smoke_status', - 'battery_health'] +PROTECT_VARS = ['co_status', 'smoke_status', 'battery_health'] PROTECT_VARS_DEPRECATED = ['battery_level'] @@ -51,19 +38,7 @@ SENSOR_TEMP_TYPES = ['temperature', 'target'] _SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED \ + list(DEPRECATED_WEATHER_VARS.keys()) + PROTECT_VARS_DEPRECATED -_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + PROTECT_VARS \ - + list(WEATHER_VARS.keys()) - -_VALID_SENSOR_TYPES_WITH_DEPRECATED = _VALID_SENSOR_TYPES \ - + _SENSOR_TYPES_DEPRECATED - -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): DOMAIN, - vol.Optional(CONF_SCAN_INTERVAL): - vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Required(CONF_MONITORED_CONDITIONS): - [vol.In(_VALID_SENSOR_TYPES_WITH_DEPRECATED)] -}) +_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + PROTECT_VARS _LOGGER = logging.getLogger(__name__) @@ -74,9 +49,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return nest = hass.data[DATA_NEST] - conf = config.get(CONF_MONITORED_CONDITIONS, _VALID_SENSOR_TYPES) - for variable in conf: + # Add all available sensors if no Nest sensor config is set + if discovery_info == {}: + conditions = _VALID_SENSOR_TYPES + else: + conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) + + for variable in conditions: if variable in _SENSOR_TYPES_DEPRECATED: if variable in DEPRECATED_WEATHER_VARS: wstr = ("Nest no longer provides weather data like %s. See " @@ -87,22 +67,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): wstr = (variable + " is no a longer supported " "monitored_conditions. See " "https://home-assistant.io/components/" - "binary_sensor.nest/ " - "for valid options, or remove monitored_conditions " - "entirely to get a reasonable default") + "binary_sensor.nest/ for valid options.") _LOGGER.error(wstr) all_sensors = [] for structure, device in chain(nest.thermostats(), nest.smoke_co_alarms()): sensors = [NestBasicSensor(structure, device, variable) - for variable in conf + for variable in conditions if variable in SENSOR_TYPES and device.is_thermostat] sensors += [NestTempSensor(structure, device, variable) - for variable in conf + for variable in conditions if variable in SENSOR_TEMP_TYPES and device.is_thermostat] sensors += [NestProtectSensor(structure, device, variable) - for variable in conf + for variable in conditions if variable in PROTECT_VARS and device.is_smoke_co_alarm] all_sensors.extend(sensors) diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 20c0f94a500..41fc4287f5f 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -10,7 +10,7 @@ from datetime import timedelta import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle from homeassistant.loader import get_component @@ -142,7 +142,12 @@ class NetAtmoSensor(Entity): def update(self): """Get the latest data from NetAtmo API and updates the states.""" self.netatmo_data.update() - data = self.netatmo_data.data[self.module_name] + data = self.netatmo_data.data.get(self.module_name) + + if data is None: + _LOGGER.warning("No data found for %s", self.module_name) + self._state = STATE_UNKNOWN + return if self.type == 'temperature': self._state = round(data['Temperature'], 1) diff --git a/homeassistant/components/sensor/neurio_energy.py b/homeassistant/components/sensor/neurio_energy.py index 2315615ca54..1ef328af8f4 100644 --- a/homeassistant/components/sensor/neurio_energy.py +++ b/homeassistant/components/sensor/neurio_energy.py @@ -14,7 +14,7 @@ from homeassistant.const import (CONF_API_KEY, CONF_NAME) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['neurio==0.2.10'] +REQUIREMENTS = ['neurio==0.3.1'] _LOGGER = logging.getLogger(__name__) @@ -66,7 +66,7 @@ class NeurioEnergy(Entity): @property def name(self): - """Return the name of th sensor.""" + """Return the name of the sensor.""" return self._name @property @@ -94,5 +94,5 @@ class NeurioEnergy(Entity): sample = neurio_client.get_samples_live_last( sensor_id=self.sensor_id) self._state = sample['consumptionPower'] - except (requests.exceptions.RequestException, ValueError): + except (requests.exceptions.RequestException, ValueError, KeyError): _LOGGER.warning('Could not update status for %s', self.name) diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index 082c6a1fcfd..f825628d9ae 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['beautifulsoup4==4.5.1'] +REQUIREMENTS = ['beautifulsoup4==4.5.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py new file mode 100644 index 00000000000..dd6a117d447 --- /dev/null +++ b/homeassistant/components/sensor/skybeacon.py @@ -0,0 +1,185 @@ +""" +Support for Skybeacon temperature/humidity Bluetooth LE sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.skybeacon/ +""" +import logging +import threading +from uuid import UUID + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_MAC, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['pygatt==3.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONNECT_LOCK = threading.Lock() + +ATTR_DEVICE = 'device' +ATTR_MODEL = 'model' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_NAME, default=""): cv.string, +}) + +BLE_TEMP_UUID = '0000ff92-0000-1000-8000-00805f9b34fb' +BLE_TEMP_HANDLE = 0x24 +SKIP_HANDLE_LOOKUP = True +CONNECT_TIMEOUT = 30 + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the sensor.""" + name = config.get(CONF_NAME) + mac = config.get(CONF_MAC) + _LOGGER.debug("Setting up...") + + mon = Monitor(hass, mac, name) + add_devices([SkybeaconTemp(name, mon)]) + add_devices([SkybeaconHumid(name, mon)]) + + def monitor_stop(_service_or_event): + """Stop the monitor thread.""" + _LOGGER.info("Stopping monitor for %s", name) + mon.terminate() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, monitor_stop) + mon.start() + + +class SkybeaconHumid(Entity): + """Representation of a humidity sensor.""" + + def __init__(self, name, mon): + """Initialize a sensor.""" + self.mon = mon + self._name = name + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self.mon.data['humid'] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return "%" + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_DEVICE: "SKYBEACON", + ATTR_MODEL: 1, + } + + +class SkybeaconTemp(Entity): + """Representation of a temperature sensor.""" + + def __init__(self, name, mon): + """Initialize a sensor.""" + self.mon = mon + self._name = name + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self.mon.data['temp'] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_DEVICE: "SKYBEACON", + ATTR_MODEL: 1, + } + + +class Monitor(threading.Thread): + """Connection handling.""" + + def __init__(self, hass, mac, name): + """Construct interface object.""" + threading.Thread.__init__(self) + self.daemon = False + self.hass = hass + self.mac = mac + self.name = name + self.data = {'temp': STATE_UNKNOWN, 'humid': STATE_UNKNOWN} + self.keep_going = True + self.event = threading.Event() + + def run(self): + """Thread that keeps connection alive.""" + import pygatt + from pygatt.backends import Characteristic + from pygatt.exceptions import ( + BLEError, NotConnectedError, NotificationTimeout) + + cached_char = Characteristic(BLE_TEMP_UUID, BLE_TEMP_HANDLE) + adapter = pygatt.backends.GATTToolBackend() + while True: + try: + _LOGGER.info("Connecting to %s", self.name) + # we need concurrent connect, so lets not reset the device + adapter.start(reset_on_start=False) + # seems only one connection can be initiated at a time + with CONNECT_LOCK: + device = adapter.connect(self.mac, + CONNECT_TIMEOUT, + pygatt.BLEAddressType.random) + if SKIP_HANDLE_LOOKUP: + # HACK: inject handle mapping collected offline + # pylint: disable=protected-access + device._characteristics[UUID(BLE_TEMP_UUID)] = cached_char + # magic: writing this makes device happy + device.char_write_handle(0x1b, bytearray([255]), False) + device.subscribe(BLE_TEMP_UUID, self._update) + _LOGGER.info("Subscribed to %s", self.name) + while self.keep_going: + # protect against stale connections, just read temperature + device.char_read(BLE_TEMP_UUID, timeout=CONNECT_TIMEOUT) + self.event.wait(60) + break + except (BLEError, NotConnectedError, NotificationTimeout) as ex: + _LOGGER.error("Exception: %s ", str(ex)) + finally: + adapter.stop() + + def _update(self, handle, value): + """Notification callback from pygatt.""" + _LOGGER.debug("%s: %15s temperature = %-2d.%-2d, humidity = %3d", + handle, self.name, value[0], value[2], value[1]) + self.data['temp'] = float(("%d.%d" % (value[0], value[2]))) + self.data['humid'] = value[1] + + def terminate(self): + """Signal runner to stop and join thread.""" + self.keep_going = False + self.event.set() + self.join() diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 3b661062198..a629b3fdde0 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -REQUIREMENTS = ['speedtest-cli==1.0.1'] +REQUIREMENTS = ['speedtest-cli==1.0.2'] _LOGGER = logging.getLogger(__name__) _SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' diff --git a/homeassistant/components/sensor/usps.py b/homeassistant/components/sensor/usps.py index 0bc7f6cbd5a..680b4e4142d 100644 --- a/homeassistant/components/sensor/usps.py +++ b/homeassistant/components/sensor/usps.py @@ -11,14 +11,15 @@ from datetime import timedelta import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, ATTR_ATTRIBUTION +from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_datetime import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['myusps==1.0.1'] +REQUIREMENTS = ['myusps==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,7 @@ STATUS_DELIVERED = 'delivered' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=1800)): ( vol.All(cv.time_period, cv.positive_timedelta)), }) @@ -48,16 +50,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.exception('Could not connect to My USPS') return False - add_devices([USPSSensor(session, config.get(CONF_UPDATE_INTERVAL))]) + add_devices([USPSSensor(session, config.get(CONF_NAME), + config.get(CONF_UPDATE_INTERVAL))]) class USPSSensor(Entity): """USPS Sensor.""" - def __init__(self, session, interval): + def __init__(self, session, name, interval): """Initialize the sensor.""" import myusps self._session = session + self._name = name self._profile = myusps.get_profile(session) self._attributes = None self._state = None @@ -67,7 +71,7 @@ class USPSSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self._profile.get('address') + return self._name or self._profile.get('address') @property def state(self): diff --git a/homeassistant/components/sensor/waqi.py b/homeassistant/components/sensor/waqi.py index b893eeaf204..73de98c0168 100644 --- a/homeassistant/components/sensor/waqi.py +++ b/homeassistant/components/sensor/waqi.py @@ -24,12 +24,15 @@ ATTR_DOMINENTPOL = 'dominentpol' ATTR_HUMIDITY = 'humidity' ATTR_NITROGEN_DIOXIDE = 'nitrogen_dioxide' ATTR_OZONE = 'ozone' -ATTR_PARTICLE = 'particle' +ATTR_PM10 = 'pm_10' +ATTR_PM2_5 = 'pm_2_5' ATTR_PRESSURE = 'pressure' +ATTR_SULFUR_DIOXIDE = 'sulfur_dioxide' ATTR_TIME = 'time' ATTRIBUTION = 'Data provided by the World Air Quality Index project' CONF_LOCATIONS = 'locations' +CONF_STATIONS = 'stations' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -38,7 +41,8 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_LOCATIONS): cv.ensure_list + vol.Optional(CONF_STATIONS): cv.ensure_list, + vol.Required(CONF_LOCATIONS): cv.ensure_list, }) @@ -47,11 +51,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import pwaqi dev = [] + station_filter = config.get(CONF_STATIONS) for location_name in config.get(CONF_LOCATIONS): station_ids = pwaqi.findStationCodesByCity(location_name) - _LOGGER.error('The following stations were returned: %s', station_ids) + _LOGGER.info("The following stations were returned: %s", station_ids) for station in station_ids: - dev.append(WaqiSensor(WaqiData(station), station)) + waqi_sensor = WaqiSensor(WaqiData(station), station) + if (not station_filter) or \ + (waqi_sensor.station_name in station_filter): + dev.append(WaqiSensor(WaqiData(station), station)) add_devices(dev) @@ -74,6 +82,14 @@ class WaqiSensor(Entity): except (KeyError, TypeError): return 'WAQI {}'.format(self._station_id) + @property + def station_name(self): + """Return the name of the station.""" + try: + return self._details['city']['name'] + except (KeyError, TypeError): + return None + @property def icon(self): """Icon to use in the frontend, if any.""" @@ -93,22 +109,35 @@ class WaqiSensor(Entity): return 'AQI' @property - def state_attributes(self): + def device_state_attributes(self): """Return the state attributes of the last update.""" - try: - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_TIME: self._details.get('time'), - ATTR_HUMIDITY: self._details['iaqi'][5]['cur'], - ATTR_PRESSURE: self._details['iaqi'][4]['cur'], - ATTR_TEMPERATURE: self._details['iaqi'][3]['cur'], - ATTR_OZONE: self._details['iaqi'][1]['cur'], - ATTR_PARTICLE: self._details['iaqi'][0]['cur'], - ATTR_NITROGEN_DIOXIDE: self._details['iaqi'][2]['cur'], - ATTR_DOMINENTPOL: self._details.get('dominentpol'), - } - except (IndexError, KeyError): - return {ATTR_ATTRIBUTION: ATTRIBUTION} + attrs = {} + + if self.data is not None: + try: + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + attrs[ATTR_TIME] = self._details.get('time') + attrs[ATTR_DOMINENTPOL] = self._details.get('dominentpol') + for values in self._details['iaqi']: + if values['p'] == 'pm25': + attrs[ATTR_PM2_5] = values['cur'] + elif values['p'] == 'pm10': + attrs[ATTR_PM10] = values['cur'] + elif values['p'] == 'h': + attrs[ATTR_HUMIDITY] = values['cur'] + elif values['p'] == 'p': + attrs[ATTR_PRESSURE] = values['cur'] + elif values['p'] == 't': + attrs[ATTR_TEMPERATURE] = values['cur'] + elif values['p'] == 'o3': + attrs[ATTR_OZONE] = values['cur'] + elif values['p'] == 'no2': + attrs[ATTR_NITROGEN_DIOXIDE] = values['cur'] + elif values['p'] == 'so2': + attrs[ATTR_SULFUR_DIOXIDE] = values['cur'] + return attrs + except (IndexError, KeyError): + return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data and updates the states.""" diff --git a/homeassistant/components/sensor/wink.py b/homeassistant/components/sensor/wink.py index 379d9ac43e5..b43952e6330 100644 --- a/homeassistant/components/sensor/wink.py +++ b/homeassistant/components/sensor/wink.py @@ -25,7 +25,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([WinkSensorDevice(sensor, hass)]) for eggtray in pywink.get_eggtrays(): - add_devices([WinkEggMinder(eggtray, hass)]) + add_devices([WinkSensorDevice(eggtray, hass)]) for piggy_bank in pywink.get_piggy_banks(): try: @@ -43,53 +43,32 @@ class WinkSensorDevice(WinkDevice, Entity): super().__init__(wink, hass) wink = get_component('wink') self.capability = self.wink.capability() - if self.wink.UNIT == '°': + if self.wink.unit() == '°': self._unit_of_measurement = TEMP_CELSIUS else: - self._unit_of_measurement = self.wink.UNIT + self._unit_of_measurement = self.wink.unit() @property def state(self): """Return the state.""" state = None if self.capability == 'humidity': - if self.wink.humidity_percentage() is not None: - state = round(self.wink.humidity_percentage()) + if self.wink.state() is not None: + state = round(self.wink.state()) elif self.capability == 'temperature': - if self.wink.temperature_float() is not None: - state = round(self.wink.temperature_float(), 1) + if self.wink.state() is not None: + state = round(self.wink.state(), 1) elif self.capability == 'balance': - if self.wink.balance() is not None: - state = round(self.wink.balance() / 100, 2) + if self.wink.state() is not None: + state = round(self.wink.state() / 100, 2) elif self.capability == 'proximity': - if self.wink.proximity_float() is not None: - state = self.wink.proximity_float() + if self.wink.state() is not None: + state = self.wink.state() else: - # A sensor should never get here, anything that does - # will require an update to python-wink - logging.getLogger(__name__).error("Please report this as an issue") - state = None + state = self.wink.state() return state - @property - def available(self): - """True if connection == True.""" - return self.wink.available - @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - - -class WinkEggMinder(WinkDevice, Entity): - """Representation of a Wink Egg Minder.""" - - def __init__(self, wink, hass): - """Initialize the sensor.""" - WinkDevice.__init__(self, wink, hass) - - @property - def state(self): - """Return the state.""" - return self.wink.state() diff --git a/homeassistant/components/sensor/wsdot.py b/homeassistant/components/sensor/wsdot.py new file mode 100644 index 00000000000..fecff260716 --- /dev/null +++ b/homeassistant/components/sensor/wsdot.py @@ -0,0 +1,143 @@ +""" +Support for Washington State Department of Transportation (WSDOT) data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.wsdot/ +""" +import logging +import re +from datetime import datetime, timezone, timedelta + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, ATTR_ATTRIBUTION, CONF_ID + ) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_TRAVEL_TIMES = 'travel_time' + +# API codes for travel time details +ATTR_ACCESS_CODE = 'AccessCode' +ATTR_TRAVEL_TIME_ID = 'TravelTimeID' +ATTR_CURRENT_TIME = 'CurrentTime' +ATTR_AVG_TIME = 'AverageTime' +ATTR_NAME = 'Name' +ATTR_TIME_UPDATED = 'TimeUpdated' +ATTR_DESCRIPTION = 'Description' +ATTRIBUTION = "Data provided by WSDOT" + +SCAN_INTERVAL = timedelta(minutes=3) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_TRAVEL_TIMES): [{ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME): cv.string}] +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Get the WSDOT sensor.""" + sensors = [] + for travel_time in config.get(CONF_TRAVEL_TIMES): + name = (travel_time.get(CONF_NAME) or + travel_time.get(CONF_ID)) + sensors.append( + WashingtonStateTravelTimeSensor( + name, + config.get(CONF_API_KEY), + travel_time.get(CONF_ID))) + add_devices(sensors, True) + + +class WashingtonStateTransportSensor(Entity): + """ + Sensor that reads the WSDOT web API. + + WSDOT provides ferry schedules, toll rates, weather conditions, + mountain pass conditions, and more. Subclasses of this + can read them and make them available. + """ + + ICON = 'mdi:car' + + def __init__(self, name, access_code): + """Initialize the sensor.""" + self._data = {} + self._access_code = access_code + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.ICON + + +class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): + """Travel time sensor from WSDOT.""" + + RESOURCE = ('http://www.wsdot.wa.gov/Traffic/api/TravelTimes/' + 'TravelTimesREST.svc/GetTravelTimeAsJson') + ICON = 'mdi:car' + + def __init__(self, name, access_code, travel_time_id): + """Construct a travel time sensor.""" + self._travel_time_id = travel_time_id + WashingtonStateTransportSensor.__init__(self, name, access_code) + + def update(self): + """Get the latest data from WSDOT.""" + params = {ATTR_ACCESS_CODE: self._access_code, + ATTR_TRAVEL_TIME_ID: self._travel_time_id} + + response = requests.get(self.RESOURCE, params, timeout=10) + if response.status_code != 200: + _LOGGER.warning("Invalid response from WSDOT API") + else: + self._data = response.json() + self._state = self._data.get(ATTR_CURRENT_TIME) + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + if self._data is not None: + attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + for key in [ATTR_AVG_TIME, ATTR_NAME, ATTR_DESCRIPTION, + ATTR_TRAVEL_TIME_ID]: + attrs[key] = self._data.get(key) + attrs[ATTR_TIME_UPDATED] = _parse_wsdot_timestamp( + self._data.get(ATTR_TIME_UPDATED)) + return attrs + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "min" + + +def _parse_wsdot_timestamp(timestamp): + """Convert WSDOT timestamp to datetime.""" + if not timestamp: + return None + # ex: Date(1485040200000-0800) + milliseconds, tzone = re.search( + r'Date\((\d+)([+-]\d\d)\d\d\)', timestamp).groups() + return datetime.fromtimestamp(int(milliseconds) / 1000, + tz=timezone(timedelta(hours=int(tzone)))) diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 7da72b6fd38..96b67776000 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -201,6 +201,7 @@ class YrData(object): for time_entry in self.data['product']['time']: valid_from = dt_util.parse_datetime(time_entry['@from']) valid_to = dt_util.parse_datetime(time_entry['@to']) + new_state = None loc_data = time_entry['location'] diff --git a/homeassistant/components/sensor/zabbix.py b/homeassistant/components/sensor/zabbix.py new file mode 100644 index 00000000000..6c3d0a3d653 --- /dev/null +++ b/homeassistant/components/sensor/zabbix.py @@ -0,0 +1,174 @@ +""" +Support for Zabbix Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.zabbix/ +""" +import logging +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.components.zabbix as zabbix +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['zabbix'] + +_CONF_TRIGGERS = "triggers" +_CONF_HOSTIDS = "hostids" +_CONF_INDIVIDUAL = "individual" +_CONF_NAME = "name" + +_ZABBIX_ID_LIST_SCHEMA = vol.Schema([int]) +_ZABBIX_TRIGGER_SCHEMA = vol.Schema({ + vol.Optional(_CONF_HOSTIDS, default=[]): _ZABBIX_ID_LIST_SCHEMA, + vol.Optional(_CONF_INDIVIDUAL, default=False): cv.boolean(True), + vol.Optional(_CONF_NAME, default=None): cv.string, +}) + +# SCAN_INTERVAL = 30 +# +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(_CONF_TRIGGERS): vol.Any(_ZABBIX_TRIGGER_SCHEMA, None) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Zabbix sensor platform.""" + sensors = [] + + zapi = hass.data[zabbix.DOMAIN] + if not zapi: + _LOGGER.error("zapi is None. Zabbix component hasn't been loaded?") + return False + + _LOGGER.info("Connected to Zabbix API Version %s", + zapi.api_version()) + + trigger_conf = config.get(_CONF_TRIGGERS) + # The following code seems overly complex. Need to think about this... + if trigger_conf: + hostids = trigger_conf.get(_CONF_HOSTIDS) + individual = trigger_conf.get(_CONF_INDIVIDUAL) + name = trigger_conf.get(_CONF_NAME) + + if individual: + # Individual sensor per host + if not hostids: + # We need hostids + _LOGGER.error("If using 'individual', must specify hostids") + return False + + for hostid in hostids: + _LOGGER.debug("Creating Zabbix Sensor: " + str(hostid)) + sensor = ZabbixSingleHostTriggerCountSensor(zapi, + [hostid], + name) + sensors.append(sensor) + else: + if not hostids: + # Single sensor that provides the total count of triggers. + _LOGGER.debug("Creating Zabbix Sensor") + sensor = ZabbixTriggerCountSensor(zapi, name) + else: + # Single sensor that sums total issues for all hosts + _LOGGER.debug("Creating Zabbix Sensor group: " + str(hostids)) + sensor = ZabbixMultipleHostTriggerCountSensor(zapi, + hostids, + name) + sensors.append(sensor) + else: + # Single sensor that provides the total count of triggers. + _LOGGER.debug("Creating Zabbix Sensor") + sensor = ZabbixTriggerCountSensor(zapi) + sensors.append(sensor) + + add_devices(sensors) + + +class ZabbixTriggerCountSensor(Entity): + """Get the active trigger count for all Zabbix monitored hosts.""" + + def __init__(self, zApi, name="Zabbix"): + """Initiate Zabbix sensor.""" + self._name = name + self._zapi = zApi + self._state = None + self._attributes = {} + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return 'issues' + + def _call_zabbix_api(self): + return self._zapi.trigger.get(output="extend", + only_true=1, + monitored=1, + filter={"value": 1}) + + def update(self): + """Update the sensor.""" + _LOGGER.debug("Updating ZabbixTriggerCountSensor: " + str(self._name)) + triggers = self._call_zabbix_api() + self._state = len(triggers) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._attributes + + +class ZabbixSingleHostTriggerCountSensor(ZabbixTriggerCountSensor): + """Get the active trigger count for a single Zabbix monitored host.""" + + def __init__(self, zApi, hostid, name=None): + """Initiate Zabbix sensor.""" + super().__init__(zApi, name) + self._hostid = hostid + if not name: + self._name = self._zapi.host.get(hostids=self._hostid, + output="extend")[0]["name"] + + self._attributes["Host ID"] = self._hostid + + def _call_zabbix_api(self): + return self._zapi.trigger.get(hostids=self._hostid, + output="extend", + only_true=1, + monitored=1, + filter={"value": 1}) + + +class ZabbixMultipleHostTriggerCountSensor(ZabbixTriggerCountSensor): + """Get the active trigger count for specified Zabbix monitored hosts.""" + + def __init__(self, zApi, hostids, name=None): + """Initiate Zabbix sensor.""" + super().__init__(zApi, name) + self._hostids = hostids + if not name: + host_names = self._zapi.host.get(hostids=self._hostids, + output="extend") + self._name = " ".join(name["name"] for name in host_names) + self._attributes["Host IDs"] = self._hostids + + def _call_zabbix_api(self): + return self._zapi.trigger.get(hostids=self._hostids, + output="extend", + only_true=1, + monitored=1, + filter={"value": 1}) diff --git a/homeassistant/components/sensor/zamg.py b/homeassistant/components/sensor/zamg.py index 6bb9dd0748d..6f621b683b6 100644 --- a/homeassistant/components/sensor/zamg.py +++ b/homeassistant/components/sensor/zamg.py @@ -35,7 +35,8 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) VALID_STATION_IDS = ( '11010', '11012', '11022', '11035', '11036', '11101', '11121', '11126', - '11130', '11150', '11155', '11157', '11171', '11190', '11204' + '11130', '11150', '11155', '11157', '11171', '11190', '11204', '11240', + '11244', '11265', '11331', '11343', '11389' ) SENSOR_TYPES = { diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index 67e2801974f..c66816541fb 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -10,7 +10,6 @@ import logging from homeassistant.components.sensor import DOMAIN from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -48,18 +47,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ZWaveAlarmSensor(value)]) -class ZWaveSensor(zwave.ZWaveDeviceEntity, Entity): +class ZWaveSensor(zwave.ZWaveDeviceEntity): """Representation of a Z-Wave sensor.""" - def __init__(self, sensor_value): + def __init__(self, value): """Initialize the sensor.""" - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher - - zwave.ZWaveDeviceEntity.__init__(self, sensor_value, DOMAIN) - - dispatcher.connect( - self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) @property def state(self): @@ -71,13 +64,6 @@ class ZWaveSensor(zwave.ZWaveDeviceEntity, Entity): """Return the unit of measurement the value is expressed in.""" return self._value.units - def value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - self.schedule_update_ha_state() - class ZWaveMultilevelSensor(ZWaveSensor): """Representation of a multi level sensor Z-Wave sensor.""" diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 54c0e18a3ee..c390f65f5a0 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -133,17 +133,70 @@ homematic: reconnect: description: Reconnect to all Homematic Hubs. -openalpr: - scan: - description: Scan immediately a device. +microsoft_face: + create_group: + description: Create a new person group. fields: - entity_id: - description: Name(s) of entities to scan - example: 'openalpr.garage' + name: + description: Name of the group + example: 'family' - restart: - description: Restart ffmpeg process of device. + delete_group: + description: Delete a new person group. + + fields: + name: + description: Name of the group + example: 'family' + + train_group: + description: Train a person group. + + fields: + group: + description: Name of the group + example: 'family' + + create_person: + description: Create a new person in the group. + + fields: + name: + description: Name of the person + example: 'Hans' + + group: + description: Name of the group + example: 'family' + + delete_person: + description: Delete a person in the group. + + fields: + name: + description: Name of the person + example: 'Hans' + + group: + description: Name of the group + example: 'family' + + face_person: + description: Add a new picture to a person. + + fields: + person: + description: Name of the person + example: 'Hans' + + group: + description: Name of the group + example: 'family' + + camera_entity: + description: Camera to take a picture + example: camera.door verisure: capture_smartcam: @@ -153,3 +206,58 @@ verisure: device_serial: description: The serial number of the smartcam you want to capture an image from. example: '2DEU AT5Z' + +hdmi_cec: + send_command: + description: Sends CEC command into HDMI CEC capable adapter. + + fields: + raw: + description: 'Raw CEC command in format "00:00:00:00" where first two digits are source and destination, second byte is command and optional other bytes are command parameters. If raw command specified, other params are ignored.' + example: '"10:36"' + + src: + desctiption: 'Source of command. Could be decimal number or string with hexadeximal notation: "0x10".' + example: '12 or "0xc"' + + dst: + description: 'Destination for command. Could be decimal number or string with hexadeximal notation: "0x10".' + example: '5 or "0x5"' + + cmd: + description: 'Command itself. Could be decimal number or string with hexadeximal notation: "0x10".' + example: '144 or "0x90"' + + att: + description: Optional parameters. + example: [0, 2] + + update: + description: Update devices state from network. + + volume: + description: Increase or decrease volume of system. + + fields: + up: + description: Increases volume x levels. + example: 3 + down: + description: Decreases volume x levels. + example: 3 + mute: + description: Mutes audio system. Value should be on, off or toggle. + example: "toggle" + + select_device: + description: Select HDMI device. + fields: + device: + description: Addres of device to select. Can be entity_id, physical address or alias from confuguration. + example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"' + + power_on: + description: Power on all devices which supports it. + + standby: + description: Standby all devices which supports it. diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index 7817a127642..416f6431ba5 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -4,7 +4,6 @@ Use serial protocol of Acer projector to obtain state of the projector. For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.acer_projector/ """ -import os import logging import re @@ -47,17 +46,8 @@ CMD_DICT = {LAMP: '* 0 Lamp ?\r', STATE_OFF: '* 0 IR 002\r'} -def isdevice(dev): - """Check if dev a real device.""" - try: - os.stat(dev) - return str(dev) - except OSError: - raise vol.Invalid("No device found!") - - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_FILENAME): isdevice, + vol.Required(CONF_FILENAME): cv.isdevice, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT): diff --git a/homeassistant/components/switch/arest.py b/homeassistant/components/switch/arest.py index 9ae33698fa4..eba05c64555 100644 --- a/homeassistant/components/switch/arest.py +++ b/homeassistant/components/switch/arest.py @@ -36,19 +36,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the aREST switches.""" + """Set up the aREST switches.""" resource = config.get(CONF_RESOURCE) try: response = requests.get(resource, timeout=10) except requests.exceptions.MissingSchema: _LOGGER.error("Missing resource or schema in configuration. " - "Add http:// to your URL.") + "Add http:// to your URL") return False except requests.exceptions.ConnectionError: - _LOGGER.error("No route to device at %s. " - "Please check the IP address in the configuration file.", - resource) + _LOGGER.error("No route to device at %s", resource) return False dev = [] @@ -105,16 +103,15 @@ class ArestSwitchFunction(ArestSwitchBase): '{}/{}'.format(self._resource, self._func), timeout=10) if request.status_code is not 200: - _LOGGER.error("Can't find function. Is device offline?") + _LOGGER.error("Can't find function") return try: request.json()['return_value'] except KeyError: - _LOGGER.error("No return_value received. " - "Is the function name correct.") + _LOGGER.error("No return_value received") except ValueError: - _LOGGER.error("Response invalid. Is the function name correct?") + _LOGGER.error("Response invalid") def turn_on(self, **kwargs): """Turn the device on.""" @@ -125,8 +122,8 @@ class ArestSwitchFunction(ArestSwitchBase): if request.status_code == 200: self._state = True else: - _LOGGER.error("Can't turn on function %s at %s. " - "Is device offline?", self._func, self._resource) + _LOGGER.error( + "Can't turn on function %s at %s", self._func, self._resource) def turn_off(self, **kwargs): """Turn the device off.""" @@ -137,19 +134,18 @@ class ArestSwitchFunction(ArestSwitchBase): if request.status_code == 200: self._state = False else: - _LOGGER.error("Can't turn off function %s at %s. " - "Is device offline?", self._func, self._resource) + _LOGGER.error( + "Can't turn off function %s at %s", self._func, self._resource) def update(self): """Get the latest data from aREST API and update the state.""" try: - request = requests.get('{}/{}'.format(self._resource, - self._func), timeout=10) + request = requests.get( + '{}/{}'.format(self._resource, self._func), timeout=10) self._state = request.json()['return_value'] != 0 self._available = True except requests.exceptions.ConnectionError: - _LOGGER.warning("No route to device %s. Is device offline?", - self._resource) + _LOGGER.warning("No route to device %s", self._resource) self._available = False @@ -164,7 +160,7 @@ class ArestSwitchPin(ArestSwitchBase): request = requests.get( '{}/mode/{}/o'.format(self._resource, self._pin), timeout=10) if request.status_code is not 200: - _LOGGER.error("Can't set mode. Is device offline?") + _LOGGER.error("Can't set mode") self._available = False def turn_on(self, **kwargs): @@ -174,8 +170,8 @@ class ArestSwitchPin(ArestSwitchBase): if request.status_code == 200: self._state = True else: - _LOGGER.error("Can't turn on pin %s at %s. Is device offline?", - self._pin, self._resource) + _LOGGER.error( + "Can't turn on pin %s at %s", self._pin, self._resource) def turn_off(self, **kwargs): """Turn the device off.""" @@ -184,18 +180,16 @@ class ArestSwitchPin(ArestSwitchBase): if request.status_code == 200: self._state = False else: - _LOGGER.error("Can't turn off pin %s at %s. Is device offline?", - self._pin, self._resource) + _LOGGER.error( + "Can't turn off pin %s at %s", self._pin, self._resource) def update(self): """Get the latest data from aREST API and update the state.""" try: - request = requests.get('{}/digital/{}'.format(self._resource, - self._pin), - timeout=10) + request = requests.get( + '{}/digital/{}'.format(self._resource, self._pin), timeout=10) self._state = request.json()['return_value'] != 0 self._available = True except requests.exceptions.ConnectionError: - _LOGGER.warning("No route to device %s. Is device offline?", - self._resource) + _LOGGER.warning("No route to device %s", self._resource) self._available = False diff --git a/homeassistant/components/switch/command_line.py b/homeassistant/components/switch/command_line.py index eca8f8b6023..e80348b0bee 100644 --- a/homeassistant/components/switch/command_line.py +++ b/homeassistant/components/switch/command_line.py @@ -124,7 +124,7 @@ class CommandSwitch(SwitchDevice): @property def assumed_state(self): """Return true if we do optimistic updates.""" - return self._command_state is False + return self._command_state is None def _query_state(self): """Query for state.""" diff --git a/homeassistant/components/switch/digital_ocean.py b/homeassistant/components/switch/digital_ocean.py index 8df79bebc5d..11414ce96c5 100644 --- a/homeassistant/components/switch/digital_ocean.py +++ b/homeassistant/components/switch/digital_ocean.py @@ -64,7 +64,7 @@ class DigitalOceanSwitch(SwitchDevice): return self.data.status == 'active' @property - def state_attributes(self): + def device_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { ATTR_CREATED_AT: self.data.created_at, diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 9fccf75ea4f..354e3b409db 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -14,12 +14,11 @@ from homeassistant.components.light import is_on, turn_on from homeassistant.components.sun import next_setting, next_rising from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import CONF_NAME, CONF_PLATFORM -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers.event import track_time_change from homeassistant.util.color import ( color_temperature_to_rgb, color_RGB_to_xy, color_temperature_kelvin_to_mired, HASS_COLOR_MIN, HASS_COLOR_MAX) from homeassistant.util.dt import now as dt_now -from homeassistant.util.dt import as_local import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['sun', 'light'] @@ -33,6 +32,7 @@ CONF_START_CT = 'start_colortemp' CONF_SUNSET_CT = 'sunset_colortemp' CONF_STOP_CT = 'stop_colortemp' CONF_BRIGHTNESS = 'brightness' +CONF_DISABLE_BRIGTNESS_ADJUST = 'disable_brightness_adjust' CONF_MODE = 'mode' MODE_XY = 'xy' @@ -53,6 +53,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_BRIGHTNESS): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), + vol.Optional(CONF_DISABLE_BRIGTNESS_ADJUST): cv.boolean, vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.Any(MODE_XY, MODE_MIRED) }) @@ -89,10 +90,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sunset_colortemp = config.get(CONF_SUNSET_CT) stop_colortemp = config.get(CONF_STOP_CT) brightness = config.get(CONF_BRIGHTNESS) + disable_brightness_adjust = config.get(CONF_DISABLE_BRIGTNESS_ADJUST) mode = config.get(CONF_MODE) flux = FluxSwitch(name, hass, False, lights, start_time, stop_time, start_colortemp, sunset_colortemp, stop_colortemp, - brightness, mode) + brightness, disable_brightness_adjust, mode) add_devices([flux]) def update(call=None): @@ -107,7 +109,7 @@ class FluxSwitch(SwitchDevice): def __init__(self, name, hass, state, lights, start_time, stop_time, start_colortemp, sunset_colortemp, stop_colortemp, - brightness, mode): + brightness, disable_brightness_adjust, mode): """Initialize the Flux switch.""" self._name = name self.hass = hass @@ -119,6 +121,7 @@ class FluxSwitch(SwitchDevice): self._sunset_colortemp = sunset_colortemp self._stop_colortemp = stop_colortemp self._brightness = brightness + self._disable_brightness_adjust = disable_brightness_adjust self._mode = mode self.unsub_tracker = None @@ -134,9 +137,11 @@ class FluxSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn on flux.""" + if not self._state: # make initial update + self.flux_update() self._state = True - self.unsub_tracker = track_utc_time_change(self.hass, self.flux_update, - second=[0, 30]) + self.unsub_tracker = track_time_change(self.hass, self.flux_update, + second=[0, 30]) self.schedule_update_ha_state() def turn_off(self, **kwargs): @@ -191,14 +196,15 @@ class FluxSwitch(SwitchDevice): temp = self._sunset_colortemp + temp_offset x_val, y_val, b_val = color_RGB_to_xy(*color_temperature_to_rgb(temp)) brightness = self._brightness if self._brightness else b_val + if self._disable_brightness_adjust: + brightness = None if self._mode == MODE_XY: set_lights_xy(self.hass, self._lights, x_val, y_val, brightness) _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%%" " of %s cycle complete at %s", x_val, y_val, brightness, round( - percentage_complete * 100), time_state, - as_local(now)) + percentage_complete * 100), time_state, now) else: # Convert to mired and clamp to allowed values mired = color_temperature_kelvin_to_mired(temp) @@ -206,8 +212,7 @@ class FluxSwitch(SwitchDevice): set_lights_temp(self.hass, self._lights, mired, brightness) _LOGGER.info("Lights updated to mired:%s brightness:%s, %s%%" " of %s cycle complete at %s", mired, brightness, - round(percentage_complete * 100), - time_state, as_local(now)) + round(percentage_complete * 100), time_state, now) def find_start_time(self, now): """Return sunrise or start_time if given.""" diff --git a/homeassistant/components/switch/hdmi_cec.py b/homeassistant/components/switch/hdmi_cec.py new file mode 100644 index 00000000000..0e17aeab8e4 --- /dev/null +++ b/homeassistant/components/switch/hdmi_cec.py @@ -0,0 +1,71 @@ +""" +Support for HDMI CEC devices as switches. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hdmi_cec/ +""" +import logging + +from homeassistant.components.hdmi_cec import CecDevice, ATTR_NEW +from homeassistant.components.switch import SwitchDevice, DOMAIN +from homeassistant.const import STATE_OFF, STATE_STANDBY, STATE_ON +from homeassistant.core import HomeAssistant + +DEPENDENCIES = ['hdmi_cec'] + +_LOGGER = logging.getLogger(__name__) + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Find and return HDMI devices as switches.""" + if ATTR_NEW in discovery_info: + _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) + add_devices(CecSwitchDevice(hass, hass.data.get(device), + hass.data.get(device).logical_address) for + device in discovery_info[ATTR_NEW]) + + +class CecSwitchDevice(CecDevice, SwitchDevice): + """Representation of a HDMI device as a Switch.""" + + def __init__(self, hass: HomeAssistant, device, logical): + """Initialize the HDMI device.""" + CecDevice.__init__(self, hass, device, logical) + self.entity_id = "%s.%s_%s" % ( + DOMAIN, 'hdmi', hex(self._logical_address)[2:]) + self.update() + + def turn_on(self, **kwargs) -> None: + """Turn device on.""" + self._device.turn_on() + self._state = STATE_ON + + def turn_off(self, **kwargs) -> None: + """Turn device off.""" + self._device.turn_off() + self._state = STATE_ON + + def toggle(self): + """Toggle the entity.""" + self._device.toggle() + if self._state == STATE_ON: + self._state = STATE_OFF + else: + self._state = STATE_ON + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._state == STATE_ON + + @property + def is_standby(self): + """Return true if device is in standby.""" + return self._state == STATE_OFF or self._state == STATE_STANDBY + + @property + def state(self) -> str: + """Cached state of device.""" + return self._state diff --git a/homeassistant/components/switch/insteon_local.py b/homeassistant/components/switch/insteon_local.py index 54350781344..6935ad21abe 100644 --- a/homeassistant/components/switch/insteon_local.py +++ b/homeassistant/components/switch/insteon_local.py @@ -22,7 +22,7 @@ DOMAIN = 'switch' INSTEON_LOCAL_SWITCH_CONF = 'insteon_local_switch.conf' MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -36,15 +36,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): setup_switch( device_id, conf_switches[device_id], insteonhub, hass, add_devices) + else: + linked = insteonhub.get_linked() - linked = insteonhub.get_linked() - - for device_id in linked: - if linked[device_id]['cat_type'] == 'switch'\ - and device_id not in conf_switches: - request_configuration(device_id, insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], hass, add_devices) + for device_id in linked: + if linked[device_id]['cat_type'] == 'switch'\ + and device_id not in conf_switches: + request_configuration(device_id, insteonhub, + linked[device_id]['model_name'] + ' ' + + linked[device_id]['sku'], + hass, add_devices) def request_configuration(device_id, insteonhub, model, hass, diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index 84cbbd9fb0e..40c459dc189 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -23,6 +23,7 @@ CONF_ON_CODE_RECIEVE = 'on_code_receive' CONF_SYSTEMCODE = 'systemcode' CONF_UNIT = 'unit' CONF_UNITCODE = 'unitcode' +CONF_ECHO = 'echo' DEPENDENCIES = ['pilight'] @@ -37,6 +38,10 @@ COMMAND_SCHEMA = vol.Schema({ vol.Optional(CONF_SYSTEMCODE): cv.positive_int, }, extra=vol.ALLOW_EXTRA) +RECEIVE_SCHEMA = COMMAND_SCHEMA.extend({ + vol.Optional(CONF_ECHO): cv.boolean +}) + SWITCHES_SCHEMA = vol.Schema({ vol.Required(CONF_ON_CODE): COMMAND_SCHEMA, vol.Required(CONF_OFF_CODE): COMMAND_SCHEMA, @@ -73,6 +78,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) +class _ReceiveHandle(object): + def __init__(self, config, echo): + """Initialize the handle.""" + self.config_items = config.items() + self.echo = echo + + def match(self, code): + """Test if the received code matches the configured values. + + The received values have to be a subset of the configured options. + """ + return self.config_items <= code.items() + + def run(self, switch, turn_on): + """Change the state of the switch.""" + switch.set_state(turn_on=turn_on, send_code=self.echo) + + class PilightSwitch(SwitchDevice): """Representation of a Pilight switch.""" @@ -84,8 +107,15 @@ class PilightSwitch(SwitchDevice): self._state = False self._code_on = code_on self._code_off = code_off - self._code_on_receive = code_on_receive - self._code_off_receive = code_off_receive + + self._code_on_receive = [] + self._code_off_receive = [] + + for code_list, conf in ((self._code_on_receive, code_on_receive), + (self._code_off_receive, code_off_receive)): + for code in conf: + echo = code.pop(CONF_ECHO, True) + code_list.append(_ReceiveHandle(code, echo)) if any(self._code_on_receive) or any(self._code_off_receive): hass.bus.listen(pilight.EVENT, self._handle_code) @@ -116,26 +146,38 @@ class PilightSwitch(SwitchDevice): # - Call turn on/off only once, even if more than one code is received if any(self._code_on_receive): for on_code in self._code_on_receive: - if on_code.items() <= call.data.items(): - self.turn_on() + if on_code.match(call.data): + on_code.run(switch=self, turn_on=True) break if any(self._code_off_receive): for off_code in self._code_off_receive: - if off_code.items() <= call.data.items(): - self.turn_off() + if off_code.match(call.data): + off_code.run(switch=self, turn_on=False) break + def set_state(self, turn_on, send_code=True): + """Set the state of the switch. + + This sets the state of the switch. If send_code is set to True, then + it will call the pilight.send service to actually send the codes + to the pilight daemon. + """ + if send_code: + if turn_on: + self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, + self._code_on, blocking=True) + else: + self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, + self._code_off, blocking=True) + + self._state = turn_on + self.schedule_update_ha_state() + def turn_on(self): """Turn the switch on by calling pilight.send service with on code.""" - self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, - self._code_on, blocking=True) - self._state = True - self.schedule_update_ha_state() + self.set_state(turn_on=True) def turn_off(self): """Turn the switch on by calling pilight.send service with off code.""" - self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, - self._code_off, blocking=True) - self._state = False - self.schedule_update_ha_state() + self.set_state(turn_on=False) diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py index c3adc33deff..7aea1dea1e1 100644 --- a/homeassistant/components/switch/qwikswitch.py +++ b/homeassistant/components/switch/qwikswitch.py @@ -5,8 +5,11 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.qwikswitch/ """ import logging + import homeassistant.components.qwikswitch as qwikswitch +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['qwikswitch'] @@ -14,8 +17,7 @@ DEPENDENCIES = ['qwikswitch'] def setup_platform(hass, config, add_devices, discovery_info=None): """Add switched from the main Qwikswitch component.""" if discovery_info is None: - logging.getLogger(__name__).error( - 'Configure main Qwikswitch component') + _LOGGER.error("Configure Qwikswitch component") return False add_devices(qwikswitch.QSUSB['switch']) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 2457e49f955..961ee72496e 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -66,7 +66,7 @@ class SmartPlugSwitch(SwitchDevice): @property def is_on(self): """Return true if switch is on.""" - return self.smartplug.is_on + return self._state def turn_on(self, **kwargs): """Turn the switch on.""" @@ -84,7 +84,8 @@ class SmartPlugSwitch(SwitchDevice): def update(self): """Update the TP-Link switch's state.""" try: - self._state = self.smartplug.state + self._state = self.smartplug.state == \ + self.smartplug.SWITCH_STATE_ON if self.smartplug.has_emeter: emeter_readings = self.smartplug.get_emeter_realtime() diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 2d3d5ea5547..3af93d08fc8 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -18,9 +18,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_SENSOR_STATE = "sensor_state" ATTR_SWITCH_MODE = "switch_mode" ATTR_CURRENT_STATE_DETAIL = 'state_detail' - -MAKER_SWITCH_MOMENTARY = "momentary" -MAKER_SWITCH_TOGGLE = "toggle" +ATTR_COFFEMAKER_MODE = "coffeemaker_mode" MAKER_SWITCH_MOMENTARY = "momentary" MAKER_SWITCH_TOGGLE = "toggle" @@ -52,7 +50,10 @@ class WemoSwitch(SwitchDevice): self.wemo = device self.insight_params = None self.maker_params = None + self.coffeemaker_mode = None self._state = None + # look up model name once as it incurs network traffic + self._model_name = self.wemo.model_name wemo = get_component('wemo') wemo.SUBSCRIPTION_REGISTRY.register(self.wemo) @@ -63,7 +64,11 @@ class WemoSwitch(SwitchDevice): _LOGGER.info( 'Subscription update for %s', _device) - self.update() + if self._model_name == 'CoffeeMaker': + self.wemo.subscription_callback(_params) + self._update(force_update=False) + else: + self.update() if not hasattr(self, 'hass'): return self.schedule_update_ha_state() @@ -102,9 +107,12 @@ class WemoSwitch(SwitchDevice): else: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_TOGGLE - if self.insight_params: + if self.insight_params or (self.coffeemaker_mode is not None): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state + if self.coffeemaker_mode is not None: + attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode + return attr @property @@ -122,6 +130,8 @@ class WemoSwitch(SwitchDevice): @property def detail_state(self): """Return the state of the device.""" + if self.coffeemaker_mode is not None: + return self.wemo.mode_string if self.insight_params: standby_state = int(self.insight_params['state']) if standby_state == WEMO_ON: @@ -141,12 +151,22 @@ class WemoSwitch(SwitchDevice): @property def available(self): """True if switch is available.""" - if self.wemo.model_name == 'Insight' and self.insight_params is None: + if self._model_name == 'Insight' and self.insight_params is None: return False - if self.wemo.model_name == 'Maker' and self.maker_params is None: + if self._model_name == 'Maker' and self.maker_params is None: + return False + if self._model_name == 'CoffeeMaker' and self.coffeemaker_mode is None: return False return True + @property + def icon(self): + """Icon of device based on its type.""" + if self._model_name == 'CoffeeMaker': + return 'mdi:coffee' + else: + return super().icon + def turn_on(self, **kwargs): """Turn the switch on.""" self._state = WEMO_ON @@ -161,13 +181,18 @@ class WemoSwitch(SwitchDevice): def update(self): """Update WeMo state.""" + self._update(force_update=True) + + def _update(self, force_update=True): try: - self._state = self.wemo.get_state(True) - if self.wemo.model_name == 'Insight': + self._state = self.wemo.get_state(force_update) + if self._model_name == 'Insight': self.insight_params = self.wemo.insight_params self.insight_params['standby_state'] = ( self.wemo.get_standby_state) - elif self.wemo.model_name == 'Maker': + elif self._model_name == 'Maker': self.maker_params = self.wemo.maker_params + elif self._model_name == 'CoffeeMaker': + self.coffeemaker_mode = self.wemo.mode except AttributeError: _LOGGER.warning('Could not update status for %s', self.name) diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index 22793d81f3f..5df37d87b53 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -17,10 +17,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for switch in pywink.get_switches(): add_devices([WinkToggleDevice(switch, hass)]) - for switch in pywink.get_powerstrip_outlets(): + for switch in pywink.get_powerstrips(): add_devices([WinkToggleDevice(switch, hass)]) for switch in pywink.get_sirens(): add_devices([WinkToggleDevice(switch, hass)]) + for sprinkler in pywink.get_sprinklers(): + add_devices([WinkToggleDevice(sprinkler, hass)]) class WinkToggleDevice(WinkDevice, ToggleEntity): diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index 2f409f94ef3..fa50156ba4e 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -37,26 +37,12 @@ class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice): def __init__(self, value): """Initialize the Z-Wave switch device.""" - from openzwave.network import ZWaveNetwork - from pydispatch import dispatcher - zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) - self._state = value.data - dispatcher.connect( - self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) - - def _value_changed(self, value): - """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id: - _LOGGER.debug('Value changed for label %s', self._value.label) - self._state = value.data - self.schedule_update_ha_state() - @property def is_on(self): """Return true if device is on.""" - return self._state + return self._value.data def turn_on(self, **kwargs): """Turn the device on.""" diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0f731a51485..9b4df2749c0 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -5,6 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tts/ """ import asyncio +import ctypes +import functools as ft import hashlib import logging import mimetypes @@ -49,9 +51,11 @@ SERVICE_CLEAR_CACHE = 'clear_cache' ATTR_MESSAGE = 'message' ATTR_CACHE = 'cache' ATTR_LANGUAGE = 'language' +ATTR_OPTIONS = 'options' -_RE_VOICE_FILE = re.compile(r"([a-f0-9]{40})_([^_]+)_([a-z]+)\.[a-z0-9]{3,4}") -KEY_PATTERN = '{}_{}_{}' +_RE_VOICE_FILE = re.compile( + r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}") +KEY_PATTERN = '{0}_{1}_{2}_{3}' PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean, @@ -60,12 +64,12 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Range(min=60, max=57600)), }) - SCHEMA_SERVICE_SAY = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_CACHE): cv.boolean, - vol.Optional(ATTR_LANGUAGE): cv.string + vol.Optional(ATTR_LANGUAGE): cv.string, + vol.Optional(ATTR_OPTIONS): dict, }) SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) @@ -125,10 +129,13 @@ def async_setup(hass, config): message = service.data.get(ATTR_MESSAGE) cache = service.data.get(ATTR_CACHE) language = service.data.get(ATTR_LANGUAGE) + options = service.data.get(ATTR_OPTIONS) try: url = yield from tts.async_get_url( - p_type, message, cache=cache, language=language) + p_type, message, cache=cache, language=language, + options=options + ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) return @@ -212,7 +219,9 @@ class SpeechManager(object): record = _RE_VOICE_FILE.match(file_data) if record: key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3)) + record.group(1), record.group(2), record.group(3), + record.group(4) + ) cache[key.lower()] = file_data.lower() return cache @@ -249,22 +258,37 @@ class SpeechManager(object): self.providers[engine] = provider @asyncio.coroutine - def async_get_url(self, engine, message, cache=None, language=None): + def async_get_url(self, engine, message, cache=None, language=None, + options=None): """Get URL for play message. This method is a coroutine. """ provider = self.providers[engine] + msg_hash = hashlib.sha1(bytes(message, 'utf-8')).hexdigest() + use_cache = cache if cache is not None else self.use_cache + # languages language = language or provider.default_language if language is None or \ language not in provider.supported_languages: raise HomeAssistantError("Not supported language {0}".format( language)) - msg_hash = hashlib.sha1(bytes(message, 'utf-8')).hexdigest() - key = KEY_PATTERN.format(msg_hash, language, engine).lower() - use_cache = cache if cache is not None else self.use_cache + # options + options = options or provider.default_options + if options is not None: + invalid_opts = [opt_name for opt_name in options.keys() + if opt_name not in provider.supported_options] + if invalid_opts: + raise HomeAssistantError( + "Invalid options found: %s", invalid_opts) + options_key = ctypes.c_size_t(hash(frozenset(options))).value + else: + options_key = '-' + + key = KEY_PATTERN.format( + msg_hash, language, options_key, engine).lower() # is speech allready in memory if key in self.mem_cache: @@ -276,20 +300,21 @@ class SpeechManager(object): # load speech from provider into memory else: filename = yield from self.async_get_tts_audio( - engine, key, message, use_cache, language) + engine, key, message, use_cache, language, options) return "{}/api/tts_proxy/{}".format( self.hass.config.api.base_url, filename) @asyncio.coroutine - def async_get_tts_audio(self, engine, key, message, cache, language): + def async_get_tts_audio(self, engine, key, message, cache, language, + options): """Receive TTS and store for view in cache. This method is a coroutine. """ provider = self.providers[engine] extension, data = yield from provider.async_get_tts_audio( - message, language) + message, language, options) if data is None or extension is None: raise HomeAssistantError( @@ -346,6 +371,7 @@ class SpeechManager(object): try: data = yield from self.hass.loop.run_in_executor(None, load_speech) except OSError: + del self.file_cache[key] raise HomeAssistantError("Can't read {}".format(voice_file)) self._async_store_to_memcache(key, filename, data) @@ -376,7 +402,7 @@ class SpeechManager(object): raise HomeAssistantError("Wrong tts file format!") key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3)) + record.group(1), record.group(2), record.group(3), record.group(4)) if key not in self.mem_cache: if key not in self.file_cache: @@ -402,11 +428,21 @@ class Provider(object): """List of supported languages.""" return None - def get_tts_audio(self, message, language): + @property + def supported_options(self): + """List of supported options like voice, emotionen.""" + return None + + @property + def default_options(self): + """Dict include default options.""" + return None + + def get_tts_audio(self, message, language, options=None): """Load tts audio file from provider.""" raise NotImplementedError() - def async_get_tts_audio(self, message, language): + def async_get_tts_audio(self, message, language, options=None): """Load tts audio file from provider. Return a tuple of file extension and data as bytes. @@ -414,7 +450,8 @@ class Provider(object): This method must be run in the event loop and returns a coroutine. """ return self.hass.loop.run_in_executor( - None, self.get_tts_audio, message, language) + None, ft.partial( + self.get_tts_audio, message, language, options=options)) class TextToSpeechView(HomeAssistantView): diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py new file mode 100644 index 00000000000..e40c10f5e14 --- /dev/null +++ b/homeassistant/components/tts/amazon_polly.py @@ -0,0 +1,180 @@ +""" +Support for the Amazon Polly text to speech service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tts.amazon_polly/ +""" +import logging +import voluptuous as vol + +from homeassistant.components.tts import Provider, PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.4.3"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" +ATTR_CREDENTIALS = "credentials" + +DEFAULT_REGION = "us-east-1" +SUPPORTED_REGIONS = ["us-east-1", "us-east-2", "us-west-2", "eu-west-1"] + +CONF_VOICE = "voice" +CONF_OUTPUT_FORMAT = "output_format" +CONF_SAMPLE_RATE = "sample_rate" +CONF_TEXT_TYPE = "text_type" + +SUPPORTED_VOICES = ["Geraint", "Gwyneth", "Mads", "Naja", "Hans", "Marlene", + "Nicole", "Russell", "Amy", "Brian", "Emma", "Raveena", + "Ivy", "Joanna", "Joey", "Justin", "Kendra", "Kimberly", + "Salli", "Conchita", "Enrique", "Miguel", "Penelope", + "Chantal", "Celine", "Mathieu", "Dora", "Karl", "Carla", + "Giorgio", "Mizuki", "Liv", "Lotte", "Ruben", "Ewa", + "Jacek", "Jan", "Maja", "Ricardo", "Vitoria", "Cristiano", + "Ines", "Carmen", "Maxim", "Tatyana", "Astrid", "Filiz"] + +SUPPORTED_OUTPUT_FORMATS = ["mp3", "ogg_vorbis", "pcm"] + +SUPPORTED_SAMPLE_RATES = ["8000", "16000", "22050"] + +SUPPORTED_SAMPLE_RATES_MAP = { + "mp3": ["8000", "16000", "22050"], + "ogg_vorbis": ["8000", "16000", "22050"], + "pcm": ["8000", "16000"] +} + +SUPPORTED_TEXT_TYPES = ["text", "ssml"] + +CONTENT_TYPE_EXTENSIONS = { + "audio/mpeg": "mp3", + "audio/ogg": "ogg", + "audio/pcm": "pcm" +} + +DEFAULT_VOICE = "Joanna" +DEFAULT_OUTPUT_FORMAT = "mp3" +DEFAULT_TEXT_TYPE = "text" + +DEFAULT_SAMPLE_RATES = { + "mp3": "22050", + "ogg_vorbis": "22050", + "pcm": "16000" +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default=DEFAULT_REGION): + vol.In(SUPPORTED_REGIONS), + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES), + vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT): + vol.In(SUPPORTED_OUTPUT_FORMATS), + vol.Optional(CONF_SAMPLE_RATE): vol.All(cv.string, + vol.In(SUPPORTED_SAMPLE_RATES)), + vol.Optional(CONF_TEXT_TYPE, default=DEFAULT_TEXT_TYPE): + vol.In(SUPPORTED_TEXT_TYPES), +}) + + +def get_engine(hass, config): + """Setup Amazon Polly speech component.""" + # pylint: disable=import-error + output_format = config.get(CONF_OUTPUT_FORMAT) + sample_rate = config.get(CONF_SAMPLE_RATE, + DEFAULT_SAMPLE_RATES[output_format]) + if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP.get(output_format): + _LOGGER.error("%s is not a valid sample rate for %s", + sample_rate, output_format) + return None + + config[CONF_SAMPLE_RATE] = sample_rate + + import boto3 + + profile = config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) + + aws_config = { + CONF_REGION: config.get(CONF_REGION), + CONF_ACCESS_KEY_ID: config.get(CONF_ACCESS_KEY_ID), + CONF_SECRET_ACCESS_KEY: config.get(CONF_SECRET_ACCESS_KEY), + } + + del config[CONF_REGION] + del config[CONF_ACCESS_KEY_ID] + del config[CONF_SECRET_ACCESS_KEY] + + polly_client = boto3.client("polly", **aws_config) + + supported_languages = [] + + all_voices = {} + + all_voices_req = polly_client.describe_voices() + + for voice in all_voices_req.get("Voices"): + all_voices[voice.get("Id")] = voice + if voice.get("LanguageCode") not in supported_languages: + supported_languages.append(voice.get("LanguageCode")) + + return AmazonPollyProvider(polly_client, config, supported_languages, + all_voices) + + +class AmazonPollyProvider(Provider): + """Amazon Polly speech api provider.""" + + def __init__(self, polly_client, config, supported_languages, + all_voices): + """Initialize Amazon Polly provider for TTS.""" + self.client = polly_client + self.config = config + self.supported_langs = supported_languages + self.all_voices = all_voices + self.default_voice = self.config.get(CONF_VOICE) + + @property + def supported_languages(self): + """List of supported languages.""" + return self.supported_langs + + @property + def default_language(self): + """Default language.""" + return self.all_voices.get(self.default_voice).get("LanguageCode") + + @property + def default_options(self): + """Dict include default options.""" + return {CONF_VOICE: self.default_voice} + + @property + def supported_options(self): + """List of supported options.""" + return [CONF_VOICE] + + def get_tts_audio(self, message, language=None, options=None): + """Request TTS file from Polly.""" + voice_id = options.get(CONF_VOICE, self.default_voice) + voice_in_dict = self.all_voices.get(voice_id) + if language is not voice_in_dict.get("LanguageCode"): + _LOGGER.error("%s does not support the %s language", + voice_id, language) + return (None, None) + + resp = self.client.synthesize_speech( + OutputFormat=self.config[CONF_OUTPUT_FORMAT], + SampleRate=self.config[CONF_SAMPLE_RATE], + Text=message, + TextType=self.config[CONF_TEXT_TYPE], + VoiceId=voice_id + ) + + return (CONTENT_TYPE_EXTENSIONS[resp.get("ContentType")], + resp.get("AudioStream").read()) diff --git a/homeassistant/components/tts/demo.py b/homeassistant/components/tts/demo.py index 88afa0643f2..95362b49db9 100644 --- a/homeassistant/components/tts/demo.py +++ b/homeassistant/components/tts/demo.py @@ -43,7 +43,12 @@ class DemoProvider(Provider): """List of supported languages.""" return SUPPORT_LANGUAGES - def get_tts_audio(self, message, language): + @property + def supported_options(self): + """List of supported options like voice, emotionen.""" + return ['voice', 'age'] + + def get_tts_audio(self, message, language, options=None): """Load TTS from demo.""" filename = os.path.join(os.path.dirname(__file__), "demo.mp3") try: diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 10ce3de6c6b..32c9663eedc 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -70,7 +70,7 @@ class GoogleProvider(Provider): return SUPPORT_LANGUAGES @asyncio.coroutine - def async_get_tts_audio(self, message, language): + def async_get_tts_audio(self, message, language, options=None): """Load TTS from google.""" from gtts_token import gtts_token diff --git a/homeassistant/components/tts/picotts.py b/homeassistant/components/tts/picotts.py index 3cc133864b6..49addd9b177 100644 --- a/homeassistant/components/tts/picotts.py +++ b/homeassistant/components/tts/picotts.py @@ -49,7 +49,7 @@ class PicoProvider(Provider): """List of supported languages.""" return SUPPORT_LANGUAGES - def get_tts_audio(self, message, language): + def get_tts_audio(self, message, language, options=None): """Load TTS using pico2wave.""" with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmpf: fname = tmpf.name diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index b44ef6ac66c..7e69d4939f0 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -16,7 +16,11 @@ say: language: description: Language to use for speech generation. - example: 'ru' + example: 'ru' + + options: + description: A dictionary containing platform-specific options. Optional depending on the platform. + example: platform specific clear_cache: description: Remove cache files and RAM cache. diff --git a/homeassistant/components/tts/voicerss.py b/homeassistant/components/tts/voicerss.py index f7a97a354f0..b0c74d1de30 100644 --- a/homeassistant/components/tts/voicerss.py +++ b/homeassistant/components/tts/voicerss.py @@ -114,7 +114,7 @@ class VoiceRSSProvider(Provider): return SUPPORT_LANGUAGES @asyncio.coroutine - def async_get_tts_audio(self, message, language): + def async_get_tts_audio(self, message, language, options=None): """Load TTS from VoiceRSS.""" websession = async_get_clientsession(self.hass) form_data = self._form_data.copy() diff --git a/homeassistant/components/tts/yandextts.py b/homeassistant/components/tts/yandextts.py index d5825ce297f..824ca6ca38f 100644 --- a/homeassistant/components/tts/yandextts.py +++ b/homeassistant/components/tts/yandextts.py @@ -33,19 +33,34 @@ SUPPORT_VOICES = [ 'jane', 'oksana', 'alyss', 'omazh', 'zahar', 'ermil' ] + +SUPPORTED_EMOTION = [ + 'good', 'evil', 'neutral' +] + +MIN_SPEED = 0.1 +MAX_SPEED = 3 + CONF_CODEC = 'codec' CONF_VOICE = 'voice' +CONF_EMOTION = 'emotion' +CONF_SPEED = 'speed' DEFAULT_LANG = 'en-US' DEFAULT_CODEC = 'mp3' DEFAULT_VOICE = 'zahar' - +DEFAULT_EMOTION = 'neutral' +DEFAULT_SPEED = 1 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), vol.Optional(CONF_CODEC, default=DEFAULT_CODEC): vol.In(SUPPORT_CODECS), vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORT_VOICES), + vol.Optional(CONF_EMOTION, default=DEFAULT_EMOTION): + vol.In(SUPPORTED_EMOTION), + vol.Optional(CONF_SPEED, default=DEFAULT_SPEED): + vol.Range(min=MIN_SPEED, max=MAX_SPEED) }) @@ -65,6 +80,8 @@ class YandexSpeechKitProvider(Provider): self._key = conf.get(CONF_API_KEY) self._speaker = conf.get(CONF_VOICE) self._language = conf.get(CONF_LANG) + self._emotion = conf.get(CONF_EMOTION) + self._speed = str(conf.get(CONF_SPEED)) @property def default_language(self): @@ -77,7 +94,7 @@ class YandexSpeechKitProvider(Provider): return SUPPORT_LANGUAGES @asyncio.coroutine - def async_get_tts_audio(self, message, language): + def async_get_tts_audio(self, message, language, options=None): """Load TTS from yandex.""" websession = async_get_clientsession(self.hass) @@ -92,6 +109,8 @@ class YandexSpeechKitProvider(Provider): 'key': self._key, 'speaker': self._speaker, 'format': self._codec, + 'emotion': self._emotion, + 'speed': self._speed } request = yield from websession.get(YANDEX_API_URL, diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 75dd7428010..ff75f6e7314 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.21'] +REQUIREMENTS = ['pyvera==0.2.23'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index ba068905087..3fea17ccee5 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.9'] +REQUIREMENTS = ['pywemo==0.4.11'] DOMAIN = 'wemo' @@ -25,7 +25,8 @@ WEMO_MODEL_DISPATCH = { 'Maker': 'switch', 'Sensor': 'binary_sensor', 'Socket': 'switch', - 'LightSwitch': 'switch' + 'LightSwitch': 'switch', + 'CoffeeMaker': 'switch' } SUBSCRIPTION_REGISTRY = None diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 39c4c21aaa5..c1de7e340c1 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-wink==0.12.0', 'pubnubsub-handler==0.0.7'] +REQUIREMENTS = ['python-wink==1.0.0', 'pubnubsub-handler==1.0.0'] _LOGGER = logging.getLogger(__name__) @@ -28,14 +28,16 @@ CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' CONF_USER_AGENT = 'user_agent' CONF_OATH = 'oath' +CONF_APPSPOT = 'appspot' CONF_DEFINED_BOTH_MSG = 'Remove access token to use oath2.' CONF_MISSING_OATH_MSG = 'Missing oath2 credentials.' +CONF_TOKEN_URL = "https://winkbearertoken.appspot.com/token" CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Inclusive(CONF_EMAIL, CONF_OATH, + vol.Inclusive(CONF_EMAIL, CONF_APPSPOT, msg=CONF_MISSING_OATH_MSG): cv.string, - vol.Inclusive(CONF_PASSWORD, CONF_OATH, + vol.Inclusive(CONF_PASSWORD, CONF_APPSPOT, msg=CONF_MISSING_OATH_MSG): cv.string, vol.Inclusive(CONF_CLIENT_ID, CONF_OATH, msg=CONF_MISSING_OATH_MSG): cv.string, @@ -45,19 +47,22 @@ CONFIG_SCHEMA = vol.Schema({ msg=CONF_DEFINED_BOTH_MSG): cv.string, vol.Exclusive(CONF_ACCESS_TOKEN, CONF_OATH, msg=CONF_DEFINED_BOTH_MSG): cv.string, + vol.Exclusive(CONF_ACCESS_TOKEN, CONF_APPSPOT, + msg=CONF_DEFINED_BOTH_MSG): cv.string, vol.Optional(CONF_USER_AGENT, default=None): cv.string }) }, extra=vol.ALLOW_EXTRA) WINK_COMPONENTS = [ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate', - 'fan' + 'fan', 'alarm_control_panel' ] def setup(hass, config): """Set up the Wink component.""" import pywink + import requests from pubnubsubhandler import PubNubSubscriptionHandler user_agent = config[DOMAIN].get(CONF_USER_AGENT) @@ -66,16 +71,24 @@ def setup(hass, config): pywink.set_user_agent(user_agent) access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) + client_id = config[DOMAIN].get('client_id') if access_token: pywink.set_bearer_token(access_token) - else: + elif client_id: email = config[DOMAIN][CONF_EMAIL] password = config[DOMAIN][CONF_PASSWORD] client_id = config[DOMAIN]['client_id'] client_secret = config[DOMAIN]['client_secret'] pywink.set_wink_credentials(email, password, client_id, client_secret) + else: + email = config[DOMAIN][CONF_EMAIL] + password = config[DOMAIN][CONF_PASSWORD] + payload = {'username': email, 'password': password} + token_response = requests.post(CONF_TOKEN_URL, data=payload) + token = token_response.text.split(':')[1].split()[0].rstrip(' bool: class CoreState(enum.Enum): """Represent the current state of Home Assistant.""" - not_running = "NOT_RUNNING" - starting = "STARTING" - running = "RUNNING" - stopping = "STOPPING" + not_running = 'NOT_RUNNING' + starting = 'STARTING' + running = 'RUNNING' + stopping = 'STOPPING' def __str__(self) -> str: """Return the event.""" @@ -103,7 +103,7 @@ class HomeAssistant(object): def __init__(self, loop=None): """Initialize new Home Assistant object.""" - if sys.platform == "win32": + if sys.platform == 'win32': self.loop = loop or asyncio.ProactorEventLoop() else: self.loop = loop or asyncio.get_event_loop() @@ -164,13 +164,13 @@ class HomeAssistant(object): self.loop.add_signal_handler( signal.SIGTERM, self._async_stop_handler) except ValueError: - _LOGGER.warning('Could not bind to SIGTERM.') + _LOGGER.warning("Could not bind to SIGTERM") try: self.loop.add_signal_handler( signal.SIGHUP, self._async_restart_handler) except ValueError: - _LOGGER.warning('Could not bind to SIGHUP.') + _LOGGER.warning("Could not bind to SIGHUP") # pylint: disable=protected-access self.loop._thread_ident = threading.get_ident() @@ -185,7 +185,7 @@ class HomeAssistant(object): args: parameters for method to call. """ if target is None: - raise ValueError("Don't call add_job with None.") + raise ValueError("Don't call add_job with None") self.loop.call_soon_threadsafe(self.async_add_job, target, *args) @callback @@ -322,8 +322,7 @@ class HomeAssistant(object): kwargs['exc_info'] = (type(exception), exception, exception.__traceback__) - _LOGGER.error('Error doing job: %s', context['message'], - **kwargs) + _LOGGER.error("Error doing job: %s", context['message'], **kwargs) @callback def _async_stop_handler(self, *args): @@ -341,8 +340,8 @@ class HomeAssistant(object): class EventOrigin(enum.Enum): """Represent the origin of an event.""" - local = "LOCAL" - remote = "REMOTE" + local = 'LOCAL' + remote = 'REMOTE' def __str__(self): """Return the event.""" @@ -420,8 +419,8 @@ class EventBus(object): def fire(self, event_type: str, event_data=None, origin=EventOrigin.local): """Fire an event.""" - self._hass.loop.call_soon_threadsafe(self.async_fire, event_type, - event_data, origin) + self._hass.loop.call_soon_threadsafe( + self.async_fire, event_type, event_data, origin) @callback def async_fire(self, event_type: str, event_data=None, @@ -432,7 +431,7 @@ class EventBus(object): """ if event_type != EVENT_HOMEASSISTANT_STOP and \ self._hass.state == CoreState.stopping: - raise ShuttingDown('Home Assistant is shutting down.') + raise ShuttingDown("Home Assistant is shutting down") # Copy the list of the current listeners because some listeners # remove themselves as a listener while being executed which @@ -549,8 +548,7 @@ class EventBus(object): except (KeyError, ValueError): # KeyError is key event_type listener did not exist # ValueError if listener did not exist within event_type - _LOGGER.warning('Unable to remove unknown listener %s', - listener) + _LOGGER.warning("Unable to remove unknown listener %s", listener) class State(object): @@ -995,14 +993,14 @@ class ServiceRegistry(object): if event.data[ATTR_SERVICE_CALL_ID] == call_id: fut.set_result(True) - unsub = self._hass.bus.async_listen(EVENT_SERVICE_EXECUTED, - service_executed) + unsub = self._hass.bus.async_listen( + EVENT_SERVICE_EXECUTED, service_executed) self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data) if blocking: - done, _ = yield from asyncio.wait([fut], loop=self._hass.loop, - timeout=SERVICE_CALL_LIMIT) + done, _ = yield from asyncio.wait( + [fut], loop=self._hass.loop, timeout=SERVICE_CALL_LIMIT) success = bool(done) unsub() return success @@ -1017,7 +1015,7 @@ class ServiceRegistry(object): if not self.has_service(domain, service): if event.origin == EventOrigin.local: - _LOGGER.warning('Unable to find service %s/%s', + _LOGGER.warning("Unable to find service %s/%s", domain, service) return @@ -1040,7 +1038,7 @@ class ServiceRegistry(object): if service_handler.schema: service_data = service_handler.schema(service_data) except vol.Invalid as ex: - _LOGGER.error('Invalid service data for %s.%s: %s', + _LOGGER.error("Invalid service data for %s.%s: %s", domain, service, humanize_error(service_data, ex)) fire_service_executed() return @@ -1064,7 +1062,7 @@ class ServiceRegistry(object): def _generate_unique_id(self): """Generate a unique service call id.""" self._cur_id += 1 - return "{}-{}".format(id(self), self._cur_id) + return '{}-{}'.format(id(self), self._cur_id) class Config(object): @@ -1118,6 +1116,7 @@ class Config(object): return { 'latitude': self.latitude, 'longitude': self.longitude, + 'elevation': self.elevation, 'unit_system': self.units.as_dict(), 'location_name': self.location_name, 'time_zone': time_zone.zone, diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 32e0861ff53..2825eb9e49c 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -1,9 +1,13 @@ """Helper for aiohttp webclient stuff.""" -import sys import asyncio -import aiohttp +import sys + +import aiohttp +from aiohttp.hdrs import USER_AGENT, CONTENT_TYPE +from aiohttp import web +from aiohttp.web_exceptions import HTTPGatewayTimeout +import async_timeout -from aiohttp.hdrs import USER_AGENT from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import __version__ @@ -65,6 +69,45 @@ def async_create_clientsession(hass, verify_ssl=True, auto_cleanup=True, return clientsession +@asyncio.coroutine +def async_aiohttp_proxy_stream(hass, request, stream_coro, buffer_size=102400, + timeout=10): + """Stream websession request to aiohttp web response.""" + response = None + stream = None + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + stream = yield from stream_coro + + response = web.StreamResponse() + response.content_type = stream.headers.get(CONTENT_TYPE) + + yield from response.prepare(request) + + while True: + data = yield from stream.content.read(buffer_size) + if not data: + break + response.write(data) + + except asyncio.TimeoutError: + raise HTTPGatewayTimeout() + + except (aiohttp.errors.ClientError, + aiohttp.errors.ClientDisconnectedError): + pass + + except (asyncio.CancelledError, ConnectionResetError): + response = None + + finally: + if stream is not None: + stream.close() + if response is not None: + yield from response.write_eof() + + @callback # pylint: disable=invalid-name def _async_register_clientsession_shutdown(hass, clientsession): diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index b78eedec8c2..cd26c2779b1 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -70,6 +70,15 @@ def boolean(value: Any) -> bool: return bool(value) +def isdevice(value): + """Validate that value is a real device.""" + try: + os.stat(value) + return str(value) + except OSError: + raise vol.Invalid('No device at {} found'.format(value)) + + def isfile(value: Any) -> str: """Validate that the value is an existing file.""" if value is None: @@ -376,6 +385,8 @@ def ordered_dict(value_validator, key_validator=match_all): """Validate ordered dict.""" config = OrderedDict() + if not isinstance(value, dict): + raise vol.Invalid('Value {} is not a dictionary'.format(value)) for key, val in value.items(): v_res = item_validator({key: val}) config.update(v_res) @@ -385,6 +396,13 @@ def ordered_dict(value_validator, key_validator=match_all): return validator +def ensure_list_csv(value: Any) -> Sequence: + """Ensure that input is a list or make one from comma-separated string.""" + if isinstance(value, str): + return [member.strip() for member in value.split(',')] + return ensure_list(value) + + # Validator helpers def key_dependency(key, dependency): diff --git a/homeassistant/helpers/customize.py b/homeassistant/helpers/customize.py new file mode 100644 index 00000000000..e9cd7c0269a --- /dev/null +++ b/homeassistant/helpers/customize.py @@ -0,0 +1,107 @@ +"""A helper module for customization.""" +import collections +from typing import Any, Dict, List +import fnmatch +import voluptuous as vol + +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, split_entity_id +import homeassistant.helpers.config_validation as cv + +_OVERWRITE_KEY_FORMAT = '{}.overwrite' +_OVERWRITE_CACHE_KEY_FORMAT = '{}.overwrite_cache' + +_CUSTOMIZE_SCHEMA_ENTRY = vol.Schema({ + vol.Required(CONF_ENTITY_ID): vol.All( + cv.ensure_list_csv, vol.Length(min=1), [vol.Schema(str)], [vol.Lower]) +}, extra=vol.ALLOW_EXTRA) + + +def _convert_old_config(inp: Any) -> List: + if not isinstance(inp, dict): + return cv.ensure_list(inp) + if CONF_ENTITY_ID in inp: + return [inp] # sigle entry + res = [] + + inp = vol.Schema({cv.match_all: dict})(inp) + for key, val in inp.items(): + val = dict(val) + val[CONF_ENTITY_ID] = key + res.append(val) + return res + + +CUSTOMIZE_SCHEMA = vol.All(_convert_old_config, [_CUSTOMIZE_SCHEMA_ENTRY]) + + +def set_customize( + hass: HomeAssistant, domain: str, customize: List[Dict]) -> None: + """Overwrite all current customize settings. + + Async friendly. + """ + hass.data[_OVERWRITE_KEY_FORMAT.format(domain)] = customize + hass.data[_OVERWRITE_CACHE_KEY_FORMAT.format(domain)] = {} + + +def get_overrides(hass: HomeAssistant, domain: str, entity_id: str) -> Dict: + """Return a dictionary of overrides related to entity_id. + + Whole-domain overrides are of lowest priorities, + then glob on entity ID, and finally exact entity_id + matches are of highest priority. + + The lookups are cached. + """ + cache_key = _OVERWRITE_CACHE_KEY_FORMAT.format(domain) + if cache_key in hass.data and entity_id in hass.data[cache_key]: + return hass.data[cache_key][entity_id] + overwrite_key = _OVERWRITE_KEY_FORMAT.format(domain) + if overwrite_key not in hass.data: + return {} + domain_result = {} # type: Dict[str, Any] + glob_result = {} # type: Dict[str, Any] + exact_result = {} # type: Dict[str, Any] + domain = split_entity_id(entity_id)[0] + + def clean_entry(entry: Dict) -> Dict: + """Clean up entity-matching keys.""" + entry.pop(CONF_ENTITY_ID, None) + return entry + + def deep_update(target: Dict, source: Dict) -> None: + """Deep update a dictionary.""" + for key, value in source.items(): + if isinstance(value, collections.Mapping): + updated_value = target.get(key, {}) + # If the new value is map, but the old value is not - + # overwrite the old value. + if not isinstance(updated_value, collections.Mapping): + updated_value = {} + deep_update(updated_value, value) + target[key] = updated_value + else: + target[key] = source[key] + + for rule in hass.data[overwrite_key]: + if CONF_ENTITY_ID in rule: + entities = rule[CONF_ENTITY_ID] + if domain in entities: + deep_update(domain_result, rule) + if entity_id in entities: + deep_update(exact_result, rule) + for entity_id_glob in entities: + if entity_id_glob == entity_id: + continue + if fnmatch.fnmatchcase(entity_id, entity_id_glob): + deep_update(glob_result, rule) + break + result = {} + deep_update(result, clean_entry(domain_result)) + deep_update(result, clean_entry(glob_result)) + deep_update(result, clean_entry(exact_result)) + if cache_key not in hass.data: + hass.data[cache_key] = {} + hass.data[cache_key][entity_id] = result + return result diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 0d2f56f1807..ac124b3abf3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,21 +4,19 @@ import logging import functools as ft from timeit import default_timer as timer -from typing import Any, Optional, List, Dict +from typing import Optional, List from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_ENTITY_PICTURE) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, DOMAIN as CORE_DOMAIN from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) - -# Entity attributes that we will overwrite -_OVERWRITE = {} # type: Dict[str, Any] +from homeassistant.helpers.customize import get_overrides _LOGGER = logging.getLogger(__name__) @@ -57,16 +55,6 @@ def async_generate_entity_id(entity_id_format: str, name: Optional[str], entity_id_format.format(slugify(name)), current_ids) -def set_customize(customize: Dict[str, Any]) -> None: - """Overwrite all current customize settings. - - Async friendly. - """ - global _OVERWRITE - - _OVERWRITE = {key.lower(): val for key, val in customize.items()} - - class Entity(object): """An abstract class for Home Assistant entities.""" @@ -254,7 +242,7 @@ class Entity(object): end - start) # Overwrite properties that have been set in the config file. - attr.update(_OVERWRITE.get(self.entity_id, {})) + attr.update(get_overrides(self.hass, CORE_DOMAIN, self.entity_id)) # Remove hidden property if false so it won't show up. if not attr.get(ATTR_HIDDEN, True): @@ -278,7 +266,7 @@ class Entity(object): self.entity_id, state, attr, self.force_update) def schedule_update_ha_state(self, force_refresh=False): - """Shedule a update ha state change task. + """Schedule a update ha state change task. That is only needed on executor to not block. """ @@ -297,7 +285,7 @@ class Entity(object): @asyncio.coroutine def async_remove(self) -> None: - """Remove entitiy from async HASS. + """Remove entity from async HASS. This method must be run in the event loop. """ diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 3be344e7d9d..ea33d27a814 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -16,11 +16,11 @@ from homeassistant.components.sun import ( from homeassistant.components.switch.mysensors import ( ATTR_IR_CODE, SERVICE_SEND_IR_CODE) from homeassistant.components.climate import ( - ATTR_AUX_HEAT, ATTR_AWAY_MODE, ATTR_FAN_MODE, ATTR_HUMIDITY, - ATTR_OPERATION_MODE, ATTR_SWING_MODE, - SERVICE_SET_AUX_HEAT, SERVICE_SET_AWAY_MODE, SERVICE_SET_FAN_MODE, - SERVICE_SET_HUMIDITY, SERVICE_SET_OPERATION_MODE, SERVICE_SET_SWING_MODE, - SERVICE_SET_TEMPERATURE) + ATTR_AUX_HEAT, ATTR_AWAY_MODE, ATTR_FAN_MODE, ATTR_HOLD_MODE, + ATTR_HUMIDITY, ATTR_OPERATION_MODE, ATTR_SWING_MODE, + SERVICE_SET_AUX_HEAT, SERVICE_SET_AWAY_MODE, SERVICE_SET_HOLD_MODE, + SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_OPERATION_MODE, + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE) from homeassistant.components.climate.ecobee import ( ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) @@ -57,6 +57,7 @@ SERVICE_ATTRIBUTES = { SERVICE_SET_TEMPERATURE: [ATTR_TEMPERATURE], SERVICE_SET_HUMIDITY: [ATTR_HUMIDITY], SERVICE_SET_SWING_MODE: [ATTR_SWING_MODE], + SERVICE_SET_HOLD_MODE: [ATTR_HOLD_MODE], SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION_MODE], SERVICE_SET_AUX_HEAT: [ATTR_AUX_HEAT], SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 69e8b88305f..c124d509f9c 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -61,7 +61,9 @@ class API(object): self.port = port self.api_password = api_password - if use_ssl: + if host.startswith(("http://", "https://")): + self.base_url = host + elif use_ssl: self.base_url = "https://{}".format(host) else: self.base_url = "http://{}".format(host) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 986630e4db0..cb825ad44c8 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -27,7 +27,9 @@ MOCKS = { 'get': ("homeassistant.loader.get_component", loader.get_component), 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml), 'except': ("homeassistant.bootstrap.async_log_exception", - bootstrap.async_log_exception) + bootstrap.async_log_exception), + 'package_error': ("homeassistant.config._log_pkg_error", + config_util._log_pkg_error), } SILENCE = ( 'homeassistant.bootstrap.clear_secret_cache', @@ -146,7 +148,7 @@ def run(script_args: List) -> int: print(' -', skey + ':', sval, color('cyan', '[from:', flatsecret .get(skey, 'keyring') + ']')) - return 0 + return len(res['except']) def check(config_path): @@ -213,6 +215,15 @@ def check(config_path): MOCKS['except'][1](ex, domain, config, hass) res['except'][domain] = config.get(domain, config) + def mock_package_error( # pylint: disable=unused-variable + package, component, config, message): + """Mock config_util._log_pkg_error.""" + MOCKS['package_error'][1](package, component, config, message) + + pkg_key = 'homeassistant.packages.{}'.format(package) + res['except'][pkg_key] = config.get('homeassistant', {}) \ + .get('packages', {}).get(package) + # Patches to skip functions for sil in SILENCE: PATCHES[sil] = patch(sil) @@ -247,25 +258,24 @@ def check(config_path): return res +def line_info(obj, **kwargs): + """Display line config source.""" + if hasattr(obj, '__config_file__'): + return color('cyan', "[source {}:{}]" + .format(obj.__config_file__, obj.__line__ or '?'), + **kwargs) + return '?' + + def dump_dict(layer, indent_count=3, listi=False, **kwargs): """Display a dict. A friendly version of print yaml.yaml.dump(config). """ - def line_src(this): - """Display line config source.""" - if hasattr(this, '__config_file__'): - return color('cyan', "[source {}:{}]" - .format(this.__config_file__, this.__line__ or '?'), - **kwargs) - return '' - def sort_dict_key(val): """Return the dict key for sorting.""" - skey = str.lower(val[0]) - if str(skey) == 'platform': - skey = '0' - return skey + key = str.lower(val[0]) + return '0' if key == 'platform' else key indent_str = indent_count * ' ' if listi or isinstance(layer, list): @@ -273,7 +283,7 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): if isinstance(layer, Dict): for key, value in sorted(layer.items(), key=sort_dict_key): if isinstance(value, dict) or isinstance(value, list): - print(indent_str, key + ':', line_src(value)) + print(indent_str, key + ':', line_info(value, **kwargs)) dump_dict(value, indent_count + 2) else: print(indent_str, key + ':', value) diff --git a/pylintrc b/pylintrc index 9a46acc6a56..4c0b1523078 100644 --- a/pylintrc +++ b/pylintrc @@ -30,6 +30,7 @@ disable= too-many-public-methods, too-many-return-statements, too-many-statements, + too-many-lines, too-few-public-methods, abstract-method diff --git a/requirements_all.txt b/requirements_all.txt index 9b4fbcd7aa6..2e983ea0f54 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,11 +33,16 @@ SoCo==0.12 # homeassistant.components.notify.twitter TwitterAPI==2.4.3 +# homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.5.0 # homeassistant.components.camera.amcrest -amcrest==1.0.0 +# homeassistant.components.sensor.amcrest +amcrest==1.1.3 + +# homeassistant.components.media_player.anthemav +anthemav==1.1.8 # homeassistant.components.apcupsd apcaccess==0.0.4 @@ -48,12 +53,16 @@ apns2==0.1.1 # homeassistant.components.sun astral==1.3.3 +# homeassistant.components.light.avion +# avion==0.5 + # homeassistant.components.sensor.linux_battery batinfo==0.4.2 +# homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.hydroquebec # homeassistant.components.sensor.scrape -beautifulsoup4==4.5.1 +beautifulsoup4==4.5.3 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -64,7 +73,8 @@ blockchain==1.3.3 # homeassistant.components.notify.aws_lambda # homeassistant.components.notify.aws_sns # homeassistant.components.notify.aws_sqs -boto3==1.3.1 +# homeassistant.components.tts.amazon_polly +boto3==1.4.3 # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink @@ -80,12 +90,18 @@ colorlog>2.1,<3 # homeassistant.components.binary_sensor.concord232 concord232==0.14 +# homeassistant.components.light.decora +# decora==0.3 + # homeassistant.components.media_player.denonavr -denonavr==0.3.0 +denonavr==0.3.1 # homeassistant.components.media_player.directv directpy==0.1 +# homeassistant.components.notify.discord +discord.py==0.16.0 + # homeassistant.components.updater distro==1.0.2 @@ -99,7 +115,7 @@ dnspython3==1.15.0 dovado==0.1.15 # homeassistant.components.sensor.dsmr -dsmr_parser==0.4 +dsmr_parser==0.6 # homeassistant.components.dweet # homeassistant.components.sensor.dweet @@ -135,6 +151,9 @@ flux_led==0.12 # homeassistant.components.notify.free_mobile freesms==0.1.1 +# homeassistant.components.device_tracker.fritz +# fritzconnection==0.6 + # homeassistant.components.conversation fuzzywuzzy==0.14.0 @@ -156,11 +175,8 @@ googlemaps==2.4.4 # homeassistant.components.sensor.gpsd gps3==0.33.3 -# homeassistant.components.openalpr -ha-alpr==0.3 - # homeassistant.components.ffmpeg -ha-ffmpeg==0.15 +ha-ffmpeg==1.2 # homeassistant.components.media_player.philips_js ha-philipsjs==0.0.1 @@ -198,7 +214,7 @@ https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 https://github.com/aparraga/braviarc/archive/0.3.6.zip#braviarc==0.3.6 # homeassistant.components.media_player.roku -https://github.com/bah2830/python-roku/archive/3.1.2.zip#roku==3.1.2 +https://github.com/bah2830/python-roku/archive/3.1.3.zip#roku==3.1.3 # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 @@ -206,11 +222,8 @@ https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b6 # homeassistant.components.media_player.onkyo https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9.2 -# homeassistant.components.device_tracker.fritz -# https://github.com/deisi/fritzconnection/archive/b5c14515e1c8e2652b06b6316a7f3913df942841.zip#fritzconnection==0.4.6 - # homeassistant.components.netatmo -https://github.com/jabesq/netatmo-api-python/archive/v0.8.1.zip#lnetatmo==0.8.1 +https://github.com/jabesq/netatmo-api-python/archive/v0.9.1.zip#lnetatmo==0.9.1 # homeassistant.components.neato https://github.com/jabesq/pybotvac/archive/v0.0.1.zip#pybotvac==0.0.1 @@ -219,7 +232,7 @@ https://github.com/jabesq/pybotvac/archive/v0.0.1.zip#pybotvac==0.0.1 https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 # homeassistant.components.media_player.nad -https://github.com/joopert/nad_receiver/archive/0.0.2.zip#nad_receiver==0.0.2 +https://github.com/joopert/nad_receiver/archive/0.0.3.zip#nad_receiver==0.0.3 # homeassistant.components.media_player.russound_rnet https://github.com/laf/russound/archive/0.1.6.zip#russound==0.1.6 @@ -234,9 +247,6 @@ https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a8 # homeassistant.components.notify.joaoapps_join https://github.com/nkgilley/python-join-api/archive/3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1 -# homeassistant.components.openalpr -https://github.com/pvizeli/cloudapi/releases/download/1.0.2/python-1.0.2.zip#openalpr_api==1.0.2 - # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 @@ -252,6 +262,9 @@ https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 # homeassistant.components.light.osramlightify https://github.com/tfriedel/python-lightify/archive/d6eadcf311e6e21746182d1480e97b350dda2b3e.zip#lightify==1.0.4 +# homeassistant.components.lutron +https://github.com/thecynic/pylutron/archive/v0.1.0.zip#pylutron==0.1.0 + # homeassistant.components.mysensors https://github.com/theolind/pymysensors/archive/0b705119389be58332f17753c53167f551254b6c.zip#pymysensors==0.8 @@ -309,13 +322,13 @@ mficlient==0.3.0 miflora==0.1.14 # homeassistant.components.sensor.usps -myusps==1.0.1 +myusps==1.0.2 # homeassistant.components.discovery netdisco==0.8.1 # homeassistant.components.sensor.neurio_energy -neurio==0.2.10 +neurio==0.3.1 # homeassistant.components.google oauth2client==3.0.0 @@ -341,6 +354,9 @@ pexpect==4.0.1 # homeassistant.components.light.hue phue==0.9 +# homeassistant.components.light.piglow +piglow==1.2.4 + # homeassistant.components.pilight pilight==0.1.1 @@ -359,7 +375,7 @@ proliphix==0.4.1 psutil==5.0.1 # homeassistant.components.wink -pubnubsub-handler==0.0.7 +pubnubsub-handler==1.0.0 # homeassistant.components.notify.pushbullet pushbullet.py==0.10.0 @@ -373,11 +389,14 @@ pwaqi==1.3 # homeassistant.components.sensor.cpuspeed py-cpuinfo==0.2.3 +# homeassistant.components.hdmi_cec +pyCEC==0.4.12 + # homeassistant.components.switch.tplink pyHS100==0.2.3 # homeassistant.components.rfxtrx -pyRFXtrx==0.14.0 +pyRFXtrx==0.15.0 # homeassistant.components.notify.xmpp pyasn1-modules==0.0.8 @@ -415,6 +434,9 @@ pyenvisalink==2.0 # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.sensor.skybeacon +pygatt==3.0.0 + # homeassistant.components.remote.harmony pyharmony==1.0.12 @@ -422,16 +444,16 @@ pyharmony==1.0.12 pyhik==0.0.7 # homeassistant.components.homematic -pyhomematic==0.1.19 +pyhomematic==0.1.20 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 -# homeassistant.components.sensor.iss +# homeassistant.components.binary_sensor.iss pyiss==1.0.1 # homeassistant.components.sensor.lastfm -pylast==1.6.0 +pylast==1.7.0 # homeassistant.components.litejet pylitejet==0.1 @@ -476,7 +498,7 @@ pysnmp==4.3.2 python-digitalocean==1.10.1 # homeassistant.components.climate.eq3btsmart -python-eq3bt==0.1.4 +# python-eq3bt==0.1.4 # homeassistant.components.sensor.darksky python-forecastio==1.3.5 @@ -512,7 +534,7 @@ python-twitch==1.3.0 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==0.12.0 +python-wink==1.0.0 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 @@ -524,17 +546,20 @@ pyunifi==1.3 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.21 +pyvera==0.2.23 # homeassistant.components.notify.html5 pywebpush==0.6.1 # homeassistant.components.wemo -pywemo==0.4.9 +pywemo==0.4.11 # homeassistant.components.light.yeelight pyyeelight==1.0-beta +# homeassistant.components.zabbix +pyzabbix==0.7.4 + # homeassistant.components.climate.radiotherm radiotherm==1.2 @@ -575,14 +600,14 @@ sleepyq==0.6 snapcast==1.2.2 # homeassistant.components.climate.honeywell -somecomfort==0.3.2 +somecomfort==0.4.1 # homeassistant.components.sensor.speedtest -speedtest-cli==1.0.1 +speedtest-cli==1.0.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.4 +sqlalchemy==1.1.5 # homeassistant.components.statsd statsd==3.2.1 @@ -610,8 +635,9 @@ tikteck==0.4 # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.notify.twilio_call # homeassistant.components.notify.twilio_sms -twilio==5.4.0 +twilio==5.7.0 # homeassistant.components.sensor.uber uber_rides==0.2.7 diff --git a/requirements_docs.txt b/requirements_docs.txt index e5331727ec9..f3d712e8da2 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.5.1 +Sphinx==1.5.2 sphinx-autodoc-typehints==1.1.0 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index d001c5d1a78..3ce07cff7ef 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,3 +16,4 @@ pytest-sugar>=0.7.1 requests_mock>=1.0 mock-open>=1.3.1 flake8-docstrings==1.0.2 +asynctest>=0.8.0 diff --git a/script/bootstrap b/script/bootstrap index 4e77ba60ed5..05e69cc4db7 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -6,4 +6,9 @@ set -e cd "$(dirname "$0")/.." script/bootstrap_server -script/bootstrap_frontend + +if command -v yarn >/dev/null ; then + script/bootstrap_frontend +else + echo "Frontend development not possible without Node/yarn" +fi diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend index 296f56c8f88..ed3321b1d93 100755 --- a/script/bootstrap_frontend +++ b/script/bootstrap_frontend @@ -12,10 +12,10 @@ git submodule update cd homeassistant/components/frontend/www_static/home-assistant-polymer # Install node modules -npm install +yarn install # Install bower web components. Allow to download the components as root since the user in docker is root. ./node_modules/.bin/bower install --allow-root -npm run setup_js_dev +yarn run setup_js_dev cd ../../../../.. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0231e0d5177..2b7721d2d35 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -19,12 +19,20 @@ COMMENT_REQUIREMENTS = ( 'pyuserinput', 'evdev', 'pycups', + 'python-eq3bt', + 'avion', + 'decora' ) IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', ) +IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') + +URL_PIN = ('https://home-assistant.io/developers/code_review_platform/' + '#1-requirements') + def explore_module(package, explore_children): """Explore the modules.""" @@ -77,6 +85,10 @@ def gather_modules(): continue for req in module.REQUIREMENTS: + if req.partition('==')[1] == '' and req not in IGNORE_PIN: + errors.append( + "{}[Please pin requirement {}, see {}]".format( + package, req, URL_PIN)) reqs.setdefault(req, []).append(package) for key in reqs: diff --git a/script/install_phantomjs b/script/install_phantomjs new file mode 100755 index 00000000000..178dfad540e --- /dev/null +++ b/script/install_phantomjs @@ -0,0 +1,15 @@ +#!/bin/bash +# Sets up phantomjs to be used with Home Assistant. + +# Stop on errors +set -e + +PHANTOMJS_VERSION="2.1.1" + +cd "$(dirname "$0")/.." +mkdir -p build && cd build + +curl -LSO https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 +tar -xjf phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 +mv phantomjs-$PHANTOMJS_VERSION-linux-x86_64/bin/phantomjs /usr/bin/phantomjs +/usr/bin/phantomjs -v diff --git a/script/setup_docker_prereqs b/script/setup_docker_prereqs index d6ec2789c80..a482d9a0ec7 100755 --- a/script/setup_docker_prereqs +++ b/script/setup_docker_prereqs @@ -35,7 +35,7 @@ echo "deb http://download.telldus.com/debian/ stable main" >> /etc/apt/sources.l wget -qO - http://download.telldus.se/debian/telldus-public.key | apt-key add - # Add jessie-backports -echo "deb http://httpredir.debian.org/debian jessie-backports main" >> /etc/apt/sources.list +echo "deb http://deb.debian.org/debian jessie-backports main" >> /etc/apt/sources.list # Install packages apt-get update @@ -50,6 +50,9 @@ cp -R /usr/src/app/build/python-openzwave/openzwave/config /usr/local/share/pyth # Build and install libcec script/build_libcec +# Install phantomjs +script/install_phantomjs + # Remove packages apt-get remove -y --purge ${PACKAGES_DEV[@]} apt-get -y --purge autoremove diff --git a/tests/common.py b/tests/common.py index 514a4973202..b602edbd717 100644 --- a/tests/common.py +++ b/tests/common.py @@ -45,7 +45,6 @@ def get_test_home_assistant(): hass = loop.run_until_complete(async_test_home_assistant(loop)) - # FIXME should not be a daemon. Means hass.stop() not called in teardown stop_event = threading.Event() def run_loop(): @@ -56,8 +55,6 @@ def get_test_home_assistant(): loop.close() stop_event.set() - threading.Thread(name="LoopThread", target=run_loop, daemon=True).start() - orig_start = hass.start orig_stop = hass.stop @@ -76,6 +73,8 @@ def get_test_home_assistant(): hass.start = start_hass hass.stop = stop_hass + threading.Thread(name="LoopThread", target=run_loop, daemon=False).start() + return hass diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 2e58676edcc..13286beae61 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,10 +7,12 @@ import pytest from homeassistant.bootstrap import setup_component from homeassistant.const import ATTR_ENTITY_PICTURE import homeassistant.components.camera as camera +import homeassistant.components.http as http from homeassistant.exceptions import HomeAssistantError from homeassistant.util.async import run_coroutine_threadsafe -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import ( + get_test_home_assistant, get_test_instance_port, assert_setup_component) class TestSetupCamera(object): @@ -43,6 +45,10 @@ class TestGetImage(object): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() + setup_component( + self.hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}}) + config = { camera.DOMAIN: { 'platform': 'demo' diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 518e4ca2c81..898f6ba2df6 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -208,6 +208,27 @@ class TestDemoClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('away_mode')) + def test_set_hold_mode_home(self): + """Test setting the hold mode home.""" + climate.set_hold_mode(self.hass, 'home', ENTITY_ECOBEE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual('home', state.attributes.get('hold_mode')) + + def test_set_hold_mode_away(self): + """Test setting the hold mode away.""" + climate.set_hold_mode(self.hass, 'away', ENTITY_ECOBEE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual('away', state.attributes.get('hold_mode')) + + def test_set_hold_mode_none(self): + """Test setting the hold mode off/false.""" + climate.set_hold_mode(self.hass, None, ENTITY_ECOBEE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(None, state.attributes.get('hold_mode')) + def test_set_aux_heat_bad_attr(self): """Test setting the auxillary heater without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) diff --git a/tests/components/device_tracker/test_upc_connect.py b/tests/components/device_tracker/test_upc_connect.py index 728eb104b8b..1bcbc841d3a 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -230,6 +230,50 @@ class TestUPCConnect(object): cookies={'sessionToken': '1235678'} ) + scanner.token = None + mac_list = run_coroutine_threadsafe( + scanner.async_scan_devices(), self.hass.loop).result() + + assert len(aioclient_mock.mock_calls) == 3 + assert aioclient_mock.mock_calls[1][2]['fun'] == 15 + assert mac_list == ['30:D3:2D:0:69:21', '5C:AA:FD:25:32:02', + '70:EE:50:27:A1:38'] + + def test_scan_devices_without_session_wrong_re(self, aioclient_mock): + """Setup a upc platform and scan device with no token and wrong.""" + aioclient_mock.get( + "http://{}/common_page/login.html".format(self.host), + cookies={'sessionToken': '654321'} + ) + aioclient_mock.post( + "http://{}/xml/getter.xml".format(self.host), + content=b'successful', + cookies={'sessionToken': '654321'} + ) + + scanner = run_coroutine_threadsafe(platform.async_get_scanner( + self.hass, {DOMAIN: { + CONF_PLATFORM: 'upc_connect', + CONF_HOST: self.host, + CONF_PASSWORD: '123456' + }} + ), self.hass.loop).result() + + assert aioclient_mock.mock_calls[1][2]['Password'] == '123456' + assert aioclient_mock.mock_calls[1][2]['fun'] == 15 + assert aioclient_mock.mock_calls[1][2]['token'] == '654321' + + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://{}/common_page/login.html".format(self.host), + cookies={'sessionToken': '654321'} + ) + aioclient_mock.post( + "http://{}/xml/getter.xml".format(self.host), + status=400, + cookies={'sessionToken': '1235678'} + ) + scanner.token = None mac_list = run_coroutine_threadsafe( scanner.async_scan_devices(), self.hass.loop).result() @@ -237,3 +281,42 @@ class TestUPCConnect(object): assert len(aioclient_mock.mock_calls) == 2 assert aioclient_mock.mock_calls[1][2]['fun'] == 15 assert mac_list == [] + + def test_scan_devices_parse_error(self, aioclient_mock): + """Setup a upc platform and scan device with parse error.""" + aioclient_mock.get( + "http://{}/common_page/login.html".format(self.host), + cookies={'sessionToken': '654321'} + ) + aioclient_mock.post( + "http://{}/xml/getter.xml".format(self.host), + content=b'successful', + cookies={'sessionToken': '654321'} + ) + + scanner = run_coroutine_threadsafe(platform.async_get_scanner( + self.hass, {DOMAIN: { + CONF_PLATFORM: 'upc_connect', + CONF_HOST: self.host, + CONF_PASSWORD: '123456' + }} + ), self.hass.loop).result() + + assert aioclient_mock.mock_calls[1][2]['Password'] == '123456' + assert aioclient_mock.mock_calls[1][2]['fun'] == 15 + assert aioclient_mock.mock_calls[1][2]['token'] == '654321' + + aioclient_mock.clear_requests() + aioclient_mock.post( + "http://{}/xml/getter.xml".format(self.host), + text="Blablebla blabalble", + cookies={'sessionToken': '1235678'} + ) + + mac_list = run_coroutine_threadsafe( + scanner.async_scan_devices(), self.hass.loop).result() + + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2]['fun'] == 123 + assert scanner.token is None + assert mac_list == [] diff --git a/tests/components/device_tracker/test_xiaomi.py b/tests/components/device_tracker/test_xiaomi.py index 482ed7c0c0d..94a4566a17b 100644 --- a/tests/components/device_tracker/test_xiaomi.py +++ b/tests/components/device_tracker/test_xiaomi.py @@ -15,9 +15,12 @@ from tests.common import get_test_home_assistant _LOGGER = logging.getLogger(__name__) INVALID_USERNAME = 'bob' +TOKEN_TIMEOUT_USERNAME = 'tok' URL_AUTHORIZE = 'http://192.168.0.1/cgi-bin/luci/api/xqsystem/login' URL_LIST_END = 'api/misystem/devicelist' +FIRST_CALL = True + def mocked_requests(*args, **kwargs): """Mock requests.get invocations.""" @@ -44,20 +47,38 @@ def mocked_requests(*args, **kwargs): raise requests.HTTPError(self.status_code) data = kwargs.get('data') + global FIRST_CALL if data and data.get('username', None) == INVALID_USERNAME: + # deliver an invalid token return MockResponse({ "code": "401", "msg": "Invalid token" }, 200) + elif data and data.get('username', None) == TOKEN_TIMEOUT_USERNAME: + # deliver an expired token + return MockResponse({ + "url": "/cgi-bin/luci/;stok=ef5860/web/home", + "token": "timedOut", + "code": "0" + }, 200) elif str(args[0]).startswith(URL_AUTHORIZE): - print("deliver authorized") + # deliver an authorized token return MockResponse({ "url": "/cgi-bin/luci/;stok=ef5860/web/home", "token": "ef5860", "code": "0" }, 200) + elif str(args[0]).endswith("timedOut/" + URL_LIST_END) \ + and FIRST_CALL is True: + FIRST_CALL = False + # deliver an error when called with expired token + return MockResponse({ + "code": "401", + "msg": "Invalid token" + }, 200) elif str(args[0]).endswith(URL_LIST_END): + # deliver the device list return MockResponse({ "mac": "1C:98:EC:0E:D5:A4", "list": [ @@ -144,7 +165,7 @@ class TestXiaomiDeviceScanner(unittest.TestCase): self.hass.stop() @mock.patch( - 'homeassistant.components.device_tracker.xiaomi.XioamiDeviceScanner', + 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', return_value=mock.MagicMock()) def test_config(self, xiaomi_mock): """Testing minimal configuration.""" @@ -165,7 +186,7 @@ class TestXiaomiDeviceScanner(unittest.TestCase): self.assertEqual(call_arg['platform'], 'device_tracker') @mock.patch( - 'homeassistant.components.device_tracker.xiaomi.XioamiDeviceScanner', + 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', return_value=mock.MagicMock()) def test_config_full(self, xiaomi_mock): """Testing full configuration.""" @@ -219,3 +240,26 @@ class TestXiaomiDeviceScanner(unittest.TestCase): scanner.get_device_name("23:83:BF:F6:38:A0")) self.assertEqual("Device2", scanner.get_device_name("1D:98:EC:5E:D5:A6")) + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_token_timed_out(self, mock_get, mock_post): + """"Testing refresh with a timed out token. + + New token is requested and list is downloaded a second time. + """ + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: TOKEN_TIMEOUT_USERNAME, + CONF_PASSWORD: 'passwordTest' + }) + } + scanner = get_scanner(self.hass, config) + self.assertIsNotNone(scanner) + self.assertEqual(2, len(scanner.scan_devices())) + self.assertEqual("Device1", + scanner.get_device_name("23:83:BF:F6:38:A0")) + self.assertEqual("Device2", + scanner.get_device_name("1D:98:EC:5E:D5:A6")) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 0b36b835cd5..c3888bd9cf7 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -8,7 +8,7 @@ import pytest from homeassistant import bootstrap, const, core import homeassistant.components as core_components from homeassistant.components import ( - emulated_hue, http, light, script, media_player + emulated_hue, http, light, script, media_player, fan ) from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.components.emulated_hue.hue_api import ( @@ -83,6 +83,15 @@ def hass_hue(loop, hass): ] })) + loop.run_until_complete( + bootstrap.async_setup_component(hass, fan.DOMAIN, { + 'fan': [ + { + 'platform': 'demo', + } + ] + })) + # Kitchen light is explicitly excluded from being exposed kitchen_light_entity = hass.states.get('light.kitchen_lights') attrs = dict(kitchen_light_entity.attributes) @@ -106,7 +115,7 @@ def hass_hue(loop, hass): def hue_client(loop, hass_hue, test_client): """Create web client for emulated hue api.""" web_app = mock_http_component_app(hass_hue) - config = Config({'type': 'alexa'}) + config = Config(None, {'type': 'alexa'}) HueUsernameView().register(web_app.router) HueAllLightsStateView(config).register(web_app.router) @@ -137,6 +146,7 @@ def test_discover_lights(hue_client): assert 'media_player.bedroom' in devices assert 'media_player.walkman' in devices assert 'media_player.lounge_room' in devices + assert 'fan.living_room_fan' in devices @asyncio.coroutine @@ -281,6 +291,33 @@ def test_put_light_state_media_player(hass_hue, hue_client): assert walkman.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL] == level +@asyncio.coroutine +def test_put_light_state_fan(hass_hue, hue_client): + """Test turning on fan and setting speed.""" + # Turn the fan off first + yield from hass_hue.services.async_call( + fan.DOMAIN, const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: 'fan.living_room_fan'}, + blocking=True) + + # Emulated hue converts 0-100% to 0-255. + level = 23 + brightness = round(level * 255 / 100) + + fan_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'fan.living_room_fan', True, brightness) + + fan_result_json = yield from fan_result.json() + + assert fan_result.status == 200 + assert len(fan_result_json) == 2 + + living_room_fan = hass_hue.states.get('fan.living_room_fan') + assert living_room_fan.state == 'on' + assert living_room_fan.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + + # pylint: disable=invalid-name @asyncio.coroutine def test_put_with_form_urlencoded_content_type(hass_hue, hue_client): diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 2ee7c385d8d..8c0a6dc4f60 100755 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,31 +1,44 @@ """Test the Emulated Hue component.""" -from unittest.mock import patch +import json + +from unittest.mock import patch, Mock, mock_open from homeassistant.components.emulated_hue import Config, _LOGGER def test_config_google_home_entity_id_to_number(): """Test config adheres to the type.""" - conf = Config({ + conf = Config(Mock(), { 'type': 'google_home' }) - number = conf.entity_id_to_number('light.test') - assert number == '1' + mop = mock_open(read_data=json.dumps({'1': 'light.test2'})) + handle = mop() - number = conf.entity_id_to_number('light.test') - assert number == '1' + with patch('homeassistant.components.emulated_hue.open', mop, create=True): + number = conf.entity_id_to_number('light.test') + assert number == '2' + assert handle.write.call_count == 1 + assert json.loads(handle.write.mock_calls[0][1][0]) == { + '1': 'light.test2', + '2': 'light.test', + } - number = conf.entity_id_to_number('light.test2') - assert number == '2' + number = conf.entity_id_to_number('light.test') + assert number == '2' + assert handle.write.call_count == 1 - entity_id = conf.number_to_entity_id('1') - assert entity_id == 'light.test' + number = conf.entity_id_to_number('light.test2') + assert number == '1' + assert handle.write.call_count == 1 + + entity_id = conf.number_to_entity_id('1') + assert entity_id == 'light.test2' def test_config_alexa_entity_id_to_number(): """Test config adheres to the type.""" - conf = Config({ + conf = Config(None, { 'type': 'alexa' }) @@ -45,7 +58,7 @@ def test_config_alexa_entity_id_to_number(): def test_warning_config_google_home_listen_port(): """Test we warn when non-default port is used for Google Home.""" with patch.object(_LOGGER, 'warning') as mock_warn: - Config({ + Config(None, { 'type': 'google_home', 'host_ip': '123.123.123.123', 'listen_port': 8300 diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index eb52e3262ab..77cfd19bf92 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -5,9 +5,11 @@ from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.bootstrap import setup_component from homeassistant.exceptions import HomeAssistantError +import homeassistant.components.http as http import homeassistant.components.image_processing as ip -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import ( + get_test_home_assistant, get_test_instance_port, assert_setup_component) class TestSetupImageProcessing(object): @@ -53,6 +55,10 @@ class TestImageProcessing(object): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() + setup_component( + self.hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}}) + config = { ip.DOMAIN: { 'platform': 'demo' @@ -207,3 +213,64 @@ class TestImageProcessingAlpr(object): assert event_data[0]['plate'] == 'AC3829' assert event_data[0]['confidence'] == 98.3 assert event_data[0]['entity_id'] == 'image_processing.demo_alpr' + + +class TestImageProcessingFaceIdentify(object): + """Test class for image processing.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + config = { + ip.DOMAIN: { + 'platform': 'demo' + }, + 'camera': { + 'platform': 'demo' + }, + } + + with patch('homeassistant.components.image_processing.demo.' + 'DemoImageProcessingFaceIdentify.should_poll', + new_callable=PropertyMock(return_value=False)): + setup_component(self.hass, ip.DOMAIN, config) + + state = self.hass.states.get('camera.demo_camera') + self.url = "{0}{1}".format( + self.hass.config.api.base_url, + state.attributes.get(ATTR_ENTITY_PICTURE)) + + self.face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + self.face_events.append(event) + + self.hass.bus.listen('identify_face', mock_face_event) + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_face_event_call(self, aioclient_mock): + """Setup and scan a picture and test faces from event.""" + aioclient_mock.get(self.url, content=b'image') + + ip.scan(self.hass, entity_id='image_processing.demo_face_identify') + self.hass.block_till_done() + + state = self.hass.states.get('image_processing.demo_face_identify') + + assert len(self.face_events) == 2 + assert state.state == 'Hans' + assert state.attributes['total_faces'] == 4 + + event_data = [event.data for event in self.face_events if + event.data.get('name') == 'Hans'] + assert len(event_data) == 1 + assert event_data[0]['name'] == 'Hans' + assert event_data[0]['confidence'] == 98.34 + assert event_data[0]['entity_id'] == \ + 'image_processing.demo_face_identify' diff --git a/tests/components/image_processing/test_microsoft_face_identify.py b/tests/components/image_processing/test_microsoft_face_identify.py new file mode 100644 index 00000000000..8d75f6ff1d3 --- /dev/null +++ b/tests/components/image_processing/test_microsoft_face_identify.py @@ -0,0 +1,163 @@ +"""The tests for the microsoft face identify platform.""" +from unittest.mock import patch, PropertyMock + +from homeassistant.core import callback +from homeassistant.const import ATTR_ENTITY_PICTURE +from homeassistant.bootstrap import setup_component +import homeassistant.components.image_processing as ip +import homeassistant.components.microsoft_face as mf + +from tests.common import ( + get_test_home_assistant, assert_setup_component, load_fixture, mock_coro) + + +class TestMicrosoftFaceIdentifySetup(object): + """Test class for image processing.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_platform(self, store_mock): + """Setup platform with one entity.""" + config = { + ip.DOMAIN: { + 'platform': 'microsoft_face_identify', + 'source': { + 'entity_id': 'camera.demo_camera' + }, + 'group': 'Test Group1', + }, + 'camera': { + 'platform': 'demo' + }, + mf.DOMAIN: { + 'api_key': '12345678abcdef6', + } + } + + with assert_setup_component(1, ip.DOMAIN): + setup_component(self.hass, ip.DOMAIN, config) + + assert self.hass.states.get( + 'image_processing.microsoftface_demo_camera') + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_platform_name(self, store_mock): + """Setup platform with one entity and set name.""" + config = { + ip.DOMAIN: { + 'platform': 'microsoft_face_identify', + 'source': { + 'entity_id': 'camera.demo_camera', + 'name': 'test local' + }, + 'group': 'Test Group1', + }, + 'camera': { + 'platform': 'demo' + }, + mf.DOMAIN: { + 'api_key': '12345678abcdef6', + } + } + + with assert_setup_component(1, ip.DOMAIN): + setup_component(self.hass, ip.DOMAIN, config) + + assert self.hass.states.get('image_processing.test_local') + + +class TestMicrosoftFaceIdentify(object): + """Test class for image processing.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + self.config = { + ip.DOMAIN: { + 'platform': 'microsoft_face_identify', + 'source': { + 'entity_id': 'camera.demo_camera', + 'name': 'test local' + }, + 'group': 'Test Group1', + }, + 'camera': { + 'platform': 'demo' + }, + mf.DOMAIN: { + 'api_key': '12345678abcdef6', + } + } + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.image_processing.microsoft_face_identify.' + 'MicrosoftFaceIdentifyEntity.should_poll', + new_callable=PropertyMock(return_value=False)) + def test_openalpr_process_image(self, poll_mock, aioclient_mock): + """Setup and scan a picture and test plates from event.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + setup_component(self.hass, ip.DOMAIN, self.config) + + state = self.hass.states.get('camera.demo_camera') + url = "{0}{1}".format( + self.hass.config.api.base_url, + state.attributes.get(ATTR_ENTITY_PICTURE)) + + face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + face_events.append(event) + + self.hass.bus.listen('identify_face', mock_face_event) + + aioclient_mock.get(url, content=b'image') + + aioclient_mock.post( + mf.FACE_API_URL.format("detect"), + text=load_fixture('microsoft_face_detect.json') + ) + aioclient_mock.post( + mf.FACE_API_URL.format("identify"), + text=load_fixture('microsoft_face_identify.json') + ) + + ip.scan(self.hass, entity_id='image_processing.test_local') + self.hass.block_till_done() + + state = self.hass.states.get('image_processing.test_local') + + assert len(face_events) == 1 + assert state.attributes.get('total_faces') == 2 + assert state.state == 'David' + + assert face_events[0].data['name'] == 'David' + assert face_events[0].data['confidence'] == float(92) + assert face_events[0].data['entity_id'] == \ + 'image_processing.test_local' diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 32b527ac4f1..784c54f6d62 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -105,7 +105,8 @@ class TestSyncMediaPlayer(unittest.TestCase): self.assertEqual(self.player.volume_level, 0) self.player.set_volume_level(0.5) self.assertEqual(self.player.volume_level, 0.5) - self.player.volume_up() + run_coroutine_threadsafe( + self.player.async_volume_up(), self.hass.loop).result() self.assertEqual(self.player.volume_level, 0.7) def test_volume_down(self): @@ -113,5 +114,6 @@ class TestSyncMediaPlayer(unittest.TestCase): self.assertEqual(self.player.volume_level, 0) self.player.set_volume_level(0.5) self.assertEqual(self.player.volume_level, 0.5) - self.player.volume_down() + run_coroutine_threadsafe( + self.player.async_volume_down(), self.hass.loop).result() self.assertEqual(self.player.volume_level, 0.3) diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 9835b8d7635..3d80536fb82 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -84,8 +84,8 @@ class SoCoMock(): """Return true if coordinator.""" return True - def partymode(self): - """Cause the speaker to join all other speakers in the network.""" + def join(self, master): + """Join speaker to a group.""" return def set_sleep_timer(self, sleep_time_seconds): @@ -100,6 +100,10 @@ class SoCoMock(): """Return a player uid.""" return "RINCON_XXXXXXXXXXXXXXXXX" + def group(self): + """Return all group data of this player.""" + return + def fake_add_device(devices, update_befor_add=False): """Fake add device / update.""" @@ -129,7 +133,6 @@ class TestSonosMediaPlayer(unittest.TestCase): """Stop everything that was started.""" # Monkey patches sonos.SonosDevice.available = self.real_available - sonos.DEVICES = [] self.hass.stop() @mock.patch('soco.SoCo', new=SoCoMock) @@ -138,8 +141,8 @@ class TestSonosMediaPlayer(unittest.TestCase): """Test a single device using the autodiscovery provided by HASS.""" sonos.setup_platform(self.hass, {}, fake_add_device, '192.0.2.1') - self.assertEqual(len(sonos.DEVICES), 1) - self.assertEqual(sonos.DEVICES[0].name, 'Kitchen') + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -157,7 +160,7 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(sonos.DEVICES), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) self.assertEqual(discover_mock.call_count, 1) @mock.patch('soco.SoCo', new=SoCoMock) @@ -177,7 +180,7 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(sonos.DEVICES), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) self.assertEqual(discover_mock.call_count, 1) self.assertEqual(soco.config.EVENT_ADVERTISE_IP, '192.0.1.1') @@ -194,8 +197,8 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(sonos.DEVICES), 1) - self.assertEqual(sonos.DEVICES[0].name, 'Kitchen') + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -210,8 +213,8 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(sonos.DEVICES), 2) - self.assertEqual(sonos.DEVICES[0].name, 'Kitchen') + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) + self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -226,8 +229,8 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(sonos.DEVICES), 2) - self.assertEqual(sonos.DEVICES[0].name, 'Kitchen') + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) + self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch.object(soco, 'discover', new=socoDiscoverMock.discover) @@ -235,20 +238,25 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" sonos.setup_platform(self.hass, {}, fake_add_device) - self.assertEqual(len(sonos.DEVICES), 1) - self.assertEqual(sonos.DEVICES[0].name, 'Kitchen') + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'partymode') - def test_sonos_group_players(self, partymodeMock, *args): + @mock.patch.object(SoCoMock, 'join') + def test_sonos_group_players(self, join_mock, *args): """Ensuring soco methods called for sonos_group_players service.""" sonos.setup_platform(self.hass, {}, fake_add_device, '192.0.2.1') - device = sonos.DEVICES[-1] - partymodeMock.return_value = True - device.group_players() - self.assertEqual(partymodeMock.call_count, 1) - self.assertEqual(partymodeMock.call_args, mock.call()) + device = self.hass.data[sonos.DATA_SONOS][-1] + + device_master = mock.MagicMock() + device_master.entity_id = "media_player.test" + device_master.soco_device = mock.MagicMock() + self.hass.data[sonos.DATA_SONOS].append(device_master) + + join_mock.return_value = True + device.join("media_player.test") + self.assertEqual(join_mock.call_count, 1) @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -256,7 +264,7 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_sonos_unjoin(self, unjoinMock, *args): """Ensuring soco methods called for sonos_unjoin service.""" sonos.setup_platform(self.hass, {}, fake_add_device, '192.0.2.1') - device = sonos.DEVICES[-1] + device = self.hass.data[sonos.DATA_SONOS][-1] unjoinMock.return_value = True device.unjoin() self.assertEqual(unjoinMock.call_count, 1) @@ -268,7 +276,7 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args): """Ensuring soco methods called for sonos_set_sleep_timer service.""" sonos.setup_platform(self.hass, {}, fake_add_device, '192.0.2.1') - device = sonos.DEVICES[-1] + device = self.hass.data[sonos.DATA_SONOS][-1] device.set_sleep_timer(30) set_sleep_timerMock.assert_called_once_with(30) @@ -278,7 +286,7 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args): """Ensuring soco methods called for sonos_clear_sleep_timer service.""" sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1') - device = sonos.DEVICES[-1] + device = self.hass.data[sonos.DATA_SONOS][-1] device.set_sleep_timer(None) set_sleep_timerMock.assert_called_once_with(None) @@ -288,7 +296,7 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_sonos_snapshot(self, snapshotMock, *args): """Ensuring soco methods called for sonos_snapshot service.""" sonos.setup_platform(self.hass, {}, fake_add_device, '192.0.2.1') - device = sonos.DEVICES[-1] + device = self.hass.data[sonos.DATA_SONOS][-1] snapshotMock.return_value = True device.snapshot() self.assertEqual(snapshotMock.call_count, 1) @@ -300,8 +308,10 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_sonos_restore(self, restoreMock, *args): """Ensuring soco methods called for sonos_restor service.""" sonos.setup_platform(self.hass, {}, fake_add_device, '192.0.2.1') - device = sonos.DEVICES[-1] + device = self.hass.data[sonos.DATA_SONOS][-1] restoreMock.return_value = True + device._snapshot_coordinator = mock.MagicMock() + device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(True)) diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index ff70fe36a17..4a06d989ce2 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -538,6 +538,25 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual(check_flags, ump.supported_media_commands) + def test_service_call_no_active_child(self): + """Test a service call to children with no active child.""" + config = self.config_children_only + universal.validate_config(config) + + ump = universal.UniversalMediaPlayer(self.hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) + ump.update() + + self.mock_mp_1._state = STATE_OFF + self.mock_mp_1.update_ha_state() + self.mock_mp_2._state = STATE_OFF + self.mock_mp_2.update_ha_state() + ump.update() + + ump.turn_off() + self.assertEqual(0, len(self.mock_mp_1.service_calls['turn_off'])) + self.assertEqual(0, len(self.mock_mp_2.service_calls['turn_off'])) + def test_service_call_to_child(self): """Test service calls that should be routed to a child.""" config = self.config_children_only diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 6949863280c..7246aea3302 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -41,7 +41,9 @@ class TestApns(unittest.TestCase): assert setup_component(self.hass, notify.DOMAIN, CONFIG) assert handle_config[notify.DOMAIN] - def test_apns_setup_full(self): + @patch('os.path.isfile', return_value=True) + @patch('os.access', return_value=True) + def test_apns_setup_full(self, mock_access, mock_isfile): """Test setup with all data.""" config = { 'notify': { @@ -53,7 +55,9 @@ class TestApns(unittest.TestCase): } } - self.assertTrue(notify.setup(self.hass, config)) + with assert_setup_component(1) as handle_config: + assert setup_component(self.hass, notify.DOMAIN, config) + assert handle_config[notify.DOMAIN] def test_apns_setup_missing_name(self): """Test setup with missing name.""" diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 1ccb3f5c56d..de13f678ae0 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -1,4 +1,5 @@ """The tests for the notify demo platform.""" +import asyncio import unittest from unittest.mock import patch @@ -16,6 +17,12 @@ CONFIG = { } +@asyncio.coroutine +def mock_setup_platform(): + """Mock prepare_setup_platform.""" + return None + + class TestNotifyDemo(unittest.TestCase): """Test the demo notify.""" @@ -45,23 +52,16 @@ class TestNotifyDemo(unittest.TestCase): """Test setup.""" self._setup_notify() - @patch('homeassistant.bootstrap.prepare_setup_platform') + @patch('homeassistant.bootstrap.async_prepare_setup_platform', + return_value=mock_setup_platform()) def test_no_prepare_setup_platform(self, mock_prep_setup_platform): """Test missing notify platform.""" - mock_prep_setup_platform.return_value = None - with self.assertLogs('homeassistant.components.notify', - level='ERROR') as log_handle: - self._setup_notify() - self.hass.block_till_done() - assert mock_prep_setup_platform.called - self.assertEqual( - log_handle.output, - ['ERROR:homeassistant.components.notify:' - 'Unknown notification service specified', - 'ERROR:homeassistant.components.notify:' - 'Failed to set up platform demo']) + with assert_setup_component(0): + setup_component(self.hass, notify.DOMAIN, CONFIG) - @patch('homeassistant.components.notify.demo.get_service') + assert mock_prep_setup_platform.called + + @patch('homeassistant.components.notify.demo.get_service', autospec=True) def test_no_notify_service(self, mock_demo_get_service): """Test missing platform notify service instance.""" mock_demo_get_service.return_value = None @@ -73,11 +73,9 @@ class TestNotifyDemo(unittest.TestCase): self.assertEqual( log_handle.output, ['ERROR:homeassistant.components.notify:' - 'Failed to initialize notification service demo', - 'ERROR:homeassistant.components.notify:' - 'Failed to set up platform demo']) + 'Failed to initialize notification service demo']) - @patch('homeassistant.components.notify.demo.get_service') + @patch('homeassistant.components.notify.demo.get_service', autospec=True) def test_discover_notify(self, mock_demo_get_service): """Test discovery of notify demo platform.""" assert notify.DOMAIN not in self.hass.config.components diff --git a/tests/components/notify/test_group.py b/tests/components/notify/test_group.py index 14c8c46b6c3..1aa07fed583 100644 --- a/tests/components/notify/test_group.py +++ b/tests/components/notify/test_group.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from homeassistant.bootstrap import setup_component import homeassistant.components.notify as notify from homeassistant.components.notify import group, demo +from homeassistant.util.async import run_coroutine_threadsafe from tests.common import assert_setup_component, get_test_home_assistant @@ -16,8 +17,11 @@ class TestNotifyGroup(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() self.events = [] - self.service1 = MagicMock() - self.service2 = MagicMock() + self.service1 = demo.DemoNotificationService(self.hass) + self.service2 = demo.DemoNotificationService(self.hass) + + self.service1.send_message = MagicMock(autospec=True) + self.service2.send_message = MagicMock(autospec=True) def mock_get_service(hass, config, discovery_info=None): if config['name'] == 'demo1': @@ -37,11 +41,14 @@ class TestNotifyGroup(unittest.TestCase): }] }) - self.service = group.get_service(self.hass, {'services': [ - {'service': 'demo1'}, - {'service': 'demo2', - 'data': {'target': 'unnamed device', - 'data': {'test': 'message'}}}]}) + self.service = run_coroutine_threadsafe( + group.async_get_service(self.hass, {'services': [ + {'service': 'demo1'}, + {'service': 'demo2', + 'data': {'target': 'unnamed device', + 'data': {'test': 'message'}}}]}), + self.hass.loop + ).result() assert self.service is not None @@ -51,17 +58,19 @@ class TestNotifyGroup(unittest.TestCase): def test_send_message_with_data(self): """Test sending a message with to a notify group.""" - self.service.send_message('Hello', title='Test notification', - data={'hello': 'world'}) + run_coroutine_threadsafe( + self.service.async_send_message( + 'Hello', title='Test notification', data={'hello': 'world'}), + self.hass.loop).result() self.hass.block_till_done() + assert self.service1.send_message.mock_calls[0][1][0] == 'Hello' assert self.service1.send_message.mock_calls[0][2] == { - 'message': 'Hello', 'title': 'Test notification', 'data': {'hello': 'world'} } + assert self.service2.send_message.mock_calls[0][1][0] == 'Hello' assert self.service2.send_message.mock_calls[0][2] == { - 'message': 'Hello', 'target': ['unnamed device'], 'title': 'Test notification', 'data': {'hello': 'world', 'test': 'message'} diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py index 6a2f8c7acbf..509310099e3 100644 --- a/tests/components/notify/test_smtp.py +++ b/tests/components/notify/test_smtp.py @@ -1,5 +1,6 @@ """The tests for the notify smtp platform.""" import unittest +from unittest.mock import patch from homeassistant.components.notify import smtp @@ -9,10 +10,6 @@ from tests.common import get_test_home_assistant class MockSMTP(smtp.MailNotificationService): """Test SMTP object that doesn't need a working server.""" - def connection_is_valid(self): - """Pretend connection is always valid for testing.""" - return True - def _send_email(self, msg): """Just return string for testing.""" return msg.as_string() @@ -31,7 +28,8 @@ class TestNotifySmtp(unittest.TestCase): """"Stop down everything that was started.""" self.hass.stop() - def test_text_email(self): + @patch('email.utils.make_msgid', return_value='') + def test_text_email(self, mock_make_msgid): """Test build of default text email behavior.""" msg = self.mailer.send_message('Test msg') expected = ('^Content-Type: text/plain; charset="us-ascii"\n' @@ -47,7 +45,8 @@ class TestNotifySmtp(unittest.TestCase): 'Test msg$') self.assertRegex(msg, expected) - def test_mixed_email(self): + @patch('email.utils.make_msgid', return_value='') + def test_mixed_email(self, mock_make_msgid): """Test build of mixed text email behavior.""" msg = self.mailer.send_message('Test msg', data={'images': ['test.jpg']}) diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py index e3c83bad2a6..effa7b3dbd8 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -32,7 +32,8 @@ class TestDarkSkySetup(unittest.TestCase): self.key = 'foo' self.config = { 'api_key': 'foo', - 'monitored_conditions': ['summary', 'icon'], + 'forecast': [1, 2], + 'monitored_conditions': ['summary', 'icon', 'temperature_max'], 'update_interval': timedelta(seconds=120), } self.lat = 37.8267 @@ -80,4 +81,4 @@ class TestDarkSkySetup(unittest.TestCase): darksky.setup_platform(self.hass, self.config, self.add_entities) self.assertTrue(mock_get_forecast.called) self.assertEqual(mock_get_forecast.call_count, 1) - self.assertEqual(len(self.entities), 2) + self.assertEqual(len(self.entities), 7) diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 35e224253ee..aae8dfddc5b 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -1,22 +1,51 @@ """Test for DSMR components. -Tests setup of the DSMR component and ensure incoming telegrams cause Entity -to be updated with new values. +Tests setup of the DSMR component and ensure incoming telegrams cause +Entity to be updated with new values. + """ import asyncio from decimal import Decimal from unittest.mock import Mock +import asynctest from homeassistant.bootstrap import async_setup_component from homeassistant.components.sensor.dsmr import DerivativeDSMREntity from homeassistant.const import STATE_UNKNOWN -from tests.common import assert_setup_component, mock_coro +import pytest +from tests.common import assert_setup_component + + +@pytest.fixture +def mock_connection_factory(monkeypatch): + """Mock the create functions for serial and TCP Asyncio connections.""" + from dsmr_parser.protocol import DSMRProtocol + transport = asynctest.Mock(spec=asyncio.Transport) + protocol = asynctest.Mock(spec=DSMRProtocol) + + @asyncio.coroutine + def connection_factory(*args, **kwargs): + """Return mocked out Asyncio classes.""" + return (transport, protocol) + connection_factory = Mock(wraps=connection_factory) + + # apply the mock to both connection factories + monkeypatch.setattr( + 'dsmr_parser.protocol.create_dsmr_reader', + connection_factory) + monkeypatch.setattr( + 'dsmr_parser.protocol.create_tcp_dsmr_reader', + connection_factory) + + return connection_factory, transport, protocol @asyncio.coroutine -def test_default_setup(hass, monkeypatch): +def test_default_setup(hass, mock_connection_factory): """Test the default setup.""" + (connection_factory, transport, protocol) = mock_connection_factory + from dsmr_parser.obis_references import ( CURRENT_ELECTRICITY_USAGE, ELECTRICITY_ACTIVE_TARIFF, @@ -34,15 +63,11 @@ def test_default_setup(hass, monkeypatch): ]), } - # mock for injecting DSMR telegram - dsmr = Mock(return_value=mock_coro([Mock(), None])) - monkeypatch.setattr('dsmr_parser.protocol.create_dsmr_reader', dsmr) - with assert_setup_component(1): yield from async_setup_component(hass, 'sensor', {'sensor': config}) - telegram_callback = dsmr.call_args_list[0][0][2] + telegram_callback = connection_factory.call_args_list[0][0][2] # make sure entities have been created and return 'unknown' state power_consumption = hass.states.get('sensor.power_consumption') @@ -99,3 +124,80 @@ def test_derivative(): 'state should be difference between first and second update' assert entity.unit_of_measurement == 'm3/h' + + +@asyncio.coroutine +def test_tcp(hass, mock_connection_factory): + """If proper config provided TCP connection should be made.""" + (connection_factory, transport, protocol) = mock_connection_factory + + config = { + 'platform': 'dsmr', + 'host': 'localhost', + 'port': 1234, + } + + with assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', + {'sensor': config}) + + assert connection_factory.call_args_list[0][0][0] == 'localhost' + assert connection_factory.call_args_list[0][0][1] == '1234' + + +@asyncio.coroutine +def test_connection_errors_retry(hass, monkeypatch, mock_connection_factory): + """Connection should be retried on error during setup.""" + (connection_factory, transport, protocol) = mock_connection_factory + + config = { + 'platform': 'dsmr', + 'reconnect_interval': 0, + } + + # override the mock to have it fail the first time + first_fail_connection_factory = Mock( + wraps=connection_factory, side_effect=[ + TimeoutError]) + + monkeypatch.setattr( + 'dsmr_parser.protocol.create_dsmr_reader', + first_fail_connection_factory) + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + # wait for sleep to resolve + yield from hass.async_block_till_done() + assert first_fail_connection_factory.call_count == 2, \ + 'connecting not retried' + + +@asyncio.coroutine +def test_reconnect(hass, monkeypatch, mock_connection_factory): + """If transport disconnects, the connection should be retried.""" + (connection_factory, transport, protocol) = mock_connection_factory + config = { + 'platform': 'dsmr', + 'reconnect_interval': 0, + } + + # mock waiting coroutine while connection lasts + closed = asyncio.Event(loop=hass.loop) + + @asyncio.coroutine + def wait_closed(): + yield from closed.wait() + protocol.wait_closed = wait_closed + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + assert connection_factory.call_count == 1 + + # indicate disconnect, release wait lock and allow reconnect to happen + closed.set() + # wait for lock set to resolve + yield from hass.async_block_till_done() + # wait for sleep to resolve + yield from hass.async_block_till_done() + + assert connection_factory.call_count == 2, \ + 'connecting not retried' diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 4abfb2d4551..1c4910927a5 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -7,9 +7,11 @@ from requests.exceptions import Timeout, MissingSchema, RequestException import requests_mock from homeassistant.bootstrap import setup_component +import homeassistant.components.sensor as sensor import homeassistant.components.sensor.rest as rest from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers.config_validation import template + from tests.common import get_test_home_assistant, assert_setup_component @@ -26,10 +28,9 @@ class TestRestSwitchSetup(unittest.TestCase): def test_setup_missing_config(self): """Test setup with configuration missing required entries.""" - self.assertFalse(rest.setup_platform(self.hass, { - 'platform': 'rest', - 'resource': 'http://localhost' - }, None)) + with assert_setup_component(0): + assert setup_component(self.hass, sensor.DOMAIN, { + 'sensor': {'platform': 'rest'}}) def test_setup_missing_schema(self): """Test setup with resource missing schema.""" @@ -40,7 +41,8 @@ class TestRestSwitchSetup(unittest.TestCase): 'method': 'GET' }, None) - @patch('requests.get', side_effect=requests.exceptions.ConnectionError()) + @patch('requests.Session.send', + side_effect=requests.exceptions.ConnectionError()) def test_setup_failed_connect(self, mock_req): """Test setup when connection error occurs.""" self.assertFalse(rest.setup_platform(self.hass, { @@ -48,7 +50,7 @@ class TestRestSwitchSetup(unittest.TestCase): 'resource': 'http://localhost', }, None)) - @patch('requests.get', side_effect=Timeout()) + @patch('requests.Session.send', side_effect=Timeout()) def test_setup_timeout(self, mock_req): """Test setup when connection timeout occurs.""" self.assertFalse(rest.setup_platform(self.hass, { diff --git a/tests/components/sensor/test_wsdot.py b/tests/components/sensor/test_wsdot.py new file mode 100644 index 00000000000..4a2dc345f10 --- /dev/null +++ b/tests/components/sensor/test_wsdot.py @@ -0,0 +1,64 @@ +"""The tests for the WSDOT platform.""" +import re +import unittest +from datetime import timedelta, datetime, timezone + +import requests_mock + +from homeassistant.components.sensor import wsdot +from homeassistant.components.sensor.wsdot import ( + WashingtonStateTravelTimeSensor, ATTR_DESCRIPTION, + ATTR_TIME_UPDATED, CONF_API_KEY, CONF_NAME, + CONF_ID, CONF_TRAVEL_TIMES, SCAN_INTERVAL) +from homeassistant.bootstrap import setup_component +from tests.common import load_fixture, get_test_home_assistant + + +class TestWSDOT(unittest.TestCase): + """Test the WSDOT platform.""" + + def add_entities(self, new_entities, update_before_add=False): + """Mock add entities.""" + if update_before_add: + for entity in new_entities: + entity.update() + + for entity in new_entities: + self.entities.append(entity) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = { + CONF_API_KEY: 'foo', + SCAN_INTERVAL: timedelta(seconds=120), + CONF_TRAVEL_TIMES: [{ + CONF_ID: 96, + CONF_NAME: 'I90 EB'}], + } + self.entities = [] + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_with_config(self): + """Test the platform setup with configuration.""" + self.assertTrue( + setup_component(self.hass, 'sensor', {'wsdot': self.config})) + + @requests_mock.Mocker() + def test_setup(self, mock_req): + """Test for operational WSDOT sensor with proper attributes.""" + uri = re.compile(WashingtonStateTravelTimeSensor.RESOURCE + '*') + mock_req.get(uri, text=load_fixture('wsdot.json')) + wsdot.setup_platform(self.hass, self.config, self.add_entities) + self.assertEqual(len(self.entities), 1) + sensor = self.entities[0] + self.assertEqual(sensor.name, 'I90 EB') + self.assertEqual(sensor.state, 11) + self.assertEqual(sensor.device_state_attributes[ATTR_DESCRIPTION], + 'Downtown Seattle to Downtown Bellevue via I-90') + self.assertEqual(sensor.device_state_attributes[ATTR_TIME_UPDATED], + datetime(2017, 1, 21, 15, 10, + tzinfo=timezone(timedelta(hours=-8)))) diff --git a/tests/components/switch/test_command_line.py b/tests/components/switch/test_command_line.py index 008727df7f8..87eb12d8508 100644 --- a/tests/components/switch/test_command_line.py +++ b/tests/components/switch/test_command_line.py @@ -159,7 +159,7 @@ class TestCommandSwitch(unittest.TestCase): state = self.hass.states.get('switch.test') self.assertEqual(STATE_ON, state.state) - def test_assumed_state_should_be_true_if_command_state_is_false(self): + def test_assumed_state_should_be_true_if_command_state_is_none(self): """Test with state value.""" # args: hass, device_name, friendly_name, command_on, command_off, # command_state, value_template @@ -169,7 +169,7 @@ class TestCommandSwitch(unittest.TestCase): "Test friendly name!", "echo 'on command'", "echo 'off command'", - False, + None, None, ] diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index dcb675e00e5..047ca480f6f 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -117,6 +117,50 @@ class TestComponentLogbook(unittest.TestCase): self.assertEqual(0, len(entries)) + def test_exclude_new_entities(self): + """Test if events are excluded on first update.""" + entity_id = 'sensor.bla' + entity_id2 = 'sensor.blu' + pointA = dt_util.utcnow() + pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) + + eventA = self.create_state_changed_event(pointA, entity_id, 10) + eventB = self.create_state_changed_event(pointB, entity_id2, 20) + eventA.data['old_state'] = None + + events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), self.EMPTY_CONFIG) + entries = list(logbook.humanify(events)) + + self.assertEqual(2, len(entries)) + self.assert_entry( + entries[0], name='Home Assistant', message='stopped', + domain=ha.DOMAIN) + self.assert_entry( + entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2) + + def test_exclude_removed_entities(self): + """Test if events are excluded on last update.""" + entity_id = 'sensor.bla' + entity_id2 = 'sensor.blu' + pointA = dt_util.utcnow() + pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) + + eventA = self.create_state_changed_event(pointA, entity_id, 10) + eventB = self.create_state_changed_event(pointB, entity_id2, 20) + eventA.data['new_state'] = None + + events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), self.EMPTY_CONFIG) + entries = list(logbook.humanify(events)) + + self.assertEqual(2, len(entries)) + self.assert_entry( + entries[0], name='Home Assistant', message='stopped', + domain=ha.DOMAIN) + self.assert_entry( + entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2) + def test_exclude_events_hidden(self): """Test if events are excluded if entity is hidden.""" entity_id = 'sensor.bla' diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py new file mode 100644 index 00000000000..6dee9dc9a55 --- /dev/null +++ b/tests/components/test_microsoft_face.py @@ -0,0 +1,263 @@ +"""The tests for the microsoft face platform.""" +import asyncio +from unittest.mock import patch + +import homeassistant.components.microsoft_face as mf +from homeassistant.bootstrap import setup_component + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_coro, load_fixture) + + +class TestMicrosoftFaceSetup(object): + """Test the microsoft face component.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + self.config = { + mf.DOMAIN: { + 'api_key': '12345678abcdef', + } + } + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_component(self, mock_update): + """Setup component.""" + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_component_wrong_api_key(self, mock_update): + """Setup component without api key.""" + with assert_setup_component(0, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, {mf.DOMAIN: {}}) + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_setup_component_test_service(self, mock_update): + """Setup component.""" + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert self.hass.services.has_service(mf.DOMAIN, 'create_group') + assert self.hass.services.has_service(mf.DOMAIN, 'delete_group') + assert self.hass.services.has_service(mf.DOMAIN, 'train_group') + assert self.hass.services.has_service(mf.DOMAIN, 'create_person') + assert self.hass.services.has_service(mf.DOMAIN, 'delete_person') + assert self.hass.services.has_service(mf.DOMAIN, 'face_person') + + def test_setup_component_test_entities(self, aioclient_mock): + """Setup component.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert len(aioclient_mock.mock_calls) == 3 + + entity_group1 = self.hass.states.get('microsoft_face.test_group1') + entity_group2 = self.hass.states.get('microsoft_face.test_group2') + + assert entity_group1 is not None + assert entity_group2 is not None + + assert entity_group1.attributes['Ryan'] == \ + '25985303-c537-4467-b41d-bdb45cd95ca1' + assert entity_group1.attributes['David'] == \ + '2ae4935b-9659-44c3-977f-61fac20d0538' + + assert entity_group2.attributes['Ryan'] == \ + '25985303-c537-4467-b41d-bdb45cd95ca1' + assert entity_group2.attributes['David'] == \ + '2ae4935b-9659-44c3-977f-61fac20d0538' + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_groups(self, mock_update, aioclient_mock): + """Setup component, test groups services.""" + aioclient_mock.put( + mf.FACE_API_URL.format("persongroups/service_group"), + status=200, text="{}" + ) + aioclient_mock.delete( + mf.FACE_API_URL.format("persongroups/service_group"), + status=200, text="{}" + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + mf.create_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is not None + assert len(aioclient_mock.mock_calls) == 1 + + mf.delete_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is None + assert len(aioclient_mock.mock_calls) == 2 + + def test_service_person(self, aioclient_mock): + """Setup component, test person services.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert len(aioclient_mock.mock_calls) == 3 + + aioclient_mock.post( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_create_person.json') + ) + aioclient_mock.delete( + mf.FACE_API_URL.format( + "persongroups/test_group1/persons/" + "25985303-c537-4467-b41d-bdb45cd95ca1"), + status=200, text="{}" + ) + + mf.create_person(self.hass, 'test group1', 'Hans') + self.hass.block_till_done() + + entity_group1 = self.hass.states.get('microsoft_face.test_group1') + + assert len(aioclient_mock.mock_calls) == 4 + assert entity_group1 is not None + assert entity_group1.attributes['Hans'] == \ + '25985303-c537-4467-b41d-bdb45cd95ca1' + + mf.delete_person(self.hass, 'test group1', 'Hans') + self.hass.block_till_done() + + entity_group1 = self.hass.states.get('microsoft_face.test_group1') + + assert len(aioclient_mock.mock_calls) == 5 + assert entity_group1 is not None + assert 'Hans' not in entity_group1.attributes + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_train(self, mock_update, aioclient_mock): + """Setup component, test train groups services.""" + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + aioclient_mock.post( + mf.FACE_API_URL.format("persongroups/service_group/train"), + status=200, text="{}" + ) + + mf.train_group(self.hass, 'Service Group') + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + + @patch('homeassistant.components.camera.async_get_image', + return_value=mock_coro(return_value=b'Test')()) + def test_service_face(self, camera_mock, aioclient_mock): + """Setup component, test person face services.""" + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups"), + text=load_fixture('microsoft_face_persongroups.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group1/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + aioclient_mock.get( + mf.FACE_API_URL.format("persongroups/test_group2/persons"), + text=load_fixture('microsoft_face_persons.json') + ) + + self.config['camera'] = {'platform': 'demo'} + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + assert len(aioclient_mock.mock_calls) == 3 + + aioclient_mock.post( + mf.FACE_API_URL.format( + "persongroups/test_group2/persons/" + "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces"), + status=200, text="{}" + ) + + mf.face_person( + self.hass, 'test_group2', 'David', 'camera.demo_camera') + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 4 + assert aioclient_mock.mock_calls[3][2] == b'Test' + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_status_400(self, mock_update, aioclient_mock): + """Setup component, test groups services with error.""" + aioclient_mock.put( + mf.FACE_API_URL.format("persongroups/service_group"), + status=400, text="{'error': {'message': 'Error'}}" + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + mf.create_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is None + assert len(aioclient_mock.mock_calls) == 1 + + @patch('homeassistant.components.microsoft_face.' + 'MicrosoftFace.update_store', return_value=mock_coro()()) + def test_service_status_timeout(self, mock_update, aioclient_mock): + """Setup component, test groups services with timeout.""" + aioclient_mock.put( + mf.FACE_API_URL.format("persongroups/service_group"), + status=400, exc=asyncio.TimeoutError() + ) + + with assert_setup_component(2, mf.DOMAIN): + setup_component(self.hass, mf.DOMAIN, self.config) + + mf.create_group(self.hass, 'Service Group') + self.hass.block_till_done() + + entity = self.hass.states.get('microsoft_face.service_group') + assert entity is None + assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/test_zwave.py b/tests/components/test_zwave.py new file mode 100644 index 00000000000..5c9be9ba22a --- /dev/null +++ b/tests/components/test_zwave.py @@ -0,0 +1,68 @@ +"""The tests for the zwave component.""" +import unittest +from unittest.mock import MagicMock, patch + +from homeassistant.bootstrap import setup_component +from homeassistant.components import zwave +from tests.common import get_test_home_assistant + + +class TestComponentZwave(unittest.TestCase): + """Test the Zwave component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def _validate_config(self, validator, config): + libopenzwave = MagicMock() + libopenzwave.__file__ = 'test' + with patch.dict('sys.modules', { + 'libopenzwave': libopenzwave, + 'openzwave.option': MagicMock(), + 'openzwave.network': MagicMock(), + 'openzwave.group': MagicMock(), + }): + validator(setup_component(self.hass, zwave.DOMAIN, { + zwave.DOMAIN: config, + })) + + def test_empty_config(self): + """Test empty config.""" + self._validate_config(self.assertTrue, {}) + + def test_empty_customize(self): + """Test empty customize.""" + self._validate_config(self.assertTrue, {'customize': {}}) + self._validate_config(self.assertTrue, {'customize': []}) + + def test_empty_customize_content(self): + """Test empty customize.""" + self._validate_config( + self.assertTrue, {'customize': {'test.test': {}}}) + + def test_full_customize_dict(self): + """Test full customize as dict.""" + self._validate_config(self.assertTrue, {'customize': {'test.test': { + zwave.CONF_POLLING_INTENSITY: 10, + zwave.CONF_IGNORED: 1, + zwave.CONF_REFRESH_VALUE: 1, + zwave.CONF_REFRESH_DELAY: 10}}}) + + def test_full_customize_list(self): + """Test full customize as list.""" + self._validate_config(self.assertTrue, {'customize': [{ + 'entity_id': 'test.test', + zwave.CONF_POLLING_INTENSITY: 10, + zwave.CONF_IGNORED: 1, + zwave.CONF_REFRESH_VALUE: 1, + zwave.CONF_REFRESH_DELAY: 10}]}) + + def test_bad_customize(self): + """Test customize with extra keys.""" + self._validate_config( + self.assertFalse, {'customize': {'test.test': {'extra_key': 10}}}) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 715b98c4740..f7985b8af74 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,10 +1,12 @@ """The tests for the TTS component.""" +import ctypes import os import shutil -from unittest.mock import patch +from unittest.mock import patch, PropertyMock import requests +import homeassistant.components.http as http import homeassistant.components.tts as tts from homeassistant.components.tts.demo import DemoProvider from homeassistant.components.media_player import ( @@ -13,7 +15,8 @@ from homeassistant.components.media_player import ( from homeassistant.bootstrap import setup_component from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_service) + get_test_home_assistant, get_test_instance_port, assert_setup_component, + mock_service) class TestTTS(object): @@ -25,6 +28,10 @@ class TestTTS(object): self.demo_provider = DemoProvider('en') self.default_tts_cache = self.hass.config.path(tts.DEFAULT_CACHE_DIR) + setup_component( + self.hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}}) + def teardown_method(self): """Stop everything that was started.""" if os.path.isdir(self.default_tts_cache): @@ -82,11 +89,11 @@ class TestTTS(object): assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" - "_en_demo.mp3") \ + "_en_-_demo.mp3") \ != -1 assert os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")) def test_setup_component_and_test_service_with_config_language(self): """Setup the demo platform and call service.""" @@ -111,11 +118,11 @@ class TestTTS(object): assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" - "_de_demo.mp3") \ + "_de_-_demo.mp3") \ != -1 assert os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_de_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3")) def test_setup_component_and_test_service_with_wrong_conf_language(self): """Setup the demo platform and call service with wrong config.""" @@ -152,11 +159,11 @@ class TestTTS(object): assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" - "_de_demo.mp3") \ + "_de_-_demo.mp3") \ != -1 assert os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_de_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3")) def test_setup_component_test_service_with_wrong_service_language(self): """Setup the demo platform and call service.""" @@ -180,7 +187,106 @@ class TestTTS(object): assert len(calls) == 0 assert not os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_lang_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_lang_-_demo.mp3")) + + def test_setup_component_and_test_service_with_service_options(self): + """Setup the demo platform and call service with options.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: { + 'voice': 'alex' + } + }) + self.hass.block_till_done() + + opt_hash = ctypes.c_size_t(hash(frozenset({'voice': 'alex'}))).value + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( + "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" + "_de_{0}_demo.mp3".format(opt_hash)) \ + != -1 + assert os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format( + opt_hash))) + + @patch('homeassistant.components.tts.demo.DemoProvider.default_options', + new_callable=PropertyMock(return_value={'voice': 'alex'})) + def test_setup_component_and_test_with_service_options_def(self, def_mock): + """Setup the demo platform and call service with default options.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + tts.ATTR_LANGUAGE: "de", + }) + self.hass.block_till_done() + + opt_hash = ctypes.c_size_t(hash(frozenset({'voice': 'alex'}))).value + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( + "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" + "_de_{0}_demo.mp3".format(opt_hash)) \ + != -1 + assert os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format( + opt_hash))) + + def test_setup_component_and_test_service_with_service_options_wrong(self): + """Setup the demo platform and call service with wrong options.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: { + 'speed': 1 + } + }) + self.hass.block_till_done() + + opt_hash = ctypes.c_size_t(hash(frozenset({'speed': 1}))).value + + assert len(calls) == 0 + assert not os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format( + opt_hash))) def test_setup_component_and_test_service_clear_cache(self): """Setup the demo platform and call service clear cache.""" @@ -203,14 +309,14 @@ class TestTTS(object): assert len(calls) == 1 assert os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")) self.hass.services.call(tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {}) self.hass.block_till_done() assert not os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")) def test_setup_component_and_test_service_with_receive_voice(self): """Setup the demo platform and call service and receive voice.""" @@ -278,7 +384,7 @@ class TestTTS(object): self.hass.start() url = ("{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" - "_en_demo.mp3").format(self.hass.config.api.base_url) + "_en_-_demo.mp3").format(self.hass.config.api.base_url) req = requests.get(url) assert req.status_code == 404 @@ -297,7 +403,7 @@ class TestTTS(object): self.hass.start() url = ("{}/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd" - "_en_demo.mp3").format(self.hass.config.api.base_url) + "_en_-_demo.mp3").format(self.hass.config.api.base_url) req = requests.get(url) assert req.status_code == 404 @@ -324,7 +430,7 @@ class TestTTS(object): assert len(calls) == 1 assert not os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")) def test_setup_component_test_with_cache_call_service_without_cache(self): """Setup demo platform with cache and call service without cache.""" @@ -349,7 +455,7 @@ class TestTTS(object): assert len(calls) == 1 assert not os.path.isfile(os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3")) + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")) def test_setup_component_test_with_cache_dir(self): """Setup demo platform with cache and call service without cache.""" @@ -358,7 +464,7 @@ class TestTTS(object): _, demo_data = self.demo_provider.get_tts_audio("bla", 'en') cache_file = os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3") + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3") os.mkdir(self.default_tts_cache) with open(cache_file, "wb") as voice_file: @@ -384,7 +490,7 @@ class TestTTS(object): assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" - "_en_demo.mp3") \ + "_en_-_demo.mp3") \ != -1 @patch('homeassistant.components.tts.demo.DemoProvider.get_tts_audio', @@ -414,7 +520,7 @@ class TestTTS(object): _, demo_data = self.demo_provider.get_tts_audio("bla", 'en') cache_file = os.path.join( self.default_tts_cache, - "265944c108cbb00b2a621be5930513e03a0bb2cd_en_demo.mp3") + "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3") os.mkdir(self.default_tts_cache) with open(cache_file, "wb") as voice_file: @@ -433,7 +539,7 @@ class TestTTS(object): self.hass.start() url = ("{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" - "_en_demo.mp3").format(self.hass.config.api.base_url) + "_en_-_demo.mp3").format(self.hass.config.api.base_url) req = requests.get(url) assert req.status_code == 200 diff --git a/tests/components/tts/test_yandextts.py b/tests/components/tts/test_yandextts.py index f0c6eb85200..2baa94ae2b8 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -54,10 +54,17 @@ class TestTTSYandexPlatform(object): """Test service call say.""" calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url = "https://tts.voicetech.yandex.net/generate?format=mp3" \ - "&speaker=zahar&key=1234567xx&text=HomeAssistant&lang=en-US" + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 1 + } aioclient_mock.get( - url, status=200, content=b'test') + self._base_url, status=200, content=b'test', params=url_param) config = { tts.DOMAIN: { @@ -81,10 +88,17 @@ class TestTTSYandexPlatform(object): """Test service call say.""" calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url = "https://tts.voicetech.yandex.net/generate?format=mp3" \ - "&speaker=zahar&key=1234567xx&text=HomeAssistant&lang=ru-RU" + url_param = { + 'text': 'HomeAssistant', + 'lang': 'ru-RU', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 1 + } aioclient_mock.get( - url, status=200, content=b'test') + self._base_url, status=200, content=b'test', params=url_param) config = { tts.DOMAIN: { @@ -109,10 +123,17 @@ class TestTTSYandexPlatform(object): """Test service call say.""" calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url = "https://tts.voicetech.yandex.net/generate?format=mp3" \ - "&speaker=zahar&key=1234567xx&text=HomeAssistant&lang=ru-RU" + url_param = { + 'text': 'HomeAssistant', + 'lang': 'ru-RU', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 1 + } aioclient_mock.get( - url, status=200, content=b'test') + self._base_url, status=200, content=b'test', params=url_param) config = { tts.DOMAIN: { @@ -137,10 +158,18 @@ class TestTTSYandexPlatform(object): """Test service call say.""" calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url = "https://tts.voicetech.yandex.net/generate?format=mp3" \ - "&speaker=zahar&key=1234567xx&text=HomeAssistant&lang=en-US" + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 1 + } aioclient_mock.get( - url, status=200, exc=asyncio.TimeoutError()) + self._base_url, status=200, + exc=asyncio.TimeoutError(), params=url_param) config = { tts.DOMAIN: { @@ -164,10 +193,17 @@ class TestTTSYandexPlatform(object): """Test service call say.""" calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url = "https://tts.voicetech.yandex.net/generate?format=mp3" \ - "&speaker=zahar&key=1234567xx&text=HomeAssistant&lang=en-US" + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 1 + } aioclient_mock.get( - url, status=403, content=b'test') + self._base_url, status=403, content=b'test', params=url_param) config = { tts.DOMAIN: { @@ -190,10 +226,17 @@ class TestTTSYandexPlatform(object): """Test service call say.""" calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url = "https://tts.voicetech.yandex.net/generate?format=mp3" \ - "&speaker=alyss&key=1234567xx&text=HomeAssistant&lang=en-US" + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'alyss', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 1 + } aioclient_mock.get( - url, status=200, content=b'test') + self._base_url, status=200, content=b'test', params=url_param) config = { tts.DOMAIN: { @@ -213,3 +256,108 @@ class TestTTSYandexPlatform(object): assert len(aioclient_mock.mock_calls) == 1 assert len(calls) == 1 + + def test_service_say_specifed_emotion(self, aioclient_mock): + """Test service call say.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'evil', + 'speed': 1 + } + aioclient_mock.get( + self._base_url, status=200, content=b'test', params=url_param) + + config = { + tts.DOMAIN: { + 'platform': 'yandextts', + 'api_key': '1234567xx', + 'emotion': 'evil' + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'yandextts_say', { + tts.ATTR_MESSAGE: "HomeAssistant", + }) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + def test_service_say_specified_low_speed(self, aioclient_mock): + """Test service call say.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': '0.1' + } + aioclient_mock.get( + self._base_url, status=200, content=b'test', params=url_param) + + config = { + tts.DOMAIN: { + 'platform': 'yandextts', + 'api_key': '1234567xx', + 'speed': 0.1 + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'yandextts_say', { + tts.ATTR_MESSAGE: "HomeAssistant", + }) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + def test_service_say_specified_speed(self, aioclient_mock): + """Test service call say.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'neutral', + 'speed': 2 + } + aioclient_mock.get( + self._base_url, status=200, content=b'test', params=url_param) + + config = { + tts.DOMAIN: { + 'platform': 'yandextts', + 'api_key': '1234567xx', + 'speed': 2 + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'yandextts_say', { + tts.ATTR_MESSAGE: "HomeAssistant", + }) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 diff --git a/tests/fixtures/microsoft_face_create_person.json b/tests/fixtures/microsoft_face_create_person.json new file mode 100644 index 00000000000..60e7a826c13 --- /dev/null +++ b/tests/fixtures/microsoft_face_create_person.json @@ -0,0 +1,3 @@ +{ + "personId":"25985303-c537-4467-b41d-bdb45cd95ca1" +} diff --git a/tests/fixtures/microsoft_face_detect.json b/tests/fixtures/microsoft_face_detect.json new file mode 100644 index 00000000000..f9d819da239 --- /dev/null +++ b/tests/fixtures/microsoft_face_detect.json @@ -0,0 +1,27 @@ +[ + { + "faceId": "c5c24a82-6845-4031-9d5d-978df9175426", + "faceRectangle": { + "width": 78, + "height": 78, + "left": 394, + "top": 54 + }, + "faceAttributes": { + "age": 71.0, + "gender": "male", + "smile": 0.88, + "facialHair": { + "mustache": 0.8, + "beard": 0.1, + "sideburns": 0.02 + }, + "glasses": "sunglasses", + "headPose": { + "roll": 2.1, + "yaw": 3, + "pitch": 0 + } + } + } +] diff --git a/tests/fixtures/microsoft_face_identify.json b/tests/fixtures/microsoft_face_identify.json new file mode 100644 index 00000000000..5b106de5324 --- /dev/null +++ b/tests/fixtures/microsoft_face_identify.json @@ -0,0 +1,20 @@ +[ + { + "faceId":"c5c24a82-6845-4031-9d5d-978df9175426", + "candidates":[ + { + "personId":"2ae4935b-9659-44c3-977f-61fac20d0538", + "confidence":0.92 + } + ] + }, + { + "faceId":"c5c24a82-6825-4031-9d5d-978df0175426", + "candidates":[ + { + "personId":"25985303-c537-4467-b41d-bdb45cd95ca1", + "confidence":0.32 + } + ] + } +] diff --git a/tests/fixtures/microsoft_face_persongroups.json b/tests/fixtures/microsoft_face_persongroups.json new file mode 100644 index 00000000000..0eb0722a550 --- /dev/null +++ b/tests/fixtures/microsoft_face_persongroups.json @@ -0,0 +1,12 @@ +[ + { + "personGroupId":"test_group1", + "name":"test group1", + "userData":"test" + }, + { + "personGroupId":"test_group2", + "name":"test group2", + "userData":"test" + } +] diff --git a/tests/fixtures/microsoft_face_persons.json b/tests/fixtures/microsoft_face_persons.json new file mode 100644 index 00000000000..05da6816023 --- /dev/null +++ b/tests/fixtures/microsoft_face_persons.json @@ -0,0 +1,21 @@ +[ + { + "personId":"25985303-c537-4467-b41d-bdb45cd95ca1", + "name":"Ryan", + "userData":"User-provided data attached to the person", + "persistedFaceIds":[ + "015839fb-fbd9-4f79-ace9-7675fc2f1dd9", + "fce92aed-d578-4d2e-8114-068f8af4492e", + "b64d5e15-8257-4af2-b20a-5a750f8940e7" + ] + }, + { + "personId":"2ae4935b-9659-44c3-977f-61fac20d0538", + "name":"David", + "userData":"User-provided data attached to the person", + "persistedFaceIds":[ + "30ea1073-cc9e-4652-b1e3-d08fb7b95315", + "fbd2a038-dbff-452c-8e79-2ee81b1aa84e" + ] + } +] diff --git a/tests/fixtures/wsdot.json b/tests/fixtures/wsdot.json new file mode 100644 index 00000000000..de5dc80579f --- /dev/null +++ b/tests/fixtures/wsdot.json @@ -0,0 +1,20 @@ +{"Description": "Downtown Seattle to Downtown Bellevue via I-90", + "TimeUpdated": "/Date(1485040200000-0800)/", + "Distance": 10.6, + "EndPoint": {"Direction": "N", + "Description": "I-405 @ NE 8th St in Bellevue", + "Longitude": -122.18797, + "MilePost": 13.6, + "Latitude": 47.61361, + "RoadName": "I-405"}, + "StartPoint": {"Direction": "S", + "Description": "I-5 @ University St in Seattle", + "Longitude": -122.331759, + "MilePost": 165.83, + "Latitude": 47.609294, + "RoadName": "I-5"}, + "CurrentTime": 11, + "TravelTimeID": 96, + "Name": "Seattle-Bellevue via I-90 (EB AM)", + "AverageTime": 11} + diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 252f7f60c95..7255447cd49 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -165,6 +165,25 @@ def test_entity_ids(): ] +def test_ensure_list_csv(): + """Test ensure_list_csv.""" + schema = vol.Schema(cv.ensure_list_csv) + + options = ( + None, + 12, + [], + ['string'], + 'string1,string2' + ) + for value in options: + schema(value) + + assert schema('string1, string2 ') == [ + 'string1', 'string2' + ] + + def test_event_schema(): """Test event_schema validation.""" options = ( @@ -429,6 +448,15 @@ def test_has_at_least_one_key(): schema(value) +def test_ordered_dict_only_dict(): + """Test ordered_dict validator.""" + schema = vol.Schema(cv.ordered_dict(cv.match_all, cv.match_all)) + + for value in (None, [], 100, 'hello'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + def test_ordered_dict_order(): """Test ordered_dict validator.""" schema = vol.Schema(cv.ordered_dict(int, cv.string)) diff --git a/tests/helpers/test_customize.py b/tests/helpers/test_customize.py new file mode 100644 index 00000000000..0fb1b6ab14c --- /dev/null +++ b/tests/helpers/test_customize.py @@ -0,0 +1,119 @@ +"""Test the customize helper.""" +import homeassistant.helpers.customize as customize +from voluptuous import MultipleInvalid +import pytest + + +class MockHass(object): + """Mock object for HassAssistant.""" + + data = {} + + +class TestHelpersCustomize(object): + """Test homeassistant.helpers.customize module.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.entity_id = 'test.test' + self.hass = MockHass() + + def _get_overrides(self, overrides): + test_domain = 'test.domain' + customize.set_customize(self.hass, test_domain, overrides) + return customize.get_overrides(self.hass, test_domain, self.entity_id) + + def test_override_single_value(self): + """Test entity customization through configuration.""" + result = self._get_overrides([ + {'entity_id': [self.entity_id], 'key': 'value'}]) + + assert result == {'key': 'value'} + + def test_override_multiple_values(self): + """Test entity customization through configuration.""" + result = self._get_overrides([ + {'entity_id': [self.entity_id], 'key1': 'value1'}, + {'entity_id': [self.entity_id], 'key2': 'value2'}]) + + assert result == {'key1': 'value1', 'key2': 'value2'} + + def test_override_same_value(self): + """Test entity customization through configuration.""" + result = self._get_overrides([ + {'entity_id': [self.entity_id], 'key': 'value1'}, + {'entity_id': [self.entity_id], 'key': 'value2'}]) + + assert result == {'key': 'value2'} + + def test_override_by_domain(self): + """Test entity customization through configuration.""" + result = self._get_overrides([ + {'entity_id': ['test'], 'key': 'value'}]) + + assert result == {'key': 'value'} + + def test_override_by_glob(self): + """Test entity customization through configuration.""" + result = self._get_overrides([ + {'entity_id': ['test.?e*'], 'key': 'value'}]) + + assert result == {'key': 'value'} + + def test_override_exact_over_glob_over_domain(self): + """Test entity customization through configuration.""" + result = self._get_overrides([ + {'entity_id': ['test.test'], 'key1': 'valueExact'}, + {'entity_id': ['test.tes?'], + 'key1': 'valueGlob', + 'key2': 'valueGlob'}, + {'entity_id': ['test'], + 'key1': 'valueDomain', + 'key2': 'valueDomain', + 'key3': 'valueDomain'}]) + + assert result == { + 'key1': 'valueExact', + 'key2': 'valueGlob', + 'key3': 'valueDomain'} + + def test_override_deep_dict(self): + """Test we can deep-overwrite a dict.""" + result = self._get_overrides( + [{'entity_id': [self.entity_id], + 'test': {'key1': 'value1', 'key2': 'value2'}}, + {'entity_id': [self.entity_id], + 'test': {'key3': 'value3', 'key2': 'value22'}}]) + assert result['test'] == { + 'key1': 'value1', + 'key2': 'value22', + 'key3': 'value3'} + + def test_schema_bad_schema(self): + """Test bad customize schemas.""" + for value in ( + {'test.test': 10}, + {'test.test': ['hello']}, + {'entity_id': {'a': 'b'}}, + {'entity_id': 10}, + [{'test.test': 'value'}], + ): + with pytest.raises( + MultipleInvalid, + message="{} should have raised MultipleInvalid".format( + value)): + customize.CUSTOMIZE_SCHEMA(value) + + def test_get_customize_schema_allow_extra(self): + """Test schema with ALLOW_EXTRA.""" + for value in ( + {'test.test': {'hidden': True}}, + {'test.test': {'key': ['value1', 'value2']}}, + [{'entity_id': 'id1', 'key': 'value'}], + ): + customize.CUSTOMIZE_SCHEMA(value) + + def test_get_customize_schema_csv(self): + """Test schema with comma separated entity IDs.""" + assert [{'entity_id': ['id1', 'id2', 'id3']}] == \ + customize.CUSTOMIZE_SCHEMA([{'entity_id': 'id1,ID2 , id3'}]) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index b2db8277085..061c206c116 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock import pytest import homeassistant.helpers.entity as entity +from homeassistant.helpers.customize import set_customize from homeassistant.const import ATTR_HIDDEN from tests.common import get_test_home_assistant @@ -78,7 +79,6 @@ class TestHelpersEntity(object): def teardown_method(self, method): """Stop everything that was started.""" - entity.set_customize({}) self.hass.stop() def test_default_hidden_not_in_attributes(self): @@ -88,7 +88,10 @@ class TestHelpersEntity(object): def test_overwriting_hidden_property_to_true(self): """Test we can overwrite hidden property to True.""" - entity.set_customize({self.entity.entity_id: {ATTR_HIDDEN: True}}) + set_customize( + self.hass, + entity.CORE_DOMAIN, + [{'entity_id': [self.entity.entity_id], ATTR_HIDDEN: True}]) self.entity.update_ha_state() state = self.hass.states.get(self.entity.entity_id) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index b4994c5f136..1d0bbbd8dfd 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -180,3 +180,24 @@ class TestCheckConfig(unittest.TestCase): 'secrets': {'http_pw': 'abc123'}, 'yaml_files': ['.../secret.yaml', '.../secrets.yaml'] }, res) + + def test_package_invalid(self): \ + # pylint: disable=no-self-use,invalid-name + """Test a valid platform setup.""" + files = { + 'bad.yaml': BASE_CONFIG + (' packages:\n' + ' p1:\n' + ' group: ["a"]'), + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('bad.yaml')) + change_yaml_files(res) + + err = res['except'].pop('homeassistant.packages.p1') + assert res['except'] == {} + assert err == {'group': ['a']} + assert res['yaml_files'] == ['.../bad.yaml'] + + assert res['components'] == {} + assert res['secret_cache'] == {} + assert res['secrets'] == {} diff --git a/tests/test_config.py b/tests/test_config.py index 455ebe33c61..1197a0130d8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -170,16 +170,17 @@ class TestConfig(unittest.TestCase): os.path.join(CONFIG_DIR, 'non_existing_dir/'), False)) self.assertTrue(mock_print.called) + # pylint: disable=no-self-use def test_core_config_schema(self): """Test core config schema.""" for value in ( - {CONF_UNIT_SYSTEM: 'K'}, - {'time_zone': 'non-exist'}, - {'latitude': '91'}, - {'longitude': -181}, - {'customize': 'bla'}, - {'customize': {'invalid_entity_id': {}}}, - {'customize': {'light.sensor': 100}}, + {CONF_UNIT_SYSTEM: 'K'}, + {'time_zone': 'non-exist'}, + {'latitude': '91'}, + {'longitude': -181}, + {'customize': 'bla'}, + {'customize': {'light.sensor': 100}}, + {'customize': {'entity_id': []}}, ): with pytest.raises(MultipleInvalid): config_util.CORE_CONFIG_SCHEMA(value) @@ -196,13 +197,7 @@ class TestConfig(unittest.TestCase): }, }) - def test_entity_customization(self): - """Test entity customization through configuration.""" - config = {CONF_LATITUDE: 50, - CONF_LONGITUDE: 50, - CONF_NAME: 'Test', - CONF_CUSTOMIZE: {'test.test': {'hidden': True}}} - + def _compute_state(self, config): run_coroutine_threadsafe( config_util.async_process_ha_core_config(self.hass, config), self.hass.loop).result() @@ -214,7 +209,28 @@ class TestConfig(unittest.TestCase): self.hass.block_till_done() - state = self.hass.states.get('test.test') + return self.hass.states.get('test.test') + + def test_entity_customization_false(self): + """Test entity customization through configuration.""" + config = {CONF_LATITUDE: 50, + CONF_LONGITUDE: 50, + CONF_NAME: 'Test', + CONF_CUSTOMIZE: { + 'test.test': {'hidden': False}}} + + state = self._compute_state(config) + + assert 'hidden' not in state.attributes + + def test_entity_customization(self): + """Test entity customization through configuration.""" + config = {CONF_LATITUDE: 50, + CONF_LONGITUDE: 50, + CONF_NAME: 'Test', + CONF_CUSTOMIZE: {'test.test': {'hidden': True}}} + + state = self._compute_state(config) assert state.attributes['hidden'] @@ -229,6 +245,7 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() with mock.patch('homeassistant.config.open', mock_open, create=True): opened_file = mock_open.return_value + # pylint: disable=no-member opened_file.readline.return_value = ha_version self.hass.config.path = mock.Mock() @@ -258,6 +275,7 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() with mock.patch('homeassistant.config.open', mock_open, create=True): opened_file = mock_open.return_value + # pylint: disable=no-member opened_file.readline.return_value = ha_version self.hass.config.path = mock.Mock() @@ -436,7 +454,6 @@ def test_merge_type_mismatch(merge_log_err): def test_merge_once_only(merge_log_err): """Test if we have a merge for a comp that may occur only once.""" packages = { - 'pack_1': {'homeassistant': {}}, 'pack_2': { 'mqtt': {}, 'api': {}, # No config schema @@ -447,7 +464,7 @@ def test_merge_once_only(merge_log_err): 'mqtt': {}, 'api': {} } config_util.merge_packages_config(config, packages) - assert merge_log_err.call_count == 3 + assert merge_log_err.call_count == 2 assert len(config) == 3 @@ -482,3 +499,29 @@ def test_merge_duplicate_keys(merge_log_err): assert merge_log_err.call_count == 1 assert len(config) == 2 assert len(config['input_select']) == 1 + + +@pytest.mark.asyncio +def test_merge_customize(hass): + """Test loading core config onto hass object.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + 'customize': {'a.a': {'friendly_name': 'A'}}, + 'packages': {'pkg1': {'homeassistant': {'customize': { + 'b.b': {'friendly_name': 'BB'}}}}}, + } + yield from config_util.async_process_ha_core_config(hass, core_config) + + entity = Entity() + entity.entity_id = 'b.b' + entity.hass = hass + yield from entity.async_update_ha_state() + + state = hass.states.get('b.b') + assert state is not None + assert state.attributes['friendly_name'] == 'BB' diff --git a/tests/test_core.py b/tests/test_core.py index 4049d10d32d..14276584ae2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -724,6 +724,7 @@ class TestConfig(unittest.TestCase): expected = { 'latitude': None, 'longitude': None, + 'elevation': None, CONF_UNIT_SYSTEM: METRIC_SYSTEM.as_dict(), 'location_name': None, 'time_zone': 'UTC', diff --git a/tests/test_remote.py b/tests/test_remote.py index fa2a53a96cb..d20acc88857 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -23,7 +23,7 @@ HTTP_BASE_URL = 'http://127.0.0.1:{}'.format(MASTER_PORT) HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD} -broken_api = remote.API('127.0.0.1', "bladiebla") +broken_api = remote.API('127.0.0.1', "bladybla", port=get_test_instance_port()) hass, slave, master_api = None, None, None