diff --git a/homeassistant-0.88.0.dev0/LICENSE.md b/homeassistant-0.88.0.dev0/LICENSE.md new file mode 100644 index 00000000000000..261eeb9e9f8b2b --- /dev/null +++ b/homeassistant-0.88.0.dev0/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + 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/homeassistant-0.88.0.dev0/MANIFEST.in b/homeassistant-0.88.0.dev0/MANIFEST.in new file mode 100644 index 00000000000000..490b550e705e52 --- /dev/null +++ b/homeassistant-0.88.0.dev0/MANIFEST.in @@ -0,0 +1,4 @@ +include README.rst +include LICENSE.md +graft homeassistant +recursive-exclude * *.py[co] diff --git a/homeassistant-0.88.0.dev0/README.rst b/homeassistant-0.88.0.dev0/README.rst new file mode 100644 index 00000000000000..f231d6c5514c94 --- /dev/null +++ b/homeassistant-0.88.0.dev0/README.rst @@ -0,0 +1,39 @@ +Home Assistant |Build Status| |Coverage Status| |Chat Status| +================================================================================= + +Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control. + +To get started: + +.. code:: bash + + python3 -m pip install homeassistant + hass --open-ui + +Check out `home-assistant.io `__ for `a +demo `__, `installation instructions `__, +`tutorials `__ and `documentation `__. + +|screenshot-states| + +Featured integrations +--------------------- + +|screenshot-components| + +The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture `__ and the `section on creating your own +components `__. + +If you run into issues while using Home Assistant or during development +of a component, check the `Home Assistant help 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 +.. |Coverage Status| image:: https://img.shields.io/coveralls/home-assistant/home-assistant.svg + :target: https://coveralls.io/r/home-assistant/home-assistant?branch=master +.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg + :target: https://discord.gg/c5DvZ4e +.. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png + :target: https://home-assistant.io/demo/ +.. |screenshot-components| image:: https://raw.github.com/home-assistant/home-assistant/dev/docs/screenshot-components.png + :target: https://home-assistant.io/components/ diff --git a/homeassistant-0.88.0.dev0/homeassistant/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/__init__.py new file mode 100644 index 00000000000000..32da6ab0afbb95 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/__init__.py @@ -0,0 +1 @@ +"""Init file for Home Assistant.""" diff --git a/homeassistant-0.88.0.dev0/homeassistant/__main__.py b/homeassistant-0.88.0.dev0/homeassistant/__main__.py new file mode 100644 index 00000000000000..a424716f0aa1b6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/__main__.py @@ -0,0 +1,397 @@ +"""Start Home Assistant.""" +from __future__ import print_function + +import argparse +import os +import platform +import subprocess +import sys +import threading +from typing import List, Dict, Any # noqa pylint: disable=unused-import + + +from homeassistant import monkey_patch +from homeassistant.const import ( + __version__, + EVENT_HOMEASSISTANT_START, + REQUIRED_PYTHON_VER, + RESTART_EXIT_CODE, +) + + +def set_loop() -> None: + """Attempt to use uvloop.""" + import asyncio + from asyncio.events import BaseDefaultEventLoopPolicy + + policy = None + + if sys.platform == 'win32': + if hasattr(asyncio, 'WindowsProactorEventLoopPolicy'): + # pylint: disable=no-member + policy = asyncio.WindowsProactorEventLoopPolicy() + else: + class ProactorPolicy(BaseDefaultEventLoopPolicy): + """Event loop policy to create proactor loops.""" + + _loop_factory = asyncio.ProactorEventLoop + + policy = ProactorPolicy() + else: + try: + import uvloop + except ImportError: + pass + else: + policy = uvloop.EventLoopPolicy() + + if policy is not None: + asyncio.set_event_loop_policy(policy) + + +def validate_python() -> None: + """Validate that the right Python version is running.""" + if sys.version_info[:3] < REQUIRED_PYTHON_VER: + print("Home Assistant requires at least Python {}.{}.{}".format( + *REQUIRED_PYTHON_VER)) + sys.exit(1) + + +def ensure_config_path(config_dir: str) -> None: + """Validate the configuration directory.""" + import homeassistant.config as config_util + lib_dir = os.path.join(config_dir, 'deps') + + # Test if configuration directory exists + if not os.path.isdir(config_dir): + if config_dir != config_util.get_default_config_dir(): + print(('Fatal Error: Specified configuration directory does ' + 'not exist {} ').format(config_dir)) + sys.exit(1) + + try: + os.mkdir(config_dir) + except OSError: + print(('Fatal Error: Unable to create default configuration ' + 'directory {} ').format(config_dir)) + sys.exit(1) + + # Test if library directory exists + if not os.path.isdir(lib_dir): + try: + os.mkdir(lib_dir) + except OSError: + print(('Fatal Error: Unable to create library ' + 'directory {} ').format(lib_dir)) + sys.exit(1) + + +def ensure_config_file(config_dir: str) -> str: + """Ensure configuration file exists.""" + import homeassistant.config as config_util + config_path = config_util.ensure_config_exists(config_dir) + + if config_path is None: + print('Error getting configuration path') + sys.exit(1) + + return config_path + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + import homeassistant.config as config_util + parser = argparse.ArgumentParser( + description="Home Assistant: Observe, Control, Automate.") + parser.add_argument('--version', action='version', version=__version__) + parser.add_argument( + '-c', '--config', + metavar='path_to_config_dir', + default=config_util.get_default_config_dir(), + help="Directory that contains the Home Assistant configuration") + parser.add_argument( + '--demo-mode', + action='store_true', + help='Start Home Assistant in demo mode') + parser.add_argument( + '--debug', + action='store_true', + help='Start Home Assistant in debug mode') + parser.add_argument( + '--open-ui', + action='store_true', + help='Open the webinterface in a browser') + parser.add_argument( + '--skip-pip', + action='store_true', + help='Skips pip install of required packages on startup') + parser.add_argument( + '-v', '--verbose', + action='store_true', + help="Enable verbose logging to file.") + parser.add_argument( + '--pid-file', + metavar='path_to_pid_file', + default=None, + help='Path to PID file useful for running as daemon') + parser.add_argument( + '--log-rotate-days', + type=int, + default=None, + help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + default=None, + help='Log file to write to. If not set, CONFIG/home-assistant.log ' + 'is used') + parser.add_argument( + '--log-no-color', + action='store_true', + help="Disable color logs") + parser.add_argument( + '--runner', + action='store_true', + help='On restart exit with code {}'.format(RESTART_EXIT_CODE)) + parser.add_argument( + '--script', + nargs=argparse.REMAINDER, + help='Run one of the embedded scripts') + if os.name == "posix": + parser.add_argument( + '--daemon', + action='store_true', + help='Run Home Assistant as daemon') + + arguments = parser.parse_args() + if os.name != "posix" or arguments.debug or arguments.runner: + setattr(arguments, 'daemon', False) + + return arguments + + +def daemonize() -> None: + """Move current process to daemon process.""" + # Create first fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + # Decouple fork + os.setsid() + + # Create second fork + pid = os.fork() + if pid > 0: + sys.exit(0) + + # redirect standard file descriptors to devnull + infd = open(os.devnull, 'r') + outfd = open(os.devnull, 'a+') + sys.stdout.flush() + sys.stderr.flush() + os.dup2(infd.fileno(), sys.stdin.fileno()) + os.dup2(outfd.fileno(), sys.stdout.fileno()) + os.dup2(outfd.fileno(), sys.stderr.fileno()) + + +def check_pid(pid_file: str) -> None: + """Check that Home Assistant is not already running.""" + # Check pid file + try: + with open(pid_file, 'r') as file: + pid = int(file.readline()) + except IOError: + # PID File does not exist + return + + # If we just restarted, we just found our own pidfile. + if pid == os.getpid(): + return + + try: + os.kill(pid, 0) + except OSError: + # PID does not exist + return + print('Fatal Error: HomeAssistant is already running.') + sys.exit(1) + + +def write_pid(pid_file: str) -> None: + """Create a PID File.""" + pid = os.getpid() + try: + with open(pid_file, 'w') as file: + file.write(str(pid)) + except IOError: + print('Fatal Error: Unable to write pid file {}'.format(pid_file)) + sys.exit(1) + + +def closefds_osx(min_fd: int, max_fd: int) -> None: + """Make sure file descriptors get closed when we restart. + + We cannot call close on guarded fds, and we cannot easily test which fds + are guarded. But we can set the close-on-exec flag on everything we want to + get rid of. + """ + from fcntl import fcntl, F_GETFD, F_SETFD, FD_CLOEXEC + + for _fd in range(min_fd, max_fd): + try: + val = fcntl(_fd, F_GETFD) + if not val & FD_CLOEXEC: + fcntl(_fd, F_SETFD, val | FD_CLOEXEC) + except IOError: + pass + + +def cmdline() -> List[str]: + """Collect path and arguments to re-execute the current hass instance.""" + if os.path.basename(sys.argv[0]) == '__main__.py': + modulepath = os.path.dirname(sys.argv[0]) + os.environ['PYTHONPATH'] = os.path.dirname(modulepath) + return [sys.executable] + [arg for arg in sys.argv if + arg != '--daemon'] + + return [arg for arg in sys.argv if arg != '--daemon'] + + +async def setup_and_run_hass(config_dir: str, + args: argparse.Namespace) -> int: + """Set up HASS and run.""" + from homeassistant import bootstrap, core + + hass = core.HomeAssistant() + + if args.demo_mode: + config = { + 'frontend': {}, + 'demo': {} + } # type: Dict[str, Any] + bootstrap.async_from_config_dict( + config, hass, config_dir=config_dir, verbose=args.verbose, + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + log_file=args.log_file, log_no_color=args.log_no_color) + else: + config_file = ensure_config_file(config_dir) + print('Config directory:', config_dir) + await bootstrap.async_from_config_file( + config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip, + log_rotate_days=args.log_rotate_days, log_file=args.log_file, + log_no_color=args.log_no_color) + + if args.open_ui: + # Imported here to avoid importing asyncio before monkey patch + from homeassistant.util.async_ import run_callback_threadsafe + + def open_browser(_: Any) -> None: + """Open the web interface in a browser.""" + if hass.config.api is not None: + import webbrowser + webbrowser.open(hass.config.api.base_url) + + run_callback_threadsafe( + hass.loop, + hass.bus.async_listen_once, + EVENT_HOMEASSISTANT_START, open_browser + ) + + return await hass.async_run() + + +def try_to_restart() -> None: + """Attempt to clean up state and start a new Home Assistant instance.""" + # Things should be mostly shut down already at this point, now just try + # to clean up things that may have been left behind. + sys.stderr.write('Home Assistant attempting to restart.\n') + + # Count remaining threads, ideally there should only be one non-daemonized + # thread left (which is us). Nothing we really do with it, but it might be + # useful when debugging shutdown/restart issues. + try: + nthreads = sum(thread.is_alive() and not thread.daemon + for thread in threading.enumerate()) + if nthreads > 1: + sys.stderr.write( + "Found {} non-daemonic threads.\n".format(nthreads)) + + # Somehow we sometimes seem to trigger an assertion in the python threading + # module. It seems we find threads that have no associated OS level thread + # which are not marked as stopped at the python level. + except AssertionError: + sys.stderr.write("Failed to count non-daemonic threads.\n") + + # Try to not leave behind open filedescriptors with the emphasis on try. + try: + max_fd = os.sysconf("SC_OPEN_MAX") + except ValueError: + max_fd = 256 + + if platform.system() == 'Darwin': + closefds_osx(3, max_fd) + else: + os.closerange(3, max_fd) + + # Now launch into a new instance of Home Assistant. If this fails we + # fall through and exit with error 100 (RESTART_EXIT_CODE) in which case + # systemd will restart us when RestartForceExitStatus=100 is set in the + # systemd.service file. + sys.stderr.write("Restarting Home Assistant\n") + args = cmdline() + os.execv(args[0], args) + + +def main() -> int: + """Start Home Assistant.""" + validate_python() + + monkey_patch_needed = sys.version_info[:3] < (3, 6, 3) + if monkey_patch_needed and os.environ.get('HASS_NO_MONKEY') != '1': + if sys.version_info[:2] >= (3, 6): + monkey_patch.disable_c_asyncio() + monkey_patch.patch_weakref_tasks() + + set_loop() + + # Run a simple daemon runner process on Windows to handle restarts + if os.name == 'nt' and '--runner' not in sys.argv: + nt_args = cmdline() + ['--runner'] + while True: + try: + subprocess.check_call(nt_args) + sys.exit(0) + except KeyboardInterrupt: + sys.exit(0) + except subprocess.CalledProcessError as exc: + if exc.returncode != RESTART_EXIT_CODE: + sys.exit(exc.returncode) + + args = get_arguments() + + if args.script is not None: + from homeassistant import scripts + return scripts.run(args.script) + + config_dir = os.path.join(os.getcwd(), args.config) + ensure_config_path(config_dir) + + # Daemon functions + if args.pid_file: + check_pid(args.pid_file) + if args.daemon: + daemonize() + if args.pid_file: + write_pid(args.pid_file) + + from homeassistant.util.async_ import asyncio_run + exit_code = asyncio_run(setup_and_run_hass(config_dir, args)) + if exit_code == RESTART_EXIT_CODE and not args.runner: + try_to_restart() + + return exit_code # type: ignore # mypy cannot yet infer it + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/auth/__init__.py new file mode 100644 index 00000000000000..3377bb2a6aa0d5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/__init__.py @@ -0,0 +1,460 @@ +"""Provide an authentication layer for Home Assistant.""" +import asyncio +import logging +from collections import OrderedDict +from datetime import timedelta +from typing import Any, Dict, List, Optional, Tuple, cast + +import jwt + +from homeassistant import data_entry_flow +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION +from homeassistant.core import callback, HomeAssistant +from homeassistant.util import dt as dt_util + +from . import auth_store, models +from .const import GROUP_ID_ADMIN +from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule +from .providers import auth_provider_from_config, AuthProvider, LoginFlow + +EVENT_USER_ADDED = 'user_added' +EVENT_USER_REMOVED = 'user_removed' + +_LOGGER = logging.getLogger(__name__) +_MfaModuleDict = Dict[str, MultiFactorAuthModule] +_ProviderKey = Tuple[str, Optional[str]] +_ProviderDict = Dict[_ProviderKey, AuthProvider] + + +async def auth_manager_from_config( + hass: HomeAssistant, + provider_configs: List[Dict[str, Any]], + module_configs: List[Dict[str, Any]]) -> 'AuthManager': + """Initialize an auth manager from config. + + CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or + mfa modules exist in configs. + """ + store = auth_store.AuthStore(hass) + if provider_configs: + providers = await asyncio.gather( + *[auth_provider_from_config(hass, store, config) + for config in provider_configs]) + else: + providers = () + # So returned auth providers are in same order as config + provider_hash = OrderedDict() # type: _ProviderDict + for provider in providers: + key = (provider.type, provider.id) + provider_hash[key] = provider + + if module_configs: + modules = await asyncio.gather( + *[auth_mfa_module_from_config(hass, config) + for config in module_configs]) + else: + modules = () + # So returned auth modules are in same order as config + module_hash = OrderedDict() # type: _MfaModuleDict + for module in modules: + module_hash[module.id] = module + + manager = AuthManager(hass, store, provider_hash, module_hash) + return manager + + +class AuthManager: + """Manage the authentication for Home Assistant.""" + + def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore, + providers: _ProviderDict, mfa_modules: _MfaModuleDict) \ + -> None: + """Initialize the auth manager.""" + self.hass = hass + self._store = store + self._providers = providers + self._mfa_modules = mfa_modules + self.login_flow = data_entry_flow.FlowManager( + hass, self._async_create_login_flow, + self._async_finish_login_flow) + + @property + def support_legacy(self) -> bool: + """ + Return if legacy_api_password auth providers are registered. + + Should be removed when we removed legacy_api_password auth providers. + """ + for provider_type, _ in self._providers: + if provider_type == 'legacy_api_password': + return True + return False + + @property + def auth_providers(self) -> List[AuthProvider]: + """Return a list of available auth providers.""" + return list(self._providers.values()) + + @property + def auth_mfa_modules(self) -> List[MultiFactorAuthModule]: + """Return a list of available auth modules.""" + return list(self._mfa_modules.values()) + + def get_auth_mfa_module(self, module_id: str) \ + -> Optional[MultiFactorAuthModule]: + """Return an multi-factor auth module, None if not found.""" + return self._mfa_modules.get(module_id) + + async def async_get_users(self) -> List[models.User]: + """Retrieve all users.""" + return await self._store.async_get_users() + + async def async_get_user(self, user_id: str) -> Optional[models.User]: + """Retrieve a user.""" + return await self._store.async_get_user(user_id) + + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all groups.""" + return await self._store.async_get_group(group_id) + + async def async_get_user_by_credentials( + self, credentials: models.Credentials) -> Optional[models.User]: + """Get a user by credential, return None if not found.""" + for user in await self.async_get_users(): + for creds in user.credentials: + if creds.id == credentials.id: + return user + + return None + + async def async_create_system_user( + self, name: str, + group_ids: Optional[List[str]] = None) -> models.User: + """Create a system user.""" + user = await self._store.async_create_user( + name=name, + system_generated=True, + is_active=True, + group_ids=group_ids or [], + ) + + self.hass.bus.async_fire(EVENT_USER_ADDED, { + 'user_id': user.id + }) + + return user + + async def async_create_user(self, name: str) -> models.User: + """Create a user.""" + kwargs = { + 'name': name, + 'is_active': True, + 'group_ids': [GROUP_ID_ADMIN] + } # type: Dict[str, Any] + + if await self._user_should_be_owner(): + kwargs['is_owner'] = True + + user = await self._store.async_create_user(**kwargs) + + self.hass.bus.async_fire(EVENT_USER_ADDED, { + 'user_id': user.id + }) + + return user + + async def async_get_or_create_user(self, credentials: models.Credentials) \ + -> models.User: + """Get or create a user.""" + if not credentials.is_new: + user = await self.async_get_user_by_credentials(credentials) + if user is None: + raise ValueError('Unable to find the user.') + else: + return user + + auth_provider = self._async_get_auth_provider(credentials) + + if auth_provider is None: + raise RuntimeError('Credential with unknown provider encountered') + + info = await auth_provider.async_user_meta_for_credentials( + credentials) + + user = await self._store.async_create_user( + credentials=credentials, + name=info.name, + is_active=info.is_active, + group_ids=[GROUP_ID_ADMIN], + ) + + self.hass.bus.async_fire(EVENT_USER_ADDED, { + 'user_id': user.id + }) + + return user + + async def async_link_user(self, user: models.User, + credentials: models.Credentials) -> None: + """Link credentials to an existing user.""" + await self._store.async_link_user(user, credentials) + + async def async_remove_user(self, user: models.User) -> None: + """Remove a user.""" + tasks = [ + self.async_remove_credentials(credentials) + for credentials in user.credentials + ] + + if tasks: + await asyncio.wait(tasks) + + await self._store.async_remove_user(user) + + self.hass.bus.async_fire(EVENT_USER_REMOVED, { + 'user_id': user.id + }) + + async def async_update_user(self, user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + kwargs = {} # type: Dict[str,Any] + if name is not None: + kwargs['name'] = name + if group_ids is not None: + kwargs['group_ids'] = group_ids + await self._store.async_update_user(user, **kwargs) + + async def async_activate_user(self, user: models.User) -> None: + """Activate a user.""" + await self._store.async_activate_user(user) + + async def async_deactivate_user(self, user: models.User) -> None: + """Deactivate a user.""" + if user.is_owner: + raise ValueError('Unable to deactive the owner') + await self._store.async_deactivate_user(user) + + async def async_remove_credentials( + self, credentials: models.Credentials) -> None: + """Remove credentials.""" + provider = self._async_get_auth_provider(credentials) + + if (provider is not None and + hasattr(provider, 'async_will_remove_credentials')): + # https://github.com/python/mypy/issues/1424 + await provider.async_will_remove_credentials( # type: ignore + credentials) + + await self._store.async_remove_credentials(credentials) + + async def async_enable_user_mfa(self, user: models.User, + mfa_module_id: str, data: Any) -> None: + """Enable a multi-factor auth module for user.""" + if user.system_generated: + raise ValueError('System generated users cannot enable ' + 'multi-factor auth module.') + + module = self.get_auth_mfa_module(mfa_module_id) + if module is None: + raise ValueError('Unable find multi-factor auth module: {}' + .format(mfa_module_id)) + + await module.async_setup_user(user.id, data) + + async def async_disable_user_mfa(self, user: models.User, + mfa_module_id: str) -> None: + """Disable a multi-factor auth module for user.""" + if user.system_generated: + raise ValueError('System generated users cannot disable ' + 'multi-factor auth module.') + + module = self.get_auth_mfa_module(mfa_module_id) + if module is None: + raise ValueError('Unable find multi-factor auth module: {}' + .format(mfa_module_id)) + + await module.async_depose_user(user.id) + + async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: + """List enabled mfa modules for user.""" + modules = OrderedDict() # type: Dict[str, str] + for module_id, module in self._mfa_modules.items(): + if await module.async_is_user_setup(user.id): + modules[module_id] = module.name + return modules + + async def async_create_refresh_token( + self, user: models.User, client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: Optional[str] = None, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ + -> models.RefreshToken: + """Create a new refresh token for a user.""" + if not user.is_active: + raise ValueError('User is not active') + + if user.system_generated and client_id is not None: + raise ValueError( + 'System generated users cannot have refresh tokens connected ' + 'to a client.') + + if token_type is None: + if user.system_generated: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL + + if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): + raise ValueError( + 'System generated users can only have system type ' + 'refresh tokens') + + if token_type == models.TOKEN_TYPE_NORMAL and client_id is None: + raise ValueError('Client is required to generate a refresh token.') + + if (token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and + client_name is None): + raise ValueError('Client_name is required for long-lived access ' + 'token') + + if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: + for token in user.refresh_tokens.values(): + if (token.client_name == client_name and token.token_type == + models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN): + # Each client_name can only have one + # long_lived_access_token type of refresh token + raise ValueError('{} already exists'.format(client_name)) + + return await self._store.async_create_refresh_token( + user, client_id, client_name, client_icon, + token_type, access_token_expiration) + + async def async_get_refresh_token( + self, token_id: str) -> Optional[models.RefreshToken]: + """Get refresh token by id.""" + return await self._store.async_get_refresh_token(token_id) + + async def async_get_refresh_token_by_token( + self, token: str) -> Optional[models.RefreshToken]: + """Get refresh token by token.""" + return await self._store.async_get_refresh_token_by_token(token) + + async def async_remove_refresh_token(self, + refresh_token: models.RefreshToken) \ + -> None: + """Delete a refresh token.""" + await self._store.async_remove_refresh_token(refresh_token) + + @callback + def async_create_access_token(self, + refresh_token: models.RefreshToken, + remote_ip: Optional[str] = None) -> str: + """Create a new access token.""" + self._store.async_log_refresh_token_usage(refresh_token, remote_ip) + + now = dt_util.utcnow() + return jwt.encode({ + 'iss': refresh_token.id, + 'iat': now, + 'exp': now + refresh_token.access_token_expiration, + }, refresh_token.jwt_key, algorithm='HS256').decode() + + async def async_validate_access_token( + self, token: str) -> Optional[models.RefreshToken]: + """Return refresh token if an access token is valid.""" + try: + unverif_claims = jwt.decode(token, verify=False) + except jwt.InvalidTokenError: + return None + + refresh_token = await self.async_get_refresh_token( + cast(str, unverif_claims.get('iss'))) + + if refresh_token is None: + jwt_key = '' + issuer = '' + else: + jwt_key = refresh_token.jwt_key + issuer = refresh_token.id + + try: + jwt.decode( + token, + jwt_key, + leeway=10, + issuer=issuer, + algorithms=['HS256'] + ) + except jwt.InvalidTokenError: + return None + + if refresh_token is None or not refresh_token.user.is_active: + return None + + return refresh_token + + async def _async_create_login_flow( + self, handler: _ProviderKey, *, context: Optional[Dict], + data: Optional[Any]) -> data_entry_flow.FlowHandler: + """Create a login flow.""" + auth_provider = self._providers[handler] + + return await auth_provider.async_login_flow(context) + + async def _async_finish_login_flow( + self, flow: LoginFlow, result: Dict[str, Any]) \ + -> Dict[str, Any]: + """Return a user as result of login flow.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return result + + # we got final result + if isinstance(result['data'], models.User): + result['result'] = result['data'] + return result + + auth_provider = self._providers[result['handler']] + credentials = await auth_provider.async_get_or_create_credentials( + result['data']) + + if flow.context is not None and flow.context.get('credential_only'): + result['result'] = credentials + return result + + # multi-factor module cannot enabled for new credential + # which has not linked to a user yet + if auth_provider.support_mfa and not credentials.is_new: + user = await self.async_get_user_by_credentials(credentials) + if user is not None: + modules = await self.async_get_enabled_mfa(user) + + if modules: + flow.user = user + flow.available_mfa_modules = modules + return await flow.async_step_select_mfa_module() + + result['result'] = await self.async_get_or_create_user(credentials) + return result + + @callback + def _async_get_auth_provider( + self, credentials: models.Credentials) -> Optional[AuthProvider]: + """Get auth provider from a set of credentials.""" + auth_provider_key = (credentials.auth_provider_type, + credentials.auth_provider_id) + return self._providers.get(auth_provider_key) + + async def _user_should_be_owner(self) -> bool: + """Determine if user should be owner. + + A user should be an owner if it is the first non-system user that is + being created. + """ + for user in await self._store.async_get_users(): + if not user.system_generated: + return False + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/auth_store.py b/homeassistant-0.88.0.dev0/homeassistant/auth/auth_store.py new file mode 100644 index 00000000000000..c6078e03f63a78 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/auth_store.py @@ -0,0 +1,553 @@ +"""Storage for auth models.""" +import asyncio +from collections import OrderedDict +from datetime import timedelta +import hmac +from logging import getLogger +from typing import Any, Dict, List, Optional # noqa: F401 + +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import dt as dt_util + +from . import models +from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from .permissions import PermissionLookup, system_policies +from .permissions.types import PolicyType # noqa: F401 + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth' +GROUP_NAME_ADMIN = 'Administrators' +GROUP_NAME_READ_ONLY = 'Read Only' + + +class AuthStore: + """Stores authentication info. + + Any mutation to an object should happen inside the auth store. + + The auth store is lazy. It won't load the data from disk until a method is + called that needs it. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the auth store.""" + self.hass = hass + self._users = None # type: Optional[Dict[str, models.User]] + self._groups = None # type: Optional[Dict[str, models.Group]] + self._perm_lookup = None # type: Optional[PermissionLookup] + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, + private=True) + + async def async_get_groups(self) -> List[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return list(self._groups.values()) + + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return self._groups.get(group_id) + + async def async_get_users(self) -> List[models.User]: + """Retrieve all users.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + return list(self._users.values()) + + async def async_get_user(self, user_id: str) -> Optional[models.User]: + """Retrieve a user by id.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + return self._users.get(user_id) + + async def async_create_user( + self, name: Optional[str], is_owner: Optional[bool] = None, + is_active: Optional[bool] = None, + system_generated: Optional[bool] = None, + credentials: Optional[models.Credentials] = None, + group_ids: Optional[List[str]] = None) -> models.User: + """Create a new user.""" + if self._users is None: + await self._async_load() + + assert self._users is not None + assert self._groups is not None + + groups = [] + for group_id in (group_ids or []): + group = self._groups.get(group_id) + if group is None: + raise ValueError('Invalid group specified {}'.format(group_id)) + groups.append(group) + + kwargs = { + 'name': name, + # Until we get group management, we just put everyone in the + # same group. + 'groups': groups, + 'perm_lookup': self._perm_lookup, + } # type: Dict[str, Any] + + if is_owner is not None: + kwargs['is_owner'] = is_owner + + if is_active is not None: + kwargs['is_active'] = is_active + + if system_generated is not None: + kwargs['system_generated'] = system_generated + + new_user = models.User(**kwargs) + + self._users[new_user.id] = new_user + + if credentials is None: + self._async_schedule_save() + return new_user + + # Saving is done inside the link. + await self.async_link_user(new_user, credentials) + return new_user + + async def async_link_user(self, user: models.User, + credentials: models.Credentials) -> None: + """Add credentials to an existing user.""" + user.credentials.append(credentials) + self._async_schedule_save() + credentials.is_new = False + + async def async_remove_user(self, user: models.User) -> None: + """Remove a user.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + self._users.pop(user.id) + self._async_schedule_save() + + async def async_update_user( + self, user: models.User, name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in ( + ('name', name), + ('is_active', is_active), + ): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + + async def async_activate_user(self, user: models.User) -> None: + """Activate a user.""" + user.is_active = True + self._async_schedule_save() + + async def async_deactivate_user(self, user: models.User) -> None: + """Activate a user.""" + user.is_active = False + self._async_schedule_save() + + async def async_remove_credentials( + self, credentials: models.Credentials) -> None: + """Remove credentials.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + found = None + + for index, cred in enumerate(user.credentials): + if cred is credentials: + found = index + break + + if found is not None: + user.credentials.pop(found) + break + + self._async_schedule_save() + + async def async_create_refresh_token( + self, user: models.User, client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: str = models.TOKEN_TYPE_NORMAL, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ + -> models.RefreshToken: + """Create a new token for a user.""" + kwargs = { + 'user': user, + 'client_id': client_id, + 'token_type': token_type, + 'access_token_expiration': access_token_expiration + } # type: Dict[str, Any] + if client_name: + kwargs['client_name'] = client_name + if client_icon: + kwargs['client_icon'] = client_icon + + refresh_token = models.RefreshToken(**kwargs) + user.refresh_tokens[refresh_token.id] = refresh_token + + self._async_schedule_save() + return refresh_token + + async def async_remove_refresh_token( + self, refresh_token: models.RefreshToken) -> None: + """Remove a refresh token.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + if user.refresh_tokens.pop(refresh_token.id, None): + self._async_schedule_save() + break + + async def async_get_refresh_token( + self, token_id: str) -> Optional[models.RefreshToken]: + """Get refresh token by id.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + refresh_token = user.refresh_tokens.get(token_id) + if refresh_token is not None: + return refresh_token + + return None + + async def async_get_refresh_token_by_token( + self, token: str) -> Optional[models.RefreshToken]: + """Get refresh token by token.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + found = None + + for user in self._users.values(): + for refresh_token in user.refresh_tokens.values(): + if hmac.compare_digest(refresh_token.token, token): + found = refresh_token + + return found + + @callback + def async_log_refresh_token_usage( + self, refresh_token: models.RefreshToken, + remote_ip: Optional[str] = None) -> None: + """Update refresh token last used information.""" + refresh_token.last_used_at = dt_util.utcnow() + refresh_token.last_used_ip = remote_ip + self._async_schedule_save() + + async def _async_load(self) -> None: + """Load the users.""" + [ent_reg, data] = await asyncio.gather( + self.hass.helpers.entity_registry.async_get_registry(), + self._store.async_load(), + ) + + # Make sure that we're not overriding data if 2 loads happened at the + # same time + if self._users is not None: + return + + self._perm_lookup = perm_lookup = PermissionLookup(ent_reg) + + if data is None: + self._set_defaults() + return + + users = OrderedDict() # type: Dict[str, models.User] + groups = OrderedDict() # type: Dict[str, models.Group] + + # Soft-migrating data as we load. We are going to make sure we have a + # read only group and an admin group. There are two states that we can + # migrate from: + # 1. Data from a recent version which has a single group without policy + # 2. Data from old version which has no groups + has_admin_group = False + has_read_only_group = False + group_without_policy = None + + # When creating objects we mention each attribute explicitly. This + # prevents crashing if user rolls back HA version after a new property + # was added. + + for group_dict in data.get('groups', []): + policy = None # type: Optional[PolicyType] + + if group_dict['id'] == GROUP_ID_ADMIN: + has_admin_group = True + + name = GROUP_NAME_ADMIN + policy = system_policies.ADMIN_POLICY + system_generated = True + + elif group_dict['id'] == GROUP_ID_READ_ONLY: + has_read_only_group = True + + name = GROUP_NAME_READ_ONLY + policy = system_policies.READ_ONLY_POLICY + system_generated = True + + else: + name = group_dict['name'] + policy = group_dict.get('policy') + system_generated = False + + # We don't want groups without a policy that are not system groups + # This is part of migrating from state 1 + if policy is None: + group_without_policy = group_dict['id'] + continue + + groups[group_dict['id']] = models.Group( + id=group_dict['id'], + name=name, + policy=policy, + system_generated=system_generated, + ) + + # If there are no groups, add all existing users to the admin group. + # This is part of migrating from state 2 + migrate_users_to_admin_group = (not groups and + group_without_policy is None) + + # If we find a no_policy_group, we need to migrate all users to the + # admin group. We only do this if there are no other groups, as is + # the expected state. If not expected state, not marking people admin. + # This is part of migrating from state 1 + if groups and group_without_policy is not None: + group_without_policy = None + + # This is part of migrating from state 1 and 2 + if not has_admin_group: + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + + # This is part of migrating from state 1 and 2 + if not has_read_only_group: + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group + + for user_dict in data['users']: + # Collect the users group. + user_groups = [] + for group_id in user_dict.get('group_ids', []): + # This is part of migrating from state 1 + if group_id == group_without_policy: + group_id = GROUP_ID_ADMIN + user_groups.append(groups[group_id]) + + # This is part of migrating from state 2 + if (not user_dict['system_generated'] and + migrate_users_to_admin_group): + user_groups.append(groups[GROUP_ID_ADMIN]) + + users[user_dict['id']] = models.User( + name=user_dict['name'], + groups=user_groups, + id=user_dict['id'], + is_owner=user_dict['is_owner'], + is_active=user_dict['is_active'], + system_generated=user_dict['system_generated'], + perm_lookup=perm_lookup, + ) + + for cred_dict in data['credentials']: + users[cred_dict['user_id']].credentials.append(models.Credentials( + id=cred_dict['id'], + is_new=False, + auth_provider_type=cred_dict['auth_provider_type'], + auth_provider_id=cred_dict['auth_provider_id'], + data=cred_dict['data'], + )) + + for rt_dict in data['refresh_tokens']: + # Filter out the old keys that don't have jwt_key (pre-0.76) + if 'jwt_key' not in rt_dict: + continue + + created_at = dt_util.parse_datetime(rt_dict['created_at']) + if created_at is None: + getLogger(__name__).error( + 'Ignoring refresh token %(id)s with invalid created_at ' + '%(created_at)s for user_id %(user_id)s', rt_dict) + continue + + token_type = rt_dict.get('token_type') + if token_type is None: + if rt_dict['client_id'] is None: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL + + # old refresh_token don't have last_used_at (pre-0.78) + last_used_at_str = rt_dict.get('last_used_at') + if last_used_at_str: + last_used_at = dt_util.parse_datetime(last_used_at_str) + else: + last_used_at = None + + token = models.RefreshToken( + id=rt_dict['id'], + user=users[rt_dict['user_id']], + client_id=rt_dict['client_id'], + # use dict.get to keep backward compatibility + client_name=rt_dict.get('client_name'), + client_icon=rt_dict.get('client_icon'), + token_type=token_type, + created_at=created_at, + access_token_expiration=timedelta( + seconds=rt_dict['access_token_expiration']), + token=rt_dict['token'], + jwt_key=rt_dict['jwt_key'], + last_used_at=last_used_at, + last_used_ip=rt_dict.get('last_used_ip'), + ) + users[rt_dict['user_id']].refresh_tokens[token.id] = token + + self._groups = groups + self._users = users + + @callback + def _async_schedule_save(self) -> None: + """Save users.""" + if self._users is None: + return + + self._store.async_delay_save(self._data_to_save, 1) + + @callback + def _data_to_save(self) -> Dict: + """Return the data to store.""" + assert self._users is not None + assert self._groups is not None + + users = [ + { + 'id': user.id, + 'group_ids': [group.id for group in user.groups], + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'name': user.name, + 'system_generated': user.system_generated, + } + for user in self._users.values() + ] + + groups = [] + for group in self._groups.values(): + g_dict = { + 'id': group.id, + # Name not read for sys groups. Kept here for backwards compat + 'name': group.name + } # type: Dict[str, Any] + + if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): + g_dict['policy'] = group.policy + + groups.append(g_dict) + + credentials = [ + { + 'id': credential.id, + 'user_id': user.id, + 'auth_provider_type': credential.auth_provider_type, + 'auth_provider_id': credential.auth_provider_id, + 'data': credential.data, + } + for user in self._users.values() + for credential in user.credentials + ] + + refresh_tokens = [ + { + 'id': refresh_token.id, + 'user_id': user.id, + 'client_id': refresh_token.client_id, + 'client_name': refresh_token.client_name, + 'client_icon': refresh_token.client_icon, + 'token_type': refresh_token.token_type, + 'created_at': refresh_token.created_at.isoformat(), + 'access_token_expiration': + refresh_token.access_token_expiration.total_seconds(), + 'token': refresh_token.token, + 'jwt_key': refresh_token.jwt_key, + 'last_used_at': + refresh_token.last_used_at.isoformat() + if refresh_token.last_used_at else None, + 'last_used_ip': refresh_token.last_used_ip, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + ] + + return { + 'users': users, + 'groups': groups, + 'credentials': credentials, + 'refresh_tokens': refresh_tokens, + } + + def _set_defaults(self) -> None: + """Set default values for auth store.""" + self._users = OrderedDict() # type: Dict[str, models.User] + + groups = OrderedDict() # type: Dict[str, models.Group] + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group + self._groups = groups + + +def _system_admin_group() -> models.Group: + """Create system admin group.""" + return models.Group( + name=GROUP_NAME_ADMIN, + id=GROUP_ID_ADMIN, + policy=system_policies.ADMIN_POLICY, + system_generated=True, + ) + + +def _system_read_only_group() -> models.Group: + """Create read only group.""" + return models.Group( + name=GROUP_NAME_READ_ONLY, + id=GROUP_ID_READ_ONLY, + policy=system_policies.READ_ONLY_POLICY, + system_generated=True, + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/const.py b/homeassistant-0.88.0.dev0/homeassistant/auth/const.py new file mode 100644 index 00000000000000..519669ead85f0f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/const.py @@ -0,0 +1,8 @@ +"""Constants for the auth module.""" +from datetime import timedelta + +ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) +MFA_SESSION_EXPIRATION = timedelta(minutes=5) + +GROUP_ID_ADMIN = 'system-admin' +GROUP_ID_READ_ONLY = 'system-read-only' diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/__init__.py new file mode 100644 index 00000000000000..3313063679dd05 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/__init__.py @@ -0,0 +1,175 @@ +"""Plugable auth modules for Home Assistant.""" +import importlib +import logging +import types +from typing import Any, Dict, Optional + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import requirements, data_entry_flow +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.decorator import Registry + +MULTI_FACTOR_AUTH_MODULES = Registry() + +MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema({ + vol.Required(CONF_TYPE): str, + vol.Optional(CONF_NAME): str, + # Specify ID if you have two mfa auth module for same type. + vol.Optional(CONF_ID): str, +}, extra=vol.ALLOW_EXTRA) + +DATA_REQS = 'mfa_auth_module_reqs_processed' + +_LOGGER = logging.getLogger(__name__) + + +class MultiFactorAuthModule: + """Multi-factor Auth Module of validation function.""" + + DEFAULT_TITLE = 'Unnamed auth module' + MAX_RETRY_TIME = 3 + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize an auth module.""" + self.hass = hass + self.config = config + + @property + def id(self) -> str: # pylint: disable=invalid-name + """Return id of the auth module. + + Default is same as type + """ + return self.config.get(CONF_ID, self.type) + + @property + def type(self) -> str: + """Return type of the module.""" + return self.config[CONF_TYPE] # type: ignore + + @property + def name(self) -> str: + """Return the name of the auth module.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + # Implement by extending class + + @property + def input_schema(self) -> vol.Schema: + """Return a voluptuous schema to define mfa auth module's input.""" + raise NotImplementedError + + async def async_setup_flow(self, user_id: str) -> 'SetupFlow': + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + raise NotImplementedError + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up user for mfa auth module.""" + raise NotImplementedError + + async def async_depose_user(self, user_id: str) -> None: + """Remove user from mfa module.""" + raise NotImplementedError + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + raise NotImplementedError + + async def async_validate( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + raise NotImplementedError + + +class SetupFlow(data_entry_flow.FlowHandler): + """Handler for the setup flow.""" + + def __init__(self, auth_module: MultiFactorAuthModule, + setup_schema: vol.Schema, + user_id: str) -> None: + """Initialize the setup flow.""" + self._auth_module = auth_module + self._setup_schema = setup_schema + self._user_id = user_id + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the first step of setup flow. + + Return self.async_show_form(step_id='init') if user_input is None. + Return self.async_create_entry(data={'result': result}) if finish. + """ + errors = {} # type: Dict[str, str] + + if user_input: + result = await self._auth_module.async_setup_user( + self._user_id, user_input) + return self.async_create_entry( + title=self._auth_module.name, + data={'result': result} + ) + + return self.async_show_form( + step_id='init', + data_schema=self._setup_schema, + errors=errors + ) + + +async def auth_mfa_module_from_config( + hass: HomeAssistant, config: Dict[str, Any]) \ + -> MultiFactorAuthModule: + """Initialize an auth module from a config.""" + module_name = config[CONF_TYPE] + module = await _load_mfa_module(hass, module_name) + + try: + config = module.CONFIG_SCHEMA(config) # type: ignore + except vol.Invalid as err: + _LOGGER.error('Invalid configuration for multi-factor module %s: %s', + module_name, humanize_error(config, err)) + raise + + return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore + + +async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ + -> types.ModuleType: + """Load an mfa auth module.""" + module_path = 'homeassistant.auth.mfa_modules.{}'.format(module_name) + + try: + module = importlib.import_module(module_path) + except ImportError as err: + _LOGGER.error('Unable to load mfa module %s: %s', module_name, err) + raise HomeAssistantError('Unable to load mfa module {}: {}'.format( + module_name, err)) + + if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + return module + + processed = hass.data.get(DATA_REQS) + if processed and module_name in processed: + return module + + processed = hass.data[DATA_REQS] = set() + + # https://github.com/python/mypy/issues/1424 + req_success = await requirements.async_process_requirements( + hass, module_path, module.REQUIREMENTS) # type: ignore + + if not req_success: + raise HomeAssistantError( + 'Unable to process requirements of mfa module {}'.format( + module_name)) + + processed.add(module_name) + return module diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/insecure_example.py new file mode 100644 index 00000000000000..9804cbcf635883 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/insecure_example.py @@ -0,0 +1,89 @@ +"""Example auth module.""" +import logging +from typing import Any, Dict + +import voluptuous as vol + +from homeassistant.core import HomeAssistant + +from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ + vol.Required('data'): [vol.Schema({ + vol.Required('user_id'): str, + vol.Required('pin'): str, + })] +}, extra=vol.PREVENT_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +@MULTI_FACTOR_AUTH_MODULES.register('insecure_example') +class InsecureExampleModule(MultiFactorAuthModule): + """Example auth module validate pin.""" + + DEFAULT_TITLE = 'Insecure Personal Identify Number' + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._data = config['data'] + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({'pin': str}) + + @property + def setup_schema(self) -> vol.Schema: + """Validate async_setup_user input data.""" + return vol.Schema({'pin': str}) + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + return SetupFlow(self, self.setup_schema, user_id) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up user to use mfa module.""" + # data shall has been validate in caller + pin = setup_data['pin'] + + for data in self._data: + if data['user_id'] == user_id: + # already setup, override + data['pin'] = pin + return + + self._data.append({'user_id': user_id, 'pin': pin}) + + async def async_depose_user(self, user_id: str) -> None: + """Remove user from mfa module.""" + found = None + for data in self._data: + if data['user_id'] == user_id: + found = data + break + if found: + self._data.remove(found) + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + for data in self._data: + if data['user_id'] == user_id: + return True + return False + + async def async_validate( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + for data in self._data: + if data['user_id'] == user_id: + # user_input has been validate in caller + if data['pin'] == user_input['pin']: + return True + + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/notify.py b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/notify.py new file mode 100644 index 00000000000000..3c26f8b4bde446 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/notify.py @@ -0,0 +1,329 @@ +"""HMAC-based One-time Password auth module. + +Sending HOTP through notify service +""" +import logging +from collections import OrderedDict +from typing import Any, Dict, Optional, List + +import attr +import voluptuous as vol + +from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceNotFound +from homeassistant.helpers import config_validation as cv + +from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow + +REQUIREMENTS = ['pyotp==2.2.6'] + +CONF_MESSAGE = 'message' + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MESSAGE, + default='{} is your Home Assistant login code'): str +}, extra=vol.PREVENT_EXTRA) + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_module.notify' +STORAGE_USERS = 'users' +STORAGE_USER_ID = 'user_id' + +INPUT_FIELD_CODE = 'code' + +_LOGGER = logging.getLogger(__name__) + + +def _generate_secret() -> str: + """Generate a secret.""" + import pyotp + return str(pyotp.random_base32()) + + +def _generate_random() -> int: + """Generate a 8 digit number.""" + import pyotp + return int(pyotp.random_base32(length=8, chars=list('1234567890'))) + + +def _generate_otp(secret: str, count: int) -> str: + """Generate one time password.""" + import pyotp + return str(pyotp.HOTP(secret).at(count)) + + +def _verify_otp(secret: str, otp: str, count: int) -> bool: + """Verify one time password.""" + import pyotp + return bool(pyotp.HOTP(secret).verify(otp, count)) + + +@attr.s(slots=True) +class NotifySetting: + """Store notify setting for one user.""" + + secret = attr.ib(type=str, factory=_generate_secret) # not persistent + counter = attr.ib(type=int, factory=_generate_random) # not persistent + notify_service = attr.ib(type=Optional[str], default=None) + target = attr.ib(type=Optional[str], default=None) + + +_UsersDict = Dict[str, NotifySetting] + + +@MULTI_FACTOR_AUTH_MODULES.register('notify') +class NotifyAuthModule(MultiFactorAuthModule): + """Auth module send hmac-based one time password by notify service.""" + + DEFAULT_TITLE = 'Notify One-Time Password' + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._user_settings = None # type: Optional[_UsersDict] + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True) + self._include = config.get(CONF_INCLUDE, []) + self._exclude = config.get(CONF_EXCLUDE, []) + self._message_template = config[CONF_MESSAGE] + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({INPUT_FIELD_CODE: str}) + + async def _async_load(self) -> None: + """Load stored data.""" + data = await self._user_store.async_load() + + if data is None: + data = {STORAGE_USERS: {}} + + self._user_settings = { + user_id: NotifySetting(**setting) + for user_id, setting in data.get(STORAGE_USERS, {}).items() + } + + async def _async_save(self) -> None: + """Save data.""" + if self._user_settings is None: + return + + await self._user_store.async_save({STORAGE_USERS: { + user_id: attr.asdict( + notify_setting, filter=attr.filters.exclude( + attr.fields(NotifySetting).secret, + attr.fields(NotifySetting).counter, + )) + for user_id, notify_setting + in self._user_settings.items() + }}) + + @callback + def aync_get_available_notify_services(self) -> List[str]: + """Return list of notify services.""" + unordered_services = set() + + for service in self.hass.services.async_services().get('notify', {}): + if service not in self._exclude: + unordered_services.add(service) + + if self._include: + unordered_services &= set(self._include) + + return sorted(unordered_services) + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + return NotifySetupFlow( + self, self.input_schema, user_id, + self.aync_get_available_notify_services()) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up auth module for user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + self._user_settings[user_id] = NotifySetting( + notify_service=setup_data.get('notify_service'), + target=setup_data.get('target'), + ) + + await self._async_save() + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + if self._user_settings.pop(user_id, None): + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + return user_id in self._user_settings + + async def async_validate( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + return False + + # user_input has been validate in caller + return await self.hass.async_add_executor_job( + _verify_otp, notify_setting.secret, + user_input.get(INPUT_FIELD_CODE, ''), + notify_setting.counter) + + async def async_initialize_login_mfa_step(self, user_id: str) -> None: + """Generate code and notify user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + raise ValueError('Cannot find user_id') + + def generate_secret_and_one_time_password() -> str: + """Generate and send one time password.""" + assert notify_setting + # secret and counter are not persistent + notify_setting.secret = _generate_secret() + notify_setting.counter = _generate_random() + return _generate_otp( + notify_setting.secret, notify_setting.counter) + + code = await self.hass.async_add_executor_job( + generate_secret_and_one_time_password) + + await self.async_notify_user(user_id, code) + + async def async_notify_user(self, user_id: str, code: str) -> None: + """Send code by user's notify service.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + _LOGGER.error('Cannot find user %s', user_id) + return + + await self.async_notify( # type: ignore + code, notify_setting.notify_service, notify_setting.target) + + async def async_notify(self, code: str, notify_service: str, + target: Optional[str] = None) -> None: + """Send code by notify service.""" + data = {'message': self._message_template.format(code)} + if target: + data['target'] = [target] + + await self.hass.services.async_call('notify', notify_service, data) + + +class NotifySetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__(self, auth_module: NotifyAuthModule, + setup_schema: vol.Schema, + user_id: str, + available_notify_services: List[str]) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, setup_schema, user_id) + # to fix typing complaint + self._auth_module = auth_module # type: NotifyAuthModule + self._available_notify_services = available_notify_services + self._secret = None # type: Optional[str] + self._count = None # type: Optional[int] + self._notify_service = None # type: Optional[str] + self._target = None # type: Optional[str] + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Let user select available notify services.""" + errors = {} # type: Dict[str, str] + + hass = self._auth_module.hass + if user_input: + self._notify_service = user_input['notify_service'] + self._target = user_input.get('target') + self._secret = await hass.async_add_executor_job(_generate_secret) + self._count = await hass.async_add_executor_job(_generate_random) + + return await self.async_step_setup() + + if not self._available_notify_services: + return self.async_abort(reason='no_available_service') + + schema = OrderedDict() # type: Dict[str, Any] + schema['notify_service'] = vol.In(self._available_notify_services) + schema['target'] = vol.Optional(str) + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors + ) + + async def async_step_setup( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Verify user can recevie one-time password.""" + errors = {} # type: Dict[str, str] + + hass = self._auth_module.hass + if user_input: + verified = await hass.async_add_executor_job( + _verify_otp, self._secret, user_input['code'], self._count) + if verified: + await self._auth_module.async_setup_user( + self._user_id, { + 'notify_service': self._notify_service, + 'target': self._target, + }) + return self.async_create_entry( + title=self._auth_module.name, + data={} + ) + + errors['base'] = 'invalid_code' + + # generate code every time, no retry logic + assert self._secret and self._count + code = await hass.async_add_executor_job( + _generate_otp, self._secret, self._count) + + assert self._notify_service + try: + await self._auth_module.async_notify( + code, self._notify_service, self._target) + except ServiceNotFound: + return self.async_abort(reason='notify_service_not_exist') + + return self.async_show_form( + step_id='setup', + data_schema=self._setup_schema, + description_placeholders={'notify_service': self._notify_service}, + errors=errors, + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/totp.py b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/totp.py new file mode 100644 index 00000000000000..68f4e1d05968fb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/mfa_modules/totp.py @@ -0,0 +1,214 @@ +"""Time-based One Time Password auth module.""" +import logging +from io import BytesIO +from typing import Any, Dict, Optional, Tuple # noqa: F401 + +import voluptuous as vol + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant + +from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow + +REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1'] + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_module.totp' +STORAGE_USERS = 'users' +STORAGE_USER_ID = 'user_id' +STORAGE_OTA_SECRET = 'ota_secret' + +INPUT_FIELD_CODE = 'code' + +DUMMY_SECRET = 'FPPTH34D4E3MI2HG' + +_LOGGER = logging.getLogger(__name__) + + +def _generate_qr_code(data: str) -> str: + """Generate a base64 PNG string represent QR Code image of data.""" + import pyqrcode + + qr_code = pyqrcode.create(data) + + with BytesIO() as buffer: + qr_code.svg(file=buffer, scale=4) + return '{}'.format( + buffer.getvalue().decode("ascii").replace('\n', '') + .replace('' + ' Tuple[str, str, str]: + """Generate a secret, url, and QR code.""" + import pyotp + + ota_secret = pyotp.random_base32() + url = pyotp.totp.TOTP(ota_secret).provisioning_uri( + username, issuer_name="Home Assistant") + image = _generate_qr_code(url) + return ota_secret, url, image + + +@MULTI_FACTOR_AUTH_MODULES.register('totp') +class TotpAuthModule(MultiFactorAuthModule): + """Auth module validate time-based one time password.""" + + DEFAULT_TITLE = 'Time-based One Time Password' + MAX_RETRY_TIME = 5 + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._users = None # type: Optional[Dict[str, str]] + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True) + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({INPUT_FIELD_CODE: str}) + + async def _async_load(self) -> None: + """Load stored data.""" + data = await self._user_store.async_load() + + if data is None: + data = {STORAGE_USERS: {}} + + self._users = data.get(STORAGE_USERS, {}) + + async def _async_save(self) -> None: + """Save data.""" + await self._user_store.async_save({STORAGE_USERS: self._users}) + + def _add_ota_secret(self, user_id: str, + secret: Optional[str] = None) -> str: + """Create a ota_secret for user.""" + import pyotp + + ota_secret = secret or pyotp.random_base32() # type: str + + self._users[user_id] = ota_secret # type: ignore + return ota_secret + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + user = await self.hass.auth.async_get_user(user_id) # type: ignore + return TotpSetupFlow(self, self.input_schema, user) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> str: + """Set up auth module for user.""" + if self._users is None: + await self._async_load() + + result = await self.hass.async_add_executor_job( + self._add_ota_secret, user_id, setup_data.get('secret')) + + await self._async_save() + return result + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._users is None: + await self._async_load() + + if self._users.pop(user_id, None): # type: ignore + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._users is None: + await self._async_load() + + return user_id in self._users # type: ignore + + async def async_validate( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + if self._users is None: + await self._async_load() + + # user_input has been validate in caller + # set INPUT_FIELD_CODE as vol.Required is not user friendly + return await self.hass.async_add_executor_job( + self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, '')) + + def _validate_2fa(self, user_id: str, code: str) -> bool: + """Validate two factor authentication code.""" + import pyotp + + ota_secret = self._users.get(user_id) # type: ignore + if ota_secret is None: + # even we cannot find user, we still do verify + # to make timing the same as if user was found. + pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1) + return False + + return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1)) + + +class TotpSetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__(self, auth_module: TotpAuthModule, + setup_schema: vol.Schema, + user: User) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, setup_schema, user.id) + # to fix typing complaint + self._auth_module = auth_module # type: TotpAuthModule + self._user = user + self._ota_secret = None # type: Optional[str] + self._url = None # type Optional[str] + self._image = None # type Optional[str] + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the first step of setup flow. + + Return self.async_show_form(step_id='init') if user_input is None. + Return self.async_create_entry(data={'result': result}) if finish. + """ + import pyotp + + errors = {} # type: Dict[str, str] + + if user_input: + verified = await self.hass.async_add_executor_job( # type: ignore + pyotp.TOTP(self._ota_secret).verify, user_input['code']) + if verified: + result = await self._auth_module.async_setup_user( + self._user_id, {'secret': self._ota_secret}) + return self.async_create_entry( + title=self._auth_module.name, + data={'result': result} + ) + + errors['base'] = 'invalid_code' + + else: + hass = self._auth_module.hass + self._ota_secret, self._url, self._image = \ + await hass.async_add_executor_job( # type: ignore + _generate_secret_and_qr_code, str(self._user.name)) + + return self.async_show_form( + step_id='init', + data_schema=self._setup_schema, + description_placeholders={ + 'code': self._ota_secret, + 'url': self._url, + 'qr_code': self._image + }, + errors=errors + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/models.py b/homeassistant-0.88.0.dev0/homeassistant/auth/models.py new file mode 100644 index 00000000000000..588d80047bedd1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/models.py @@ -0,0 +1,128 @@ +"""Auth models.""" +from datetime import datetime, timedelta +from typing import Dict, List, NamedTuple, Optional # noqa: F401 +import uuid + +import attr + +from homeassistant.util import dt as dt_util + +from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN +from .util import generate_secret + +TOKEN_TYPE_NORMAL = 'normal' +TOKEN_TYPE_SYSTEM = 'system' +TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' + + +@attr.s(slots=True) +class Group: + """A group.""" + + name = attr.ib(type=str) # type: Optional[str] + policy = attr.ib(type=perm_mdl.PolicyType) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + system_generated = attr.ib(type=bool, default=False) + + +@attr.s(slots=True) +class User: + """A user.""" + + name = attr.ib(type=str) # type: Optional[str] + perm_lookup = attr.ib( + type=perm_mdl.PermissionLookup, cmp=False, + ) # type: perm_mdl.PermissionLookup + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + is_owner = attr.ib(type=bool, default=False) + is_active = attr.ib(type=bool, default=False) + system_generated = attr.ib(type=bool, default=False) + + groups = attr.ib(type=List, factory=list, cmp=False) # type: List[Group] + + # List of credentials of a user. + credentials = attr.ib( + type=list, factory=list, cmp=False + ) # type: List[Credentials] + + # Tokens associated with a user. + refresh_tokens = attr.ib( + type=dict, factory=dict, cmp=False + ) # type: Dict[str, RefreshToken] + + _permissions = attr.ib( + type=Optional[perm_mdl.PolicyPermissions], + init=False, + cmp=False, + default=None, + ) + + @property + def permissions(self) -> perm_mdl.AbstractPermissions: + """Return permissions object for user.""" + if self.is_owner: + return perm_mdl.OwnerPermissions + + if self._permissions is not None: + return self._permissions + + self._permissions = perm_mdl.PolicyPermissions( + perm_mdl.merge_policies([ + group.policy for group in self.groups]), + self.perm_lookup) + + return self._permissions + + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any( + gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None + + +@attr.s(slots=True) +class RefreshToken: + """RefreshToken for a user to grant new access tokens.""" + + user = attr.ib(type=User) + client_id = attr.ib(type=Optional[str]) + access_token_expiration = attr.ib(type=timedelta) + client_name = attr.ib(type=Optional[str], default=None) + client_icon = attr.ib(type=Optional[str], default=None) + token_type = attr.ib(type=str, default=TOKEN_TYPE_NORMAL, + validator=attr.validators.in_(( + TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM, + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN))) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + created_at = attr.ib(type=datetime, factory=dt_util.utcnow) + token = attr.ib(type=str, factory=lambda: generate_secret(64)) + jwt_key = attr.ib(type=str, factory=lambda: generate_secret(64)) + + last_used_at = attr.ib(type=Optional[datetime], default=None) + last_used_ip = attr.ib(type=Optional[str], default=None) + + +@attr.s(slots=True) +class Credentials: + """Credentials for a user on an auth provider.""" + + auth_provider_type = attr.ib(type=str) + auth_provider_id = attr.ib(type=Optional[str]) + + # Allow the auth provider to store data to represent their auth. + data = attr.ib(type=dict) + + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + is_new = attr.ib(type=bool, default=True) + + +UserMeta = NamedTuple("UserMeta", + [('name', Optional[str]), ('is_active', bool)]) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/__init__.py new file mode 100644 index 00000000000000..63e76dd2496906 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/__init__.py @@ -0,0 +1,73 @@ +"""Permissions for Home Assistant.""" +import logging +from typing import ( # noqa: F401 + cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union, + TYPE_CHECKING) + +import voluptuous as vol + +from .const import CAT_ENTITIES +from .models import PermissionLookup +from .types import PolicyType +from .entities import ENTITY_POLICY_SCHEMA, compile_entities +from .merge import merge_policies # noqa + + +POLICY_SCHEMA = vol.Schema({ + vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA +}) + +_LOGGER = logging.getLogger(__name__) + + +class AbstractPermissions: + """Default permissions class.""" + + _cached_entity_func = None + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + raise NotImplementedError + + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) + + +class PolicyPermissions(AbstractPermissions): + """Handle permissions.""" + + def __init__(self, policy: PolicyType, + perm_lookup: PermissionLookup) -> None: + """Initialize the permission class.""" + self._policy = policy + self._perm_lookup = perm_lookup + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES), + self._perm_lookup) + + def __eq__(self, other: Any) -> bool: + """Equals check.""" + # pylint: disable=protected-access + return (isinstance(other, PolicyPermissions) and + other._policy == self._policy) + + +class _OwnerPermissions(AbstractPermissions): + """Owner permissions.""" + + # pylint: disable=no-self-use + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True + + +OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/const.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/const.py new file mode 100644 index 00000000000000..d390d010deea4c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/const.py @@ -0,0 +1,8 @@ +"""Permission constants.""" +CAT_ENTITIES = 'entities' +CAT_CONFIG_ENTRIES = 'config_entries' +SUBCAT_ALL = 'all' + +POLICY_READ = 'read' +POLICY_CONTROL = 'control' +POLICY_EDIT = 'edit' diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/entities.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/entities.py new file mode 100644 index 00000000000000..0073c9526488e2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/entities.py @@ -0,0 +1,171 @@ +"""Entity permissions.""" +from functools import wraps +from typing import Callable, List, Union # noqa: F401 + +import voluptuous as vol + +from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT +from .models import PermissionLookup +from .types import CategoryType, ValueType + +SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ + vol.Optional(POLICY_READ): True, + vol.Optional(POLICY_CONTROL): True, + vol.Optional(POLICY_EDIT): True, +})) + +ENTITY_DOMAINS = 'domains' +ENTITY_DEVICE_IDS = 'device_ids' +ENTITY_ENTITY_IDS = 'entity_ids' + +ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({ + str: SINGLE_ENTITY_SCHEMA +})) + +ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ + vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA, + vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA, + vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA, +})) + + +def _entity_allowed(schema: ValueType, key: str) \ + -> Union[bool, None]: + """Test if an entity is allowed based on the keys.""" + if schema is None or isinstance(schema, bool): + return schema + assert isinstance(schema, dict) + return schema.get(key) + + +def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \ + -> Callable[[str, str], bool]: + """Compile policy into a function that tests policy.""" + # None, Empty Dict, False + if not policy: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all + + if policy is True: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: + """Approve all.""" + return True + + return apply_policy_allow_all + + assert isinstance(policy, dict) + + domains = policy.get(ENTITY_DOMAINS) + device_ids = policy.get(ENTITY_DEVICE_IDS) + entity_ids = policy.get(ENTITY_ENTITY_IDS) + all_entities = policy.get(SUBCAT_ALL) + + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] + + # The order of these functions matter. The more precise are at the top. + # If a function returns None, they cannot handle it. + # If a function returns a boolean, that's the result to return. + + # Setting entity_ids to a boolean is final decision for permissions + # So return right away. + if isinstance(entity_ids, bool): + def allowed_entity_id_bool(entity_id: str, key: str) -> bool: + """Test if allowed entity_id.""" + return entity_ids # type: ignore + + return allowed_entity_id_bool + + if entity_ids is not None: + def allowed_entity_id_dict(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed entity_id.""" + return _entity_allowed( + entity_ids.get(entity_id), key) # type: ignore + + funcs.append(allowed_entity_id_dict) + + if isinstance(device_ids, bool): + def allowed_device_id_bool(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed device_id.""" + return device_ids + + funcs.append(allowed_device_id_bool) + + elif device_ids is not None: + def allowed_device_id_dict(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed device_id.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + return _entity_allowed( + device_ids.get(entity_entry.device_id), key # type: ignore + ) + + funcs.append(allowed_device_id_dict) + + if isinstance(domains, bool): + def allowed_domain_bool(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed domain.""" + return domains + + funcs.append(allowed_domain_bool) + + elif domains is not None: + def allowed_domain_dict(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed domain.""" + domain = entity_id.split(".", 1)[0] + return _entity_allowed(domains.get(domain), key) # type: ignore + + funcs.append(allowed_domain_dict) + + if isinstance(all_entities, bool): + def allowed_all_entities_bool(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed domain.""" + return all_entities + funcs.append(allowed_all_entities_bool) + + elif all_entities is not None: + def allowed_all_entities_dict(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed domain.""" + return _entity_allowed(all_entities, key) + funcs.append(allowed_all_entities_dict) + + # Can happen if no valid subcategories specified + if not funcs: + def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all_2 + + if len(funcs) == 1: + func = funcs[0] + + @wraps(func) + def apply_policy_func(entity_id: str, key: str) -> bool: + """Apply a single policy function.""" + return func(entity_id, key) is True + + return apply_policy_func + + def apply_policy_funcs(entity_id: str, key: str) -> bool: + """Apply several policy functions.""" + for func in funcs: + result = func(entity_id, key) + if result is not None: + return result + return False + + return apply_policy_funcs diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/merge.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/merge.py new file mode 100644 index 00000000000000..ec6375a0e3d3a6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/merge.py @@ -0,0 +1,65 @@ +"""Merging of policies.""" +from typing import ( # noqa: F401 + cast, Dict, List, Set) + +from .types import PolicyType, CategoryType + + +def merge_policies(policies: List[PolicyType]) -> PolicyType: + """Merge policies.""" + new_policy = {} # type: Dict[str, CategoryType] + seen = set() # type: Set[str] + for policy in policies: + for category in policy: + if category in seen: + continue + seen.add(category) + new_policy[category] = _merge_policies([ + policy.get(category) for policy in policies]) + cast(PolicyType, new_policy) + return new_policy + + +def _merge_policies(sources: List[CategoryType]) -> CategoryType: + """Merge a policy.""" + # When merging policies, the most permissive wins. + # This means we order it like this: + # True > Dict > None + # + # True: allow everything + # Dict: specify more granular permissions + # None: no opinion + # + # If there are multiple sources with a dict as policy, we recursively + # merge each key in the source. + + policy = None # type: CategoryType + seen = set() # type: Set[str] + for source in sources: + if source is None: + continue + + # A source that's True will always win. Shortcut return. + if source is True: + return True + + assert isinstance(source, dict) + + if policy is None: + policy = cast(CategoryType, {}) + + assert isinstance(policy, dict) + + for key in source: + if key in seen: + continue + seen.add(key) + + key_sources = [] + for src in sources: + if isinstance(src, dict): + key_sources.append(src.get(key)) + + policy[key] = _merge_policies(key_sources) + + return policy diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/models.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/models.py new file mode 100644 index 00000000000000..7ad7d5521c5f97 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/models.py @@ -0,0 +1,17 @@ +"""Models for permissions.""" +from typing import TYPE_CHECKING + +import attr + +if TYPE_CHECKING: + # pylint: disable=unused-import + from homeassistant.helpers import ( # noqa + entity_registry as ent_reg, + ) + + +@attr.s(slots=True) +class PermissionLookup: + """Class to hold data for permission lookups.""" + + entity_registry = attr.ib(type='ent_reg.EntityRegistry') diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/system_policies.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/system_policies.py new file mode 100644 index 00000000000000..78da68c0d116db --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/system_policies.py @@ -0,0 +1,14 @@ +"""System policies.""" +from .const import CAT_ENTITIES, SUBCAT_ALL, POLICY_READ + +ADMIN_POLICY = { + CAT_ENTITIES: True, +} + +READ_ONLY_POLICY = { + CAT_ENTITIES: { + SUBCAT_ALL: { + POLICY_READ: True + } + } +} diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/types.py b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/types.py new file mode 100644 index 00000000000000..78d13b9679f74f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/permissions/types.py @@ -0,0 +1,30 @@ +"""Common code for permissions.""" +from typing import Mapping, Union + +# MyPy doesn't support recursion yet. So writing it out as far as we need. + +ValueType = Union[ + # Example: entities.all = { read: true, control: true } + Mapping[str, bool], + bool, + None +] + +SubCategoryType = Union[ + # Example: entities.domains = { light: … } + Mapping[str, ValueType], + bool, + None +] + +CategoryType = Union[ + # Example: entities.domains + Mapping[str, SubCategoryType], + # Example: entities.all + Mapping[str, ValueType], + bool, + None +] + +# Example: { entities: … } +PolicyType = Mapping[str, CategoryType] diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/providers/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/__init__.py new file mode 100644 index 00000000000000..8828782c886e96 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/__init__.py @@ -0,0 +1,272 @@ +"""Auth providers for Home Assistant.""" +import importlib +import logging +import types +from typing import Any, Dict, List, Optional + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import data_entry_flow, requirements +from homeassistant.core import callback, HomeAssistant +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util +from homeassistant.util.decorator import Registry + +from ..auth_store import AuthStore +from ..const import MFA_SESSION_EXPIRATION +from ..models import Credentials, User, UserMeta # noqa: F401 + +_LOGGER = logging.getLogger(__name__) +DATA_REQS = 'auth_prov_reqs_processed' + +AUTH_PROVIDERS = Registry() + +AUTH_PROVIDER_SCHEMA = vol.Schema({ + vol.Required(CONF_TYPE): str, + vol.Optional(CONF_NAME): str, + # Specify ID if you have two auth providers for same type. + vol.Optional(CONF_ID): str, +}, extra=vol.ALLOW_EXTRA) + + +class AuthProvider: + """Provider of user authentication.""" + + DEFAULT_TITLE = 'Unnamed auth provider' + + def __init__(self, hass: HomeAssistant, store: AuthStore, + config: Dict[str, Any]) -> None: + """Initialize an auth provider.""" + self.hass = hass + self.store = store + self.config = config + + @property + def id(self) -> Optional[str]: # pylint: disable=invalid-name + """Return id of the auth provider. + + Optional, can be None. + """ + return self.config.get(CONF_ID) + + @property + def type(self) -> str: + """Return type of the provider.""" + return self.config[CONF_TYPE] # type: ignore + + @property + def name(self) -> str: + """Return the name of the auth provider.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + @property + def support_mfa(self) -> bool: + """Return whether multi-factor auth supported by the auth provider.""" + return True + + async def async_credentials(self) -> List[Credentials]: + """Return all credentials of this provider.""" + users = await self.store.async_get_users() + return [ + credentials + for user in users + for credentials in user.credentials + if (credentials.auth_provider_type == self.type and + credentials.auth_provider_id == self.id) + ] + + @callback + def async_create_credentials(self, data: Dict[str, str]) -> Credentials: + """Create credentials.""" + return Credentials( + auth_provider_type=self.type, + auth_provider_id=self.id, + data=data, + ) + + # Implement by extending class + + async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow': + """Return the data flow for logging in with auth provider. + + Auth provider should extend LoginFlow and return an instance. + """ + raise NotImplementedError + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str]) -> Credentials: + """Get credentials based on the flow result.""" + raise NotImplementedError + + async def async_user_meta_for_credentials( + self, credentials: Credentials) -> UserMeta: + """Return extra user metadata for credentials. + + Will be used to populate info when creating a new user. + """ + raise NotImplementedError + + +async def auth_provider_from_config( + hass: HomeAssistant, store: AuthStore, + config: Dict[str, Any]) -> AuthProvider: + """Initialize an auth provider from a config.""" + provider_name = config[CONF_TYPE] + module = await load_auth_provider_module(hass, provider_name) + + try: + config = module.CONFIG_SCHEMA(config) # type: ignore + except vol.Invalid as err: + _LOGGER.error('Invalid configuration for auth provider %s: %s', + provider_name, humanize_error(config, err)) + raise + + return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore + + +async def load_auth_provider_module( + hass: HomeAssistant, provider: str) -> types.ModuleType: + """Load an auth provider.""" + try: + module = importlib.import_module( + 'homeassistant.auth.providers.{}'.format(provider)) + except ImportError as err: + _LOGGER.error('Unable to load auth provider %s: %s', provider, err) + raise HomeAssistantError('Unable to load auth provider {}: {}'.format( + provider, err)) + + if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + return module + + processed = hass.data.get(DATA_REQS) + + if processed is None: + processed = hass.data[DATA_REQS] = set() + elif provider in processed: + return module + + # https://github.com/python/mypy/issues/1424 + reqs = module.REQUIREMENTS # type: ignore + req_success = await requirements.async_process_requirements( + hass, 'auth provider {}'.format(provider), reqs) + + if not req_success: + raise HomeAssistantError( + 'Unable to process requirements of auth provider {}'.format( + provider)) + + processed.add(provider) + return module + + +class LoginFlow(data_entry_flow.FlowHandler): + """Handler for the login flow.""" + + def __init__(self, auth_provider: AuthProvider) -> None: + """Initialize the login flow.""" + self._auth_provider = auth_provider + self._auth_module_id = None # type: Optional[str] + self._auth_manager = auth_provider.hass.auth # type: ignore + self.available_mfa_modules = {} # type: Dict[str, str] + self.created_at = dt_util.utcnow() + self.invalid_mfa_times = 0 + self.user = None # type: Optional[User] + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the first step of login flow. + + Return self.async_show_form(step_id='init') if user_input is None. + Return await self.async_finish(flow_result) if login init step pass. + """ + raise NotImplementedError + + async def async_step_select_mfa_module( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the step of select mfa module.""" + errors = {} + + if user_input is not None: + auth_module = user_input.get('multi_factor_auth_module') + if auth_module in self.available_mfa_modules: + self._auth_module_id = auth_module + return await self.async_step_mfa() + errors['base'] = 'invalid_auth_module' + + if len(self.available_mfa_modules) == 1: + self._auth_module_id = list(self.available_mfa_modules.keys())[0] + return await self.async_step_mfa() + + return self.async_show_form( + step_id='select_mfa_module', + data_schema=vol.Schema({ + 'multi_factor_auth_module': vol.In(self.available_mfa_modules) + }), + errors=errors, + ) + + async def async_step_mfa( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the step of mfa validation.""" + assert self.user + + errors = {} + + auth_module = self._auth_manager.get_auth_mfa_module( + self._auth_module_id) + if auth_module is None: + # Given an invalid input to async_step_select_mfa_module + # will show invalid_auth_module error + return await self.async_step_select_mfa_module(user_input={}) + + if user_input is None and hasattr(auth_module, + 'async_initialize_login_mfa_step'): + try: + await auth_module.async_initialize_login_mfa_step(self.user.id) + except HomeAssistantError: + _LOGGER.exception('Error initializing MFA step') + return self.async_abort(reason='unknown_error') + + if user_input is not None: + expires = self.created_at + MFA_SESSION_EXPIRATION + if dt_util.utcnow() > expires: + return self.async_abort( + reason='login_expired' + ) + + result = await auth_module.async_validate( + self.user.id, user_input) + if not result: + errors['base'] = 'invalid_code' + self.invalid_mfa_times += 1 + if self.invalid_mfa_times >= auth_module.MAX_RETRY_TIME > 0: + return self.async_abort( + reason='too_many_retry' + ) + + if not errors: + return await self.async_finish(self.user) + + description_placeholders = { + 'mfa_module_name': auth_module.name, + 'mfa_module_id': auth_module.id, + } # type: Dict[str, Optional[str]] + + return self.async_show_form( + step_id='mfa', + data_schema=auth_module.input_schema, + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_finish(self, flow_result: Any) -> Dict: + """Handle the pass of login flow.""" + return self.async_create_entry( + title=self._auth_provider.name, + data=flow_result + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/providers/homeassistant.py b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/homeassistant.py new file mode 100644 index 00000000000000..b22f93f11f1354 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/homeassistant.py @@ -0,0 +1,299 @@ +"""Home Assistant auth provider.""" +import base64 +from collections import OrderedDict +import logging + +from typing import Any, Dict, List, Optional, Set, cast # noqa: F401 + +import bcrypt +import voluptuous as vol + +from homeassistant.const import CONF_ID +from homeassistant.core import callback, HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow + +from ..models import Credentials, UserMeta + + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_provider.homeassistant' + + +def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]: + """Disallow ID in config.""" + if CONF_ID in conf: + raise vol.Invalid( + 'ID is not allowed for the homeassistant auth provider.') + + return conf + + +CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) + + +class InvalidAuth(HomeAssistantError): + """Raised when we encounter invalid authentication.""" + + +class InvalidUser(HomeAssistantError): + """Raised when invalid user is specified. + + Will not be raised when validating authentication. + """ + + +class Data: + """Hold the user data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the user data store.""" + self.hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, + private=True) + self._data = None # type: Optional[Dict[str, Any]] + # Legacy mode will allow usernames to start/end with whitespace + # and will compare usernames case-insensitive. + # Remove in 2020 or when we launch 1.0. + self.is_legacy = False + + @callback + def normalize_username(self, username: str) -> str: + """Normalize a username based on the mode.""" + if self.is_legacy: + return username + + return username.strip().casefold() + + async def async_load(self) -> None: + """Load stored data.""" + data = await self._store.async_load() + + if data is None: + data = { + 'users': [] + } + + seen = set() # type: Set[str] + + for user in data['users']: + username = user['username'] + + # check if we have duplicates + folded = username.casefold() + + if folded in seen: + self.is_legacy = True + + logging.getLogger(__name__).warning( + "Home Assistant auth provider is running in legacy mode " + "because we detected usernames that are case-insensitive" + "equivalent. Please change the username: '%s'.", username) + + break + + seen.add(folded) + + # check if we have unstripped usernames + if username != username.strip(): + self.is_legacy = True + + logging.getLogger(__name__).warning( + "Home Assistant auth provider is running in legacy mode " + "because we detected usernames that start or end in a " + "space. Please change the username: '%s'.", username) + + break + + self._data = data + + @property + def users(self) -> List[Dict[str, str]]: + """Return users.""" + return self._data['users'] # type: ignore + + def validate_login(self, username: str, password: str) -> None: + """Validate a username and password. + + Raises InvalidAuth if auth invalid. + """ + username = self.normalize_username(username) + dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO' + found = None + + # Compare all users to avoid timing attacks. + for user in self.users: + if self.normalize_username(user['username']) == username: + found = user + + if found is None: + # check a hash to make timing the same as if user was found + bcrypt.checkpw(b'foo', + dummy) + raise InvalidAuth + + user_hash = base64.b64decode(found['password']) + + # bcrypt.checkpw is timing-safe + if not bcrypt.checkpw(password.encode(), + user_hash): + raise InvalidAuth + + # pylint: disable=no-self-use + def hash_password(self, password: str, for_storage: bool = False) -> bytes: + """Encode a password.""" + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) \ + # type: bytes + if for_storage: + hashed = base64.b64encode(hashed) + return hashed + + def add_auth(self, username: str, password: str) -> None: + """Add a new authenticated user/pass.""" + username = self.normalize_username(username) + + if any(self.normalize_username(user['username']) == username + for user in self.users): + raise InvalidUser + + self.users.append({ + 'username': username, + 'password': self.hash_password(password, True).decode(), + }) + + @callback + def async_remove_auth(self, username: str) -> None: + """Remove authentication.""" + username = self.normalize_username(username) + + index = None + for i, user in enumerate(self.users): + if self.normalize_username(user['username']) == username: + index = i + break + + if index is None: + raise InvalidUser + + self.users.pop(index) + + def change_password(self, username: str, new_password: str) -> None: + """Update the password. + + Raises InvalidUser if user cannot be found. + """ + username = self.normalize_username(username) + + for user in self.users: + if self.normalize_username(user['username']) == username: + user['password'] = self.hash_password( + new_password, True).decode() + break + else: + raise InvalidUser + + async def async_save(self) -> None: + """Save data.""" + await self._store.async_save(self._data) + + +@AUTH_PROVIDERS.register('homeassistant') +class HassAuthProvider(AuthProvider): + """Auth provider based on a local storage of users in HASS config dir.""" + + DEFAULT_TITLE = 'Home Assistant Local' + + data = None + + async def async_initialize(self) -> None: + """Initialize the auth provider.""" + if self.data is not None: + return + + self.data = Data(self.hass) + await self.data.async_load() + + async def async_login_flow( + self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + return HassLoginFlow(self) + + async def async_validate_login(self, username: str, password: str) -> None: + """Validate a username and password.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + await self.hass.async_add_executor_job( + self.data.validate_login, username, password) + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str]) -> Credentials: + """Get credentials based on the flow result.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + norm_username = self.data.normalize_username + username = norm_username(flow_result['username']) + + for credential in await self.async_credentials(): + if norm_username(credential.data['username']) == username: + return credential + + # Create new credentials. + return self.async_create_credentials({ + 'username': username + }) + + async def async_user_meta_for_credentials( + self, credentials: Credentials) -> UserMeta: + """Get extra info for this credential.""" + return UserMeta(name=credentials.data['username'], is_active=True) + + async def async_will_remove_credentials( + self, credentials: Credentials) -> None: + """When credentials get removed, also remove the auth.""" + if self.data is None: + await self.async_initialize() + assert self.data is not None + + try: + self.data.async_remove_auth(credentials.data['username']) + await self.data.async_save() + except InvalidUser: + # Can happen if somehow we didn't clean up a credential + pass + + +class HassLoginFlow(LoginFlow): + """Handler for the login flow.""" + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + await cast(HassAuthProvider, self._auth_provider)\ + .async_validate_login(user_input['username'], + user_input['password']) + except InvalidAuth: + errors['base'] = 'invalid_auth' + + if not errors: + user_input.pop('password') + return await self.async_finish(user_input) + + schema = OrderedDict() # type: Dict[str, type] + schema['username'] = str + schema['password'] = str + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/providers/insecure_example.py b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/insecure_example.py new file mode 100644 index 00000000000000..72e3dfe140ac0b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/insecure_example.py @@ -0,0 +1,120 @@ +"""Example auth provider.""" +from collections import OrderedDict +import hmac +from typing import Any, Dict, Optional, cast + +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import callback + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from ..models import Credentials, UserMeta + + +USER_SCHEMA = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str, + vol.Optional('name'): str, +}) + + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ + vol.Required('users'): [USER_SCHEMA] +}, extra=vol.PREVENT_EXTRA) + + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +@AUTH_PROVIDERS.register('insecure_example') +class ExampleAuthProvider(AuthProvider): + """Example auth provider based on hardcoded usernames and passwords.""" + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + return ExampleLoginFlow(self) + + @callback + def async_validate_login(self, username: str, password: str) -> None: + """Validate a username and password.""" + user = None + + # Compare all users to avoid timing attacks. + for usr in self.config['users']: + if hmac.compare_digest(username.encode('utf-8'), + usr['username'].encode('utf-8')): + user = usr + + if user is None: + # Do one more compare to make timing the same as if user was found. + hmac.compare_digest(password.encode('utf-8'), + password.encode('utf-8')) + raise InvalidAuthError + + if not hmac.compare_digest(user['password'].encode('utf-8'), + password.encode('utf-8')): + raise InvalidAuthError + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str]) -> Credentials: + """Get credentials based on the flow result.""" + username = flow_result['username'] + + for credential in await self.async_credentials(): + if credential.data['username'] == username: + return credential + + # Create new credentials. + return self.async_create_credentials({ + 'username': username + }) + + async def async_user_meta_for_credentials( + self, credentials: Credentials) -> UserMeta: + """Return extra user metadata for credentials. + + Will be used to populate info when creating a new user. + """ + username = credentials.data['username'] + name = None + + for user in self.config['users']: + if user['username'] == username: + name = user.get('name') + break + + return UserMeta(name=name, is_active=True) + + +class ExampleLoginFlow(LoginFlow): + """Handler for the login flow.""" + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + cast(ExampleAuthProvider, self._auth_provider)\ + .async_validate_login(user_input['username'], + user_input['password']) + except InvalidAuthError: + errors['base'] = 'invalid_auth' + + if not errors: + user_input.pop('password') + return await self.async_finish(user_input) + + schema = OrderedDict() # type: Dict[str, type] + schema['username'] = str + schema['password'] = str + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/providers/legacy_api_password.py b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/legacy_api_password.py new file mode 100644 index 00000000000000..6cdb12b7157fbf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/legacy_api_password.py @@ -0,0 +1,122 @@ +""" +Support Legacy API password auth provider. + +It will be removed when auth system production ready +""" +import hmac +from typing import Any, Dict, Optional, cast, TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from .. import AuthManager +from ..models import Credentials, UserMeta, User + +if TYPE_CHECKING: + from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 + + +USER_SCHEMA = vol.Schema({ + vol.Required('username'): str, +}) + + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +LEGACY_USER_NAME = 'Legacy API password user' + + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +async def async_get_user(hass: HomeAssistant) -> User: + """Return the legacy API password user.""" + auth = cast(AuthManager, hass.auth) # type: ignore + found = None + + for prv in auth.auth_providers: + if prv.type == 'legacy_api_password': + found = prv + break + + if found is None: + raise ValueError('Legacy API password provider not found') + + return await auth.async_get_or_create_user( + await found.async_get_or_create_credentials({}) + ) + + +@AUTH_PROVIDERS.register('legacy_api_password') +class LegacyApiPasswordAuthProvider(AuthProvider): + """Example auth provider based on hardcoded usernames and passwords.""" + + DEFAULT_TITLE = 'Legacy API Password' + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + return LegacyLoginFlow(self) + + @callback + def async_validate_login(self, password: str) -> None: + """Validate a username and password.""" + hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP + + if not hmac.compare_digest(hass_http.api_password.encode('utf-8'), + password.encode('utf-8')): + raise InvalidAuthError + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str]) -> Credentials: + """Return credentials for this login.""" + credentials = await self.async_credentials() + if credentials: + return credentials[0] + + return self.async_create_credentials({}) + + async def async_user_meta_for_credentials( + self, credentials: Credentials) -> UserMeta: + """ + Return info for the user. + + Will be used to populate info when creating a new user. + """ + return UserMeta(name=LEGACY_USER_NAME, is_active=True) + + +class LegacyLoginFlow(LoginFlow): + """Handler for the login flow.""" + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the step of the form.""" + errors = {} + + hass_http = getattr(self.hass, 'http', None) + if hass_http is None or not hass_http.api_password: + return self.async_abort( + reason='no_api_password_set' + ) + + if user_input is not None: + try: + cast(LegacyApiPasswordAuthProvider, self._auth_provider)\ + .async_validate_login(user_input['password']) + except InvalidAuthError: + errors['base'] = 'invalid_auth' + + if not errors: + return await self.async_finish({}) + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({'password': str}), + errors=errors, + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/providers/trusted_networks.py b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/trusted_networks.py new file mode 100644 index 00000000000000..8a7e1d67c6d253 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/providers/trusted_networks.py @@ -0,0 +1,129 @@ +"""Trusted Networks auth provider. + +It shows list of users if access from trusted network. +Abort login flow if not access from trusted network. +""" +from typing import Any, Dict, Optional, cast + +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from ..models import Credentials, UserMeta + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + + +class InvalidAuthError(HomeAssistantError): + """Raised when try to access from untrusted networks.""" + + +class InvalidUserError(HomeAssistantError): + """Raised when try to login as invalid user.""" + + +@AUTH_PROVIDERS.register('trusted_networks') +class TrustedNetworksAuthProvider(AuthProvider): + """Trusted Networks auth provider. + + Allow passwordless access from trusted network. + """ + + DEFAULT_TITLE = 'Trusted Networks' + + @property + def support_mfa(self) -> bool: + """Trusted Networks auth provider does not support MFA.""" + return False + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + assert context is not None + users = await self.store.async_get_users() + available_users = {user.id: user.name + for user in users + if not user.system_generated and user.is_active} + + return TrustedNetworksLoginFlow( + self, cast(str, context.get('ip_address')), available_users) + + async def async_get_or_create_credentials( + self, flow_result: Dict[str, str]) -> Credentials: + """Get credentials based on the flow result.""" + user_id = flow_result['user'] + + users = await self.store.async_get_users() + for user in users: + if (not user.system_generated and + user.is_active and + user.id == user_id): + for credential in await self.async_credentials(): + if credential.data['user_id'] == user_id: + return credential + cred = self.async_create_credentials({'user_id': user_id}) + await self.store.async_link_user(user, cred) + return cred + + # We only allow login as exist user + raise InvalidUserError + + async def async_user_meta_for_credentials( + self, credentials: Credentials) -> UserMeta: + """Return extra user metadata for credentials. + + Trusted network auth provider should never create new user. + """ + raise NotImplementedError + + @callback + def async_validate_access(self, ip_address: str) -> None: + """Make sure the access from trusted networks. + + Raise InvalidAuthError if not. + Raise InvalidAuthError if trusted_networks is not configured. + """ + hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP + + if not hass_http or not hass_http.trusted_networks: + raise InvalidAuthError('trusted_networks is not configured') + + if not any(ip_address in trusted_network for trusted_network + in hass_http.trusted_networks): + raise InvalidAuthError('Not in trusted_networks') + + +class TrustedNetworksLoginFlow(LoginFlow): + """Handler for the login flow.""" + + def __init__(self, auth_provider: TrustedNetworksAuthProvider, + ip_address: str, available_users: Dict[str, Optional[str]]) \ + -> None: + """Initialize the login flow.""" + super().__init__(auth_provider) + self._available_users = available_users + self._ip_address = ip_address + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the step of the form.""" + try: + cast(TrustedNetworksAuthProvider, self._auth_provider)\ + .async_validate_access(self._ip_address) + + except InvalidAuthError: + return self.async_abort( + reason='not_whitelisted' + ) + + if user_input is not None: + return await self.async_finish(user_input) + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({'user': vol.In(self._available_users)}), + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/auth/util.py b/homeassistant-0.88.0.dev0/homeassistant/auth/util.py new file mode 100644 index 00000000000000..402caae4618d0f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/auth/util.py @@ -0,0 +1,13 @@ +"""Auth utils.""" +import binascii +import os + + +def generate_secret(entropy: int = 32) -> str: + """Generate a secret. + + Backport of secrets.token_hex from Python 3.6 + + Event loop friendly. + """ + return binascii.hexlify(os.urandom(entropy)).decode('ascii') diff --git a/homeassistant-0.88.0.dev0/homeassistant/bootstrap.py b/homeassistant-0.88.0.dev0/homeassistant/bootstrap.py new file mode 100644 index 00000000000000..5dd620056094ed --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/bootstrap.py @@ -0,0 +1,351 @@ +"""Provide methods to bootstrap a Home Assistant instance.""" +import logging +import logging.handlers +import os +import sys +from time import time +from collections import OrderedDict +from typing import Any, Optional, Dict + +import voluptuous as vol + +from homeassistant import ( + core, config as conf_util, config_entries, components as core_components) +from homeassistant.components import persistent_notification +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE +from homeassistant.setup import async_setup_component +from homeassistant.util.logging import AsyncHandler +from homeassistant.util.package import async_get_user_site, is_virtual_env +from homeassistant.util.yaml import clear_secret_cache +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ERROR_LOG_FILENAME = 'home-assistant.log' + +# hass.data key for logging information. +DATA_LOGGING = 'logging' + +FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', + 'logger', 'introduction', 'frontend', 'history'} + + +def from_config_dict(config: Dict[str, Any], + hass: Optional[core.HomeAssistant] = None, + config_dir: Optional[str] = None, + enable_log: bool = True, + verbose: bool = False, + skip_pip: bool = False, + log_rotate_days: Any = None, + log_file: Any = None, + log_no_color: bool = False) \ + -> Optional[core.HomeAssistant]: + """Try to configure Home Assistant from a configuration dictionary. + + Dynamically loads required components and its dependencies. + """ + if hass is None: + hass = core.HomeAssistant() + if config_dir is not None: + config_dir = os.path.abspath(config_dir) + hass.config.config_dir = config_dir + if not is_virtual_env(): + hass.loop.run_until_complete( + async_mount_local_lib_path(config_dir)) + + # run task + hass = hass.loop.run_until_complete( + async_from_config_dict( + config, hass, config_dir, enable_log, verbose, skip_pip, + log_rotate_days, log_file, log_no_color) + ) + return hass + + +async def async_from_config_dict(config: Dict[str, Any], + hass: core.HomeAssistant, + config_dir: Optional[str] = None, + enable_log: bool = True, + verbose: bool = False, + skip_pip: bool = False, + log_rotate_days: Any = None, + log_file: Any = None, + log_no_color: bool = False) \ + -> Optional[core.HomeAssistant]: + """Try to configure Home Assistant from a configuration dictionary. + + Dynamically loads required components and its dependencies. + This method is a coroutine. + """ + start = time() + + if enable_log: + async_enable_logging(hass, verbose, log_rotate_days, log_file, + log_no_color) + + core_config = config.get(core.DOMAIN, {}) + has_api_password = bool((config.get('http') or {}).get('api_password')) + has_trusted_networks = bool((config.get('http') or {}) + .get('trusted_networks')) + + try: + await conf_util.async_process_ha_core_config( + hass, core_config, has_api_password, has_trusted_networks) + except vol.Invalid as config_err: + conf_util.async_log_exception( + config_err, 'homeassistant', core_config, hass) + return None + except HomeAssistantError: + _LOGGER.error("Home Assistant core failed to initialize. " + "Further initialization aborted") + return None + + await hass.async_add_executor_job( + conf_util.process_ha_config_upgrade, hass) + + hass.config.skip_pip = skip_pip + if skip_pip: + _LOGGER.warning("Skipping pip installation of required modules. " + "This may cause issues") + + # Make a copy because we are mutating it. + config = OrderedDict(config) + + # Merge packages + conf_util.merge_packages_config( + hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) + + hass.config_entries = config_entries.ConfigEntries(hass, config) + await hass.config_entries.async_load() + + # Filter out the repeating and common config section [homeassistant] + components = set(key.split(' ')[0] for key in config.keys() + if key != core.DOMAIN) + components.update(hass.config_entries.async_domains()) + + # setup components + res = await core_components.async_setup(hass, config) + if not res: + _LOGGER.error("Home Assistant core failed to initialize. " + "Further initialization aborted") + return hass + + await persistent_notification.async_setup(hass, config) + + _LOGGER.info("Home Assistant core initialized") + + # stage 1 + for component in components: + if component not in FIRST_INIT_COMPONENT: + continue + hass.async_create_task(async_setup_component(hass, component, config)) + + await hass.async_block_till_done() + + # stage 2 + for component in components: + if component in FIRST_INIT_COMPONENT: + continue + hass.async_create_task(async_setup_component(hass, component, config)) + + await hass.async_block_till_done() + + stop = time() + _LOGGER.info("Home Assistant initialized in %.2fs", stop-start) + + # TEMP: warn users for invalid slugs + # Remove after 0.94 or 1.0 + if cv.INVALID_SLUGS_FOUND or cv.INVALID_ENTITY_IDS_FOUND: + msg = [] + + if cv.INVALID_ENTITY_IDS_FOUND: + msg.append( + "Your configuration contains invalid entity ID references. " + "Please find and update the following. " + "This will become a breaking change." + ) + msg.append('\n'.join('- {} -> {}'.format(*item) + for item + in cv.INVALID_ENTITY_IDS_FOUND.items())) + + if cv.INVALID_SLUGS_FOUND: + msg.append( + "Your configuration contains invalid slugs. " + "Please find and update the following. " + "This will become a breaking change." + ) + msg.append('\n'.join('- {} -> {}'.format(*item) + for item in cv.INVALID_SLUGS_FOUND.items())) + + hass.components.persistent_notification.async_create( + '\n\n'.join(msg), "Config Warning", "config_warning" + ) + + return hass + + +def from_config_file(config_path: str, + hass: Optional[core.HomeAssistant] = None, + verbose: bool = False, + skip_pip: bool = True, + log_rotate_days: Any = None, + log_file: Any = None, + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: + """Read the configuration file and try to start all the functionality. + + Will add functionality to 'hass' parameter if given, + instantiates a new Home Assistant object if 'hass' is not given. + """ + if hass is None: + hass = core.HomeAssistant() + + # run task + hass = hass.loop.run_until_complete( + async_from_config_file( + config_path, hass, verbose, skip_pip, + log_rotate_days, log_file, log_no_color) + ) + + return hass + + +async def async_from_config_file(config_path: str, + hass: core.HomeAssistant, + verbose: bool = False, + skip_pip: bool = True, + log_rotate_days: Any = None, + log_file: Any = None, + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: + """Read the configuration file and try to start all the functionality. + + Will add functionality to 'hass' parameter. + This method is a coroutine. + """ + # Set config dir to directory holding config file + config_dir = os.path.abspath(os.path.dirname(config_path)) + hass.config.config_dir = config_dir + + if not is_virtual_env(): + await async_mount_local_lib_path(config_dir) + + async_enable_logging(hass, verbose, log_rotate_days, log_file, + log_no_color) + + try: + config_dict = await hass.async_add_executor_job( + conf_util.load_yaml_config_file, config_path) + except HomeAssistantError as err: + _LOGGER.error("Error loading %s: %s", config_path, err) + return None + finally: + clear_secret_cache() + + return await async_from_config_dict( + config_dict, hass, enable_log=False, skip_pip=skip_pip) + + +@core.callback +def async_enable_logging(hass: core.HomeAssistant, + verbose: bool = False, + log_rotate_days: Optional[int] = None, + log_file: Optional[str] = None, + log_no_color: bool = False) -> None: + """Set up the logging. + + This method must be run in the event loop. + """ + fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " + "[%(name)s] %(message)s") + datefmt = '%Y-%m-%d %H:%M:%S' + + if not log_no_color: + try: + from colorlog import ColoredFormatter + # basicConfig must be called after importing colorlog in order to + # ensure that the handlers it sets up wraps the correct streams. + logging.basicConfig(level=logging.INFO) + + colorfmt = "%(log_color)s{}%(reset)s".format(fmt) + logging.getLogger().handlers[0].setFormatter(ColoredFormatter( + colorfmt, + datefmt=datefmt, + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + except ImportError: + pass + + # If the above initialization failed for any reason, setup the default + # formatting. If the above succeeds, this wil result in a no-op. + logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + + # Suppress overly verbose logs from libraries that aren't helpful + logging.getLogger('requests').setLevel(logging.WARNING) + logging.getLogger('urllib3').setLevel(logging.WARNING) + logging.getLogger('aiohttp.access').setLevel(logging.WARNING) + + # Log errors to a file if we have write access to file or config dir + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) + + # Check if we can write to the error log if it exists or that + # we can create files in the containing directory if not. + if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ + (not err_path_exists and os.access(err_dir, os.W_OK)): + + if log_rotate_days: + err_handler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when='midnight', + backupCount=log_rotate_days) # type: logging.FileHandler + else: + err_handler = logging.FileHandler( + err_log_path, mode='w', delay=True) + + err_handler.setLevel(logging.INFO if verbose else logging.WARNING) + err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) + + async_handler = AsyncHandler(hass.loop, err_handler) + + async def async_stop_async_handler(_: Any) -> None: + """Cleanup async handler.""" + logging.getLogger('').removeHandler(async_handler) # type: ignore + await async_handler.async_close(blocking=True) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler) + + logger = logging.getLogger('') + logger.addHandler(async_handler) # type: ignore + logger.setLevel(logging.INFO) + + # Save the log file location for access by other components. + hass.data[DATA_LOGGING] = err_log_path + else: + _LOGGER.error( + "Unable to set up error log %s (access denied)", err_log_path) + + +async def async_mount_local_lib_path(config_dir: str) -> str: + """Add local library to Python Path. + + This function is a coroutine. + """ + deps_dir = os.path.join(config_dir, 'deps') + lib_dir = await async_get_user_site(deps_dir) + if lib_dir not in sys.path: + sys.path.insert(0, lib_dir) + return deps_dir diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/__init__.py new file mode 100644 index 00000000000000..8715f0baa96e38 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/__init__.py @@ -0,0 +1,175 @@ +""" +This package contains components that can be plugged into Home Assistant. + +Component design guidelines: +- Each component defines a constant DOMAIN that is equal to its filename. +- Each component that tracks states should create state entity names in the + format ".". +- Each component should publish services only under its own domain. +""" +import asyncio +import itertools as it +import logging +from typing import Awaitable + +import voluptuous as vol + +import homeassistant.core as ha +import homeassistant.config as conf_util +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers import intent +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, + SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, + RESTART_EXIT_CODE) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' +SERVICE_CHECK_CONFIG = 'check_config' +SERVICE_UPDATE_ENTITY = 'update_entity' +SCHEMA_UPDATE_ENTITY = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids +}) + + +def is_on(hass, entity_id=None): + """Load up the module to call the is_on method. + + If there is no entity id given we will check all. + """ + if entity_id: + entity_ids = hass.components.group.expand_entity_ids([entity_id]) + else: + entity_ids = hass.states.entity_ids() + + for ent_id in entity_ids: + domain = ha.split_entity_id(ent_id)[0] + + try: + component = getattr(hass.components, domain) + + except ImportError: + _LOGGER.error('Failed to call %s.is_on: component not found', + domain) + continue + + if not hasattr(component, 'is_on'): + _LOGGER.warning("Component %s has no is_on method.", domain) + continue + + if component.is_on(ent_id): + return True + + return False + + +async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: + """Set up general services related to Home Assistant.""" + async def async_handle_turn_service(service): + """Handle calls to homeassistant.turn_on/off.""" + entity_ids = extract_entity_ids(hass, service) + + # Generic turn on/off method requires entity id + if not entity_ids: + _LOGGER.error( + "homeassistant/%s cannot be called without entity_id", + service.service) + return + + # Group entity_ids by domain. groupby requires sorted data. + by_domain = it.groupby(sorted(entity_ids), + lambda item: ha.split_entity_id(item)[0]) + + tasks = [] + + for domain, ent_ids in by_domain: + # We want to block for all calls and only return when all calls + # have been processed. If a service does not exist it causes a 10 + # second delay while we're blocking waiting for a response. + # But services can be registered on other HA instances that are + # listening to the bus too. So as an in between solution, we'll + # block only if the service is defined in the current HA instance. + blocking = hass.services.has_service(domain, service.service) + + # Create a new dict for this call + data = dict(service.data) + + # ent_ids is a generator, convert it to a list. + data[ATTR_ENTITY_ID] = list(ent_ids) + + tasks.append(hass.services.async_call( + domain, service.service, data, blocking)) + + await asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, + "Turned {} off")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) + + async def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + hass.async_create_task(hass.async_stop()) + return + + try: + errors = await conf_util.async_check_ha_config_file(hass) + except HomeAssistantError: + return + + if errors: + _LOGGER.error(errors) + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", + "Config validating", "{0}.check_config".format(ha.DOMAIN)) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) + + async def async_handle_update_service(call): + """Service handler for updating an entity.""" + tasks = [hass.helpers.entity_component.async_update_entity(entity) + for entity in call.data[ATTR_ENTITY_ID]] + + if tasks: + await asyncio.wait(tasks) + + hass.services.async_register( + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_UPDATE_ENTITY, async_handle_update_service, + schema=SCHEMA_UPDATE_ENTITY) + + async def async_handle_reload_config(call): + """Service handler for reloading core config.""" + try: + conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + await conf_util.async_process_ha_core_config( + hass, conf.get(ha.DOMAIN) or {}) + + hass.services.async_register( + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/__init__.py new file mode 100644 index 00000000000000..8a1a39a726f0d6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/__init__.py @@ -0,0 +1,344 @@ +""" +This component provides basic support for Abode Home Security system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/abode/ +""" +import logging +from functools import partial +from requests.exceptions import HTTPError, ConnectTimeout + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME, + CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['abodepy==0.15.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by goabode.com" +CONF_POLLING = 'polling' + +DOMAIN = 'abode' +DEFAULT_CACHEDB = './abodepy_cache.pickle' + +NOTIFICATION_ID = 'abode_notification' +NOTIFICATION_TITLE = 'Abode Security Setup' + +EVENT_ABODE_ALARM = 'abode_alarm' +EVENT_ABODE_ALARM_END = 'abode_alarm_end' +EVENT_ABODE_AUTOMATION = 'abode_automation' +EVENT_ABODE_FAULT = 'abode_panel_fault' +EVENT_ABODE_RESTORE = 'abode_panel_restore' + +SERVICE_SETTINGS = 'change_setting' +SERVICE_CAPTURE_IMAGE = 'capture_image' +SERVICE_TRIGGER = 'trigger_quick_action' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_DEVICE_TYPE = 'device_type' +ATTR_EVENT_CODE = 'event_code' +ATTR_EVENT_NAME = 'event_name' +ATTR_EVENT_TYPE = 'event_type' +ATTR_EVENT_UTC = 'event_utc' +ATTR_SETTING = 'setting' +ATTR_USER_NAME = 'user_name' +ATTR_VALUE = 'value' + +ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA + }), +}, extra=vol.ALLOW_EXTRA) + +CHANGE_SETTING_SCHEMA = vol.Schema({ + vol.Required(ATTR_SETTING): cv.string, + vol.Required(ATTR_VALUE): cv.string +}) + +CAPTURE_IMAGE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +TRIGGER_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +ABODE_PLATFORMS = [ + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', + 'camera', 'light', 'sensor' +] + + +class AbodeSystem: + """Abode System class.""" + + def __init__(self, username, password, cache, + name, polling, exclude, lights): + """Initialize the system.""" + import abodepy + self.abode = abodepy.Abode( + username, password, auto_login=True, get_devices=True, + get_automations=True, cache_path=cache) + self.name = name + self.polling = polling + self.exclude = exclude + self.lights = lights + self.devices = [] + + def is_excluded(self, device): + """Check if a device is configured to be excluded.""" + return device.device_id in self.exclude + + def is_automation_excluded(self, automation): + """Check if an automation is configured to be excluded.""" + return automation.automation_id in self.exclude + + def is_light(self, device): + """Check if a switch device is configured as a light.""" + import abodepy.helpers.constants as CONST + + return (device.generic_type == CONST.TYPE_LIGHT or + (device.generic_type == CONST.TYPE_SWITCH and + device.device_id in self.lights)) + + +def setup(hass, config): + """Set up Abode component.""" + from abodepy.exceptions import AbodeException + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + polling = conf.get(CONF_POLLING) + exclude = conf.get(CONF_EXCLUDE) + lights = conf.get(CONF_LIGHTS) + + try: + cache = hass.config.path(DEFAULT_CACHEDB) + hass.data[DOMAIN] = AbodeSystem( + username, password, cache, name, polling, exclude, lights) + except (AbodeException, ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + setup_hass_services(hass) + setup_hass_events(hass) + setup_abode_events(hass) + + for platform in ABODE_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True + + +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException + + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) + + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) + + def capture_image(call): + """Capture a new image.""" + entity_ids = call.data.get(ATTR_ENTITY_ID) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.capture() + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.trigger() + + hass.services.register( + DOMAIN, SERVICE_SETTINGS, change_setting, + schema=CHANGE_SETTING_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, + schema=CAPTURE_IMAGE_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_quick_action, + schema=TRIGGER_SCHEMA) + + +def setup_hass_events(hass): + """Home Assistant start and stop callbacks.""" + def startup(event): + """Listen for push events.""" + hass.data[DOMAIN].abode.events.start() + + def logout(event): + """Logout of Abode.""" + if not hass.data[DOMAIN].polling: + hass.data[DOMAIN].abode.events.stop() + + hass.data[DOMAIN].abode.logout() + _LOGGER.info("Logged out of Abode") + + if not hass.data[DOMAIN].polling: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) + + +def setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE + + def event_callback(event, event_json): + """Handle an event callback from Abode.""" + data = { + ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''), + ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''), + ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''), + ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''), + ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''), + ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''), + ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''), + ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''), + ATTR_DATE: event_json.get(ATTR_DATE, ''), + ATTR_TIME: event_json.get(ATTR_TIME, ''), + } + + hass.bus.fire(event, data) + + events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP] + + for event in events: + hass.data[DOMAIN].abode.events.add_event_callback( + event, + partial(event_callback, event)) + + +class AbodeDevice(Entity): + """Representation of an Abode device.""" + + def __init__(self, data, device): + """Initialize a sensor for Abode device.""" + self._data = data + self._device = device + + async def async_added_to_hass(self): + """Subscribe Abode events.""" + self.hass.async_add_job( + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_low': self._device.battery_low, + 'no_response': self._device.no_response, + 'device_type': self._device.type + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() + + +class AbodeAutomation(Entity): + """Representation of an Abode automation.""" + + def __init__(self, data, automation, event=None): + """Initialize for Abode automation.""" + self._data = data + self._automation = automation + self._event = event + + async def async_added_to_hass(self): + """Subscribe Abode events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._automation.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'automation_id': self._automation.automation_id, + 'type': self._automation.type, + 'sub_type': self._automation.sub_type + } + + def _update_callback(self, device): + """Update the device state.""" + self._automation.refresh() + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/alarm_control_panel.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/alarm_control_panel.py new file mode 100644 index 00000000000000..947e2916300974 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/alarm_control_panel.py @@ -0,0 +1,85 @@ +""" +This component provides HA alarm_control_panel support for Abode System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.abode/ +""" +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.abode import CONF_ATTRIBUTION, AbodeDevice +from homeassistant.components.abode import DOMAIN as ABODE_DOMAIN +from homeassistant.const import ( + ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:security' + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an alarm control panel for an Abode device.""" + data = hass.data[ABODE_DOMAIN] + + alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] + + data.devices.extend(alarm_devices) + + add_entities(alarm_devices) + + +class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel): + """An alarm_control_panel implementation for Abode.""" + + def __init__(self, data, device, name): + """Initialize the alarm control panel.""" + super().__init__(data, device) + self._name = name + + @property + def icon(self): + """Return the icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + if self._device.is_standby: + state = STATE_ALARM_DISARMED + elif self._device.is_away: + state = STATE_ALARM_ARMED_AWAY + elif self._device.is_home: + state = STATE_ALARM_ARMED_HOME + else: + state = None + return state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._device.set_standby() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._device.set_home() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._device.set_away() + + @property + def name(self): + """Return the name of the alarm.""" + return self._name or super().name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_backup': self._device.battery, + 'cellular_backup': self._device.is_cellular, + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/binary_sensor.py new file mode 100644 index 00000000000000..a821abf445b67b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/binary_sensor.py @@ -0,0 +1,74 @@ +""" +This component provides HA binary_sensor support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.abode/ +""" +import logging + +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) +from homeassistant.components.binary_sensor import BinarySensorDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a sensor for an Abode device.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING] + + devices = [] + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device): + continue + + devices.append(AbodeBinarySensor(data, device)) + + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_QUICK_ACTION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): + """A binary sensor implementation for Abode device.""" + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._device.is_on + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + def trigger(self): + """Trigger a quick automation.""" + self._automation.trigger() + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/camera.py new file mode 100644 index 00000000000000..39681760d4d27b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/camera.py @@ -0,0 +1,99 @@ +""" +This component provides HA camera support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.abode/ +""" +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + + +DEPENDENCIES = ['abode'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Abode camera devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + if data.is_excluded(device): + continue + + devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeCamera(AbodeDevice, Camera): + """Representation of an Abode camera.""" + + def __init__(self, data, device, event): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, data, device) + Camera.__init__(self) + self._event = event + self._response = None + + async def async_added_to_hass(self): + """Subscribe Abode events.""" + await super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, self._capture_callback + ) + + def capture(self): + """Request a new image capture.""" + return self._device.capture() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_image(self): + """Find a new image on the timeline.""" + if self._device.refresh_image(): + self.get_image() + + def get_image(self): + """Attempt to download the most recent capture.""" + if self._device.image_url: + try: + self._response = requests.get( + self._device.image_url, stream=True) + + self._response.raise_for_status() + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + else: + self._response = None + + def camera_image(self): + """Get a camera image.""" + self.refresh_image() + + if self._response: + return self._response.content + + return None + + def _capture_callback(self, capture): + """Update the image with the device then refresh device.""" + self._device.update_image_location(capture) + self.get_image() + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/cover.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/cover.py new file mode 100644 index 00000000000000..3ba3fb118f3217 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/cover.py @@ -0,0 +1,50 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue + + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return not self._device.is_open + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/light.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/light.py new file mode 100644 index 00000000000000..397d61f3073822 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/light.py @@ -0,0 +1,103 @@ +""" +This component provides HA light support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.abode/ +""" +import logging +from math import ceil +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin) + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Abode light devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_LIGHT, CONST.TYPE_SWITCH] + + devices = [] + + # Get all regular lights that are not excluded or switches marked as lights + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device) or not data.is_light(device): + continue + + devices.append(AbodeLight(data, device)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeLight(AbodeDevice, Light): + """Representation of an Abode light.""" + + def turn_on(self, **kwargs): + """Turn on the light.""" + if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable: + self._device.set_color_temp( + int(color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP]))) + + if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: + self._device.set_color(kwargs[ATTR_HS_COLOR]) + + if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: + # Convert HASS brightness (0-255) to Abode brightness (0-99) + # If 100 is sent to Abode, response is 99 causing an error + self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0)) + else: + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + @property + def brightness(self): + """Return the brightness of the light.""" + if self._device.is_dimmable and self._device.has_brightness: + brightness = int(self._device.brightness) + # Abode returns 100 during device initialization and device refresh + if brightness == 100: + return 255 + # Convert Abode brightness (0-99) to HASS brightness (0-255) + return ceil(brightness * 255 / 99.0) + + @property + def color_temp(self): + """Return the color temp of the light.""" + if self._device.has_color: + return color_temperature_kelvin_to_mired(self._device.color_temp) + + @property + def hs_color(self): + """Return the color of the light.""" + if self._device.has_color: + return self._device.color + + @property + def supported_features(self): + """Flag supported features.""" + if self._device.is_dimmable and self._device.is_color_capable: + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP + if self._device.is_dimmable: + return SUPPORT_BRIGHTNESS + return 0 diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/lock.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/lock.py new file mode 100644 index 00000000000000..a8777ccb503344 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/lock.py @@ -0,0 +1,50 @@ +""" +This component provides HA lock support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.lock import LockDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Abode lock devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK): + if data.is_excluded(device): + continue + + devices.append(AbodeLock(data, device)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/sensor.py new file mode 100644 index 00000000000000..4695a5f0471ada --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/sensor.py @@ -0,0 +1,83 @@ +""" +Support for Abode Security System sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['abode'] + +# Sensor types: Name, icon +SENSOR_TYPES = { + 'temp': ['Temperature', DEVICE_CLASS_TEMPERATURE], + 'humidity': ['Humidity', DEVICE_CLASS_HUMIDITY], + 'lux': ['Lux', DEVICE_CLASS_ILLUMINANCE], +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a sensor for an Abode device.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR): + if data.is_excluded(device): + continue + + for sensor_type in SENSOR_TYPES: + devices.append(AbodeSensor(data, device, sensor_type)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeSensor(AbodeDevice): + """A sensor implementation for Abode devices.""" + + def __init__(self, data, device, sensor_type): + """Initialize a sensor for an Abode device.""" + super().__init__(data, device) + self._sensor_type = sensor_type + self._name = '{0} {1}'.format( + self._device.name, SENSOR_TYPES[self._sensor_type][0]) + self._device_class = SENSOR_TYPES[self._sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def state(self): + """Return the state of the sensor.""" + if self._sensor_type == 'temp': + return self._device.temp + if self._sensor_type == 'humidity': + return self._device.humidity + if self._sensor_type == 'lux': + return self._device.lux + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + if self._sensor_type == 'temp': + return self._device.temp_unit + if self._sensor_type == 'humidity': + return self._device.humidity_unit + if self._sensor_type == 'lux': + return self._device.lux_unit diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/abode/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/abode/switch.py new file mode 100644 index 00000000000000..e3f993e5413e6c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/abode/switch.py @@ -0,0 +1,80 @@ +""" +This component provides HA switch support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.abode/ +""" +import logging + +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) +from homeassistant.components.switch import SwitchDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Abode switch devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + + # Get all regular switches that are not excluded or marked as lights + for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH): + if data.is_excluded(device) or data.is_light(device): + continue + + devices.append(AbodeSwitch(data, device)) + + # Get all Abode automations that can be enabled/disabled + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_AUTOMATION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeAutomationSwitch( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_entities(devices) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + +class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice): + """A switch implementation for Abode automations.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._automation.set_active(True) + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._automation.set_active(False) + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ads/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/ads/__init__.py new file mode 100644 index 00000000000000..360236790f880e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ads/__init__.py @@ -0,0 +1,203 @@ +""" +Support for Automation Device Specification (ADS). + +For more details about this component, please refer to the documentation. +https://home-assistant.io/components/ads/ +""" +import threading +import struct +import logging +import ctypes +from collections import namedtuple +import voluptuous as vol +from homeassistant.const import CONF_DEVICE, CONF_PORT, CONF_IP_ADDRESS, \ + EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyads==2.2.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_ADS = 'data_ads' + +# Supported Types +ADSTYPE_INT = 'int' +ADSTYPE_UINT = 'uint' +ADSTYPE_BYTE = 'byte' +ADSTYPE_BOOL = 'bool' + +DOMAIN = 'ads' + +CONF_ADS_VAR = 'adsvar' +CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness' +CONF_ADS_TYPE = 'adstype' +CONF_ADS_FACTOR = 'factor' +CONF_ADS_VALUE = 'value' + +SERVICE_WRITE_DATA_BY_NAME = 'write_data_by_name' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema({ + vol.Required(CONF_ADS_TYPE): + vol.In([ADSTYPE_INT, ADSTYPE_UINT, ADSTYPE_BYTE, ADSTYPE_BOOL]), + vol.Required(CONF_ADS_VALUE): vol.Coerce(int), + vol.Required(CONF_ADS_VAR): cv.string, +}) + + +def setup(hass, config): + """Set up the ADS component.""" + import pyads + conf = config[DOMAIN] + + net_id = conf.get(CONF_DEVICE) + ip_address = conf.get(CONF_IP_ADDRESS) + port = conf.get(CONF_PORT) + + client = pyads.Connection(net_id, port, ip_address) + + AdsHub.ADS_TYPEMAP = { + ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, + ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, + ADSTYPE_INT: pyads.PLCTYPE_INT, + ADSTYPE_UINT: pyads.PLCTYPE_UINT, + } + + AdsHub.PLCTYPE_BOOL = pyads.PLCTYPE_BOOL + AdsHub.PLCTYPE_BYTE = pyads.PLCTYPE_BYTE + AdsHub.PLCTYPE_INT = pyads.PLCTYPE_INT + AdsHub.PLCTYPE_UINT = pyads.PLCTYPE_UINT + AdsHub.ADSError = pyads.ADSError + + try: + ads = AdsHub(client) + except pyads.pyads.ADSError: + _LOGGER.error( + "Could not connect to ADS host (netid=%s, port=%s)", net_id, port) + return False + + hass.data[DATA_ADS] = ads + hass.bus.listen(EVENT_HOMEASSISTANT_STOP, ads.shutdown) + + def handle_write_data_by_name(call): + """Write a value to the connected ADS device.""" + ads_var = call.data.get(CONF_ADS_VAR) + ads_type = call.data.get(CONF_ADS_TYPE) + value = call.data.get(CONF_ADS_VALUE) + + try: + ads.write_by_name(ads_var, value, ads.ADS_TYPEMAP[ads_type]) + except pyads.ADSError as err: + _LOGGER.error(err) + + hass.services.register( + DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, + schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME) + + return True + + +# Tuple to hold data needed for notification +NotificationItem = namedtuple( + 'NotificationItem', 'hnotify huser name plc_datatype callback' +) + + +class AdsHub: + """Representation of an ADS connection.""" + + def __init__(self, ads_client): + """Initialize the ADS hub.""" + self._client = ads_client + self._client.open() + + # All ADS devices are registered here + self._devices = [] + self._notification_items = {} + self._lock = threading.Lock() + + def shutdown(self, *args, **kwargs): + """Shutdown ADS connection.""" + import pyads + _LOGGER.debug("Shutting down ADS") + for notification_item in self._notification_items.values(): + _LOGGER.debug( + "Deleting device notification %d, %d", + notification_item.hnotify, notification_item.huser) + try: + self._client.del_device_notification( + notification_item.hnotify, + notification_item.huser + ) + except pyads.ADSError as err: + _LOGGER.error(err) + try: + self._client.close() + except pyads.ADSError as err: + _LOGGER.error(err) + + def register_device(self, device): + """Register a new device.""" + self._devices.append(device) + + def write_by_name(self, name, value, plc_datatype): + """Write a value to the device.""" + with self._lock: + return self._client.write_by_name(name, value, plc_datatype) + + def read_by_name(self, name, plc_datatype): + """Read a value from the device.""" + with self._lock: + return self._client.read_by_name(name, plc_datatype) + + def add_device_notification(self, name, plc_datatype, callback): + """Add a notification to the ADS devices.""" + from pyads import NotificationAttrib + attr = NotificationAttrib(ctypes.sizeof(plc_datatype)) + + with self._lock: + hnotify, huser = self._client.add_device_notification( + name, attr, self._device_notification_callback) + hnotify = int(hnotify) + + _LOGGER.debug( + "Added device notification %d for variable %s", hnotify, name) + + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback) + + def _device_notification_callback(self, addr, notification, huser): + """Handle device notifications.""" + contents = notification.contents + + hnotify = int(contents.hNotification) + _LOGGER.debug("Received notification %d", hnotify) + data = contents.data + + try: + notification_item = self._notification_items[hnotify] + except KeyError: + _LOGGER.debug("Unknown device notification handle: %d", hnotify) + return + + # Parse data to desired datatype + if notification_item.plc_datatype == self.PLCTYPE_BOOL: + value = bool(struct.unpack(' str: + """Return the attribution.""" + return ATTRIBUTION + + @property + def device_state_attributes(self) -> dict: + """Return other details about the sensor state.""" + return self._attrs + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def air_quality_index(self) -> str: + """Return the Air Quality Index (AQI).""" + return self._max_aqi + + @property + def carbon_monoxide(self) -> str: + """Return the CO (carbon monoxide) level.""" + from niluclient import CO + return self.get_component_state(CO) + + @property + def carbon_dioxide(self) -> str: + """Return the CO2 (carbon dioxide) level.""" + from niluclient import CO2 + return self.get_component_state(CO2) + + @property + def nitrogen_oxide(self) -> str: + """Return the N2O (nitrogen oxide) level.""" + from niluclient import NOX + return self.get_component_state(NOX) + + @property + def nitrogen_monoxide(self) -> str: + """Return the NO (nitrogen monoxide) level.""" + from niluclient import NO + return self.get_component_state(NO) + + @property + def nitrogen_dioxide(self) -> str: + """Return the NO2 (nitrogen dioxide) level.""" + from niluclient import NO2 + return self.get_component_state(NO2) + + @property + def ozone(self) -> str: + """Return the O3 (ozone) level.""" + from niluclient import OZONE + return self.get_component_state(OZONE) + + @property + def particulate_matter_2_5(self) -> str: + """Return the particulate matter 2.5 level.""" + from niluclient import PM25 + return self.get_component_state(PM25) + + @property + def particulate_matter_10(self) -> str: + """Return the particulate matter 10 level.""" + from niluclient import PM10 + return self.get_component_state(PM10) + + @property + def particulate_matter_0_1(self) -> str: + """Return the particulate matter 0.1 level.""" + from niluclient import PM1 + return self.get_component_state(PM1) + + @property + def sulphur_dioxide(self) -> str: + """Return the SO2 (sulphur dioxide) level.""" + from niluclient import SO2 + return self.get_component_state(SO2) + + def get_component_state(self, component_name: str) -> str: + """Return formatted value of specified component.""" + if component_name in self._api.data.sensors: + sensor = self._api.data.sensors[component_name] + return sensor.value + return None + + def update(self) -> None: + """Update the sensor.""" + import niluclient as nilu + self._api.update() + + sensors = self._api.data.sensors.values() + if sensors: + max_index = max([s.pollution_index for s in sensors]) + self._max_aqi = max_index + self._attrs[ATTR_POLLUTION_INDEX] = \ + nilu.POLLUTION_INDEX[self._max_aqi] + + self._attrs[ATTR_AREA] = self._api.data.area diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/air_quality/opensensemap.py b/homeassistant-0.88.0.dev0/homeassistant/components/air_quality/opensensemap.py new file mode 100644 index 00000000000000..fe3cca4876ea3e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/air_quality/opensensemap.py @@ -0,0 +1,105 @@ +""" +Support for openSenseMap Air Quality data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/air_quality/opensensemap/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.air_quality import ( + PLATFORM_SCHEMA, AirQualityEntity) +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['opensensemap-api==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = 'Data provided by openSenseMap' + +CONF_STATION_ID = 'station_id' + +SCAN_INTERVAL = timedelta(minutes=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STATION_ID): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the openSenseMap air quality platform.""" + from opensensemap_api import OpenSenseMap + + name = config.get(CONF_NAME) + station_id = config[CONF_STATION_ID] + + session = async_get_clientsession(hass) + osm_api = OpenSenseMapData(OpenSenseMap(station_id, hass.loop, session)) + + await osm_api.async_update() + + if 'name' not in osm_api.api.data: + _LOGGER.error("Station %s is not available", station_id) + return + + station_name = osm_api.api.data['name'] if name is None else name + + async_add_entities([OpenSenseMapQuality(station_name, osm_api)], True) + + +class OpenSenseMapQuality(AirQualityEntity): + """Implementation of an openSenseMap air quality entity.""" + + def __init__(self, name, osm): + """Initialize the air quality entity.""" + self._name = name + self._osm = osm + + @property + def name(self): + """Return the name of the air quality entity.""" + return self._name + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._osm.api.pm2_5 + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._osm.api.pm10 + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + async def async_update(self): + """Get the latest data from the openSenseMap API.""" + await self._osm.async_update() + + +class OpenSenseMapData: + """Get the latest data and update the states.""" + + def __init__(self, api): + """Initialize the data object.""" + self.api = api + + @Throttle(SCAN_INTERVAL) + async def async_update(self): + """Get the latest data from the Pi-hole.""" + from opensensemap_api.exceptions import OpenSenseMapError + + try: + await self.api.get_data() + except OpenSenseMapError as err: + _LOGGER.error("Unable to fetch data: %s", err) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/__init__.py new file mode 100644 index 00000000000000..e02e074189cc1d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/__init__.py @@ -0,0 +1,169 @@ +""" +Component to interface with an alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, + SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) +from homeassistant.helpers.config_validation import ( # noqa + PLATFORM_SCHEMA_BASE, PLATFORM_SCHEMA_2 as PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +DOMAIN = 'alarm_control_panel' +SCAN_INTERVAL = timedelta(seconds=30) +ATTR_CHANGED_BY = 'changed_by' +FORMAT_TEXT = 'text' +FORMAT_NUMBER = 'number' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +ALARM_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_CODE): cv.string, +}) + + +async def async_setup(hass, config): + """Track states and offer events for sensors.""" + component = hass.data[DOMAIN] = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA, + 'async_alarm_disarm' + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_HOME, ALARM_SERVICE_SCHEMA, + 'async_alarm_arm_home' + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_AWAY, ALARM_SERVICE_SCHEMA, + 'async_alarm_arm_away' + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_NIGHT, ALARM_SERVICE_SCHEMA, + 'async_alarm_arm_night' + ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA, + 'async_alarm_arm_custom_bypass' + ) + component.async_register_entity_service( + SERVICE_ALARM_TRIGGER, ALARM_SERVICE_SCHEMA, + 'async_alarm_trigger' + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +# pylint: disable=no-self-use +class AlarmControlPanel(Entity): + """An abstract class for alarm control devices.""" + + @property + def code_format(self): + """Regex for code format or None if no code is required.""" + return None + + @property + def changed_by(self): + """Last change triggered by.""" + return None + + def alarm_disarm(self, code=None): + """Send disarm command.""" + raise NotImplementedError() + + def async_alarm_disarm(self, code=None): + """Send disarm command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_disarm, code) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + raise NotImplementedError() + + def async_alarm_arm_home(self, code=None): + """Send arm home command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_arm_home, code) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + raise NotImplementedError() + + def async_alarm_arm_away(self, code=None): + """Send arm away command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_arm_away, code) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + raise NotImplementedError() + + def async_alarm_arm_night(self, code=None): + """Send arm night command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_arm_night, code) + + def alarm_trigger(self, code=None): + """Send alarm trigger command.""" + raise NotImplementedError() + + def async_alarm_trigger(self, code=None): + """Send alarm trigger command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job(self.alarm_trigger, code) + + def alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command.""" + raise NotImplementedError() + + def async_alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job( + self.alarm_arm_custom_bypass, code) + + @property + def state_attributes(self): + """Return the state attributes.""" + state_attr = { + ATTR_CODE_FORMAT: self.code_format, + ATTR_CHANGED_BY: self.changed_by + } + return state_attr diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/alarmdotcom.py new file mode 100644 index 00000000000000..4f2913771b1ee0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -0,0 +1,125 @@ +""" +Interfaces with Alarm.com alarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.alarmdotcom/ +""" +import logging +import re + +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyalarmdotcom==0.3.2'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Alarm.com' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up a Alarm.com control panel.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + alarmdotcom = AlarmDotCom(hass, name, code, username, password) + await alarmdotcom.async_login() + async_add_entities([alarmdotcom]) + + +class AlarmDotCom(alarm.AlarmControlPanel): + """Representation of an Alarm.com status.""" + + def __init__(self, hass, name, code, username, password): + """Initialize the Alarm.com status.""" + from pyalarmdotcom import Alarmdotcom + _LOGGER.debug('Setting up Alarm.com...') + self._hass = hass + self._name = name + self._code = str(code) if code else None + self._username = username + self._password = password + self._websession = async_get_clientsession(self._hass) + self._state = None + self._alarm = Alarmdotcom( + username, password, self._websession, hass.loop) + + async def async_login(self): + """Login to Alarm.com.""" + await self._alarm.async_login() + + async def async_update(self): + """Fetch the latest state.""" + await self._alarm.async_update() + return self._alarm.state + + @property + def name(self): + """Return the name of the alarm.""" + return self._name + + @property + def code_format(self): + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT + + @property + def state(self): + """Return the state of the device.""" + if self._alarm.state.lower() == 'disarmed': + return STATE_ALARM_DISARMED + if self._alarm.state.lower() == 'armed stay': + return STATE_ALARM_ARMED_HOME + if self._alarm.state.lower() == 'armed away': + return STATE_ALARM_ARMED_AWAY + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'sensor_status': self._alarm.sensor_status + } + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if self._validate_code(code): + await self._alarm.async_alarm_disarm() + + async def async_alarm_arm_home(self, code=None): + """Send arm hom command.""" + if self._validate_code(code): + await self._alarm.async_alarm_arm_home() + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if self._validate_code(code): + await self._alarm.async_alarm_arm_away() + + def _validate_code(self, code): + """Validate given code.""" + check = self._code is None or code == self._code + if not check: + _LOGGER.warning("Wrong code entered") + return check diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/canary.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/canary.py new file mode 100644 index 00000000000000..b22a76fdb3b0aa --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/canary.py @@ -0,0 +1,91 @@ +""" +Support for Canary alarm. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.canary/ +""" +import logging + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, \ + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_HOME + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Canary alarms.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + devices.append(CanaryAlarm(data, location.location_id)) + + add_entities(devices, True) + + +class CanaryAlarm(AlarmControlPanel): + """Representation of a Canary alarm control panel.""" + + def __init__(self, data, location_id): + """Initialize a Canary security camera.""" + self._data = data + self._location_id = location_id + + @property + def name(self): + """Return the name of the alarm.""" + location = self._data.get_location(self._location_id) + return location.name + + @property + def state(self): + """Return the state of the device.""" + from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \ + LOCATION_MODE_NIGHT + + location = self._data.get_location(self._location_id) + + if location.is_private: + return STATE_ALARM_DISARMED + + mode = location.mode + if mode.name == LOCATION_MODE_AWAY: + return STATE_ALARM_ARMED_AWAY + if mode.name == LOCATION_MODE_HOME: + return STATE_ALARM_ARMED_HOME + if mode.name == LOCATION_MODE_NIGHT: + return STATE_ALARM_ARMED_NIGHT + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + location = self._data.get_location(self._location_id) + return { + 'private': location.is_private + } + + def alarm_disarm(self, code=None): + """Send disarm command.""" + location = self._data.get_location(self._location_id) + self._data.set_location_mode(self._location_id, location.mode.name, + True) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + from canary.api import LOCATION_MODE_HOME + self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + from canary.api import LOCATION_MODE_AWAY + self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + from canary.api import LOCATION_MODE_NIGHT + self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/concord232.py new file mode 100644 index 00000000000000..155d6b6ae49921 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/concord232.py @@ -0,0 +1,109 @@ +""" +Support for Concord232 alarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.concord232/ +""" +import datetime +import logging + +import requests +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +import homeassistant.helpers.config_validation as cv +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) + +REQUIREMENTS = ['concord232==0.15'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'CONCORD232' +DEFAULT_PORT = 5007 + +SCAN_INTERVAL = datetime.timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Concord232 alarm control panel platform.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + url = 'http://{}:{}'.format(host, port) + + try: + add_entities([Concord232Alarm(url, name)], True) + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) + + +class Concord232Alarm(alarm.AlarmControlPanel): + """Representation of the Concord232-based alarm panel.""" + + def __init__(self, url, name): + """Initialize the Concord232 alarm panel.""" + from concord232 import client as concord232_client + + self._state = None + self._name = name + self._url = url + self._alarm = concord232_client.Client(self._url) + self._alarm.partitions = self._alarm.list_partitions() + self._alarm.last_partition_update = datetime.datetime.now() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def code_format(self): + """Return the characters if code is defined.""" + return alarm.FORMAT_NUMBER + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Update values from API.""" + try: + part = self._alarm.list_partitions()[0] + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to %(host)s: %(reason)s", + dict(host=self._url, reason=ex)) + return + except IndexError: + _LOGGER.error("Concord232 reports no partitions") + return + + if part['arming_level'] == 'Off': + self._state = STATE_ALARM_DISARMED + elif 'Home' in part['arming_level']: + self._state = STATE_ALARM_ARMED_HOME + else: + self._state = STATE_ALARM_ARMED_AWAY + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._alarm.disarm(code) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._alarm.arm('stay') + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._alarm.arm('away') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/demo.py new file mode 100644 index 00000000000000..fb4dccc1c865e7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/demo.py @@ -0,0 +1,49 @@ +""" +Demo platform that has two fake alarm control panels. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import datetime +from homeassistant.components.alarm_control_panel import manual +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, CONF_DELAY_TIME, + CONF_PENDING_TIME, CONF_TRIGGER_TIME) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Demo alarm control panel platform.""" + async_add_entities([ + manual.ManualAlarm(hass, 'Alarm', '1234', None, False, { + STATE_ALARM_ARMED_AWAY: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_HOME: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_DISARMED: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_CUSTOM_BYPASS: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + }, + }), + ]) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/ialarm.py new file mode 100644 index 00000000000000..df975ef00ac44f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/ialarm.py @@ -0,0 +1,133 @@ +""" +Interfaces with iAlarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.ialarm/ +""" +import logging +import re + +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_CODE, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyialarm==0.3'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'iAlarm' + + +def no_application_protocol(value): + """Validate that value is without the application protocol.""" + protocol_separator = "://" + if not value or protocol_separator in value: + raise vol.Invalid( + 'Invalid host, {} is not allowed'.format(protocol_separator)) + + return value + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): vol.All(cv.string, no_application_protocol), + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an iAlarm control panel.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + + url = 'http://{}'.format(host) + ialarm = IAlarmPanel(name, code, username, password, url) + add_entities([ialarm], True) + + +class IAlarmPanel(alarm.AlarmControlPanel): + """Representation of an iAlarm status.""" + + def __init__(self, name, code, username, password, url): + """Initialize the iAlarm status.""" + from pyialarm import IAlarm + + self._name = name + self._code = str(code) if code else None + self._username = username + self._password = password + self._url = url + self._state = None + self._client = IAlarm(username, password, url) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def code_format(self): + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Return the state of the device.""" + status = self._client.get_status() + _LOGGER.debug('iAlarm status: %s', status) + if status: + status = int(status) + + if status == self._client.DISARMED: + state = STATE_ALARM_DISARMED + elif status == self._client.ARMED_AWAY: + state = STATE_ALARM_ARMED_AWAY + elif status == self._client.ARMED_STAY: + state = STATE_ALARM_ARMED_HOME + elif status == self._client.TRIGGERED: + state = STATE_ALARM_TRIGGERED + else: + state = None + + self._state = state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if self._validate_code(code): + self._client.disarm() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if self._validate_code(code): + self._client.arm_away() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if self._validate_code(code): + self._client.arm_stay() + + def _validate_code(self, code): + """Validate given code.""" + check = self._code is None or code == self._code + if not check: + _LOGGER.warning("Wrong code entered") + return check diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/manual.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/manual.py new file mode 100644 index 00000000000000..a36a38f596fa6b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/manual.py @@ -0,0 +1,324 @@ +""" +Support for manual alarms. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.manual/ +""" +import copy +import datetime +import logging +import re + +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.const import ( + CONF_CODE, CONF_DELAY_TIME, CONF_DISARM_AFTER_TRIGGER, CONF_NAME, + CONF_PENDING_TIME, CONF_PLATFORM, CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_point_in_time +import homeassistant.util.dt as dt_util +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_CODE_TEMPLATE = 'code_template' + +DEFAULT_ALARM_NAME = 'HA Alarm' +DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) +DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60) +DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120) +DEFAULT_DISARM_AFTER_TRIGGER = False + +SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED] + +SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_TRIGGERED] + +SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_DISARMED] + +ATTR_PRE_PENDING_STATE = 'pre_pending_state' +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + """Validate the state.""" + config = copy.deepcopy(config) + for state in SUPPORTED_PRETRIGGER_STATES: + if CONF_DELAY_TIME not in config[state]: + config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + if CONF_TRIGGER_TIME not in config[state]: + config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +def _state_schema(state): + """Validate the state.""" + schema = {} + if state in SUPPORTED_PRETRIGGER_STATES: + schema[vol.Optional(CONF_DELAY_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + if state in SUPPORTED_PENDING_STATES: + schema[vol.Optional(CONF_PENDING_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + return vol.Schema(schema) + + +PLATFORM_SCHEMA = vol.Schema(vol.All({ + vol.Required(CONF_PLATFORM): 'manual', + vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, + vol.Exclusive(CONF_CODE, 'code validation'): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DISARM_AFTER_TRIGGER, + default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): + _state_schema(STATE_ALARM_ARMED_AWAY), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): + _state_schema(STATE_ALARM_ARMED_HOME), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): + _state_schema(STATE_ALARM_ARMED_NIGHT), + vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, default={}): + _state_schema(STATE_ALARM_ARMED_CUSTOM_BYPASS), + vol.Optional(STATE_ALARM_DISARMED, default={}): + _state_schema(STATE_ALARM_DISARMED), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): + _state_schema(STATE_ALARM_TRIGGERED), +}, _state_validator)) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the manual alarm platform.""" + add_entities([ManualAlarm( + hass, + config[CONF_NAME], + config.get(CONF_CODE), + config.get(CONF_CODE_TEMPLATE), + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config + )]) + + +class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity): + """ + Representation of an alarm status. + + When armed, will be pending for 'pending_time', after that armed. + When triggered, will be pending for the triggering state's 'delay_time' + plus the triggered state's 'pending_time'. + After that will be triggered for 'trigger_time', after that we return to + the previous state or disarm if `disarm_after_trigger` is true. + A trigger_time of zero disables the alarm_trigger service. + """ + + def __init__(self, hass, name, code, code_template, + disarm_after_trigger, config): + """Init the manual alarm panel.""" + self._state = STATE_ALARM_DISARMED + self._hass = hass + self._name = name + if code_template: + self._code = code_template + self._code.hass = hass + else: + self._code = code or None + self._disarm_after_trigger = disarm_after_trigger + self._previous_state = self._state + self._state_ts = None + + self._delay_time_by_state = { + state: config[state][CONF_DELAY_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._trigger_time_by_state = { + state: config[state][CONF_TRIGGER_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._pending_time_by_state = { + state: config[state][CONF_PENDING_TIME] + for state in SUPPORTED_PENDING_STATES} + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self._state == STATE_ALARM_TRIGGERED: + if self._within_pending_time(self._state): + return STATE_ALARM_PENDING + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): + if self._disarm_after_trigger: + return STATE_ALARM_DISARMED + self._state = self._previous_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + + return self._state + + @property + def _active_state(self): + """Get the current state.""" + if self.state == STATE_ALARM_PENDING: + return self._previous_state + return self._state + + def _pending_time(self, state): + """Get the pending time.""" + pending_time = self._pending_time_by_state[state] + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + """Get if the action is in the pending time window.""" + return self._state_ts + self._pending_time(state) > dt_util.utcnow() + + @property + def code_format(self): + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if not self._validate_code(code, STATE_ALARM_DISARMED): + return + + self._state = STATE_ALARM_DISARMED + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + return + + self._update_state(STATE_ALARM_ARMED_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + return + + self._update_state(STATE_ALARM_ARMED_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._update_state(STATE_ALARM_ARMED_NIGHT) + + def alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS): + return + + self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) + + def alarm_trigger(self, code=None): + """ + Send alarm trigger command. + + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + """Update the state.""" + if self._state == state: + return + + self._previous_state = self._state + self._state = state + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) + + trigger_time = self._trigger_time_by_state[self._previous_state] + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time + trigger_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) + + def _validate_code(self, code, state): + """Validate given code.""" + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code + if not check: + _LOGGER.warning("Invalid code given for %s", state) + return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state: + if state.state == STATE_ALARM_PENDING and \ + hasattr(state, 'attributes') and \ + state.attributes['pre_pending_state']: + # If in pending state, we return to the pre_pending_state + self._state = state.attributes['pre_pending_state'] + self._state_ts = dt_util.utcnow() + else: + self._state = state.state + self._state_ts = state.last_updated diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/manual_mqtt.py new file mode 100644 index 00000000000000..693c15fa4240c0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -0,0 +1,366 @@ +""" +Support for manual alarms controllable via MQTT. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ +""" +import copy +import datetime +import logging +import re + +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME, + CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) +from homeassistant.components import mqtt + +from homeassistant.helpers.event import async_track_state_change +from homeassistant.core import callback + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_point_in_time + +_LOGGER = logging.getLogger(__name__) + +CONF_CODE_TEMPLATE = 'code_template' + +CONF_PAYLOAD_DISARM = 'payload_disarm' +CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' +CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' +CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' + +DEFAULT_ALARM_NAME = 'HA Alarm' +DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) +DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60) +DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120) +DEFAULT_DISARM_AFTER_TRIGGER = False +DEFAULT_ARM_AWAY = 'ARM_AWAY' +DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_ARM_NIGHT = 'ARM_NIGHT' +DEFAULT_DISARM = 'DISARM' + +SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED] + +SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_TRIGGERED] + +SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_DISARMED] + +ATTR_PRE_PENDING_STATE = 'pre_pending_state' +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + """Validate the state.""" + config = copy.deepcopy(config) + for state in SUPPORTED_PRETRIGGER_STATES: + if CONF_DELAY_TIME not in config[state]: + config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + if CONF_TRIGGER_TIME not in config[state]: + config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +def _state_schema(state): + """Validate the state.""" + schema = {} + if state in SUPPORTED_PRETRIGGER_STATES: + schema[vol.Optional(CONF_DELAY_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + if state in SUPPORTED_PENDING_STATES: + schema[vol.Optional(CONF_PENDING_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + return vol.Schema(schema) + + +DEPENDENCIES = ['mqtt'] + +PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PLATFORM): 'manual_mqtt', + vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, + vol.Exclusive(CONF_CODE, 'code validation'): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DISARM_AFTER_TRIGGER, + default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): + _state_schema(STATE_ALARM_ARMED_AWAY), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): + _state_schema(STATE_ALARM_ARMED_HOME), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): + _state_schema(STATE_ALARM_ARMED_NIGHT), + vol.Optional(STATE_ALARM_DISARMED, default={}): + _state_schema(STATE_ALARM_DISARMED), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): + _state_schema(STATE_ALARM_TRIGGERED), + vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, + vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, +}), _state_validator)) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the manual MQTT alarm platform.""" + add_entities([ManualMQTTAlarm( + hass, + config[CONF_NAME], + config.get(CONF_CODE), + config.get(CONF_CODE_TEMPLATE), + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config.get(mqtt.CONF_STATE_TOPIC), + config.get(mqtt.CONF_COMMAND_TOPIC), + config.get(mqtt.CONF_QOS), + config.get(CONF_PAYLOAD_DISARM), + config.get(CONF_PAYLOAD_ARM_HOME), + config.get(CONF_PAYLOAD_ARM_AWAY), + config.get(CONF_PAYLOAD_ARM_NIGHT), + config)]) + + +class ManualMQTTAlarm(alarm.AlarmControlPanel): + """ + Representation of an alarm status. + + When armed, will be pending for 'pending_time', after that armed. + When triggered, will be pending for the triggering state's 'delay_time' + plus the triggered state's 'pending_time'. + After that will be triggered for 'trigger_time', after that we return to + the previous state or disarm if `disarm_after_trigger` is true. + A trigger_time of zero disables the alarm_trigger service. + """ + + def __init__(self, hass, name, code, code_template, disarm_after_trigger, + state_topic, command_topic, qos, payload_disarm, + payload_arm_home, payload_arm_away, payload_arm_night, + config): + """Init the manual MQTT alarm panel.""" + self._state = STATE_ALARM_DISARMED + self._hass = hass + self._name = name + if code_template: + self._code = code_template + self._code.hass = hass + else: + self._code = code or None + self._disarm_after_trigger = disarm_after_trigger + self._previous_state = self._state + self._state_ts = None + + self._delay_time_by_state = { + state: config[state][CONF_DELAY_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._trigger_time_by_state = { + state: config[state][CONF_TRIGGER_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._pending_time_by_state = { + state: config[state][CONF_PENDING_TIME] + for state in SUPPORTED_PENDING_STATES} + + self._state_topic = state_topic + self._command_topic = command_topic + self._qos = qos + self._payload_disarm = payload_disarm + self._payload_arm_home = payload_arm_home + self._payload_arm_away = payload_arm_away + self._payload_arm_night = payload_arm_night + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self._state == STATE_ALARM_TRIGGERED: + if self._within_pending_time(self._state): + return STATE_ALARM_PENDING + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): + if self._disarm_after_trigger: + return STATE_ALARM_DISARMED + self._state = self._previous_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + + return self._state + + @property + def _active_state(self): + """Get the current state.""" + if self.state == STATE_ALARM_PENDING: + return self._previous_state + return self._state + + def _pending_time(self, state): + """Get the pending time.""" + pending_time = self._pending_time_by_state[state] + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + """Get if the action is in the pending time window.""" + return self._state_ts + self._pending_time(state) > dt_util.utcnow() + + @property + def code_format(self): + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return alarm.FORMAT_NUMBER + return alarm.FORMAT_TEXT + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if not self._validate_code(code, STATE_ALARM_DISARMED): + return + + self._state = STATE_ALARM_DISARMED + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + return + + self._update_state(STATE_ALARM_ARMED_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + return + + self._update_state(STATE_ALARM_ARMED_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._update_state(STATE_ALARM_ARMED_NIGHT) + + def alarm_trigger(self, code=None): + """ + Send alarm trigger command. + + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + """Update the state.""" + if self._state == state: + return + + self._previous_state = self._state + self._state = state + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) + + trigger_time = self._trigger_time_by_state[self._previous_state] + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time + trigger_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) + + def _validate_code(self, code, state): + """Validate given code.""" + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code + if not check: + _LOGGER.warning("Invalid code given for %s", state) + return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + async_track_state_change( + self.hass, self.entity_id, self._async_state_changed_listener + ) + + @callback + def message_received(topic, payload, qos): + """Run when new MQTT message has been received.""" + if payload == self._payload_disarm: + self.async_alarm_disarm(self._code) + elif payload == self._payload_arm_home: + self.async_alarm_arm_home(self._code) + elif payload == self._payload_arm_away: + self.async_alarm_arm_away(self._code) + elif payload == self._payload_arm_night: + self.async_alarm_arm_night(self._code) + else: + _LOGGER.warning("Received unexpected payload: %s", payload) + return + + await mqtt.async_subscribe( + self.hass, self._command_topic, message_received, self._qos) + + async def _async_state_changed_listener(self, entity_id, old_state, + new_state): + """Publish state change to MQTT.""" + mqtt.async_publish( + self.hass, self._state_topic, new_state.state, self._qos, True) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/ness_alarm.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/ness_alarm.py new file mode 100644 index 00000000000000..ee3a0c213cba3e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/ness_alarm.py @@ -0,0 +1,107 @@ +""" +Support for Ness D8X/D16X alarm panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.ness_alarm/ +""" + +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.ness_alarm import ( + DATA_NESS, SIGNAL_ARMING_STATE_CHANGED) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMING, + STATE_ALARM_TRIGGERED, STATE_ALARM_PENDING, STATE_ALARM_DISARMED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['ness_alarm'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Ness Alarm alarm control panel devices.""" + if discovery_info is None: + return + + device = NessAlarmPanel(hass.data[DATA_NESS], 'Alarm Panel') + async_add_entities([device]) + + +class NessAlarmPanel(alarm.AlarmControlPanel): + """Representation of a Ness alarm panel.""" + + def __init__(self, client, name): + """Initialize the alarm panel.""" + self._client = client + self._name = name + self._state = None + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ARMING_STATE_CHANGED, + self._handle_arming_state_change) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return the regex for code format or None if no code is required.""" + return alarm.FORMAT_NUMBER + + @property + def state(self): + """Return the state of the device.""" + return self._state + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self._client.disarm(code) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self._client.arm_away(code) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await self._client.arm_home(code) + + async def async_alarm_trigger(self, code=None): + """Send trigger/panic command.""" + await self._client.panic(code) + + @callback + def _handle_arming_state_change(self, arming_state): + """Handle arming state update.""" + from nessclient import ArmingState + + if arming_state == ArmingState.UNKNOWN: + self._state = None + elif arming_state == ArmingState.DISARMED: + self._state = STATE_ALARM_DISARMED + elif arming_state == ArmingState.ARMING: + self._state = STATE_ALARM_ARMING + elif arming_state == ArmingState.EXIT_DELAY: + self._state = STATE_ALARM_ARMING + elif arming_state == ArmingState.ARMED: + self._state = STATE_ALARM_ARMED_AWAY + elif arming_state == ArmingState.ENTRY_DELAY: + self._state = STATE_ALARM_PENDING + elif arming_state == ArmingState.TRIGGERED: + self._state = STATE_ALARM_TRIGGERED + else: + _LOGGER.warning("Unhandled arming state: %s", arming_state) + + self.async_schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/nx584.py new file mode 100644 index 00000000000000..c84872d0b257da --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/nx584.py @@ -0,0 +1,124 @@ +""" +Support for NX584 alarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.nx584/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pynx584==0.4'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'NX584' +DEFAULT_PORT = 5007 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the NX584 platform.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + url = 'http://{}:{}'.format(host, port) + + try: + add_entities([NX584Alarm(hass, url, name)]) + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to NX584: %s", str(ex)) + return + + +class NX584Alarm(alarm.AlarmControlPanel): + """Representation of a NX584-based alarm panel.""" + + def __init__(self, hass, url, name): + """Init the nx584 alarm panel.""" + from nx584 import client + self._hass = hass + self._name = name + self._url = url + self._alarm = client.Client(self._url) + # Do an initial list operation so that we will try to actually + # talk to the API and trigger a requests exception for setup_platform() + # to catch + self._alarm.list_zones() + self._state = None + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def code_format(self): + """Return one or more digits/characters.""" + return alarm.FORMAT_NUMBER + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Process new events from panel.""" + try: + part = self._alarm.list_partitions()[0] + zones = self._alarm.list_zones() + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to %(host)s: %(reason)s", + dict(host=self._url, reason=ex)) + self._state = None + zones = [] + except IndexError: + _LOGGER.error("NX584 reports no partitions") + self._state = None + zones = [] + + bypassed = False + for zone in zones: + if zone['bypassed']: + _LOGGER.debug("Zone %(zone)s is bypassed, assuming HOME", + dict(zone=zone['number'])) + bypassed = True + break + + if not part['armed']: + self._state = STATE_ALARM_DISARMED + elif bypassed: + self._state = STATE_ALARM_ARMED_HOME + else: + self._state = STATE_ALARM_ARMED_AWAY + + for flag in part['condition_flags']: + if flag == "Siren on": + self._state = STATE_ALARM_TRIGGERED + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._alarm.disarm(code) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._alarm.arm('stay') + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._alarm.arm('exit') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/services.yaml new file mode 100644 index 00000000000000..7918631464fee3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/services.yaml @@ -0,0 +1,133 @@ +# Describes the format for available alarm control panel services + +alarm_disarm: + description: Send the alarm the command for disarm. + fields: + entity_id: + description: Name of alarm control panel to disarm. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to disarm the alarm control panel with. + example: 1234 + +alarm_arm_home: + description: Send the alarm the command for arm home. + fields: + entity_id: + description: Name of alarm control panel to arm home. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm home the alarm control panel with. + example: 1234 + +alarm_arm_away: + description: Send the alarm the command for arm away. + fields: + entity_id: + description: Name of alarm control panel to arm away. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm away the alarm control panel with. + example: 1234 + +alarm_arm_night: + description: Send the alarm the command for arm night. + fields: + entity_id: + description: Name of alarm control panel to arm night. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm night the alarm control panel with. + example: 1234 + +alarm_trigger: + description: Send the alarm the command for trigger. + fields: + entity_id: + description: Name of alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to trigger the alarm control panel with. + example: 1234 + +envisalink_alarm_keypress: + description: Send custom keypresses to the alarm. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + keypress: + description: 'String to send to the alarm panel (1-6 characters).' + example: '*71' + +alarmdecoder_alarm_toggle_chime: + description: Send the alarm the toggle chime command. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: A required code to toggle the alarm control panel chime with. + example: 1234 + +ifttt_push_alarm_state: + description: Update the alarm state to the specified value. + fields: + entity_id: + description: Name of the alarm control panel which state has to be updated. + example: 'alarm_control_panel.downstairs' + state: + description: The state to which the alarm control panel has to be set. + example: 'armed_night' + +elkm1_alarm_arm_vacation: + description: Arm the ElkM1 in vacation mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_arm_home_instant: + description: Arm the ElkM1 in home instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_arm_night_instant: + description: Arm the ElkM1 in night instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_display_message: + description: Display a message on all of the ElkM1 keypads for an area. + fields: + entity_id: + description: Name of alarm control panel to display messages on. + example: 'alarm_control_panel.main' + clear: + description: 0=clear message, 1=clear message with * key, 2=Display until timeout; default 2 + example: 1 + beep: + description: 0=no beep, 1=beep; default 0 + example: 1 + timeout: + description: Time to display message, 0=forever, max 65535, default 0 + example: 4242 + line1: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: The answer to life, + line2: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: the universe, and everything. diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/spc.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/spc.py new file mode 100644 index 00000000000000..7adbb6167746fb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/spc.py @@ -0,0 +1,107 @@ +""" +Support for Vanderbilt (formerly Siemens) SPC alarm systems. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.spc/ +""" +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import callback +from homeassistant.components.spc import (DATA_API, SIGNAL_UPDATE_ALARM) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) + +_LOGGER = logging.getLogger(__name__) + + +def _get_alarm_state(area): + """Get the alarm state.""" + from pyspcwebgw.const import AreaMode + + if area.verified_alarm: + return STATE_ALARM_TRIGGERED + + mode_to_state = { + AreaMode.UNSET: STATE_ALARM_DISARMED, + AreaMode.PART_SET_A: STATE_ALARM_ARMED_HOME, + AreaMode.PART_SET_B: STATE_ALARM_ARMED_NIGHT, + AreaMode.FULL_SET: STATE_ALARM_ARMED_AWAY, + } + return mode_to_state.get(area.mode) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the SPC alarm control panel platform.""" + if discovery_info is None: + return + api = hass.data[DATA_API] + async_add_entities([SpcAlarm(area=area, api=api) + for area in api.areas.values()]) + + +class SpcAlarm(alarm.AlarmControlPanel): + """Representation of the SPC alarm panel.""" + + def __init__(self, area, api): + """Initialize the SPC alarm panel.""" + self._area = area + self._api = api + + async def async_added_to_hass(self): + """Call for adding new entities.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_ALARM.format(self._area.id), + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._area.name + + @property + def changed_by(self): + """Return the user the last change was triggered by.""" + return self._area.last_changed_by + + @property + def state(self): + """Return the state of the device.""" + return _get_alarm_state(self._area) + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + from pyspcwebgw.const import AreaMode + await self._api.change_mode(area=self._area, + new_mode=AreaMode.UNSET) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + from pyspcwebgw.const import AreaMode + await self._api.change_mode(area=self._area, + new_mode=AreaMode.PART_SET_A) + + async def async_alarm_arm_night(self, code=None): + """Send arm home command.""" + from pyspcwebgw.const import AreaMode + await self._api.change_mode(area=self._area, + new_mode=AreaMode.PART_SET_B) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + from pyspcwebgw.const import AreaMode + await self._api.change_mode(area=self._area, + new_mode=AreaMode.FULL_SET) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/totalconnect.py new file mode 100644 index 00000000000000..3b0725658d42ec --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/totalconnect.py @@ -0,0 +1,106 @@ +""" +Interfaces with TotalConnect alarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.totalconnect/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, CONF_NAME, + STATE_ALARM_ARMED_CUSTOM_BYPASS) + + +REQUIREMENTS = ['total_connect_client==0.22'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Total Connect' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a TotalConnect control panel.""" + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + total_connect = TotalConnect(name, username, password) + add_entities([total_connect], True) + + +class TotalConnect(alarm.AlarmControlPanel): + """Represent an TotalConnect status.""" + + def __init__(self, name, username, password): + """Initialize the TotalConnect status.""" + from total_connect_client import TotalConnectClient + + _LOGGER.debug("Setting up TotalConnect...") + self._name = name + self._username = username + self._password = password + self._state = None + self._client = TotalConnectClient.TotalConnectClient( + username, password) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Return the state of the device.""" + status = self._client.get_armed_status() + + if status == self._client.DISARMED: + state = STATE_ALARM_DISARMED + elif status == self._client.ARMED_STAY: + state = STATE_ALARM_ARMED_HOME + elif status == self._client.ARMED_AWAY: + state = STATE_ALARM_ARMED_AWAY + elif status == self._client.ARMED_STAY_NIGHT: + state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS + elif status == self._client.ARMING: + state = STATE_ALARM_ARMING + elif status == self._client.DISARMING: + state = STATE_ALARM_DISARMING + else: + state = None + + self._state = state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._client.disarm() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._client.arm_stay() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._client.arm_away() + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + self._client.arm_stay_night() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/yale_smart_alarm.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/yale_smart_alarm.py new file mode 100644 index 00000000000000..67b74033442d35 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarm_control_panel/yale_smart_alarm.py @@ -0,0 +1,98 @@ +""" +Yale Smart Alarm client for interacting with the Yale Smart Alarm System API. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/alarm_control_panel.yale_smart_alarm +""" +import logging + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanel, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_NAME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['yalesmartalarmclient==0.1.6'] + +CONF_AREA_ID = 'area_id' + +DEFAULT_NAME = 'Yale Smart Alarm' + +DEFAULT_AREA_ID = '1' + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_AREA_ID, default=DEFAULT_AREA_ID): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the alarm platform.""" + name = config[CONF_NAME] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + area_id = config[CONF_AREA_ID] + + from yalesmartalarmclient.client import ( + YaleSmartAlarmClient, AuthenticationError) + try: + client = YaleSmartAlarmClient(username, password, area_id) + except AuthenticationError: + _LOGGER.error("Authentication failed. Check credentials") + return + + add_entities([YaleAlarmDevice(name, client)], True) + + +class YaleAlarmDevice(AlarmControlPanel): + """Represent a Yale Smart Alarm.""" + + def __init__(self, name, client): + """Initialize the Yale Alarm Device.""" + self._name = name + self._client = client + self._state = None + + from yalesmartalarmclient.client import (YALE_STATE_DISARM, + YALE_STATE_ARM_PARTIAL, + YALE_STATE_ARM_FULL) + self._state_map = { + YALE_STATE_DISARM: STATE_ALARM_DISARMED, + YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME, + YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY + } + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Return the state of the device.""" + armed_status = self._client.get_armed_status() + + self._state = self._state_map.get(armed_status) + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._client.disarm() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._client.arm_partial() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._client.arm_full() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/__init__.py new file mode 100644 index 00000000000000..92eab728210c8d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/__init__.py @@ -0,0 +1,206 @@ +""" +Support for AlarmDecoder devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alarmdecoder/ +""" +import logging + +from datetime import timedelta +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import dt as dt_util +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA + +REQUIREMENTS = ['alarmdecoder==1.13.2'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'alarmdecoder' + +DATA_AD = 'alarmdecoder' + +CONF_DEVICE = 'device' +CONF_DEVICE_BAUD = 'baudrate' +CONF_DEVICE_HOST = 'host' +CONF_DEVICE_PATH = 'path' +CONF_DEVICE_PORT = 'port' +CONF_DEVICE_TYPE = 'type' +CONF_PANEL_DISPLAY = 'panel_display' +CONF_ZONE_NAME = 'name' +CONF_ZONE_TYPE = 'type' +CONF_ZONE_LOOP = 'loop' +CONF_ZONE_RFID = 'rfid' +CONF_ZONES = 'zones' +CONF_RELAY_ADDR = 'relayaddr' +CONF_RELAY_CHAN = 'relaychan' + +DEFAULT_DEVICE_TYPE = 'socket' +DEFAULT_DEVICE_HOST = 'localhost' +DEFAULT_DEVICE_PORT = 10000 +DEFAULT_DEVICE_PATH = '/dev/ttyUSB0' +DEFAULT_DEVICE_BAUD = 115200 + +DEFAULT_PANEL_DISPLAY = False + +DEFAULT_ZONE_TYPE = 'opening' + +SIGNAL_PANEL_MESSAGE = 'alarmdecoder.panel_message' +SIGNAL_PANEL_ARM_AWAY = 'alarmdecoder.panel_arm_away' +SIGNAL_PANEL_ARM_HOME = 'alarmdecoder.panel_arm_home' +SIGNAL_PANEL_DISARM = 'alarmdecoder.panel_disarm' + +SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault' +SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore' +SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message' +SIGNAL_REL_MESSAGE = 'alarmdecoder.rel_message' + +DEVICE_SOCKET_SCHEMA = vol.Schema({ + vol.Required(CONF_DEVICE_TYPE): 'socket', + vol.Optional(CONF_DEVICE_HOST, default=DEFAULT_DEVICE_HOST): cv.string, + vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT): cv.port}) + +DEVICE_SERIAL_SCHEMA = vol.Schema({ + vol.Required(CONF_DEVICE_TYPE): 'serial', + vol.Optional(CONF_DEVICE_PATH, default=DEFAULT_DEVICE_PATH): cv.string, + vol.Optional(CONF_DEVICE_BAUD, default=DEFAULT_DEVICE_BAUD): cv.string}) + +DEVICE_USB_SCHEMA = vol.Schema({ + vol.Required(CONF_DEVICE_TYPE): 'usb'}) + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_NAME): cv.string, + vol.Optional(CONF_ZONE_TYPE, + default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA), + vol.Optional(CONF_ZONE_RFID): cv.string, + vol.Optional(CONF_ZONE_LOOP): + vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), + vol.Inclusive(CONF_RELAY_ADDR, 'relaylocation', + 'Relay address and channel must exist together'): cv.byte, + vol.Inclusive(CONF_RELAY_CHAN, 'relaylocation', + 'Relay address and channel must exist together'): cv.byte}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): vol.Any( + DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA, + DEVICE_USB_SCHEMA), + vol.Optional(CONF_PANEL_DISPLAY, + default=DEFAULT_PANEL_DISPLAY): cv.boolean, + vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up for the AlarmDecoder devices.""" + from alarmdecoder import AlarmDecoder + from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice) + + conf = config.get(DOMAIN) + + restart = False + device = conf.get(CONF_DEVICE) + display = conf.get(CONF_PANEL_DISPLAY) + zones = conf.get(CONF_ZONES) + + device_type = device.get(CONF_DEVICE_TYPE) + host = DEFAULT_DEVICE_HOST + port = DEFAULT_DEVICE_PORT + path = DEFAULT_DEVICE_PATH + baud = DEFAULT_DEVICE_BAUD + + def stop_alarmdecoder(event): + """Handle the shutdown of AlarmDecoder.""" + _LOGGER.debug("Shutting down alarmdecoder") + nonlocal restart + restart = False + controller.close() + + def open_connection(now=None): + """Open a connection to AlarmDecoder.""" + from alarmdecoder.util import NoDeviceError + nonlocal restart + try: + controller.open(baud) + except NoDeviceError: + _LOGGER.debug("Failed to connect. Retrying in 5 seconds") + hass.helpers.event.track_point_in_time( + open_connection, dt_util.utcnow() + timedelta(seconds=5)) + return + _LOGGER.debug("Established a connection with the alarmdecoder") + restart = True + + def handle_closed_connection(event): + """Restart after unexpected loss of connection.""" + nonlocal restart + if not restart: + return + restart = False + _LOGGER.warning("AlarmDecoder unexpectedly lost connection.") + hass.add_job(open_connection) + + def handle_message(sender, message): + """Handle message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_PANEL_MESSAGE, message) + + def handle_rfx_message(sender, message): + """Handle RFX message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_RFX_MESSAGE, message) + + def zone_fault_callback(sender, zone): + """Handle zone fault from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_FAULT, zone) + + def zone_restore_callback(sender, zone): + """Handle zone restore from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_RESTORE, zone) + + def handle_rel_message(sender, message): + """Handle relay message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_REL_MESSAGE, message) + + controller = False + if device_type == 'socket': + host = device.get(CONF_DEVICE_HOST) + port = device.get(CONF_DEVICE_PORT) + controller = AlarmDecoder(SocketDevice(interface=(host, port))) + elif device_type == 'serial': + path = device.get(CONF_DEVICE_PATH) + baud = device.get(CONF_DEVICE_BAUD) + controller = AlarmDecoder(SerialDevice(interface=path)) + elif device_type == 'usb': + AlarmDecoder(USBDevice.find()) + return False + + controller.on_message += handle_message + controller.on_rfx_message += handle_rfx_message + controller.on_zone_fault += zone_fault_callback + controller.on_zone_restore += zone_restore_callback + controller.on_close += handle_closed_connection + controller.on_relay_changed += handle_rel_message + + hass.data[DATA_AD] = controller + + open_connection() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) + + load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) + + if zones: + load_platform( + hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) + + if display: + load_platform(hass, 'sensor', DOMAIN, conf, config) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/alarm_control_panel.py new file mode 100644 index 00000000000000..16e82280433d7e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -0,0 +1,142 @@ +""" +Support for AlarmDecoder-based alarm control panels (Honeywell/DSC). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ +""" +import logging + +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarmdecoder import DATA_AD, SIGNAL_PANEL_MESSAGE +from homeassistant.const import ( + ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['alarmdecoder'] + +SERVICE_ALARM_TOGGLE_CHIME = 'alarmdecoder_alarm_toggle_chime' +ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({ + vol.Required(ATTR_CODE): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up for AlarmDecoder alarm panels.""" + device = AlarmDecoderAlarmPanel() + add_entities([device]) + + def alarm_toggle_chime_handler(service): + """Register toggle chime handler.""" + code = service.data.get(ATTR_CODE) + device.alarm_toggle_chime(code) + + hass.services.register( + alarm.DOMAIN, SERVICE_ALARM_TOGGLE_CHIME, alarm_toggle_chime_handler, + schema=ALARM_TOGGLE_CHIME_SCHEMA) + + +class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self): + """Initialize the alarm panel.""" + self._display = "" + self._name = "Alarm Panel" + self._state = None + self._ac_power = None + self._backlight_on = None + self._battery_low = None + self._check_zone = None + self._chime = None + self._entry_delay_off = None + self._programming_mode = None + self._ready = None + self._zone_bypassed = None + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) + + def _message_callback(self, message): + """Handle received messages.""" + if message.alarm_sounding or message.fire_alarm: + self._state = STATE_ALARM_TRIGGERED + elif message.armed_away: + self._state = STATE_ALARM_ARMED_AWAY + elif message.armed_home: + self._state = STATE_ALARM_ARMED_HOME + else: + self._state = STATE_ALARM_DISARMED + + self._ac_power = message.ac_power + self._backlight_on = message.backlight_on + self._battery_low = message.battery_low + self._check_zone = message.check_zone + self._chime = message.chime_on + self._entry_delay_off = message.entry_delay_off + self._programming_mode = message.programming_mode + self._ready = message.ready + self._zone_bypassed = message.zone_bypassed + + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return one or more digits/characters.""" + return alarm.FORMAT_NUMBER + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'ac_power': self._ac_power, + 'backlight_on': self._backlight_on, + 'battery_low': self._battery_low, + 'check_zone': self._check_zone, + 'chime': self._chime, + 'entry_delay_off': self._entry_delay_off, + 'programming_mode': self._programming_mode, + 'ready': self._ready, + 'zone_bypassed': self._zone_bypassed, + } + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + self.hass.data[DATA_AD].send("{!s}1".format(code)) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + self.hass.data[DATA_AD].send("{!s}2".format(code)) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + self.hass.data[DATA_AD].send("{!s}3".format(code)) + + def alarm_toggle_chime(self, code=None): + """Send toggle chime command.""" + if code: + self.hass.data[DATA_AD].send("{!s}9".format(code)) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/binary_sensor.py new file mode 100644 index 00000000000000..d8fddeaa540b21 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/binary_sensor.py @@ -0,0 +1,145 @@ +""" +Support for AlarmDecoder zone states- represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.alarmdecoder/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.alarmdecoder import ( + ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, + CONF_ZONE_RFID, CONF_ZONE_LOOP, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE, + SIGNAL_RFX_MESSAGE, SIGNAL_REL_MESSAGE, CONF_RELAY_ADDR, + CONF_RELAY_CHAN) + +DEPENDENCIES = ['alarmdecoder'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_RF_BIT0 = 'rf_bit0' +ATTR_RF_LOW_BAT = 'rf_low_battery' +ATTR_RF_SUPERVISED = 'rf_supervised' +ATTR_RF_BIT3 = 'rf_bit3' +ATTR_RF_LOOP3 = 'rf_loop3' +ATTR_RF_LOOP2 = 'rf_loop2' +ATTR_RF_LOOP4 = 'rf_loop4' +ATTR_RF_LOOP1 = 'rf_loop1' + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the AlarmDecoder binary sensor devices.""" + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + for zone_num in configured_zones: + device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) + zone_type = device_config_data[CONF_ZONE_TYPE] + zone_name = device_config_data[CONF_ZONE_NAME] + zone_rfid = device_config_data.get(CONF_ZONE_RFID) + zone_loop = device_config_data.get(CONF_ZONE_LOOP) + relay_addr = device_config_data.get(CONF_RELAY_ADDR) + relay_chan = device_config_data.get(CONF_RELAY_CHAN) + device = AlarmDecoderBinarySensor( + zone_num, zone_name, zone_type, zone_rfid, zone_loop, relay_addr, + relay_chan) + devices.append(device) + + add_entities(devices) + + return True + + +class AlarmDecoderBinarySensor(BinarySensorDevice): + """Representation of an AlarmDecoder binary sensor.""" + + def __init__(self, zone_number, zone_name, zone_type, zone_rfid, zone_loop, + relay_addr, relay_chan): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._zone_type = zone_type + self._state = None + self._name = zone_name + self._rfid = zone_rfid + self._loop = zone_loop + self._rfstate = None + self._relay_addr = relay_addr + self._relay_chan = relay_chan + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RFX_MESSAGE, self._rfx_message_callback) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_REL_MESSAGE, self._rel_message_callback) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + if self._rfid and self._rfstate is not None: + attr[ATTR_RF_BIT0] = bool(self._rfstate & 0x01) + attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02) + attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04) + attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08) + attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10) + attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20) + attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40) + attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80) + return attr + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + def _fault_callback(self, zone): + """Update the zone's state, if needed.""" + if zone is None or int(zone) == self._zone_number: + self._state = 1 + self.schedule_update_ha_state() + + def _restore_callback(self, zone): + """Update the zone's state, if needed.""" + if zone is None or int(zone) == self._zone_number: + self._state = 0 + self.schedule_update_ha_state() + + def _rfx_message_callback(self, message): + """Update RF state.""" + if self._rfid and message and message.serial_number == self._rfid: + self._rfstate = message.value + if self._loop: + self._state = 1 if message.loop[self._loop - 1] else 0 + self.schedule_update_ha_state() + + def _rel_message_callback(self, message): + """Update relay state.""" + if (self._relay_addr == message.address and + self._relay_chan == message.channel): + _LOGGER.debug("Relay %d:%d value:%d", message.address, + message.channel, message.value) + self._state = message.value + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/sensor.py new file mode 100644 index 00000000000000..546d09299dcddf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alarmdecoder/sensor.py @@ -0,0 +1,64 @@ +""" +Support for AlarmDecoder Sensors (Shows Panel Display). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.alarmdecoder/ +""" +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.components.alarmdecoder import (SIGNAL_PANEL_MESSAGE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['alarmdecoder'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up for AlarmDecoder sensor devices.""" + _LOGGER.debug("AlarmDecoderSensor: setup_platform") + + device = AlarmDecoderSensor(hass) + + add_entities([device]) + + +class AlarmDecoderSensor(Entity): + """Representation of an AlarmDecoder keypad.""" + + def __init__(self, hass): + """Initialize the alarm panel.""" + self._display = "" + self._state = None + self._icon = 'mdi:alarm-check' + self._name = 'Alarm Panel Display' + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) + + def _message_callback(self, message): + if self._display != message.text: + self._display = message.text + self.schedule_update_ha_state() + + @property + def icon(self): + """Return the icon if any.""" + return self._icon + + @property + def state(self): + """Return the overall state.""" + return self._display + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alert.py b/homeassistant-0.88.0.dev0/homeassistant/components/alert.py new file mode 100644 index 00000000000000..579a19c1b521f5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alert.py @@ -0,0 +1,296 @@ +""" +Support for repeating alerts when conditions are met. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alert/ +""" +import asyncio +import logging +from datetime import datetime, timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, DOMAIN as DOMAIN_NOTIFY) +from homeassistant.const import ( + CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE, STATE_ON, STATE_OFF, + SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) +from homeassistant.helpers import service, event +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'alert' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_CAN_ACK = 'can_acknowledge' +CONF_NOTIFIERS = 'notifiers' +CONF_REPEAT = 'repeat' +CONF_SKIP_FIRST = 'skip_first' +CONF_ALERT_MESSAGE = 'message' +CONF_DONE_MESSAGE = 'done_message' +CONF_TITLE = 'title' +CONF_DATA = 'data' + +DEFAULT_CAN_ACK = True +DEFAULT_SKIP_FIRST = False + +ALERT_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_STATE, default=STATE_ON): cv.string, + vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), + vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, + vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, + vol.Optional(CONF_ALERT_MESSAGE): cv.template, + vol.Optional(CONF_DONE_MESSAGE): cv.template, + vol.Optional(CONF_TITLE): cv.template, + vol.Optional(CONF_DATA): dict, + vol.Required(CONF_NOTIFIERS): cv.ensure_list}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys(ALERT_SCHEMA), +}, extra=vol.ALLOW_EXTRA) + +ALERT_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +def is_on(hass, entity_id): + """Return if the alert is firing and not acknowledged.""" + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass, config): + """Set up the Alert component.""" + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + watched_entity_id = cfg.get(CONF_ENTITY_ID) + alert_state = cfg.get(CONF_STATE) + repeat = cfg.get(CONF_REPEAT) + skip_first = cfg.get(CONF_SKIP_FIRST) + message_template = cfg.get(CONF_ALERT_MESSAGE) + done_message_template = cfg.get(CONF_DONE_MESSAGE) + notifiers = cfg.get(CONF_NOTIFIERS) + can_ack = cfg.get(CONF_CAN_ACK) + title_template = cfg.get(CONF_TITLE) + data = cfg.get(CONF_DATA) + + entities.append(Alert(hass, object_id, name, + watched_entity_id, alert_state, repeat, + skip_first, message_template, + done_message_template, notifiers, + can_ack, title_template, data)) + + if not entities: + return False + + async def async_handle_alert_service(service_call): + """Handle calls to alert services.""" + alert_ids = service.extract_entity_ids(hass, service_call) + + for alert_id in alert_ids: + for alert in entities: + if alert.entity_id != alert_id: + continue + + alert.async_set_context(service_call.context) + if service_call.service == SERVICE_TURN_ON: + await alert.async_turn_on() + elif service_call.service == SERVICE_TOGGLE: + await alert.async_toggle() + else: + await alert.async_turn_off() + + # Setup service calls + hass.services.async_register( + DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service, + schema=ALERT_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_handle_alert_service, + schema=ALERT_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, + schema=ALERT_SERVICE_SCHEMA) + + tasks = [alert.async_update_ha_state() for alert in entities] + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + return True + + +class Alert(ToggleEntity): + """Representation of an alert.""" + + def __init__(self, hass, entity_id, name, watched_entity_id, + state, repeat, skip_first, message_template, + done_message_template, notifiers, can_ack, title_template, + data): + """Initialize the alert.""" + self.hass = hass + self._name = name + self._alert_state = state + self._skip_first = skip_first + self._data = data + + self._message_template = message_template + if self._message_template is not None: + self._message_template.hass = hass + + self._done_message_template = done_message_template + if self._done_message_template is not None: + self._done_message_template.hass = hass + + self._title_template = title_template + if self._title_template is not None: + self._title_template.hass = hass + + self._notifiers = notifiers + self._can_ack = can_ack + + self._delay = [timedelta(minutes=val) for val in repeat] + self._next_delay = 0 + + self._firing = False + self._ack = False + self._cancel = None + self._send_done_message = False + self.entity_id = ENTITY_ID_FORMAT.format(entity_id) + + event.async_track_state_change( + hass, watched_entity_id, self.watched_entity_change) + + @property + def name(self): + """Return the name of the alert.""" + return self._name + + @property + def should_poll(self): + """HASS need not poll these entities.""" + return False + + @property + def state(self): + """Return the alert status.""" + if self._firing: + if self._ack: + return STATE_OFF + return STATE_ON + return STATE_IDLE + + @property + def hidden(self): + """Hide the alert when it is not firing.""" + return not self._can_ack or not self._firing + + async def watched_entity_change(self, entity, from_state, to_state): + """Determine if the alert should start or stop.""" + _LOGGER.debug("Watched entity (%s) has changed", entity) + if to_state.state == self._alert_state and not self._firing: + await self.begin_alerting() + if to_state.state != self._alert_state and self._firing: + await self.end_alerting() + + async def begin_alerting(self): + """Begin the alert procedures.""" + _LOGGER.debug("Beginning Alert: %s", self._name) + self._ack = False + self._firing = True + self._next_delay = 0 + + if not self._skip_first: + await self._notify() + else: + await self._schedule_notify() + + self.async_schedule_update_ha_state() + + async def end_alerting(self): + """End the alert procedures.""" + _LOGGER.debug("Ending Alert: %s", self._name) + self._cancel() + self._ack = False + self._firing = False + if self._send_done_message: + await self._notify_done_message() + self.async_schedule_update_ha_state() + + async def _schedule_notify(self): + """Schedule a notification.""" + delay = self._delay[self._next_delay] + next_msg = datetime.now() + delay + self._cancel = \ + event.async_track_point_in_time(self.hass, self._notify, next_msg) + self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) + + async def _notify(self, *args): + """Send the alert notification.""" + if not self._firing: + return + + if not self._ack: + _LOGGER.info("Alerting: %s", self._name) + self._send_done_message = True + + if self._message_template is not None: + message = self._message_template.async_render() + else: + message = self._name + + await self._send_notification_message(message) + await self._schedule_notify() + + async def _notify_done_message(self, *args): + """Send notification of complete alert.""" + _LOGGER.info("Alerting: %s", self._done_message_template) + self._send_done_message = False + + if self._done_message_template is None: + return + + message = self._done_message_template.async_render() + + await self._send_notification_message(message) + + async def _send_notification_message(self, message): + + msg_payload = {ATTR_MESSAGE: message} + + if self._title_template is not None: + title = self._title_template.async_render() + msg_payload.update({ATTR_TITLE: title}) + if self._data: + msg_payload.update({ATTR_DATA: self._data}) + + _LOGGER.debug(msg_payload) + + for target in self._notifiers: + await self.hass.services.async_call( + DOMAIN_NOTIFY, target, msg_payload) + + async def async_turn_on(self, **kwargs): + """Async Unacknowledge alert.""" + _LOGGER.debug("Reset Alert: %s", self._name) + self._ack = False + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Async Acknowledge alert.""" + _LOGGER.debug("Acknowledged Alert: %s", self._name) + self._ack = True + await self.async_update_ha_state() + + async def async_toggle(self, **kwargs): + """Async toggle alert.""" + if self._ack: + return await self.async_turn_on() + return await self.async_turn_off() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alexa/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/__init__.py new file mode 100644 index 00000000000000..8491268dfd6deb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/__init__.py @@ -0,0 +1,77 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entityfilter + +from . import flash_briefings, intent, smart_home +from .const import ( + CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL, + CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER, + CONF_ENTITY_CONFIG) + +_LOGGER = logging.getLogger(__name__) + +CONF_FLASH_BRIEFINGS = 'flash_briefings' +CONF_SMART_HOME = 'smart_home' + +DEPENDENCIES = ['http'] + +ALEXA_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(smart_home.CONF_DESCRIPTION): cv.string, + vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(smart_home.CONF_NAME): cv.string, +}) + +SMART_HOME_SCHEMA = vol.Schema({ + vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_FLASH_BRIEFINGS: { + cv.string: vol.All(cv.ensure_list, [{ + vol.Optional(CONF_UID): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), + }, + # vol.Optional here would mean we couldn't distinguish between an empty + # smart_home: and none at all. + CONF_SMART_HOME: vol.Any(SMART_HOME_SCHEMA, None), + } +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Activate Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + try: + smart_home_config = config[CONF_SMART_HOME] + except KeyError: + pass + else: + smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) + await smart_home.async_setup(hass, smart_home_config) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alexa/auth.py b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/auth.py new file mode 100644 index 00000000000000..978cb611895575 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/auth.py @@ -0,0 +1,154 @@ +"""Support for Alexa skill auth.""" + +import asyncio +import json +import logging +from datetime import timedelta +import aiohttp +import async_timeout + +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.util import dt +from .const import DEFAULT_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token" +LWA_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" +} + +PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300 +STORAGE_KEY = 'alexa_auth' +STORAGE_VERSION = 1 +STORAGE_EXPIRE_TIME = "expire_time" +STORAGE_ACCESS_TOKEN = "access_token" +STORAGE_REFRESH_TOKEN = "refresh_token" + + +class Auth: + """Handle authentication to send events to Alexa.""" + + def __init__(self, hass, client_id, client_secret): + """Initialize the Auth class.""" + self.hass = hass + + self.client_id = client_id + self.client_secret = client_secret + + self._prefs = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + self._get_token_lock = asyncio.Lock(loop=hass.loop) + + async def async_do_auth(self, accept_grant_code): + """Do authentication with an AcceptGrant code.""" + # access token not retrieved yet for the first time, so this should + # be an access token request + + lwa_params = { + "grant_type": "authorization_code", + "code": accept_grant_code, + "client_id": self.client_id, + "client_secret": self.client_secret + } + _LOGGER.debug("Calling LWA to get the access token (first time), " + "with: %s", json.dumps(lwa_params)) + + return await self._async_request_new_token(lwa_params) + + async def async_get_access_token(self): + """Perform access token or token refresh request.""" + async with self._get_token_lock: + if self._prefs is None: + await self.async_load_preferences() + + if self.is_token_valid(): + _LOGGER.debug("Token still valid, using it.") + return self._prefs[STORAGE_ACCESS_TOKEN] + + if self._prefs[STORAGE_REFRESH_TOKEN] is None: + _LOGGER.debug("Token invalid and no refresh token available.") + return None + + lwa_params = { + "grant_type": "refresh_token", + "refresh_token": self._prefs[STORAGE_REFRESH_TOKEN], + "client_id": self.client_id, + "client_secret": self.client_secret + } + + _LOGGER.debug("Calling LWA to refresh the access token.") + return await self._async_request_new_token(lwa_params) + + @callback + def is_token_valid(self): + """Check if a token is already loaded and if it is still valid.""" + if not self._prefs[STORAGE_ACCESS_TOKEN]: + return False + + expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) + preemptive_expire_time = expire_time - timedelta( + seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS) + + return dt.utcnow() < preemptive_expire_time + + async def _async_request_new_token(self, lwa_params): + + try: + session = aiohttp_client.async_get_clientsession(self.hass) + with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self.hass.loop): + response = await session.post(LWA_TOKEN_URI, + headers=LWA_HEADERS, + data=lwa_params, + allow_redirects=True) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout calling LWA to get auth token.") + return None + + _LOGGER.debug("LWA response header: %s", response.headers) + _LOGGER.debug("LWA response status: %s", response.status) + + if response.status != 200: + _LOGGER.error("Error calling LWA to get auth token.") + return None + + response_json = await response.json() + _LOGGER.debug("LWA response body : %s", response_json) + + access_token = response_json["access_token"] + refresh_token = response_json["refresh_token"] + expires_in = response_json["expires_in"] + expire_time = dt.utcnow() + timedelta(seconds=expires_in) + + await self._async_update_preferences(access_token, refresh_token, + expire_time.isoformat()) + + return access_token + + async def async_load_preferences(self): + """Load preferences with stored tokens.""" + self._prefs = await self._store.async_load() + + if self._prefs is None: + self._prefs = { + STORAGE_ACCESS_TOKEN: None, + STORAGE_REFRESH_TOKEN: None, + STORAGE_EXPIRE_TIME: None + } + + async def _async_update_preferences(self, access_token, refresh_token, + expire_time): + """Update user preferences.""" + if self._prefs is None: + await self.async_load_preferences() + + if access_token is not None: + self._prefs[STORAGE_ACCESS_TOKEN] = access_token + if refresh_token is not None: + self._prefs[STORAGE_REFRESH_TOKEN] = refresh_token + if expire_time is not None: + self._prefs[STORAGE_EXPIRE_TIME] = expire_time + await self._store.async_save(self._prefs) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alexa/const.py b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/const.py new file mode 100644 index 00000000000000..78f7d02f5f03e3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/const.py @@ -0,0 +1,28 @@ +"""Constants for the Alexa integration.""" +DOMAIN = 'alexa' + +# Flash briefing constants +CONF_UID = 'uid' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +CONF_FILTER = 'filter' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_ENDPOINT = 'endpoint' +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' + +DEFAULT_TIMEOUT = 30 diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alexa/flash_briefings.py b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/flash_briefings.py new file mode 100644 index 00000000000000..02f47b05617942 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,95 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import copy +from datetime import datetime +import logging +import uuid + +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.helpers import template + +from .const import ( + ATTR_MAIN_TEXT, ATTR_REDIRECTION_URL, ATTR_STREAM_URL, ATTR_TITLE_TEXT, + ATTR_UID, ATTR_UPDATE_DATE, CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, + CONF_TITLE, CONF_UID, DATE_FORMAT) + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view( + AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug("Received Alexa flash briefing request for: %s", + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = "No configured Alexa flash briefing was found for: %s" + _LOGGER.error(err, briefing_id) + return b'', 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alexa/intent.py b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/intent.py new file mode 100644 index 00000000000000..85cb4f105cd41a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/intent.py @@ -0,0 +1,291 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import enum +import logging + +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent +from homeassistant.util.decorator import Registry + +from .const import DOMAIN, SYN_RESOLUTION_MATCH + +_LOGGER = logging.getLogger(__name__) + +HANDLERS = Registry() + +INTENTS_API_ENDPOINT = '/api/alexa' + + +class SpeechType(enum.Enum): + """The Alexa speech types.""" + + plaintext = 'PlainText' + ssml = 'SSML' + + +SPEECH_MAPPINGS = { + 'plain': SpeechType.plaintext, + 'ssml': SpeechType.ssml, +} + + +class CardType(enum.Enum): + """The Alexa card types.""" + + simple = 'Simple' + link_account = 'LinkAccount' + + +@callback +def async_setup(hass): + """Activate Alexa component.""" + hass.http.register_view(AlexaIntentsView) + + +class UnknownRequest(HomeAssistantError): + """When an unknown Alexa request is passed in.""" + + +class AlexaIntentsView(http.HomeAssistantView): + """Handle Alexa requests.""" + + url = INTENTS_API_ENDPOINT + name = 'api:alexa' + + async def post(self, request): + """Handle Alexa.""" + hass = request.app['hass'] + message = await request.json() + + _LOGGER.debug("Received Alexa request: %s", message) + + try: + response = await async_handle_message(hass, message) + return b'' if response is None else self.json(response) + except UnknownRequest as err: + _LOGGER.warning(str(err)) + return self.json(intent_error_response( + hass, message, str(err))) + + except intent.UnknownIntent as err: + _LOGGER.warning(str(err)) + return self.json(intent_error_response( + hass, message, + "This intent is not yet configured within Home Assistant.")) + + except intent.InvalidSlotInfo as err: + _LOGGER.error("Received invalid slot data from Alexa: %s", err) + return self.json(intent_error_response( + hass, message, + "Invalid slot information received for this intent.")) + + except intent.IntentError as err: + _LOGGER.exception(str(err)) + return self.json(intent_error_response( + hass, message, "Error handling intent.")) + + +def intent_error_response(hass, message, error): + """Return an Alexa response that will speak the error message.""" + alexa_intent_info = message.get('request').get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) + alexa_response.add_speech(SpeechType.plaintext, error) + return alexa_response.as_dict() + + +async def async_handle_message(hass, message): + """Handle an Alexa intent. + + Raises: + - UnknownRequest + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + + """ + req = message.get('request') + req_type = req['type'] + + handler = HANDLERS.get(req_type) + + if not handler: + raise UnknownRequest('Received unknown request {}'.format(req_type)) + + return await handler(hass, message) + + +@HANDLERS.register('SessionEndedRequest') +async def async_handle_session_end(hass, message): + """Handle a session end request.""" + return None + + +@HANDLERS.register('IntentRequest') +@HANDLERS.register('LaunchRequest') +async def async_handle_intent(hass, message): + """Handle an intent request. + + Raises: + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + + """ + req = message.get('request') + alexa_intent_info = req.get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) + + if req['type'] == 'LaunchRequest': + intent_name = message.get('session', {}) \ + .get('application', {}) \ + .get('applicationId') + else: + intent_name = alexa_intent_info['name'] + + intent_response = await intent.async_handle( + hass, DOMAIN, intent_name, + {key: {'value': value} for key, value + in alexa_response.variables.items()}) + + for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): + if intent_speech in intent_response.speech: + alexa_response.add_speech( + alexa_speech, + intent_response.speech[intent_speech]['speech']) + break + + if 'simple' in intent_response.card: + alexa_response.add_card( + CardType.simple, intent_response.card['simple']['title'], + intent_response.card['simple']['content']) + + return alexa_response.as_dict() + + +def resolve_slot_synonyms(key, request): + """Check slot request for synonym resolutions.""" + # Default to the spoken slot value if more than one or none are found. For + # reference to the request object structure, see the Alexa docs: + # https://tinyurl.com/ybvm7jhs + resolved_value = request['value'] + + if ('resolutions' in request and + 'resolutionsPerAuthority' in request['resolutions'] and + len(request['resolutions']['resolutionsPerAuthority']) >= 1): + + # Extract all of the possible values from each authority with a + # successful match + possible_values = [] + + for entry in request['resolutions']['resolutionsPerAuthority']: + if entry['status']['code'] != SYN_RESOLUTION_MATCH: + continue + + possible_values.extend([item['value']['name'] + for item + in entry['values']]) + + # If there is only one match use the resolved value, otherwise the + # resolution cannot be determined, so use the spoken slot value + if len(possible_values) == 1: + resolved_value = possible_values[0] + else: + _LOGGER.debug( + 'Found multiple synonym resolutions for slot value: {%s: %s}', + key, + request['value'] + ) + + return resolved_value + + +class AlexaResponse: + """Help generating the response for Alexa.""" + + def __init__(self, hass, intent_info): + """Initialize the response.""" + self.hass = hass + self.speech = None + self.card = None + self.reprompt = None + self.session_attributes = {} + self.should_end_session = True + self.variables = {} + + # Intent is None if request was a LaunchRequest or SessionEndedRequest + if intent_info is not None: + for key, value in intent_info.get('slots', {}).items(): + # Only include slots with values + if 'value' not in value: + continue + + _key = key.replace('.', '_') + + self.variables[_key] = resolve_slot_synonyms(key, value) + + def add_card(self, card_type, title, content): + """Add a card to the response.""" + assert self.card is None + + card = { + "type": card_type.value + } + + if card_type == CardType.link_account: + self.card = card + return + + card["title"] = title + card["content"] = content + self.card = card + + def add_speech(self, speech_type, text): + """Add speech to the response.""" + assert self.speech is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.speech = { + 'type': speech_type.value, + key: text + } + + def add_reprompt(self, speech_type, text): + """Add reprompt if user does not answer.""" + assert self.reprompt is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.reprompt = { + 'type': speech_type.value, + key: text.async_render(self.variables) + } + + def as_dict(self): + """Return response in an Alexa valid dict.""" + response = { + 'shouldEndSession': self.should_end_session + } + + if self.card is not None: + response['card'] = self.card + + if self.speech is not None: + response['outputSpeech'] = self.speech + + if self.reprompt is not None: + response['reprompt'] = { + 'outputSpeech': self.reprompt + } + + return { + 'version': '1.0', + 'sessionAttributes': self.session_attributes, + 'response': response, + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/alexa/smart_home.py b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/smart_home.py new file mode 100644 index 00000000000000..7240912883a88a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,2123 @@ +"""Support for alexa Smart Home Skill API. + +API documentation: +https://developer.amazon.com/docs/smarthome/understand-the-smart-home-skill-api.html +https://developer.amazon.com/docs/device-apis/message-guide.html +""" + +import asyncio +from collections import OrderedDict +from datetime import datetime +import json +import logging +import math +from uuid import uuid4 + +import aiohttp +import async_timeout + +from homeassistant.components import ( + alert, automation, binary_sensor, climate, cover, fan, group, http, + input_boolean, light, lock, media_player, scene, script, sensor, switch) +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.event import async_track_state_change +from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, + STATE_UNAVAILABLE, STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, + MATCH_ALL) +import homeassistant.core as ha +import homeassistant.util.color as color_util +from homeassistant.util.decorator import Registry +from homeassistant.util.temperature import convert as convert_temperature + +from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \ + CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT +from .auth import Auth + +_LOGGER = logging.getLogger(__name__) + +API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' +API_EVENT = 'event' +API_CONTEXT = 'context' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_SCOPE = 'scope' +API_CHANGE = 'change' + +API_TEMP_UNITS = { + TEMP_FAHRENHEIT: 'FAHRENHEIT', + TEMP_CELSIUS: 'CELSIUS', +} + +# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a +# reverse mapping of this dict and we want to map the first occurrance of OFF +# back to HA state. +API_THERMOSTAT_MODES = OrderedDict([ + (climate.STATE_HEAT, 'HEAT'), + (climate.STATE_COOL, 'COOL'), + (climate.STATE_AUTO, 'AUTO'), + (climate.STATE_ECO, 'ECO'), + (climate.STATE_OFF, 'OFF'), + (climate.STATE_IDLE, 'OFF'), + (climate.STATE_FAN_ONLY, 'OFF'), + (climate.STATE_DRY, 'OFF') +]) + +SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' + +CONF_DESCRIPTION = 'description' +CONF_DISPLAY_CATEGORIES = 'display_categories' + +HANDLERS = Registry() +ENTITY_ADAPTERS = Registry() +EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' + +AUTH_KEY = "alexa.smart_home.auth" + + +class _DisplayCategory: + """Possible display categories for Discovery response. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories + """ + + # Describes a combination of devices set to a specific state, when the + # state change must occur in a specific order. For example, a "watch + # Netflix" scene might require the: 1. TV to be powered on & 2. Input set + # to HDMI1. Applies to Scenes + ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" + + # Indicates media devices with video or photo capabilities. + CAMERA = "CAMERA" + + # Indicates an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Describes a combination of devices set to a specific state, when the + # order of the state change is not important. For example a bedtime scene + # might include turning off lights and lowering the thermostat, but the + # order is unimportant. Applies to Scenes + SCENE_TRIGGER = "SCENE_TRIGGER" + + # Indicates an endpoint that locks. + SMARTLOCK = "SMARTLOCK" + + # Indicates modules that are plugged into an existing electrical outlet. + # Can control a variety of devices. + SMARTPLUG = "SMARTPLUG" + + # Indicates the endpoint is a speaker or speaker system. + SPEAKER = "SPEAKER" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates endpoints that report the temperature only. + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" + + # Indicates endpoints that control temperature, stand-alone air + # conditioners, or heaters with direct temperature control. + THERMOSTAT = "THERMOSTAT" + + # Indicates the endpoint is a television. + TV = "TV" + + +def _capability(interface, + version=3, + supports_deactivation=None, + retrievable=None, + properties_supported=None, + cap_type='AlexaInterface'): + """Return a Smart Home API capability object. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object + + There are some additional fields allowed but not implemented here since + we've no use case for them yet: + + - proactively_reported + + `supports_deactivation` applies only to scenes. + """ + result = { + 'type': cap_type, + 'interface': interface, + 'version': version, + } + + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + + if retrievable is not None: + result['retrievable'] = retrievable + + if properties_supported is not None: + result['properties'] = {'supported': properties_supported} + + return result + + +class _UnsupportedInterface(Exception): + """This entity does not support the requested Smart Home API interface.""" + + +class _UnsupportedProperty(Exception): + """This entity does not support the requested Smart Home API property.""" + + +class _AlexaError(Exception): + """Base class for errors that can be serialized by the Alexa API. + + A handler can raise subclasses of this to return an error to the request. + """ + + namespace = None + error_type = None + + def __init__(self, error_message, payload=None): + Exception.__init__(self) + self.error_message = error_message + self.payload = None + + +class _AlexaInvalidEndpointError(_AlexaError): + """The endpoint in the request does not exist.""" + + namespace = 'Alexa' + error_type = 'NO_SUCH_ENDPOINT' + + def __init__(self, endpoint_id): + msg = 'The endpoint {} does not exist'.format(endpoint_id) + _AlexaError.__init__(self, msg) + self.endpoint_id = endpoint_id + + +class _AlexaInvalidValueError(_AlexaError): + namespace = 'Alexa' + error_type = 'INVALID_VALUE' + + +class _AlexaUnsupportedThermostatModeError(_AlexaError): + namespace = 'Alexa.ThermostatController' + error_type = 'UNSUPPORTED_THERMOSTAT_MODE' + + +class _AlexaTempRangeError(_AlexaError): + namespace = 'Alexa' + error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + def __init__(self, hass, temp, min_temp, max_temp): + unit = hass.config.units.temperature_unit + temp_range = { + 'minimumValue': { + 'value': min_temp, + 'scale': API_TEMP_UNITS[unit], + }, + 'maximumValue': { + 'value': max_temp, + 'scale': API_TEMP_UNITS[unit], + }, + } + payload = {'validRange': temp_range} + msg = 'The requested temperature {} is out of range'.format(temp) + + _AlexaError.__init__(self, msg, payload) + + +class _AlexaBridgeUnreachableError(_AlexaError): + namespace = 'Alexa' + error_type = 'BRIDGE_UNREACHABLE' + + +class _AlexaEntity: + """An adaptation of an entity, expressed in Alexa's terms. + + The API handlers should manipulate entities only through this interface. + """ + + def __init__(self, hass, config, entity): + self.hass = hass + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + def friendly_name(self): + """Return the Alexa API friendly name.""" + return self.entity_conf.get(CONF_NAME, self.entity.name) + + def description(self): + """Return the Alexa API description.""" + return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) + + def entity_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace('.', '#') + + def display_categories(self): + """Return a list of display categories.""" + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + return [entity_conf[CONF_DISPLAY_CATEGORIES]] + return self.default_display_categories() + + def default_display_categories(self): + """Return a list of default display categories. + + This can be overridden by the user in the Home Assistant configuration. + + See also _DisplayCategory. + """ + raise NotImplementedError + + def get_interface(self, capability): + """Return the given _AlexaInterface. + + Raises _UnsupportedInterface. + """ + pass + + def interfaces(self): + """Return a list of supported interfaces. + + Used for discovery. The list should contain _AlexaInterface instances. + If the list is empty, this entity will not be discovered. + """ + raise NotImplementedError + + def serialize_properties(self): + """Yield each supported property in API format.""" + for interface in self.interfaces(): + for prop in interface.serialize_properties(): + yield prop + + +class _AlexaInterface: + """Base class for Alexa capability interfaces. + + The Smart Home Skills API defines a number of "capability interfaces", + roughly analogous to domains in Home Assistant. The supported interfaces + describe what actions can be performed on a particular device. + + https://developer.amazon.com/docs/device-apis/message-guide.html + """ + + def __init__(self, entity): + self.entity = entity + + def name(self): + """Return the Alexa API name of this interface.""" + raise NotImplementedError + + @staticmethod + def properties_supported(): + """Return what properties this entity supports.""" + return [] + + @staticmethod + def properties_proactively_reported(): + """Return True if properties asynchronously reported.""" + return False + + @staticmethod + def properties_retrievable(): + """Return True if properties can be retrieved.""" + return False + + @staticmethod + def get_property(name): + """Read and return a property. + + Return value should be a dict, or raise _UnsupportedProperty. + + Properties can also have a timeOfSample and uncertaintyInMilliseconds, + but returning those metadata is not yet implemented. + """ + raise _UnsupportedProperty(name) + + @staticmethod + def supports_deactivation(): + """Applicable only to scenes.""" + return None + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = { + 'type': 'AlexaInterface', + 'interface': self.name(), + 'version': '3', + 'properties': { + 'supported': self.properties_supported(), + 'proactivelyReported': self.properties_proactively_reported(), + 'retrievable': self.properties_retrievable(), + }, + } + + # pylint: disable=assignment-from-none + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop['name'] + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + 'timeOfSample': datetime.now().strftime(DATE_FORMAT), + 'uncertaintyInMilliseconds': 0 + } + + +class _AlexaEndpointHealth(_AlexaInterface): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + def __init__(self, hass, entity): + super().__init__(entity) + self.hass = hass + + def name(self): + return 'Alexa.EndpointHealth' + + def properties_supported(self): + return [{'name': 'connectivity'}] + + def properties_proactively_reported(self): + return False + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'connectivity': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_UNAVAILABLE: + return {'value': 'UNREACHABLE'} + return {'value': 'OK'} + + +class _AlexaPowerController(_AlexaInterface): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + + def name(self): + return 'Alexa.PowerController' + + def properties_supported(self): + return [{'name': 'powerState'}] + + def properties_proactively_reported(self): + return True + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'powerState': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'ON' + return 'OFF' + + +class _AlexaLockController(_AlexaInterface): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + + def name(self): + return 'Alexa.LockController' + + def properties_supported(self): + return [{'name': 'lockState'}] + + def properties_retrievable(self): + return True + + def properties_proactively_reported(self): + return True + + def get_property(self, name): + if name != 'lockState': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_LOCKED: + return 'LOCKED' + if self.entity.state == STATE_UNLOCKED: + return 'UNLOCKED' + return 'JAMMED' + + +class _AlexaSceneController(_AlexaInterface): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + + def __init__(self, entity, supports_deactivation): + _AlexaInterface.__init__(self, entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + return 'Alexa.SceneController' + + +class _AlexaBrightnessController(_AlexaInterface): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + + def name(self): + return 'Alexa.BrightnessController' + + def properties_supported(self): + return [{'name': 'brightness'}] + + def properties_proactively_reported(self): + return True + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'brightness': + raise _UnsupportedProperty(name) + if 'brightness' in self.entity.attributes: + return round(self.entity.attributes['brightness'] / 255.0 * 100) + return 0 + + +class _AlexaColorController(_AlexaInterface): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + + def name(self): + return 'Alexa.ColorController' + + def properties_supported(self): + return [{'name': 'color'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'color': + raise _UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get( + light.ATTR_HS_COLOR, (0, 0)) + + return { + 'hue': hue, + 'saturation': saturation / 100.0, + 'brightness': self.entity.attributes.get( + light.ATTR_BRIGHTNESS, 0) / 255.0, + } + + +class _AlexaColorTemperatureController(_AlexaInterface): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + + def name(self): + return 'Alexa.ColorTemperatureController' + + def properties_supported(self): + return [{'name': 'colorTemperatureInKelvin'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'colorTemperatureInKelvin': + raise _UnsupportedProperty(name) + if 'color_temp' in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes['color_temp']) + return 0 + + +class _AlexaPercentageController(_AlexaInterface): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + + def name(self): + return 'Alexa.PercentageController' + + +class _AlexaSpeaker(_AlexaInterface): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + + def name(self): + return 'Alexa.Speaker' + + +class _AlexaStepSpeaker(_AlexaInterface): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + + def name(self): + return 'Alexa.StepSpeaker' + + +class _AlexaPlaybackController(_AlexaInterface): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + + def name(self): + return 'Alexa.PlaybackController' + + +class _AlexaInputController(_AlexaInterface): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + + def name(self): + return 'Alexa.InputController' + + +class _AlexaTemperatureSensor(_AlexaInterface): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + + def __init__(self, hass, entity): + _AlexaInterface.__init__(self, entity) + self.hass = hass + + def name(self): + return 'Alexa.TemperatureSensor' + + def properties_supported(self): + return [{'name': 'temperature'}] + + def properties_proactively_reported(self): + return True + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'temperature': + raise _UnsupportedProperty(name) + + unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = self.entity.state + if self.entity.domain == climate.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get( + climate.ATTR_CURRENT_TEMPERATURE) + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } + + +class _AlexaContactSensor(_AlexaInterface): + """Implements Alexa.ContactSensor. + + The Alexa.ContactSensor interface describes the properties and events used + to report the state of an endpoint that detects contact between two + surfaces. For example, a contact sensor can report whether a door or window + is open. + + https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html + """ + + def __init__(self, hass, entity): + _AlexaInterface.__init__(self, entity) + self.hass = hass + + def name(self): + return 'Alexa.ContactSensor' + + def properties_supported(self): + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + return True + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'detectionState': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class _AlexaMotionSensor(_AlexaInterface): + def __init__(self, hass, entity): + _AlexaInterface.__init__(self, entity) + self.hass = hass + + def name(self): + return 'Alexa.MotionSensor' + + def properties_supported(self): + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + return True + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'detectionState': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class _AlexaThermostatController(_AlexaInterface): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + + def __init__(self, hass, entity): + _AlexaInterface.__init__(self, entity) + self.hass = hass + + def name(self): + return 'Alexa.ThermostatController' + + def properties_supported(self): + properties = [] + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_TARGET_TEMPERATURE: + properties.append({'name': 'targetSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: + properties.append({'name': 'lowerSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: + properties.append({'name': 'upperSetpoint'}) + if supported & climate.SUPPORT_OPERATION_MODE: + properties.append({'name': 'thermostatMode'}) + return properties + + def properties_proactively_reported(self): + return True + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name == 'thermostatMode': + ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) + mode = API_THERMOSTAT_MODES.get(ha_mode) + if mode is None: + _LOGGER.error("%s (%s) has unsupported %s value '%s'", + self.entity.entity_id, type(self.entity), + climate.ATTR_OPERATION_MODE, ha_mode) + raise _UnsupportedProperty(name) + return mode + + unit = self.hass.config.units.temperature_unit + if name == 'targetSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TEMPERATURE) + elif name == 'lowerSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + elif name == 'upperSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) + else: + raise _UnsupportedProperty(name) + + if temp is None: + return None + + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } + + +@ENTITY_ADAPTERS.register(alert.DOMAIN) +@ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) +class _GenericCapabilities(_AlexaEntity): + """A generic, on/off device. + + The choice of last resort. + """ + + def default_display_categories(self): + return [_DisplayCategory.OTHER] + + def interfaces(self): + return [_AlexaPowerController(self.entity), + _AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class _SwitchCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.SWITCH] + + def interfaces(self): + return [_AlexaPowerController(self.entity), + _AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(climate.DOMAIN) +class _ClimateCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.THERMOSTAT] + + def interfaces(self): + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_ON_OFF: + yield _AlexaPowerController(self.entity) + yield _AlexaThermostatController(self.hass, self.entity) + yield _AlexaTemperatureSensor(self.hass, self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(cover.DOMAIN) +class _CoverCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.DOOR] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield _AlexaPercentageController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class _LightCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.LIGHT] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & light.SUPPORT_BRIGHTNESS: + yield _AlexaBrightnessController(self.entity) + if supported & light.SUPPORT_COLOR: + yield _AlexaColorController(self.entity) + if supported & light.SUPPORT_COLOR_TEMP: + yield _AlexaColorTemperatureController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class _FanCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.OTHER] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.SUPPORT_SET_SPEED: + yield _AlexaPercentageController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class _LockCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.SMARTLOCK] + + def interfaces(self): + return [_AlexaLockController(self.entity), + _AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(media_player.DOMAIN) +class _MediaPlayerCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.TV] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.SUPPORT_VOLUME_SET: + yield _AlexaSpeaker(self.entity) + + step_volume_features = (media_player.SUPPORT_VOLUME_MUTE | + media_player.SUPPORT_VOLUME_STEP) + if supported & step_volume_features: + yield _AlexaStepSpeaker(self.entity) + + playback_features = (media_player.SUPPORT_PLAY | + media_player.SUPPORT_PAUSE | + media_player.SUPPORT_STOP | + media_player.SUPPORT_NEXT_TRACK | + media_player.SUPPORT_PREVIOUS_TRACK) + if supported & playback_features: + yield _AlexaPlaybackController(self.entity) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + yield _AlexaInputController(self.entity) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class _SceneCapabilities(_AlexaEntity): + def description(self): + # Required description as per Amazon Scene docs + scene_fmt = '{} (Scene connected via Home Assistant)' + return scene_fmt.format(_AlexaEntity.description(self)) + + def default_display_categories(self): + return [_DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + return [_AlexaSceneController(self.entity, + supports_deactivation=False)] + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class _ScriptCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + can_cancel = bool(self.entity.attributes.get('can_cancel')) + return [_AlexaSceneController(self.entity, + supports_deactivation=can_cancel)] + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class _SensorCapabilities(_AlexaEntity): + def default_display_categories(self): + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [_DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self): + attrs = self.entity.attributes + if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in ( + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + ): + yield _AlexaTemperatureSensor(self.hass, self.entity) + yield _AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class _BinarySensorCapabilities(_AlexaEntity): + TYPE_CONTACT = 'contact' + TYPE_MOTION = 'motion' + + def default_display_categories(self): + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + return [_DisplayCategory.CONTACT_SENSOR] + if sensor_type is self.TYPE_MOTION: + return [_DisplayCategory.MOTION_SENSOR] + + def interfaces(self): + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + yield _AlexaContactSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_MOTION: + yield _AlexaMotionSensor(self.hass, self.entity) + + yield _AlexaEndpointHealth(self.hass, self.entity) + + def get_type(self): + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + 'door', + 'garage_door', + 'opening', + 'window', + ): + return self.TYPE_CONTACT + if attrs.get(ATTR_DEVICE_CLASS) == 'motion': + return self.TYPE_MOTION + + +class _Cause: + """Possible causes for property changes. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object + """ + + # Indicates that the event was caused by a customer interaction with an + # application. For example, a customer switches on a light, or locks a door + # using the Alexa app or an app provided by a device vendor. + APP_INTERACTION = 'APP_INTERACTION' + + # Indicates that the event was caused by a physical interaction with an + # endpoint. For example manually switching on a light or manually locking a + # door lock + PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION' + + # Indicates that the event was caused by the periodic poll of an appliance, + # which found a change in value. For example, you might poll a temperature + # sensor every hour, and send the updated temperature to Alexa. + PERIODIC_POLL = 'PERIODIC_POLL' + + # Indicates that the event was caused by the application of a device rule. + # For example, a customer configures a rule to switch on a light if a + # motion sensor detects motion. In this case, Alexa receives an event from + # the motion sensor, and another event from the light to indicate that its + # state change was caused by the rule. + RULE_TRIGGER = 'RULE_TRIGGER' + + # Indicates that the event was caused by a voice interaction with Alexa. + # For example a user speaking to their Echo device. + VOICE_INTERACTION = 'VOICE_INTERACTION' + + +class Config: + """Hold the configuration for Alexa.""" + + def __init__(self, endpoint, async_get_access_token, should_expose, + entity_config=None): + """Initialize the configuration.""" + self.endpoint = endpoint + self.async_get_access_token = async_get_access_token + self.should_expose = should_expose + self.entity_config = entity_config or {} + + +async def async_setup(hass, config): + """Activate Smart Home functionality of Alexa component. + + This is optional, triggered by having a `smart_home:` sub-section in the + alexa configuration. + + Even if that's disabled, the functionality in this module may still be used + by the cloud component which will call async_handle_message directly. + """ + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET]) + + async_get_access_token = \ + hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ + else None + + smart_home_config = Config( + endpoint=config.get(CONF_ENDPOINT), + async_get_access_token=async_get_access_token, + should_expose=config[CONF_FILTER], + entity_config=config.get(CONF_ENTITY_CONFIG), + ) + hass.http.register_view(SmartHomeView(smart_home_config)) + + if AUTH_KEY in hass.data: + await async_enable_proactive_mode(hass, smart_home_config) + + +async def async_enable_proactive_mode(hass, smart_home_config): + """Enable the proactive mode. + + Proactive mode makes this component report state changes to Alexa. + """ + if smart_home_config.async_get_access_token is None: + # no function to call to get token + return + + if await smart_home_config.async_get_access_token() is None: + # not ready yet + return + + async def async_entity_state_listener(changed_entity, old_state, + new_state): + if not smart_home_config.should_expose(changed_entity): + _LOGGER.debug("Not exposing %s because filtered by config", + changed_entity) + return + + if new_state.domain not in ENTITY_ADAPTERS: + return + + alexa_changed_entity = \ + ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config, + new_state) + + for interface in alexa_changed_entity.interfaces(): + if interface.properties_proactively_reported(): + await async_send_changereport_message(hass, smart_home_config, + alexa_changed_entity) + return + + async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) + + +class SmartHomeView(http.HomeAssistantView): + """Expose Smart Home v3 payload interface via HTTP POST.""" + + url = SMART_HOME_HTTP_ENDPOINT + name = 'api:alexa:smart_home' + + def __init__(self, smart_home_config): + """Initialize.""" + self.smart_home_config = smart_home_config + + async def post(self, request): + """Handle Alexa Smart Home requests. + + The Smart Home API requires the endpoint to be implemented in AWS + Lambda, which will need to forward the requests to here and pass back + the response. + """ + hass = request.app['hass'] + message = await request.json() + + _LOGGER.debug("Received Alexa Smart Home request: %s", message) + + response = await async_handle_message( + hass, self.smart_home_config, message) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b'' if response is None else self.json(response) + + +class _AlexaDirective: + def __init__(self, request): + self._directive = request[API_DIRECTIVE] + self.namespace = self._directive[API_HEADER]['namespace'] + self.name = self._directive[API_HEADER]['name'] + self.payload = self._directive[API_PAYLOAD] + self.has_endpoint = API_ENDPOINT in self._directive + + self.entity = self.entity_id = self.endpoint = None + + def load_entity(self, hass, config): + """Set attributes related to the entity for this request. + + Sets these attributes when self.has_endpoint is True: + + - entity + - entity_id + - endpoint + + Behavior when self.has_endpoint is False is undefined. + + Will raise _AlexaInvalidEndpointError if the endpoint in the request is + malformed or nonexistant. + """ + _endpoint_id = self._directive[API_ENDPOINT]['endpointId'] + self.entity_id = _endpoint_id.replace('#', '.') + + self.entity = hass.states.get(self.entity_id) + if not self.entity: + raise _AlexaInvalidEndpointError(_endpoint_id) + + self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( + hass, config, self.entity) + + def response(self, + name='Response', + namespace='Alexa', + payload=None): + """Create an API formatted response. + + Async friendly. + """ + response = _AlexaResponse(name, namespace, payload) + + token = self._directive[API_HEADER].get('correlationToken') + if token: + response.set_correlation_token(token) + + if self.has_endpoint: + response.set_endpoint(self._directive[API_ENDPOINT].copy()) + + return response + + def error( + self, + namespace='Alexa', + error_type='INTERNAL_ERROR', + error_message="", + payload=None + ): + """Create a API formatted error response. + + Async friendly. + """ + payload = payload or {} + payload['type'] = error_type + payload['message'] = error_message + + _LOGGER.info("Request %s/%s error %s: %s", + self._directive[API_HEADER]['namespace'], + self._directive[API_HEADER]['name'], + error_type, error_message) + + return self.response( + name='ErrorResponse', + namespace=namespace, + payload=payload + ) + + +class _AlexaResponse: + def __init__(self, name, namespace, payload=None): + payload = payload or {} + self._response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } + } + + @property + def name(self): + """Return the name of this response.""" + return self._response[API_EVENT][API_HEADER]['name'] + + @property + def namespace(self): + """Return the namespace of this response.""" + return self._response[API_EVENT][API_HEADER]['namespace'] + + def set_correlation_token(self, token): + """Set the correlationToken. + + This should normally mirror the value from a request, and is set by + _AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_HEADER]['correlationToken'] = token + + def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None): + """Set the endpoint dictionary. + + This is used to send proactive messages to Alexa. + """ + self._response[API_EVENT][API_ENDPOINT] = { + API_SCOPE: { + 'type': 'BearerToken', + 'token': bearer_token + } + } + + if endpoint_id is not None: + self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id + + if cookie is not None: + self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie + + def set_endpoint(self, endpoint): + """Set the endpoint. + + This should normally mirror the value from a request, and is set by + _AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_ENDPOINT] = endpoint + + def _properties(self): + context = self._response.setdefault(API_CONTEXT, {}) + return context.setdefault('properties', []) + + def add_context_property(self, prop): + """Add a property to the response context. + + The Alexa response includes a list of properties which provides + feedback on how states have changed. For example if a user asks, + "Alexa, set theromstat to 20 degrees", the API expects a response with + the new value of the property, and Alexa will respond to the user + "Thermostat set to 20 degrees". + + async_handle_message() will call .merge_context_properties() for every + request automatically, however often handlers will call services to + change state but the effects of those changes are applied + asynchronously. Thus, handlers should call this method to confirm + changes before returning. + """ + self._properties().append(prop) + + def merge_context_properties(self, endpoint): + """Add all properties from given endpoint if not already set. + + Handlers should be using .add_context_property(). + """ + properties = self._properties() + already_set = {(p['namespace'], p['name']) for p in properties} + + for prop in endpoint.serialize_properties(): + if (prop['namespace'], prop['name']) not in already_set: + self.add_context_property(prop) + + def serialize(self): + """Return response as a JSON-able data structure.""" + return self._response + + +async def async_handle_message( + hass, + config, + request, + context=None, + enabled=True, +): + """Handle incoming API messages. + + If enabled is False, the response to all messagess will be a + BRIDGE_UNREACHABLE error. This can be used if the API has been disabled in + configuration. + """ + assert request[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' + + if context is None: + context = ha.Context() + + directive = _AlexaDirective(request) + + try: + if not enabled: + raise _AlexaBridgeUnreachableError( + 'Alexa API not enabled in Home Assistant configuration') + + if directive.has_endpoint: + directive.load_entity(hass, config) + + funct_ref = HANDLERS.get((directive.namespace, directive.name)) + if funct_ref: + response = await funct_ref(hass, config, directive, context) + if directive.has_endpoint: + response.merge_context_properties(directive.endpoint) + else: + _LOGGER.warning( + "Unsupported API request %s/%s", + directive.namespace, + directive.name, + ) + response = directive.error() + except _AlexaError as err: + response = directive.error( + error_type=err.error_type, + error_message=err.error_message) + + request_info = { + 'namespace': directive.namespace, + 'name': directive.name, + } + + if directive.has_endpoint: + request_info['entity_id'] = directive.entity_id + + hass.bus.async_fire(EVENT_ALEXA_SMART_HOME, { + 'request': request_info, + 'response': { + 'namespace': response.namespace, + 'name': response.name, + } + }, context=context) + + return response.serialize() + + +async def async_send_changereport_message(hass, config, alexa_entity): + """Send a ChangeReport message for an Alexa entity.""" + token = await config.async_get_access_token() + if not token: + _LOGGER.error("Invalid access token.") + return + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoint = alexa_entity.entity_id() + + # this sends all the properties of the Alexa Entity, whether they have + # changed or not. this should be improved, and properties that have not + # changed should be moved to the 'context' object + properties = list(alexa_entity.serialize_properties()) + + payload = { + API_CHANGE: { + 'cause': {'type': _Cause.APP_INTERACTION}, + 'properties': properties + } + } + + message = _AlexaResponse(name='ChangeReport', namespace='Alexa', + payload=payload) + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + + try: + session = aiohttp_client.async_get_clientsession(hass) + with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): + response = await session.post(config.endpoint, + headers=headers, + json=message_serialized, + allow_redirects=True) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout calling LWA to get auth token.") + return None + + response_text = await response.text() + + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) + + if response.status != 202: + response_json = json.loads(response_text) + _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"]) + + +@HANDLERS.register(('Alexa.Discovery', 'Discover')) +async def async_api_discovery(hass, config, directive, context): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [] + + for entity in hass.states.async_all(): + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + _LOGGER.debug("Not exposing %s because it is never exposed", + entity.entity_id) + continue + + if not config.should_expose(entity.entity_id): + _LOGGER.debug("Not exposing %s because filtered by config", + entity.entity_id) + continue + + if entity.domain not in ENTITY_ADAPTERS: + continue + alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity) + + endpoint = { + 'displayCategories': alexa_entity.display_categories(), + 'cookie': {}, + 'endpointId': alexa_entity.entity_id(), + 'friendlyName': alexa_entity.friendly_name(), + 'description': alexa_entity.description(), + 'manufacturerName': 'Home Assistant', + } + + endpoint['capabilities'] = [ + i.serialize_discovery() for i in alexa_entity.interfaces()] + + if not endpoint['capabilities']: + _LOGGER.debug( + "Not exposing %s because it has no capabilities", + entity.entity_id) + continue + discovery_endpoints.append(endpoint) + + return directive.response( + name='Discover.Response', + namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}, + ) + + +@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant')) +async def async_api_accept_grant(hass, config, directive, context): + """Create a API formatted AcceptGrant response. + + Async friendly. + """ + auth_code = directive.payload['grant']['code'] + _LOGGER.debug("AcceptGrant code: %s", auth_code) + + if AUTH_KEY in hass.data: + await hass.data[AUTH_KEY].async_do_auth(auth_code) + await async_enable_proactive_mode(hass, config) + + return directive.response( + name='AcceptGrant.Response', + namespace='Alexa.Authorization', + payload={}) + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) +async def async_api_turn_on(hass, config, directive, context): + """Process a turn on request.""" + entity = directive.entity + domain = entity.domain + if domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_ON + if domain == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) +async def async_api_turn_off(hass, config, directive, context): + """Process a turn off request.""" + entity = directive.entity + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_OFF + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) +async def async_api_set_brightness(hass, config, directive, context): + """Process a set brightness request.""" + entity = directive.entity + brightness = int(directive.payload['brightness']) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) +async def async_api_adjust_brightness(hass, config, directive, context): + """Process an adjust brightness request.""" + entity = directive.entity + brightness_delta = int(directive.payload['brightnessDelta']) + + # read current state + try: + current = math.floor( + int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100) + except ZeroDivisionError: + current = 0 + + # set brightness + brightness = max(0, brightness_delta + current) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.ColorController', 'SetColor')) +async def async_api_set_color(hass, config, directive, context): + """Process a set color request.""" + entity = directive.entity + rgb = color_util.color_hsb_to_RGB( + float(directive.payload['color']['hue']), + float(directive.payload['color']['saturation']), + float(directive.payload['color']['brightness']) + ) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_RGB_COLOR: rgb, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) +async def async_api_set_color_temperature(hass, config, directive, context): + """Process a set color temperature request.""" + entity = directive.entity + kelvin = int(directive.payload['colorTemperatureInKelvin']) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_KELVIN: kelvin, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) +async def async_api_decrease_color_temp(hass, config, directive, context): + """Process a decrease color temperature request.""" + entity = directive.entity + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) + + value = min(max_mireds, current + 50) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) +async def async_api_increase_color_temp(hass, config, directive, context): + """Process an increase color temperature request.""" + entity = directive.entity + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) + + value = max(min_mireds, current - 50) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.SceneController', 'Activate')) +async def async_api_activate(hass, config, directive, context): + """Process an activate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call(domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + payload = { + 'cause': {'type': _Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return directive.response( + name='ActivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.SceneController', 'Deactivate')) +async def async_api_deactivate(hass, config, directive, context): + """Process a deactivate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call(domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + payload = { + 'cause': {'type': _Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return directive.response( + name='DeactivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) +async def async_api_set_percentage(hass, config, directive, context): + """Process a set percentage request.""" + entity = directive.entity + percentage = int(directive.payload['percentage']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = percentage + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) +async def async_api_adjust_percentage(hass, config, directive, context): + """Process an adjust percentage request.""" + entity = directive.entity + percentage_delta = int(directive.payload['percentageDelta']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = entity.attributes.get(fan.ATTR_SPEED) + + if speed == "off": + current = 0 + elif speed == "low": + current = 33 + elif speed == "medium": + current = 66 + elif speed == "high": + current = 100 + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + + current = entity.attributes.get(cover.ATTR_POSITION) + + data[cover.ATTR_POSITION] = max(0, percentage_delta + current) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.LockController', 'Lock')) +async def async_api_lock(hass, config, directive, context): + """Process a lock request.""" + entity = directive.entity + await hass.services.async_call(entity.domain, SERVICE_LOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + response = directive.response() + response.add_context_property({ + 'name': 'lockState', + 'namespace': 'Alexa.LockController', + 'value': 'LOCKED' + }) + return response + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +async def async_api_unlock(hass, config, directive, context): + """Process an unlock request.""" + entity = directive.entity + await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) +async def async_api_set_volume(hass, config, directive, context): + """Process a set volume request.""" + volume = round(float(directive.payload['volume'] / 100), 2) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.InputController', 'SelectInput')) +async def async_api_select_input(hass, config, directive, context): + """Process a set input request.""" + media_input = directive.payload['input'] + entity = directive.entity + + # attempt to map the ALL UPPERCASE payload name to a source + source_list = entity.attributes[media_player.ATTR_INPUT_SOURCE_LIST] or [] + for source in source_list: + # response will always be space separated, so format the source in the + # most likely way to find a match + formatted_source = source.lower().replace('-', ' ').replace('_', ' ') + if formatted_source in media_input.lower(): + media_input = source + break + else: + msg = 'failed to map input {} to a media source on {}'.format( + media_input, entity.entity_id) + raise _AlexaInvalidValueError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_INPUT_SOURCE: media_input, + } + + await hass.services.async_call( + entity.domain, media_player.SERVICE_SELECT_SOURCE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) +async def async_api_adjust_volume(hass, config, directive, context): + """Process an adjust volume request.""" + volume_delta = int(directive.payload['volume']) + + entity = directive.entity + current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) + + # read current state + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + + volume = float(max(0, volume_delta + current) / 100) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_SET, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) +async def async_api_adjust_volume_step(hass, config, directive, context): + """Process an adjust volume step request.""" + # media_player volume up/down service does not support specifying steps + # each component handles it differently e.g. via config. + # For now we use the volumeSteps returned to figure out if we + # should step up/down + volume_step = directive.payload['volumeSteps'] + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + + if volume_step > 0: + await hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_UP, + data, blocking=False, context=context) + elif volume_step < 0: + await hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_DOWN, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) +@HANDLERS.register(('Alexa.Speaker', 'SetMute')) +async def async_api_set_mute(hass, config, directive, context): + """Process a set mute request.""" + mute = bool(directive.payload['mute']) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + await hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_MUTE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Play')) +async def async_api_play(hass, config, directive, context): + """Process a play request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) +async def async_api_pause(hass, config, directive, context): + """Process a pause request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) +async def async_api_stop(hass, config, directive, context): + """Process a stop request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Next')) +async def async_api_next(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) +async def async_api_previous(hass, config, directive, context): + """Process a previous request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=False, context=context) + + return directive.response() + + +def temperature_from_object(hass, temp_obj, interval=False): + """Get temperature from Temperature object in requested unit.""" + to_unit = hass.config.units.temperature_unit + from_unit = TEMP_CELSIUS + temp = float(temp_obj['value']) + + if temp_obj['scale'] == 'FAHRENHEIT': + from_unit = TEMP_FAHRENHEIT + elif temp_obj['scale'] == 'KELVIN': + # convert to Celsius if absolute temperature + if not interval: + temp -= 273.15 + + return convert_temperature(temp, from_unit, to_unit, interval) + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature')) +async def async_api_set_target_temp(hass, config, directive, context): + """Process a set target temperature request.""" + entity = directive.entity + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit + + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + payload = directive.payload + response = directive.response() + if 'targetSetpoint' in payload: + temp = temperature_from_object(hass, payload['targetSetpoint']) + if temp < min_temp or temp > max_temp: + raise _AlexaTempRangeError(hass, temp, min_temp, max_temp) + data[ATTR_TEMPERATURE] = temp + response.add_context_property({ + 'name': 'targetSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]}, + }) + if 'lowerSetpoint' in payload: + temp_low = temperature_from_object(hass, payload['lowerSetpoint']) + if temp_low < min_temp or temp_low > max_temp: + raise _AlexaTempRangeError(hass, temp_low, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + response.add_context_property({ + 'name': 'lowerSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]}, + }) + if 'upperSetpoint' in payload: + temp_high = temperature_from_object(hass, payload['upperSetpoint']) + if temp_high < min_temp or temp_high > max_temp: + raise _AlexaTempRangeError(hass, temp_high, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + response.add_context_property({ + 'name': 'upperSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]}, + }) + + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, + context=context) + + return response + + +@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) +async def async_api_adjust_target_temp(hass, config, directive, context): + """Process an adjust target temperature request.""" + entity = directive.entity + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit + + temp_delta = temperature_from_object( + hass, directive.payload['targetSetpointDelta'], interval=True) + target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta + + if target_temp < min_temp or target_temp > max_temp: + raise _AlexaTempRangeError(hass, target_temp, min_temp, max_temp) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + ATTR_TEMPERATURE: target_temp, + } + + response = directive.response() + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, + context=context) + response.add_context_property({ + 'name': 'targetSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]}, + }) + + return response + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) +async def async_api_set_thermostat_mode(hass, config, directive, context): + """Process a set thermostat mode request.""" + entity = directive.entity + mode = directive.payload['thermostatMode'] + mode = mode if isinstance(mode, str) else mode['value'] + + operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) + ha_mode = next( + (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), + None + ) + if ha_mode not in operation_list: + msg = 'The requested thermostat mode {} is not supported'.format(mode) + raise _AlexaUnsupportedThermostatModeError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_OPERATION_MODE: ha_mode, + } + + response = directive.response() + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, + blocking=False, context=context) + response.add_context_property({ + 'name': 'thermostatMode', + 'namespace': 'Alexa.ThermostatController', + 'value': mode, + }) + + return response + + +@HANDLERS.register(('Alexa', 'ReportState')) +async def async_api_reportstate(hass, config, directive, context): + """Process a ReportState request.""" + return directive.response(name='StateReport') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ca.json new file mode 100644 index 00000000000000..d3c451f3e3ff81 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Clau d'aplicaci\u00f3 i/o clau API ja registrada", + "invalid_key": "Clau API i/o clau d'aplicaci\u00f3 inv\u00e0lida/es", + "no_devices": "No s'ha trobat cap dispositiu al compte" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "app_key": "Clau d'aplicaci\u00f3" + }, + "title": "Introdueix la teva informaci\u00f3" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/en.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/en.json new file mode 100644 index 00000000000000..5bd643da55cfa3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Application Key" + }, + "title": "Fill in your information" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ko.json new file mode 100644 index 00000000000000..51a09514159efe --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_key": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "API \ud0a4", + "app_key": "Application \ud0a4" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/lb.json new file mode 100644 index 00000000000000..0f0d60d4458636 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Applikatioun's Schl\u00ebssel an/oder API Schl\u00ebssel ass scho registr\u00e9iert", + "invalid_key": "Ong\u00ebltegen API Schl\u00ebssel an/oder Applikatioun's Schl\u00ebssel", + "no_devices": "Keng Apparater am Kont fonnt" + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "app_key": "Applikatioun's Schl\u00ebssel" + }, + "title": "F\u00ebllt \u00e4r Informatiounen aus" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ru.json new file mode 100644 index 00000000000000..d1264010b75c1c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d", + "invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", + "no_devices": "\u0412 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b" + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "app_key": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "title": "Ambient PWS" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/zh-Hant.json new file mode 100644 index 00000000000000..7e3ed3ef888509 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u5df2\u8a3b\u518a", + "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548", + "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "app_key": "\u61c9\u7528\u5bc6\u9470" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + }, + "title": "\u74b0\u5883 PWS" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/__init__.py new file mode 100644 index 00000000000000..0991336f42a2c8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/__init__.py @@ -0,0 +1,205 @@ +""" +Support for Ambient Weather Station Service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ambient_station/ +""" +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_NAME, ATTR_LOCATION, CONF_API_KEY, CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .config_flow import configured_instances +from .const import ( + ATTR_LAST_DATA, CONF_APP_KEY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE) + +REQUIREMENTS = ['aioambient==0.1.0'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SOCKET_MIN_RETRY = 15 + +SENSOR_TYPES = { + '24hourrainin': ('24 Hr Rain', 'in'), + 'baromabsin': ('Abs Pressure', 'inHg'), + 'baromrelin': ('Rel Pressure', 'inHg'), + 'battout': ('Battery', ''), + 'co2': ('co2', 'ppm'), + 'dailyrainin': ('Daily Rain', 'in'), + 'dewPoint': ('Dew Point', '°F'), + 'eventrainin': ('Event Rain', 'in'), + 'feelsLike': ('Feels Like', '°F'), + 'hourlyrainin': ('Hourly Rain Rate', 'in/hr'), + 'humidity': ('Humidity', '%'), + 'humidityin': ('Humidity In', '%'), + 'lastRain': ('Last Rain', ''), + 'maxdailygust': ('Max Gust', 'mph'), + 'monthlyrainin': ('Monthly Rain', 'in'), + 'solarradiation': ('Solar Rad', 'W/m^2'), + 'tempf': ('Temp', '°F'), + 'tempinf': ('Inside Temp', '°F'), + 'totalrainin': ('Lifetime Rain', 'in'), + 'uv': ('uv', 'Index'), + 'weeklyrainin': ('Weekly Rain', 'in'), + 'winddir': ('Wind Dir', '°'), + 'winddir_avg10m': ('Wind Dir Avg 10m', '°'), + 'winddir_avg2m': ('Wind Dir Avg 2m', 'mph'), + 'windgustdir': ('Gust Dir', '°'), + 'windgustmph': ('Wind Gust', 'mph'), + 'windspdmph_avg10m': ('Wind Avg 10m', 'mph'), + 'windspdmph_avg2m': ('Wind Avg 2m', 'mph'), + 'windspeedmph': ('Wind Speed', 'mph'), + 'yearlyrainin': ('Yearly Rain', 'in'), +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: + vol.Schema({ + vol.Required(CONF_APP_KEY): + cv.string, + vol.Required(CONF_API_KEY): + cv.string, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Ambient PWS component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + if conf[CONF_APP_KEY] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': SOURCE_IMPORT}, data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Ambient PWS as config entry.""" + from aioambient import Client + from aioambient.errors import WebsocketConnectionError + + session = aiohttp_client.async_get_clientsession(hass) + + try: + ambient = AmbientStation( + hass, + config_entry, + Client( + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_APP_KEY], session), + config_entry.data.get( + CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES))) + hass.loop.create_task(ambient.ws_connect()) + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient + except WebsocketConnectionError as err: + _LOGGER.error('Config entry failed: %s', err) + raise ConfigEntryNotReady + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect()) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Ambient PWS config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + hass.async_create_task(ambient.ws_disconnect()) + + await hass.config_entries.async_forward_entry_unload( + config_entry, 'sensor') + + return True + + +class AmbientStation: + """Define a class to handle the Ambient websocket.""" + + def __init__(self, hass, config_entry, client, monitored_conditions): + """Initialize.""" + self._config_entry = config_entry + self._hass = hass + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self.client = client + self.monitored_conditions = monitored_conditions + self.stations = {} + + async def ws_connect(self): + """Register handlers and connect to the websocket.""" + from aioambient.errors import WebsocketError + + def on_connect(): + """Define a handler to fire when the websocket is connected.""" + _LOGGER.info('Connected to websocket') + + def on_data(data): + """Define a handler to fire when the data is received.""" + mac_address = data['macAddress'] + if data != self.stations[mac_address][ATTR_LAST_DATA]: + _LOGGER.debug('New data received: %s', data) + self.stations[mac_address][ATTR_LAST_DATA] = data + async_dispatcher_send(self._hass, TOPIC_UPDATE) + + def on_disconnect(): + """Define a handler to fire when the websocket is disconnected.""" + _LOGGER.info('Disconnected from websocket') + + def on_subscribed(data): + """Define a handler to fire when the subscription is set.""" + for station in data['devices']: + if station['macAddress'] in self.stations: + continue + + _LOGGER.debug('New station subscription: %s', data) + + self.stations[station['macAddress']] = { + ATTR_LAST_DATA: station['lastData'], + ATTR_LOCATION: station['info']['location'], + ATTR_NAME: station['info']['name'], + } + + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'sensor')) + + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + + self.client.websocket.on_connect(on_connect) + self.client.websocket.on_data(on_data) + self.client.websocket.on_disconnect(on_disconnect) + self.client.websocket.on_subscribed(on_subscribed) + + try: + await self.client.websocket.connect() + except WebsocketError as err: + _LOGGER.error("Error with the websocket connection: %s", err) + + self._ws_reconnect_delay = min( + 2 * self._ws_reconnect_delay, 480) + + async_call_later( + self._hass, self._ws_reconnect_delay, self.ws_connect) + + async def ws_disconnect(self): + """Disconnect from the websocket.""" + await self.client.websocket.disconnect() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/config_flow.py b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/config_flow.py new file mode 100644 index 00000000000000..56e747ce5e096d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow to configure the Ambient PWS component.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_APP_KEY, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured Ambient PWS instances.""" + return set( + entry.data[CONF_APP_KEY] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class AmbientStationFlowHandler(config_entries.ConfigFlow): + """Handle an Ambient PWS config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema({ + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_APP_KEY): str, + }) + + return self.async_show_form( + step_id='user', + data_schema=data_schema, + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from aioambient import Client + from aioambient.errors import AmbientError + + if not user_input: + return await self._show_form() + + if user_input[CONF_APP_KEY] in configured_instances(self.hass): + return await self._show_form({CONF_APP_KEY: 'identifier_exists'}) + + session = aiohttp_client.async_get_clientsession(self.hass) + client = Client( + user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + + try: + devices = await client.api.get_devices() + except AmbientError: + return await self._show_form({'base': 'invalid_key'}) + + if not devices: + return await self._show_form({'base': 'no_devices'}) + + # The Application Key (which identifies each config entry) is too long + # to show nicely in the UI, so we take the first 12 characters (similar + # to how GitHub does it): + return self.async_create_entry( + title=user_input[CONF_APP_KEY][:12], data=user_input) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/const.py b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/const.py new file mode 100644 index 00000000000000..75606a1c699fc5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/const.py @@ -0,0 +1,10 @@ +"""Define constants for the Ambient PWS component.""" +DOMAIN = 'ambient_station' + +ATTR_LAST_DATA = 'last_data' + +CONF_APP_KEY = 'app_key' + +DATA_CLIENT = 'data_client' + +TOPIC_UPDATE = 'update' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/sensor.py new file mode 100644 index 00000000000000..9e0833e3441329 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/sensor.py @@ -0,0 +1,102 @@ +""" +Support for Ambient Weather Station Service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ambient_station/ +""" +import logging + +from homeassistant.components.ambient_station import SENSOR_TYPES +from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TOPIC_UPDATE + +DEPENDENCIES = ['ambient_station'] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up an Ambient PWS sensor based on existing config.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up an Ambient PWS sensor based on a config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + + sensor_list = [] + for mac_address, station in ambient.stations.items(): + for condition in ambient.monitored_conditions: + name, unit = SENSOR_TYPES[condition] + sensor_list.append( + AmbientWeatherSensor( + ambient, mac_address, station[ATTR_NAME], condition, name, + unit)) + + async_add_entities(sensor_list, True) + + +class AmbientWeatherSensor(Entity): + """Define an Ambient sensor.""" + + def __init__( + self, ambient, mac_address, station_name, sensor_type, sensor_name, + unit): + """Initialize the sensor.""" + self._ambient = ambient + self._async_unsub_dispatcher_connect = None + self._mac_address = mac_address + self._sensor_name = sensor_name + self._sensor_type = sensor_type + self._state = None + self._station_name = station_name + self._unit = unit + + @property + def name(self): + """Return the name of the sensor.""" + return '{0}_{1}'.format(self._station_name, self._sensor_name) + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def unique_id(self): + """Return a unique, unchanging string that represents this sensor.""" + return '{0}_{1}'.format(self._mac_address, self._sensor_name) + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + async def async_update(self): + """Fetch new state data for the sensor.""" + self._state = self._ambient.stations[ + self._mac_address][ATTR_LAST_DATA].get(self._sensor_type) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/strings.json b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/strings.json new file mode 100644 index 00000000000000..657b3477bb2259 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ambient_station/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Ambient PWS", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "api_key": "API Key", + "app_key": "Application Key" + } + } + }, + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + } + } +} diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/__init__.py new file mode 100644 index 00000000000000..bcd0c38c3bdff2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/__init__.py @@ -0,0 +1,178 @@ +""" +This component provides basic support for Amcrest IP cameras. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/amcrest/ +""" +import logging +from datetime import timedelta + +import aiohttp +import voluptuous as vol +from requests.exceptions import HTTPError, ConnectTimeout +from requests.exceptions import ConnectionError as ConnectError + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, + CONF_SENSORS, CONF_SWITCHES, CONF_SCAN_INTERVAL, HTTP_BASIC_AUTHENTICATION) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['amcrest==1.2.3'] +DEPENDENCIES = ['ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTHENTICATION = 'authentication' +CONF_RESOLUTION = 'resolution' +CONF_STREAM_SOURCE = 'stream_source' +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEFAULT_NAME = 'Amcrest Camera' +DEFAULT_PORT = 80 +DEFAULT_RESOLUTION = 'high' +DEFAULT_STREAM_SOURCE = 'snapshot' +TIMEOUT = 10 + +DATA_AMCREST = 'amcrest' +DOMAIN = 'amcrest' + +NOTIFICATION_ID = 'amcrest_notification' +NOTIFICATION_TITLE = 'Amcrest Camera Setup' + +RESOLUTION_LIST = { + 'high': 0, + 'low': 1, +} + +SCAN_INTERVAL = timedelta(seconds=10) + +AUTHENTICATION_LIST = { + 'basic': 'basic' +} + +STREAM_SOURCE_LIST = { + 'mjpeg': 0, + 'snapshot': 1, + 'rtsp': 2, +} + +# Sensor types are defined like: Name, units, icon +SENSORS = { + 'motion_detector': ['Motion Detected', None, 'mdi:run'], + 'sdcard': ['SD Used', '%', 'mdi:sd'], + 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], +} + +# Switch types are defined like: Name, icon +SWITCHES = { + 'motion_detection': ['Motion Detection', 'mdi:run-fast'], + 'motion_recording': ['Motion Recording', 'mdi:record-rec'] +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + 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_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): + vol.All(vol.In(AUTHENTICATION_LIST)), + vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): + vol.All(vol.In(RESOLUTION_LIST)), + vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): + vol.All(vol.In(STREAM_SOURCE_LIST)), + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSORS)]), + vol.Optional(CONF_SWITCHES): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + })]) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Amcrest IP Camera component.""" + from amcrest import AmcrestCamera + + hass.data[DATA_AMCREST] = {} + amcrest_cams = config[DOMAIN] + + for device in amcrest_cams: + try: + camera = AmcrestCamera(device.get(CONF_HOST), + device.get(CONF_PORT), + device.get(CONF_USERNAME), + device.get(CONF_PASSWORD)).camera + # pylint: disable=pointless-statement + camera.current_time + + except (ConnectError, ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Amcrest camera: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + continue + + ffmpeg_arguments = device.get(CONF_FFMPEG_ARGUMENTS) + name = device.get(CONF_NAME) + resolution = RESOLUTION_LIST[device.get(CONF_RESOLUTION)] + sensors = device.get(CONF_SENSORS) + switches = device.get(CONF_SWITCHES) + stream_source = STREAM_SOURCE_LIST[device.get(CONF_STREAM_SOURCE)] + + username = device.get(CONF_USERNAME) + password = device.get(CONF_PASSWORD) + + # currently aiohttp only works with basic authentication + # only valid for mjpeg streaming + if username is not None and password is not None: + if device.get(CONF_AUTHENTICATION) == HTTP_BASIC_AUTHENTICATION: + authentication = aiohttp.BasicAuth(username, password) + else: + authentication = None + + hass.data[DATA_AMCREST][name] = AmcrestDevice( + camera, name, authentication, ffmpeg_arguments, stream_source, + resolution) + + discovery.load_platform( + hass, 'camera', DOMAIN, { + CONF_NAME: name, + }, config) + + if sensors: + discovery.load_platform( + hass, 'sensor', DOMAIN, { + CONF_NAME: name, + CONF_SENSORS: sensors, + }, config) + + if switches: + discovery.load_platform( + hass, 'switch', DOMAIN, { + CONF_NAME: name, + CONF_SWITCHES: switches + }, config) + + return True + + +class AmcrestDevice: + """Representation of a base Amcrest discovery device.""" + + def __init__(self, camera, name, authentication, ffmpeg_arguments, + stream_source, resolution): + """Initialize the entity.""" + self.device = camera + self.name = name + self.authentication = authentication + self.ffmpeg_arguments = ffmpeg_arguments + self.stream_source = stream_source + self.resolution = resolution diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/camera.py new file mode 100644 index 00000000000000..4ba527b480528f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/camera.py @@ -0,0 +1,92 @@ +""" +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 logging + +from homeassistant.components.amcrest import ( + DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT) +from homeassistant.components.camera import Camera +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_web, + async_aiohttp_proxy_stream) + +DEPENDENCIES = ['amcrest', 'ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up an Amcrest IP Camera.""" + if discovery_info is None: + return + + device_name = discovery_info[CONF_NAME] + amcrest = hass.data[DATA_AMCREST][device_name] + + async_add_entities([AmcrestCam(hass, amcrest)], True) + + return True + + +class AmcrestCam(Camera): + """An implementation of an Amcrest IP camera.""" + + def __init__(self, hass, amcrest): + """Initialize an Amcrest camera.""" + super(AmcrestCam, self).__init__() + self._name = amcrest.name + self._camera = amcrest.device + self._base_url = self._camera.get_base_url() + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = amcrest.ffmpeg_arguments + self._stream_source = amcrest.stream_source + self._resolution = amcrest.resolution + self._token = self._auth = amcrest.authentication + + def camera_image(self): + """Return a still image response from the camera.""" + # Send the request to snap a picture and return raw jpg data + response = self._camera.snapshot(channel=self._resolution) + return response.data + + async 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']: + return await super().handle_async_mjpeg_stream(request) + + if self._stream_source == STREAM_SOURCE_LIST['mjpeg']: + # stream an MJPEG image stream directly from the camera + websession = async_get_clientsession(self.hass) + streaming_url = self._camera.mjpeg_url(typeno=self._resolution) + stream_coro = websession.get( + streaming_url, auth=self._token, timeout=TIMEOUT) + + return await async_aiohttp_proxy_web( + self.hass, request, stream_coro) + + # streaming via ffmpeg + from haffmpeg import CameraMjpeg + + streaming_url = self._camera.rtsp_url(typeno=self._resolution) + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera( + streaming_url, extra_cmd=self._ffmpeg_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/sensor.py new file mode 100644 index 00000000000000..22e13d05e20e01 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/sensor.py @@ -0,0 +1,107 @@ +""" +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 + +from homeassistant.components.amcrest import DATA_AMCREST, SENSORS +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_NAME, CONF_SENSORS + +DEPENDENCIES = ['amcrest'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up a sensor for an Amcrest IP Camera.""" + if discovery_info is None: + return + + device_name = discovery_info[CONF_NAME] + sensors = discovery_info[CONF_SENSORS] + amcrest = hass.data[DATA_AMCREST][device_name] + + amcrest_sensors = [] + for sensor_type in sensors: + amcrest_sensors.append( + AmcrestSensor(amcrest.name, amcrest.device, sensor_type)) + + async_add_entities(amcrest_sensors, True) + return True + + +class AmcrestSensor(Entity): + """A sensor implementation for Amcrest IP camera.""" + + def __init__(self, name, camera, sensor_type): + """Initialize a sensor for Amcrest camera.""" + self._attrs = {} + self._camera = camera + self._sensor_type = sensor_type + self._name = '{0}_{1}'.format(name, + SENSORS.get(self._sensor_type)[0]) + self._icon = 'mdi:{}'.format(SENSORS.get(self._sensor_type)[2]) + 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 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 SENSORS.get(self._sensor_type)[1] + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Pulling data from %s sensor.", self._name) + + try: + version, build_date = self._camera.software_information + self._attrs['Build Date'] = build_date.split('=')[-1] + self._attrs['Version'] = version.split('=')[-1] + except ValueError: + self._attrs['Build Date'] = 'Not Available' + self._attrs['Version'] = 'Not Available' + + try: + self._attrs['Serial Number'] = self._camera.serial_number + except ValueError: + self._attrs['Serial Number'] = 'Not Available' + + 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.storage_used_percent diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/switch.py new file mode 100644 index 00000000000000..4eb2030885065e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/amcrest/switch.py @@ -0,0 +1,91 @@ +""" +Support for toggling Amcrest IP camera settings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.amcrest/ +""" +import logging + +from homeassistant.components.amcrest import DATA_AMCREST, SWITCHES +from homeassistant.const import ( + CONF_NAME, CONF_SWITCHES, STATE_OFF, STATE_ON) +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['amcrest'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the IP Amcrest camera switch platform.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + switches = discovery_info[CONF_SWITCHES] + camera = hass.data[DATA_AMCREST][name].device + + all_switches = [] + + for setting in switches: + all_switches.append(AmcrestSwitch(setting, camera, name)) + + async_add_entities(all_switches, True) + + +class AmcrestSwitch(ToggleEntity): + """Representation of an Amcrest IP camera switch.""" + + def __init__(self, setting, camera, name): + """Initialize the Amcrest switch.""" + self._setting = setting + self._camera = camera + self._name = '{} {}'.format(SWITCHES[setting][0], name) + self._icon = SWITCHES[setting][1] + self._state = None + + @property + def name(self): + """Return the name of the switch if any.""" + return self._name + + @property + def state(self): + """Return the state of the switch.""" + return self._state + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """Turn setting on.""" + if self._setting == 'motion_detection': + self._camera.motion_detection = 'true' + elif self._setting == 'motion_recording': + self._camera.motion_recording = 'true' + + def turn_off(self, **kwargs): + """Turn setting off.""" + if self._setting == 'motion_detection': + self._camera.motion_detection = 'false' + elif self._setting == 'motion_recording': + self._camera.motion_recording = 'false' + + def update(self): + """Update setting state.""" + _LOGGER.debug("Polling state for setting: %s ", self._name) + + if self._setting == 'motion_detection': + detection = self._camera.is_motion_detector_on() + elif self._setting == 'motion_recording': + detection = self._camera.is_record_on_motion_detection() + + self._state = STATE_ON if detection else STATE_OFF + + @property + def icon(self): + """Return the icon for the switch.""" + return self._icon diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/__init__.py new file mode 100644 index 00000000000000..1cf46174371e59 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/__init__.py @@ -0,0 +1,290 @@ +""" +Support for IP Webcam, an Android app that acts as a full-featured webcam. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/android_ip_webcam/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, + CONF_SENSORS, CONF_SWITCHES, CONF_TIMEOUT, CONF_SCAN_INTERVAL, + CONF_PLATFORM) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow +from homeassistant.components.camera.mjpeg import ( + CONF_MJPEG_URL, CONF_STILL_IMAGE_URL) + +REQUIREMENTS = ['pydroid-ipcam==0.8'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUD_CONNS = 'Audio Connections' +ATTR_HOST = 'host' +ATTR_VID_CONNS = 'Video Connections' + +CONF_MOTION_SENSOR = 'motion_sensor' + +DATA_IP_WEBCAM = 'android_ip_webcam' +DEFAULT_NAME = 'IP Webcam' +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 10 +DOMAIN = 'android_ip_webcam' + +SCAN_INTERVAL = timedelta(seconds=10) +SIGNAL_UPDATE_DATA = 'android_ip_webcam_update' + +KEY_MAP = { + 'audio_connections': 'Audio Connections', + 'adet_limit': 'Audio Trigger Limit', + 'antibanding': 'Anti-banding', + 'audio_only': 'Audio Only', + 'battery_level': 'Battery Level', + 'battery_temp': 'Battery Temperature', + 'battery_voltage': 'Battery Voltage', + 'coloreffect': 'Color Effect', + 'exposure': 'Exposure Level', + 'exposure_lock': 'Exposure Lock', + 'ffc': 'Front-facing Camera', + 'flashmode': 'Flash Mode', + 'focus': 'Focus', + 'focus_homing': 'Focus Homing', + 'focus_region': 'Focus Region', + 'focusmode': 'Focus Mode', + 'gps_active': 'GPS Active', + 'idle': 'Idle', + 'ip_address': 'IPv4 Address', + 'ipv6_address': 'IPv6 Address', + 'ivideon_streaming': 'Ivideon Streaming', + 'light': 'Light Level', + 'mirror_flip': 'Mirror Flip', + 'motion': 'Motion', + 'motion_active': 'Motion Active', + 'motion_detect': 'Motion Detection', + 'motion_event': 'Motion Event', + 'motion_limit': 'Motion Limit', + 'night_vision': 'Night Vision', + 'night_vision_average': 'Night Vision Average', + 'night_vision_gain': 'Night Vision Gain', + 'orientation': 'Orientation', + 'overlay': 'Overlay', + 'photo_size': 'Photo Size', + 'pressure': 'Pressure', + 'proximity': 'Proximity', + 'quality': 'Quality', + 'scenemode': 'Scene Mode', + 'sound': 'Sound', + 'sound_event': 'Sound Event', + 'sound_timeout': 'Sound Timeout', + 'torch': 'Torch', + 'video_connections': 'Video Connections', + 'video_chunk_len': 'Video Chunk Length', + 'video_recording': 'Video Recording', + 'video_size': 'Video Size', + 'whitebalance': 'White Balance', + 'whitebalance_lock': 'White Balance Lock', + 'zoom': 'Zoom' +} + +ICON_MAP = { + 'audio_connections': 'mdi:speaker', + 'battery_level': 'mdi:battery', + 'battery_temp': 'mdi:thermometer', + 'battery_voltage': 'mdi:battery-charging-100', + 'exposure_lock': 'mdi:camera', + 'ffc': 'mdi:camera-front-variant', + 'focus': 'mdi:image-filter-center-focus', + 'gps_active': 'mdi:crosshairs-gps', + 'light': 'mdi:flashlight', + 'motion': 'mdi:run', + 'night_vision': 'mdi:weather-night', + 'overlay': 'mdi:monitor', + 'pressure': 'mdi:gauge', + 'proximity': 'mdi:map-marker-radius', + 'quality': 'mdi:quality-high', + 'sound': 'mdi:speaker', + 'sound_event': 'mdi:speaker', + 'sound_timeout': 'mdi:speaker', + 'torch': 'mdi:white-balance-sunny', + 'video_chunk_len': 'mdi:video', + 'video_connections': 'mdi:eye', + 'video_recording': 'mdi:record-rec', + 'whitebalance_lock': 'mdi:white-balance-auto' +} + +SWITCHES = ['exposure_lock', 'ffc', 'focus', 'gps_active', + 'motion_detect', 'night_vision', 'overlay', + 'torch', 'whitebalance_lock', 'video_recording'] + +SENSORS = ['audio_connections', 'battery_level', 'battery_temp', + 'battery_voltage', 'light', 'motion', 'pressure', 'proximity', + 'sound', 'video_connections'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_SWITCHES): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSORS)]), + vol.Optional(CONF_MOTION_SENSOR): cv.boolean, + })]) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the IP Webcam component.""" + from pydroid_ipcam import PyDroidIPCam + + webcams = hass.data[DATA_IP_WEBCAM] = {} + websession = async_get_clientsession(hass) + + async def async_setup_ipcamera(cam_config): + """Set up an IP camera.""" + host = cam_config[CONF_HOST] + username = cam_config.get(CONF_USERNAME) + password = cam_config.get(CONF_PASSWORD) + name = cam_config[CONF_NAME] + interval = cam_config[CONF_SCAN_INTERVAL] + switches = cam_config.get(CONF_SWITCHES) + sensors = cam_config.get(CONF_SENSORS) + motion = cam_config.get(CONF_MOTION_SENSOR) + + # Init ip webcam + cam = PyDroidIPCam( + hass.loop, websession, host, cam_config[CONF_PORT], + username=username, password=password, + timeout=cam_config[CONF_TIMEOUT] + ) + + if switches is None: + switches = [setting for setting in cam.enabled_settings + if setting in SWITCHES] + + if sensors is None: + sensors = [sensor for sensor in cam.enabled_sensors + if sensor in SENSORS] + sensors.extend(['audio_connections', 'video_connections']) + + if motion is None: + motion = 'motion_active' in cam.enabled_sensors + + async def async_update_data(now): + """Update data from IP camera in SCAN_INTERVAL.""" + await cam.update() + async_dispatcher_send(hass, SIGNAL_UPDATE_DATA, host) + + async_track_point_in_utc_time( + hass, async_update_data, utcnow() + interval) + + await async_update_data(None) + + # Load platforms + webcams[host] = cam + + mjpeg_camera = { + CONF_PLATFORM: 'mjpeg', + CONF_MJPEG_URL: cam.mjpeg_url, + CONF_STILL_IMAGE_URL: cam.image_url, + CONF_NAME: name, + } + if username and password: + mjpeg_camera.update({ + CONF_USERNAME: username, + CONF_PASSWORD: password + }) + + hass.async_create_task(discovery.async_load_platform( + hass, 'camera', 'mjpeg', mjpeg_camera, config)) + + if sensors: + hass.async_create_task(discovery.async_load_platform( + hass, 'sensor', DOMAIN, { + CONF_NAME: name, + CONF_HOST: host, + CONF_SENSORS: sensors, + }, config)) + + if switches: + hass.async_create_task(discovery.async_load_platform( + hass, 'switch', DOMAIN, { + CONF_NAME: name, + CONF_HOST: host, + CONF_SWITCHES: switches, + }, config)) + + if motion: + hass.async_create_task(discovery.async_load_platform( + hass, 'binary_sensor', DOMAIN, { + CONF_HOST: host, + CONF_NAME: name, + }, config)) + + tasks = [async_setup_ipcamera(conf) for conf in config[DOMAIN]] + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + return True + + +class AndroidIPCamEntity(Entity): + """The Android device running IP Webcam.""" + + def __init__(self, host, ipcam): + """Initialize the data object.""" + self._host = host + self._ipcam = ipcam + + async def async_added_to_hass(self): + """Register update dispatcher.""" + @callback + def async_ipcam_update(host): + """Update callback.""" + if self._host != host: + return + self.async_schedule_update_ha_state(True) + + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self._ipcam.available + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {ATTR_HOST: self._host} + if self._ipcam.status_data is None: + return state_attr + + state_attr[ATTR_VID_CONNS] = \ + self._ipcam.status_data.get('video_connections') + state_attr[ATTR_AUD_CONNS] = \ + self._ipcam.status_data.get('audio_connections') + + return state_attr diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/binary_sensor.py new file mode 100644 index 00000000000000..085bafd3ae3f60 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/binary_sensor.py @@ -0,0 +1,59 @@ +""" +Support for IP Webcam binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.android_ip_webcam/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.android_ip_webcam import ( + KEY_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST, CONF_NAME) + +DEPENDENCIES = ['android_ip_webcam'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the IP Webcam binary sensors.""" + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] + ipcam = hass.data[DATA_IP_WEBCAM][host] + + async_add_entities( + [IPWebcamBinarySensor(name, host, ipcam, 'motion_active')], True) + + +class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorDevice): + """Representation of an IP Webcam binary sensor.""" + + def __init__(self, name, host, ipcam, sensor): + """Initialize the binary sensor.""" + super().__init__(host, ipcam) + + self._sensor = sensor + self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the binary sensor, if any.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + async def async_update(self): + """Retrieve latest state.""" + state, _ = self._ipcam.export_sensor(self._sensor) + self._state = state == 1.0 + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'motion' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/sensor.py new file mode 100644 index 00000000000000..0f795f85dcd498 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/sensor.py @@ -0,0 +1,78 @@ +""" +Support for IP Webcam sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.android_ip_webcam/ +""" + +from homeassistant.components.android_ip_webcam import ( + KEY_MAP, ICON_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST, + CONF_NAME, CONF_SENSORS) +from homeassistant.helpers.icon import icon_for_battery_level + +DEPENDENCIES = ['android_ip_webcam'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the IP Webcam Sensor.""" + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] + sensors = discovery_info[CONF_SENSORS] + ipcam = hass.data[DATA_IP_WEBCAM][host] + + all_sensors = [] + + for sensor in sensors: + all_sensors.append(IPWebcamSensor(name, host, ipcam, sensor)) + + async_add_entities(all_sensors, True) + + +class IPWebcamSensor(AndroidIPCamEntity): + """Representation of a IP Webcam sensor.""" + + def __init__(self, name, host, ipcam, sensor): + """Initialize the sensor.""" + super().__init__(host, ipcam) + + self._sensor = sensor + self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Retrieve latest state.""" + if self._sensor in ('audio_connections', 'video_connections'): + if not self._ipcam.status_data: + return + self._state = self._ipcam.status_data.get(self._sensor) + self._unit = 'Connections' + else: + self._state, self._unit = self._ipcam.export_sensor(self._sensor) + + @property + def icon(self): + """Return the icon for the sensor.""" + if self._sensor == 'battery_level' and self._state is not None: + return icon_for_battery_level(int(self._state)) + return ICON_MAP.get(self._sensor, 'mdi:eye') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/switch.py new file mode 100644 index 00000000000000..f770b9d5ebf342 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/android_ip_webcam/switch.py @@ -0,0 +1,90 @@ +""" +Support for IP Webcam settings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.android_ip_webcam/ +""" + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.android_ip_webcam import ( + KEY_MAP, ICON_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST, + CONF_NAME, CONF_SWITCHES) + +DEPENDENCIES = ['android_ip_webcam'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the IP Webcam switch platform.""" + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] + switches = discovery_info[CONF_SWITCHES] + ipcam = hass.data[DATA_IP_WEBCAM][host] + + all_switches = [] + + for setting in switches: + all_switches.append(IPWebcamSettingsSwitch(name, host, ipcam, setting)) + + async_add_entities(all_switches, True) + + +class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): + """An abstract class for an IP Webcam setting.""" + + def __init__(self, name, host, ipcam, setting): + """Initialize the settings switch.""" + super().__init__(host, ipcam) + + self._setting = setting + self._mapped_name = KEY_MAP.get(self._setting, self._setting) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = False + + @property + def name(self): + """Return the name of the node.""" + return self._name + + async def async_update(self): + """Get the updated status of the switch.""" + self._state = bool(self._ipcam.current_settings.get(self._setting)) + + @property + def is_on(self): + """Return the boolean response if the node is on.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn device on.""" + if self._setting == 'torch': + await self._ipcam.torch(activate=True) + elif self._setting == 'focus': + await self._ipcam.focus(activate=True) + elif self._setting == 'video_recording': + await self._ipcam.record(record=True) + else: + await self._ipcam.change_setting(self._setting, True) + self._state = True + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn device off.""" + if self._setting == 'torch': + await self._ipcam.torch(activate=False) + elif self._setting == 'focus': + await self._ipcam.focus(activate=False) + elif self._setting == 'video_recording': + await self._ipcam.record(record=False) + else: + await self._ipcam.change_setting(self._setting, False) + self._state = False + self.async_schedule_update_ha_state() + + @property + def icon(self): + """Return the icon for the switch.""" + return ICON_MAP.get(self._setting, 'mdi:flash') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/__init__.py new file mode 100644 index 00000000000000..79b88378169418 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/__init__.py @@ -0,0 +1,89 @@ +""" +Support for status output of APCUPSd via its Network Information Server (NIS). + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/apcupsd/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import (CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['apcaccess==0.0.13'] + +_LOGGER = logging.getLogger(__name__) + +CONF_TYPE = 'type' + +DATA = None +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 3551 +DOMAIN = 'apcupsd' + +KEY_STATUS = 'STATUS' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +VALUE_ONLINE = 'ONLINE' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Use config values to set up a function enabling status retrieval.""" + global DATA + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + + DATA = APCUPSdData(host, port) + + # It doesn't really matter why we're not able to get the status, just that + # we can't. + try: + DATA.update(no_throttle=True) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failure while testing APCUPSd status retrieval.") + return False + return True + + +class APCUPSdData: + """Stores the data retrieved from APCUPSd. + + For each entity to use, acts as the single point responsible for fetching + updates from the server. + """ + + def __init__(self, host, port): + """Initialize the data object.""" + from apcaccess import status + self._host = host + self._port = port + self._status = None + self._get = status.get + self._parse = status.parse + + @property + def status(self): + """Get latest update if throttle allows. Return status.""" + self.update() + return self._status + + def _get_status(self): + """Get the status from APCUPSd and parse it into a dict.""" + return self._parse(self._get(host=self._host, port=self._port)) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Fetch the latest status from APCUPSd.""" + self._status = self._get_status() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/binary_sensor.py new file mode 100644 index 00000000000000..f876b8cc34b1c2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/binary_sensor.py @@ -0,0 +1,49 @@ +""" +Support for tracking the online status of a UPS. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.apcupsd/ +""" +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.components import apcupsd + +DEFAULT_NAME = 'UPS Online Status' +DEPENDENCIES = [apcupsd.DOMAIN] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an APCUPSd Online Status binary sensor.""" + add_entities([OnlineStatus(config, apcupsd.DATA)], True) + + +class OnlineStatus(BinarySensorDevice): + """Representation of an UPS online status.""" + + def __init__(self, config, data): + """Initialize the APCUPSd binary device.""" + self._config = config + self._data = data + self._state = None + + @property + def name(self): + """Return the name of the UPS online status sensor.""" + return self._config.get(CONF_NAME) + + @property + def is_on(self): + """Return true if the UPS is online, else false.""" + return self._state == apcupsd.VALUE_ONLINE + + def update(self): + """Get the status report from APCUPSd and set this entity's state.""" + self._state = self._data.status[apcupsd.KEY_STATUS] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/sensor.py new file mode 100644 index 00000000000000..90c1f2e6795c14 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/apcupsd/sensor.py @@ -0,0 +1,188 @@ +""" +Provides a sensor to track various status aspects of a UPS. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.apcupsd/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.components import apcupsd +from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = [apcupsd.DOMAIN] + +SENSOR_PREFIX = 'UPS ' +SENSOR_TYPES = { + 'alarmdel': ['Alarm Delay', '', 'mdi:alarm'], + 'ambtemp': ['Ambient Temperature', '', 'mdi:thermometer'], + 'apc': ['Status Data', '', 'mdi:information-outline'], + 'apcmodel': ['Model', '', 'mdi:information-outline'], + 'badbatts': ['Bad Batteries', '', 'mdi:information-outline'], + 'battdate': ['Battery Replaced', '', 'mdi:calendar-clock'], + 'battstat': ['Battery Status', '', 'mdi:information-outline'], + 'battv': ['Battery Voltage', 'V', 'mdi:flash'], + 'bcharge': ['Battery', '%', 'mdi:battery'], + 'cable': ['Cable Type', '', 'mdi:ethernet-cable'], + 'cumonbatt': ['Total Time on Battery', '', 'mdi:timer'], + 'date': ['Status Date', '', 'mdi:calendar-clock'], + 'dipsw': ['Dip Switch Settings', '', 'mdi:information-outline'], + 'dlowbatt': ['Low Battery Signal', '', 'mdi:clock-alert'], + 'driver': ['Driver', '', 'mdi:information-outline'], + 'dshutd': ['Shutdown Delay', '', 'mdi:timer'], + 'dwake': ['Wake Delay', '', 'mdi:timer'], + 'endapc': ['Date and Time', '', 'mdi:calendar-clock'], + 'extbatts': ['External Batteries', '', 'mdi:information-outline'], + 'firmware': ['Firmware Version', '', 'mdi:information-outline'], + 'hitrans': ['Transfer High', 'V', 'mdi:flash'], + 'hostname': ['Hostname', '', 'mdi:information-outline'], + 'humidity': ['Ambient Humidity', '%', 'mdi:water-percent'], + 'itemp': ['Internal Temperature', TEMP_CELSIUS, 'mdi:thermometer'], + 'lastxfer': ['Last Transfer', '', 'mdi:transfer'], + 'linefail': ['Input Voltage Status', '', 'mdi:information-outline'], + 'linefreq': ['Line Frequency', 'Hz', 'mdi:information-outline'], + 'linev': ['Input Voltage', 'V', 'mdi:flash'], + 'loadpct': ['Load', '%', 'mdi:gauge'], + 'loadapnt': ['Load Apparent Power', '%', 'mdi:gauge'], + 'lotrans': ['Transfer Low', 'V', 'mdi:flash'], + 'mandate': ['Manufacture Date', '', 'mdi:calendar'], + 'masterupd': ['Master Update', '', 'mdi:information-outline'], + 'maxlinev': ['Input Voltage High', 'V', 'mdi:flash'], + 'maxtime': ['Battery Timeout', '', 'mdi:timer-off'], + 'mbattchg': ['Battery Shutdown', '%', 'mdi:battery-alert'], + 'minlinev': ['Input Voltage Low', 'V', 'mdi:flash'], + 'mintimel': ['Shutdown Time', '', 'mdi:timer'], + 'model': ['Model', '', 'mdi:information-outline'], + 'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'], + 'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'], + 'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'], + 'nompower': ['Nominal Output Power', 'W', 'mdi:flash'], + 'nomapnt': ['Nominal Apparent Power', 'VA', 'mdi:flash'], + 'numxfers': ['Transfer Count', '', 'mdi:counter'], + 'outcurnt': ['Output Current', 'A', 'mdi:flash'], + 'outputv': ['Output Voltage', 'V', 'mdi:flash'], + 'reg1': ['Register 1 Fault', '', 'mdi:information-outline'], + 'reg2': ['Register 2 Fault', '', 'mdi:information-outline'], + 'reg3': ['Register 3 Fault', '', 'mdi:information-outline'], + 'retpct': ['Restore Requirement', '%', 'mdi:battery-alert'], + 'selftest': ['Last Self Test', '', 'mdi:calendar-clock'], + 'sense': ['Sensitivity', '', 'mdi:information-outline'], + 'serialno': ['Serial Number', '', 'mdi:information-outline'], + 'starttime': ['Startup Time', '', 'mdi:calendar-clock'], + 'statflag': ['Status Flag', '', 'mdi:information-outline'], + 'status': ['Status', '', 'mdi:information-outline'], + 'stesti': ['Self Test Interval', '', 'mdi:information-outline'], + 'timeleft': ['Time Left', '', 'mdi:clock-alert'], + 'tonbatt': ['Time on Battery', '', 'mdi:timer'], + 'upsmode': ['Mode', '', 'mdi:information-outline'], + 'upsname': ['Name', '', 'mdi:information-outline'], + 'version': ['Daemon Info', '', 'mdi:information-outline'], + 'xoffbat': ['Transfer from Battery', '', 'mdi:transfer'], + 'xoffbatt': ['Transfer from Battery', '', 'mdi:transfer'], + 'xonbatt': ['Transfer to Battery', '', 'mdi:transfer'], +} + +SPECIFIC_UNITS = { + 'ITEMP': TEMP_CELSIUS +} +INFERRED_UNITS = { + ' Minutes': 'min', + ' Seconds': 'sec', + ' Percent': '%', + ' Volts': 'V', + ' Ampere': 'A', + ' Volt-Ampere': 'VA', + ' Watts': 'W', + ' Hz': 'Hz', + ' C': TEMP_CELSIUS, + ' Percent Load Capacity': '%', +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCES, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the APCUPSd sensors.""" + entities = [] + + for resource in config[CONF_RESOURCES]: + sensor_type = resource.lower() + + if sensor_type not in SENSOR_TYPES: + SENSOR_TYPES[sensor_type] = [ + sensor_type.title(), '', 'mdi:information-outline'] + + if sensor_type.upper() not in apcupsd.DATA.status: + _LOGGER.warning( + "Sensor type: %s does not appear in the APCUPSd status output", + sensor_type) + + entities.append(APCUPSdSensor(apcupsd.DATA, sensor_type)) + + add_entities(entities, True) + + +def infer_unit(value): + """If the value ends with any of the units from ALL_UNITS. + + Split the unit off the end of the value and return the value, unit tuple + pair. Else return the original value and None as the unit. + """ + from apcaccess.status import ALL_UNITS + for unit in ALL_UNITS: + if value.endswith(unit): + return value[:-len(unit)], INFERRED_UNITS.get(unit, unit.strip()) + return value, None + + +class APCUPSdSensor(Entity): + """Representation of a sensor entity for APCUPSd status values.""" + + def __init__(self, data, sensor_type): + """Initialize the sensor.""" + self._data = data + self.type = sensor_type + self._name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] + self._unit = SENSOR_TYPES[sensor_type][1] + self._inferred_unit = None + self._state = None + + @property + def name(self): + """Return the name of the UPS sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self.type][2] + + @property + def state(self): + """Return true if the UPS is online, else False.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + if not self._unit: + return self._inferred_unit + return self._unit + + def update(self): + """Get the latest status and use it to update our sensor state.""" + if self.type.upper() not in self._data.status: + self._state = None + self._inferred_unit = None + else: + self._state, self._inferred_unit = infer_unit( + self._data.status[self.type.upper()]) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/api.py b/homeassistant-0.88.0.dev0/homeassistant/components/api.py new file mode 100644 index 00000000000000..961350bfa89771 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/api.py @@ -0,0 +1,408 @@ +""" +Rest API for Home Assistant. + +For more details about the RESTful API, please refer to the documentation at +https://developers.home-assistant.io/docs/en/external_api_rest.html +""" +import asyncio +import json +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPBadRequest +import async_timeout +import voluptuous as vol + +from homeassistant.bootstrap import DATA_LOGGING +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, + HTTP_CREATED, HTTP_NOT_FOUND, MATCH_ALL, URL_API, URL_API_COMPONENTS, + URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENTS, + URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, + URL_API_TEMPLATE, __version__) +import homeassistant.core as ha +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.exceptions import ( + TemplateError, Unauthorized, ServiceNotFound) +from homeassistant.helpers import template +from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.state import AsyncTrackStates +from homeassistant.helpers.json import JSONEncoder + +_LOGGER = logging.getLogger(__name__) + +ATTR_BASE_URL = 'base_url' +ATTR_LOCATION_NAME = 'location_name' +ATTR_REQUIRES_API_PASSWORD = 'requires_api_password' +ATTR_VERSION = 'version' + +DOMAIN = 'api' +DEPENDENCIES = ['http'] + +STREAM_PING_PAYLOAD = 'ping' +STREAM_PING_INTERVAL = 50 # seconds + + +def setup(hass, config): + """Register the API with the HTTP interface.""" + hass.http.register_view(APIStatusView) + hass.http.register_view(APIEventStream) + hass.http.register_view(APIConfigView) + hass.http.register_view(APIDiscoveryView) + hass.http.register_view(APIStatesView) + hass.http.register_view(APIEntityStateView) + hass.http.register_view(APIEventListenersView) + hass.http.register_view(APIEventView) + hass.http.register_view(APIServicesView) + hass.http.register_view(APIDomainServicesView) + hass.http.register_view(APIComponentsView) + hass.http.register_view(APITemplateView) + + if DATA_LOGGING in hass.data: + hass.http.register_view(APIErrorLog) + + return True + + +class APIStatusView(HomeAssistantView): + """View to handle Status requests.""" + + url = URL_API + name = 'api:status' + + @ha.callback + def get(self, request): + """Retrieve if API is running.""" + return self.json_message("API running.") + + +class APIEventStream(HomeAssistantView): + """View to handle EventStream requests.""" + + url = URL_API_STREAM + name = 'api:stream' + + async def get(self, request): + """Provide a streaming interface for the event bus.""" + if not request['hass_user'].is_admin: + raise Unauthorized() + hass = request.app['hass'] + stop_obj = object() + to_write = asyncio.Queue(loop=hass.loop) + + restrict = request.query.get('restrict') + if restrict: + restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP] + + async def forward_events(event): + """Forward events to the open request.""" + if event.event_type == EVENT_TIME_CHANGED: + return + + if restrict and event.event_type not in restrict: + return + + _LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event) + + if event.event_type == EVENT_HOMEASSISTANT_STOP: + data = stop_obj + else: + data = json.dumps(event, cls=JSONEncoder) + + await to_write.put(data) + + response = web.StreamResponse() + response.content_type = 'text/event-stream' + await response.prepare(request) + + unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events) + + try: + _LOGGER.debug("STREAM %s ATTACHED", id(stop_obj)) + + # Fire off one message so browsers fire open event right away + await to_write.put(STREAM_PING_PAYLOAD) + + while True: + try: + with async_timeout.timeout(STREAM_PING_INTERVAL, + loop=hass.loop): + payload = await to_write.get() + + if payload is stop_obj: + break + + msg = "data: {}\n\n".format(payload) + _LOGGER.debug( + "STREAM %s WRITING %s", id(stop_obj), msg.strip()) + await response.write(msg.encode('UTF-8')) + except asyncio.TimeoutError: + await to_write.put(STREAM_PING_PAYLOAD) + + except asyncio.CancelledError: + _LOGGER.debug("STREAM %s ABORT", id(stop_obj)) + + finally: + _LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj)) + unsub_stream() + + return response + + +class APIConfigView(HomeAssistantView): + """View to handle Configuration requests.""" + + url = URL_API_CONFIG + name = 'api:config' + + @ha.callback + def get(self, request): + """Get current configuration.""" + return self.json(request.app['hass'].config.as_dict()) + + +class APIDiscoveryView(HomeAssistantView): + """View to provide Discovery information.""" + + requires_auth = False + url = URL_API_DISCOVERY_INFO + name = 'api:discovery' + + @ha.callback + def get(self, request): + """Get discovery information.""" + hass = request.app['hass'] + needs_auth = hass.config.api.api_password is not None + return self.json({ + ATTR_BASE_URL: hass.config.api.base_url, + ATTR_LOCATION_NAME: hass.config.location_name, + ATTR_REQUIRES_API_PASSWORD: needs_auth, + ATTR_VERSION: __version__, + }) + + +class APIStatesView(HomeAssistantView): + """View to handle States requests.""" + + url = URL_API_STATES + name = "api:states" + + @ha.callback + def get(self, request): + """Get current states.""" + user = request['hass_user'] + entity_perm = user.permissions.check_entity + states = [ + state for state in request.app['hass'].states.async_all() + if entity_perm(state.entity_id, 'read') + ] + return self.json(states) + + +class APIEntityStateView(HomeAssistantView): + """View to handle EntityState requests.""" + + url = '/api/states/{entity_id}' + name = 'api:entity-state' + + @ha.callback + def get(self, request, entity_id): + """Retrieve state of entity.""" + user = request['hass_user'] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + + state = request.app['hass'].states.get(entity_id) + if state: + return self.json(state) + return self.json_message("Entity not found.", HTTP_NOT_FOUND) + + async def post(self, request, entity_id): + """Update state of entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) + hass = request.app['hass'] + try: + data = await request.json() + except ValueError: + return self.json_message( + "Invalid JSON specified.", HTTP_BAD_REQUEST) + + new_state = data.get('state') + + if new_state is None: + return self.json_message("No state specified.", HTTP_BAD_REQUEST) + + attributes = data.get('attributes') + force_update = data.get('force_update', False) + + is_new_state = hass.states.get(entity_id) is None + + # Write state + hass.states.async_set(entity_id, new_state, attributes, force_update, + self.context(request)) + + # Read the state back for our response + status_code = HTTP_CREATED if is_new_state else 200 + resp = self.json(hass.states.get(entity_id), status_code) + + resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id)) + + return resp + + @ha.callback + def delete(self, request, entity_id): + """Remove entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) + if request.app['hass'].states.async_remove(entity_id): + return self.json_message("Entity removed.") + return self.json_message("Entity not found.", HTTP_NOT_FOUND) + + +class APIEventListenersView(HomeAssistantView): + """View to handle EventListeners requests.""" + + url = URL_API_EVENTS + name = 'api:event-listeners' + + @ha.callback + def get(self, request): + """Get event listeners.""" + return self.json(async_events_json(request.app['hass'])) + + +class APIEventView(HomeAssistantView): + """View to handle Event requests.""" + + url = '/api/events/{event_type}' + name = 'api:event' + + async def post(self, request, event_type): + """Fire events.""" + if not request['hass_user'].is_admin: + raise Unauthorized() + body = await request.text() + try: + event_data = json.loads(body) if body else None + except ValueError: + return self.json_message( + "Event data should be valid JSON.", HTTP_BAD_REQUEST) + + if event_data is not None and not isinstance(event_data, dict): + return self.json_message( + "Event data should be a JSON object", HTTP_BAD_REQUEST) + + # Special case handling for event STATE_CHANGED + # We will try to convert state dicts back to State objects + if event_type == ha.EVENT_STATE_CHANGED and event_data: + for key in ('old_state', 'new_state'): + state = ha.State.from_dict(event_data.get(key)) + + if state: + event_data[key] = state + + request.app['hass'].bus.async_fire( + event_type, event_data, ha.EventOrigin.remote, + self.context(request)) + + return self.json_message("Event {} fired.".format(event_type)) + + +class APIServicesView(HomeAssistantView): + """View to handle Services requests.""" + + url = URL_API_SERVICES + name = 'api:services' + + async def get(self, request): + """Get registered services.""" + services = await async_services_json(request.app['hass']) + return self.json(services) + + +class APIDomainServicesView(HomeAssistantView): + """View to handle DomainServices requests.""" + + url = '/api/services/{domain}/{service}' + name = 'api:domain-services' + + async def post(self, request, domain, service): + """Call a service. + + Returns a list of changed states. + """ + hass = request.app['hass'] + body = await request.text() + try: + data = json.loads(body) if body else None + except ValueError: + return self.json_message( + "Data should be valid JSON.", HTTP_BAD_REQUEST) + + with AsyncTrackStates(hass) as changed_states: + try: + await hass.services.async_call( + domain, service, data, True, self.context(request)) + except (vol.Invalid, ServiceNotFound): + raise HTTPBadRequest() + + return self.json(changed_states) + + +class APIComponentsView(HomeAssistantView): + """View to handle Components requests.""" + + url = URL_API_COMPONENTS + name = 'api:components' + + @ha.callback + def get(self, request): + """Get current loaded components.""" + return self.json(request.app['hass'].config.components) + + +class APITemplateView(HomeAssistantView): + """View to handle Template requests.""" + + url = URL_API_TEMPLATE + name = 'api:template' + + async def post(self, request): + """Render a template.""" + if not request['hass_user'].is_admin: + raise Unauthorized() + try: + data = await request.json() + tpl = template.Template(data['template'], request.app['hass']) + return tpl.async_render(data.get('variables')) + except (ValueError, TemplateError) as ex: + return self.json_message( + "Error rendering template: {}".format(ex), HTTP_BAD_REQUEST) + + +class APIErrorLog(HomeAssistantView): + """View to fetch the API error log.""" + + url = URL_API_ERROR_LOG + name = 'api:error_log' + + async def get(self, request): + """Retrieve API error log.""" + if not request['hass_user'].is_admin: + raise Unauthorized() + return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) + + +async def async_services_json(hass): + """Generate services data to JSONify.""" + descriptions = await async_get_all_descriptions(hass) + return [{'domain': key, 'services': value} + for key, value in descriptions.items()] + + +def async_events_json(hass): + """Generate event data to JSONify.""" + return [{'event': key, 'listener_count': value} + for key, value in hass.bus.async_listeners().items()] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/__init__.py new file mode 100644 index 00000000000000..73cabdfbae615e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/__init__.py @@ -0,0 +1,255 @@ +""" +Support for Apple TV. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/apple_tv/ +""" +import asyncio +import logging +from typing import Sequence, TypeVar, Union + +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_APPLE_TV +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyatv==0.3.12'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'apple_tv' + +SERVICE_SCAN = 'apple_tv_scan' +SERVICE_AUTHENTICATE = 'apple_tv_authenticate' + +ATTR_ATV = 'atv' +ATTR_POWER = 'power' + +CONF_LOGIN_ID = 'login_id' +CONF_START_OFF = 'start_off' +CONF_CREDENTIALS = 'credentials' + +DEFAULT_NAME = 'Apple TV' + +DATA_APPLE_TV = 'data_apple_tv' +DATA_ENTITIES = 'data_apple_tv_entities' + +KEY_CONFIG = 'apple_tv_configuring' + +NOTIFICATION_AUTH_ID = 'apple_tv_auth_notification' +NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication' +NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' +NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' + +T = TypeVar('T') # pylint: disable=invalid-name + + +# This version of ensure_list interprets an empty dict as no value +def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: + """Wrap value in list if it is not one.""" + if value is None or (isinstance(value, dict) and not value): + return [] + return value if isinstance(value, list) else [value] + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(ensure_list, [vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_LOGIN_ID): cv.string, + vol.Optional(CONF_CREDENTIALS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_START_OFF, default=False): cv.boolean, + })]) +}, extra=vol.ALLOW_EXTRA) + +# Currently no attributes but it might change later +APPLE_TV_SCAN_SCHEMA = vol.Schema({}) + +APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + + +def request_configuration(hass, config, atv, credentials): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + async def configuration_callback(callback_data): + """Handle the submitted configuration.""" + from pyatv import exceptions + pin = callback_data.get('pin') + + try: + await atv.airplay.finish_authentication(pin) + hass.components.persistent_notification.async_create( + 'Authentication succeeded!

Add the following ' + 'to credentials: in your apple_tv configuration:

' + '{0}'.format(credentials), + title=NOTIFICATION_AUTH_TITLE, + notification_id=NOTIFICATION_AUTH_ID) + except exceptions.DeviceAuthenticationError as ex: + hass.components.persistent_notification.async_create( + 'Authentication failed! Did you enter correct PIN?

' + 'Details: {0}'.format(ex), + title=NOTIFICATION_AUTH_TITLE, + notification_id=NOTIFICATION_AUTH_ID) + + hass.async_add_job(configurator.request_done, instance) + + instance = configurator.request_config( + 'Apple TV Authentication', configuration_callback, + description='Please enter PIN code shown on screen.', + submit_caption='Confirm', + fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}] + ) + + +async def scan_for_apple_tvs(hass): + """Scan for devices and present a notification of the ones found.""" + import pyatv + atvs = await pyatv.scan_for_apple_tvs(hass.loop, timeout=3) + + devices = [] + for atv in atvs: + login_id = atv.login_id + if login_id is None: + login_id = 'Home Sharing disabled' + devices.append('Name: {0}
Host: {1}
Login ID: {2}'.format( + atv.name, atv.address, login_id)) + + if not devices: + devices = ['No device(s) found'] + + hass.components.persistent_notification.async_create( + 'The following devices were found:

' + + '

'.join(devices), + title=NOTIFICATION_SCAN_TITLE, + notification_id=NOTIFICATION_SCAN_ID) + + +async def async_setup(hass, config): + """Set up the Apple TV component.""" + if DATA_APPLE_TV not in hass.data: + hass.data[DATA_APPLE_TV] = {} + + async def async_service_handler(service): + """Handle service calls.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_for_apple_tvs, hass) + return + + if entity_ids: + devices = [device for device in hass.data[DATA_ENTITIES] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_ENTITIES] + + for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + + atv = device.atv + credentials = await atv.airplay.generate_credentials() + await atv.airplay.load_credentials(credentials) + _LOGGER.debug('Generated new credentials: %s', credentials) + await atv.airplay.start_authentication() + hass.async_add_job(request_configuration, + hass, config, atv, credentials) + + async def atv_discovered(service, info): + """Set up an Apple TV that was auto discovered.""" + await _setup_atv(hass, config, { + CONF_NAME: info['name'], + CONF_HOST: info['host'], + CONF_LOGIN_ID: info['properties']['hG'], + CONF_START_OFF: False + }) + + discovery.async_listen(hass, SERVICE_APPLE_TV, atv_discovered) + + tasks = [_setup_atv(hass, config, conf) for conf in config.get(DOMAIN, [])] + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SCAN, async_service_handler, + schema=APPLE_TV_SCAN_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_AUTHENTICATE, async_service_handler, + schema=APPLE_TV_AUTHENTICATE_SCHEMA) + + return True + + +async def _setup_atv(hass, hass_config, atv_config): + """Set up an Apple TV.""" + import pyatv + name = atv_config.get(CONF_NAME) + host = atv_config.get(CONF_HOST) + login_id = atv_config.get(CONF_LOGIN_ID) + start_off = atv_config.get(CONF_START_OFF) + credentials = atv_config.get(CONF_CREDENTIALS) + + if host in hass.data[DATA_APPLE_TV]: + return + + details = pyatv.AppleTVDevice(name, host, login_id) + session = async_get_clientsession(hass) + atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session) + if credentials: + await atv.airplay.load_credentials(credentials) + + power = AppleTVPowerManager(hass, atv, start_off) + hass.data[DATA_APPLE_TV][host] = { + ATTR_ATV: atv, + ATTR_POWER: power + } + + hass.async_create_task(discovery.async_load_platform( + hass, 'media_player', DOMAIN, atv_config, hass_config)) + + hass.async_create_task(discovery.async_load_platform( + hass, 'remote', DOMAIN, atv_config, hass_config)) + + +class AppleTVPowerManager: + """Manager for global power management of an Apple TV. + + An instance is used per device to share the same power state between + several platforms. + """ + + def __init__(self, hass, atv, is_off): + """Initialize power manager.""" + self.hass = hass + self.atv = atv + self.listeners = [] + self._is_on = not is_off + + def init(self): + """Initialize power management.""" + if self._is_on: + self.atv.push_updater.start() + + @property + def turned_on(self): + """Return true if device is on or off.""" + return self._is_on + + def set_power_on(self, value): + """Change if a device is on or off.""" + if value != self._is_on: + self._is_on = value + if not self._is_on: + self.atv.push_updater.stop() + else: + self.atv.push_updater.start() + + for listener in self.listeners: + self.hass.async_create_task(listener.async_update_ha_state()) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/media_player.py b/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/media_player.py new file mode 100644 index 00000000000000..bff8834639d578 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/media_player.py @@ -0,0 +1,266 @@ +""" +Support for Apple TV. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.apple_tv/ +""" +import logging + +from homeassistant.components.apple_tv import ( + ATTR_ATV, ATTR_POWER, DATA_APPLE_TV, DATA_ENTITIES) +from homeassistant.components.media_player import ( + MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + MediaPlayerDevice) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, + STATE_PAUSED, STATE_PLAYING, STATE_STANDBY) +from homeassistant.core import callback +import homeassistant.util.dt as dt_util + +DEPENDENCIES = ['apple_tv'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_APPLE_TV = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \ + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ + SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Apple TV platform.""" + if not discovery_info: + return + + # Manage entity cache for service handler + if DATA_ENTITIES not in hass.data: + hass.data[DATA_ENTITIES] = [] + + name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV] + power = hass.data[DATA_APPLE_TV][host][ATTR_POWER] + entity = AppleTvDevice(atv, name, power) + + @callback + def on_hass_stop(event): + """Stop push updates when hass stops.""" + atv.push_updater.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + + if entity not in hass.data[DATA_ENTITIES]: + hass.data[DATA_ENTITIES].append(entity) + + async_add_entities([entity]) + + +class AppleTvDevice(MediaPlayerDevice): + """Representation of an Apple TV device.""" + + def __init__(self, atv, name, power): + """Initialize the Apple TV device.""" + self.atv = atv + self._name = name + self._playing = None + self._power = power + self._power.listeners.append(self) + self.atv.push_updater.listener = self + + async def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + self._power.init() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self.atv.metadata.device_id + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the device.""" + if not self._power.turned_on: + return STATE_OFF + + if self._playing: + from pyatv import const + state = self._playing.play_state + if state in (const.PLAY_STATE_IDLE, const.PLAY_STATE_NO_MEDIA, + const.PLAY_STATE_LOADING): + return STATE_IDLE + if state == const.PLAY_STATE_PLAYING: + return STATE_PLAYING + if state in (const.PLAY_STATE_PAUSED, + const.PLAY_STATE_FAST_FORWARD, + const.PLAY_STATE_FAST_BACKWARD): + # Catch fast forward/backward here so "play" is default action + return STATE_PAUSED + return STATE_STANDBY # Bad or unknown state? + + @callback + def playstatus_update(self, updater, playing): + """Print what is currently playing when it changes.""" + self._playing = playing + self.async_schedule_update_ha_state() + + @callback + def playstatus_error(self, updater, exception): + """Inform about an error and restart push updates.""" + _LOGGER.warning('A %s error occurred: %s', + exception.__class__, exception) + + # This will wait 10 seconds before restarting push updates. If the + # connection continues to fail, it will flood the log (every 10 + # seconds) until it succeeds. A better approach should probably be + # implemented here later. + updater.start(initial_delay=10) + self._playing = None + self.async_schedule_update_ha_state() + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self._playing: + from pyatv import const + media_type = self._playing.media_type + if media_type == const.MEDIA_TYPE_VIDEO: + return MEDIA_TYPE_VIDEO + if media_type == const.MEDIA_TYPE_MUSIC: + return MEDIA_TYPE_MUSIC + if media_type == const.MEDIA_TYPE_TV: + return MEDIA_TYPE_TVSHOW + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + if self._playing: + return self._playing.total_time + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self._playing: + return self._playing.position + + @property + def media_position_updated_at(self): + """Last valid time of media position.""" + state = self.state + if state in (STATE_PLAYING, STATE_PAUSED): + return dt_util.utcnow() + + async def async_play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the media player.""" + await self.atv.airplay.play_url(media_id) + + @property + def media_image_hash(self): + """Hash value for media image.""" + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return self._playing.hash + + async def async_get_media_image(self): + """Fetch media image of current playing image.""" + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return (await self.atv.metadata.artwork()), 'image/png' + + return None, None + + @property + def media_title(self): + """Title of current playing media.""" + if self._playing: + if self.state == STATE_IDLE: + return 'Nothing playing' + title = self._playing.title + return title if title else 'No title' + + return 'Establishing a connection to {0}...'.format(self._name) + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_APPLE_TV + + async def async_turn_on(self): + """Turn the media player on.""" + self._power.set_power_on(True) + + async def async_turn_off(self): + """Turn the media player off.""" + self._playing = None + self._power.set_power_on(False) + + def async_media_play_pause(self): + """Pause media on media player. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + state = self.state + if state == STATE_PAUSED: + return self.atv.remote_control.play() + if state == STATE_PLAYING: + return self.atv.remote_control.pause() + + def async_media_play(self): + """Play media. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.play() + + def async_media_stop(self): + """Stop the media player. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.stop() + + def async_media_pause(self): + """Pause the media player. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.pause() + + def async_media_next_track(self): + """Send next track command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.next() + + def async_media_previous_track(self): + """Send previous track command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.previous() + + def async_media_seek(self, position): + """Send seek command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.set_position(position) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/remote.py b/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/remote.py new file mode 100644 index 00000000000000..72696143bfedc1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/apple_tv/remote.py @@ -0,0 +1,87 @@ +""" +Remote control support for Apple TV. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/remote.apple_tv/ +""" + +from homeassistant.components.apple_tv import ( + ATTR_ATV, ATTR_POWER, DATA_APPLE_TV) +from homeassistant.components import remote +from homeassistant.const import (CONF_NAME, CONF_HOST) + + +DEPENDENCIES = ['apple_tv'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Apple TV remote platform.""" + if not discovery_info: + return + + name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV] + power = hass.data[DATA_APPLE_TV][host][ATTR_POWER] + async_add_entities([AppleTVRemote(atv, power, name)]) + + +class AppleTVRemote(remote.RemoteDevice): + """Device that sends commands to an Apple TV.""" + + def __init__(self, atv, power, name): + """Initialize device.""" + self._atv = atv + self._name = name + self._power = power + self._power.listeners.append(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._atv.metadata.device_id + + @property + def is_on(self): + """Return true if device is on.""" + return self._power.turned_on + + @property + def should_poll(self): + """No polling needed for Apple TV.""" + return False + + async def async_turn_on(self, **kwargs): + """Turn the device on. + + This method is a coroutine. + """ + self._power.set_power_on(True) + + async def async_turn_off(self, **kwargs): + """Turn the device off. + + This method is a coroutine. + """ + self._power.set_power_on(False) + + def async_send_command(self, command, **kwargs): + """Send a command to one device. + + This method must be run in the event loop and returns a coroutine. + """ + # Send commands in specified order but schedule only one coroutine + async def _send_commands(): + for single_command in command: + if not hasattr(self._atv.remote_control, single_command): + continue + + await getattr(self._atv.remote_control, single_command)() + + return _send_commands() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/__init__.py new file mode 100644 index 00000000000000..abb61d42ca38b8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/__init__.py @@ -0,0 +1,95 @@ +""" +Support for AquaLogic component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/aqualogic/ +""" +from datetime import timedelta +import logging +import time +import threading + +import voluptuous as vol + +from homeassistant.const import (CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv + +REQUIREMENTS = ["aqualogic==1.0"] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "aqualogic" +UPDATE_TOPIC = DOMAIN + "_update" +CONF_UNIT = "unit" +RECONNECT_INTERVAL = timedelta(seconds=10) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up AquaLogic platform.""" + host = config[DOMAIN][CONF_HOST] + port = config[DOMAIN][CONF_PORT] + processor = AquaLogicProcessor(hass, host, port) + hass.data[DOMAIN] = processor + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, + processor.start_listen) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + processor.shutdown) + _LOGGER.debug("AquaLogicProcessor %s:%i initialized", host, port) + return True + + +class AquaLogicProcessor(threading.Thread): + """AquaLogic event processor thread.""" + + def __init__(self, hass, host, port): + """Initialize the data object.""" + super().__init__(daemon=True) + self._hass = hass + self._host = host + self._port = port + self._shutdown = False + self._panel = None + + def start_listen(self, event): + """Start event-processing thread.""" + _LOGGER.debug("Event processing thread started") + self.start() + + def shutdown(self, event): + """Signal shutdown of processing event.""" + _LOGGER.debug("Event processing signaled exit") + self._shutdown = True + + def data_changed(self, panel): + """Aqualogic data changed callback.""" + self._hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC) + + def run(self): + """Event thread.""" + from aqualogic.core import AquaLogic + + while True: + self._panel = AquaLogic() + self._panel.connect(self._host, self._port) + self._panel.process(self.data_changed) + + if self._shutdown: + return + + _LOGGER.error("Connection to %s:%d lost", + self._host, self._port) + time.sleep(RECONNECT_INTERVAL.seconds) + + @property + def panel(self): + """Retrieve the AquaLogic object.""" + return self._panel diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/sensor.py new file mode 100644 index 00000000000000..f10fd05b83f8ef --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/sensor.py @@ -0,0 +1,111 @@ +""" +Support for AquaLogic sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.aqualogic/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_MONITORED_CONDITIONS, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +import homeassistant.components.aqualogic as aq +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['aqualogic'] + +TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] +PERCENT_UNITS = ['%', '%'] +SALT_UNITS = ['g/L', 'PPM'] +WATT_UNITS = ['W', 'W'] +NO_UNITS = [None, None] + +# sensor_type [ description, unit, icon ] +# sensor_type corresponds to property names in aqualogic.core.AquaLogic +SENSOR_TYPES = { + 'air_temp': ['Air Temperature', TEMP_UNITS, 'mdi:thermometer'], + 'pool_temp': ['Pool Temperature', TEMP_UNITS, 'mdi:oil-temperature'], + 'spa_temp': ['Spa Temperature', TEMP_UNITS, 'mdi:oil-temperature'], + 'pool_chlorinator': ['Pool Chlorinator', PERCENT_UNITS, 'mdi:gauge'], + 'spa_chlorinator': ['Spa Chlorinator', PERCENT_UNITS, 'mdi:gauge'], + 'salt_level': ['Salt Level', SALT_UNITS, 'mdi:gauge'], + 'pump_speed': ['Pump Speed', PERCENT_UNITS, 'mdi:speedometer'], + 'pump_power': ['Pump Power', WATT_UNITS, 'mdi:gauge'], + 'status': ['Status', NO_UNITS, 'mdi:alert'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]) +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the sensor platform.""" + sensors = [] + + processor = hass.data[aq.DOMAIN] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + sensors.append(AquaLogicSensor(processor, sensor_type)) + + async_add_entities(sensors) + + +class AquaLogicSensor(Entity): + """Sensor implementation for the AquaLogic component.""" + + def __init__(self, processor, sensor_type): + """Initialize sensor.""" + self._processor = processor + self._type = sensor_type + self._state = None + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return "AquaLogic {}".format(SENSOR_TYPES[self._type][0]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement the value is expressed in.""" + panel = self._processor.panel + if panel is None: + return None + if panel.is_metric: + return SENSOR_TYPES[self._type][1][0] + return SENSOR_TYPES[self._type][1][1] + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SENSOR_TYPES[self._type][2] + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + aq.UPDATE_TOPIC, self.async_update_callback) + + @callback + def async_update_callback(self): + """Update callback.""" + panel = self._processor.panel + if panel is not None: + self._state = getattr(panel, self._type) + self.async_schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/switch.py new file mode 100644 index 00000000000000..48c4702aca0c80 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/aqualogic/switch.py @@ -0,0 +1,114 @@ +""" +Support for AquaLogic switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.aqualogic/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +import homeassistant.components.aqualogic as aq +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import (CONF_MONITORED_CONDITIONS) + +DEPENDENCIES = ['aqualogic'] + +_LOGGER = logging.getLogger(__name__) + +SWITCH_TYPES = { + 'lights': 'Lights', + 'filter': 'Filter', + 'filter_low_speed': 'Filter Low Speed', + 'aux_1': 'Aux 1', + 'aux_2': 'Aux 2', + 'aux_3': 'Aux 3', + 'aux_4': 'Aux 4', + 'aux_5': 'Aux 5', + 'aux_6': 'Aux 6', + 'aux_7': 'Aux 7', +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCH_TYPES)): + vol.All(cv.ensure_list, [vol.In(SWITCH_TYPES)]), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the switch platform.""" + switches = [] + + processor = hass.data[aq.DOMAIN] + for switch_type in config.get(CONF_MONITORED_CONDITIONS): + switches.append(AquaLogicSwitch(processor, switch_type)) + + async_add_entities(switches) + + +class AquaLogicSwitch(SwitchDevice): + """Switch implementation for the AquaLogic component.""" + + def __init__(self, processor, switch_type): + """Initialize switch.""" + from aqualogic.core import States + self._processor = processor + self._type = switch_type + self._state_name = { + 'lights': States.LIGHTS, + 'filter': States.FILTER, + 'filter_low_speed': States.FILTER_LOW_SPEED, + 'aux_1': States.AUX_1, + 'aux_2': States.AUX_2, + 'aux_3': States.AUX_3, + 'aux_4': States.AUX_4, + 'aux_5': States.AUX_5, + 'aux_6': States.AUX_6, + 'aux_7': States.AUX_7 + }[switch_type] + + @property + def name(self): + """Return the name of the switch.""" + return "AquaLogic {}".format(SWITCH_TYPES[self._type]) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + panel = self._processor.panel + if panel is None: + return False + state = panel.get_state(self._state_name) + return state + + def turn_on(self, **kwargs): + """Turn the device on.""" + panel = self._processor.panel + if panel is None: + return + panel.set_state(self._state_name, True) + + def turn_off(self, **kwargs): + """Turn the device off.""" + panel = self._processor.panel + if panel is None: + return + panel.set_state(self._state_name, False) + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + aq.UPDATE_TOPIC, self.async_update_callback) + + @callback + def async_update_callback(self): + """Update callback.""" + self.async_schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arduino/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/arduino/__init__.py new file mode 100644 index 00000000000000..785f8c57f943ea --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arduino/__init__.py @@ -0,0 +1,120 @@ +""" +Support for Arduino boards running with the Firmata firmware. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/arduino/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import CONF_PORT +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['PyMata==2.14'] + +_LOGGER = logging.getLogger(__name__) + +BOARD = None + +DOMAIN = 'arduino' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Arduino component.""" + import serial + + port = config[DOMAIN][CONF_PORT] + + global BOARD + try: + BOARD = ArduinoBoard(port) + except (serial.serialutil.SerialException, FileNotFoundError): + _LOGGER.error("Your port %s is not accessible", port) + return False + + try: + if BOARD.get_firmata()[1] <= 2: + _LOGGER.error("The StandardFirmata sketch should be 2.2 or newer") + return False + except IndexError: + _LOGGER.warning("The version of the StandardFirmata sketch was not" + "detected. This may lead to side effects") + + def stop_arduino(event): + """Stop the Arduino service.""" + BOARD.disconnect() + + def start_arduino(event): + """Start the Arduino service.""" + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino) + + return True + + +class ArduinoBoard: + """Representation of an Arduino board.""" + + def __init__(self, port): + """Initialize the board.""" + from PyMata.pymata import PyMata + self._port = port + self._board = PyMata(self._port, verbose=False) + + def set_mode(self, pin, direction, mode): + """Set the mode and the direction of a given pin.""" + if mode == 'analog' and direction == 'in': + self._board.set_pin_mode( + pin, self._board.INPUT, self._board.ANALOG) + elif mode == 'analog' and direction == 'out': + self._board.set_pin_mode( + pin, self._board.OUTPUT, self._board.ANALOG) + elif mode == 'digital' and direction == 'in': + self._board.set_pin_mode( + pin, self._board.INPUT, self._board.DIGITAL) + elif mode == 'digital' and direction == 'out': + self._board.set_pin_mode( + pin, self._board.OUTPUT, self._board.DIGITAL) + elif mode == 'pwm': + self._board.set_pin_mode( + pin, self._board.OUTPUT, self._board.PWM) + + def get_analog_inputs(self): + """Get the values from the pins.""" + self._board.capability_query() + return self._board.get_analog_response_table() + + def set_digital_out_high(self, pin): + """Set a given digital pin to high.""" + self._board.digital_write(pin, 1) + + def set_digital_out_low(self, pin): + """Set a given digital pin to low.""" + self._board.digital_write(pin, 0) + + def get_digital_in(self, pin): + """Get the value from a given digital pin.""" + self._board.digital_read(pin) + + def get_analog_in(self, pin): + """Get the value from a given analog pin.""" + self._board.analog_read(pin) + + def get_firmata(self): + """Return the version of the Firmata firmware.""" + return self._board.get_firmata_version() + + def disconnect(self): + """Disconnect the board and close the serial connection.""" + self._board.reset() + self._board.close() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arduino/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/arduino/sensor.py new file mode 100644 index 00000000000000..f46eebce1b2913 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arduino/sensor.py @@ -0,0 +1,75 @@ +""" +Support for getting information from Arduino pins. + +Only analog pins are supported. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.arduino/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components import arduino +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_PINS = 'pins' +CONF_TYPE = 'analog' + +DEPENDENCIES = ['arduino'] + +PIN_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PINS): + vol.Schema({cv.positive_int: PIN_SCHEMA}), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arduino platform.""" + if arduino.BOARD is None: + _LOGGER.error("A connection has not been made to the Arduino board") + return False + + pins = config.get(CONF_PINS) + + sensors = [] + for pinnum, pin in pins.items(): + sensors.append(ArduinoSensor(pin.get(CONF_NAME), pinnum, CONF_TYPE)) + add_entities(sensors) + + +class ArduinoSensor(Entity): + """Representation of an Arduino Sensor.""" + + def __init__(self, name, pin, pin_type): + """Initialize the sensor.""" + self._pin = pin + self._name = name + self.pin_type = pin_type + self.direction = 'in' + self._value = None + + arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + + @property + def state(self): + """Return the state of the sensor.""" + return self._value + + @property + def name(self): + """Get the name of the sensor.""" + return self._name + + def update(self): + """Get the latest value from the pin.""" + self._value = arduino.BOARD.get_analog_inputs()[self._pin][1] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arduino/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/arduino/switch.py new file mode 100644 index 00000000000000..ee8f0e878a3076 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arduino/switch.py @@ -0,0 +1,94 @@ +""" +Support for switching Arduino pins on and off. + +So far only digital pins are supported. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.arduino/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components import arduino +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['arduino'] + +_LOGGER = logging.getLogger(__name__) + +CONF_PINS = 'pins' +CONF_TYPE = 'digital' +CONF_NEGATE = 'negate' +CONF_INITIAL = 'initial' + +PIN_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=False): cv.boolean, + vol.Optional(CONF_NEGATE, default=False): cv.boolean, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PINS, default={}): + vol.Schema({cv.positive_int: PIN_SCHEMA}), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arduino platform.""" + # Verify that Arduino board is present + if arduino.BOARD is None: + _LOGGER.error("A connection has not been made to the Arduino board") + return False + + pins = config.get(CONF_PINS) + + switches = [] + for pinnum, pin in pins.items(): + switches.append(ArduinoSwitch(pinnum, pin)) + add_entities(switches) + + +class ArduinoSwitch(SwitchDevice): + """Representation of an Arduino switch.""" + + def __init__(self, pin, options): + """Initialize the Pin.""" + self._pin = pin + self._name = options.get(CONF_NAME) + self.pin_type = CONF_TYPE + self.direction = 'out' + + self._state = options.get(CONF_INITIAL) + + if options.get(CONF_NEGATE): + self.turn_on_handler = arduino.BOARD.set_digital_out_low + self.turn_off_handler = arduino.BOARD.set_digital_out_high + else: + self.turn_on_handler = arduino.BOARD.set_digital_out_high + self.turn_off_handler = arduino.BOARD.set_digital_out_low + + arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + (self.turn_on_handler if self._state else self.turn_off_handler)(pin) + + @property + def name(self): + """Get the name of the pin.""" + return self._name + + @property + def is_on(self): + """Return true if pin is high/on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the pin to high/on.""" + self._state = True + self.turn_on_handler(self._pin) + + def turn_off(self, **kwargs): + """Turn the pin to low/off.""" + self._state = False + self.turn_off_handler(self._pin) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arlo/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/__init__.py new file mode 100644 index 00000000000000..aebd57098b55c7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/__init__.py @@ -0,0 +1,94 @@ +""" +This component provides support for Netgear Arlo IP cameras. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/arlo/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests.exceptions import HTTPError, ConnectTimeout + +from homeassistant.helpers import config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.dispatcher import dispatcher_send + +REQUIREMENTS = ['pyarlo==0.2.3'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by arlo.netgear.com" + +DATA_ARLO = 'data_arlo' +DEFAULT_BRAND = 'Netgear Arlo' +DOMAIN = 'arlo' + +NOTIFICATION_ID = 'arlo_notification' +NOTIFICATION_TITLE = 'Arlo Component Setup' + +SCAN_INTERVAL = timedelta(seconds=60) + +SIGNAL_UPDATE_ARLO = "arlo_update" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up an Arlo component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + try: + from pyarlo import PyArlo + + arlo = PyArlo(username, password, preload=False) + if not arlo.is_connected: + return False + + # assign refresh period to base station thread + arlo_base_station = next(( + station for station in arlo.base_stations), None) + + if arlo_base_station is not None: + arlo_base_station.refresh_rate = scan_interval.total_seconds() + elif not arlo.cameras: + _LOGGER.error("No Arlo camera or base station available.") + return False + + hass.data[DATA_ARLO] = arlo + + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + def hub_refresh(event_time): + """Call ArloHub to refresh information.""" + _LOGGER.debug("Updating Arlo Hub component") + hass.data[DATA_ARLO].update(update_cameras=True, + update_base_station=True) + dispatcher_send(hass, SIGNAL_UPDATE_ARLO) + + # register service + hass.services.register(DOMAIN, 'update', hub_refresh) + + # register scan interval for ArloHub + track_time_interval(hass, hub_refresh, scan_interval) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/alarm_control_panel.py new file mode 100644 index 00000000000000..66f11fab83f744 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/alarm_control_panel.py @@ -0,0 +1,141 @@ +""" +Support for Arlo Alarm Control Panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.arlo/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanel, PLATFORM_SCHEMA) +from homeassistant.components.arlo import ( + DATA_ARLO, CONF_ATTRIBUTION, SIGNAL_UPDATE_ARLO) +from homeassistant.const import ( + ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT) + +_LOGGER = logging.getLogger(__name__) + +ARMED = 'armed' + +CONF_HOME_MODE_NAME = 'home_mode_name' +CONF_AWAY_MODE_NAME = 'away_mode_name' +CONF_NIGHT_MODE_NAME = 'night_mode_name' + +DEPENDENCIES = ['arlo'] + +DISARMED = 'disarmed' + +ICON = 'mdi:security' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, + vol.Optional(CONF_AWAY_MODE_NAME, default=ARMED): cv.string, + vol.Optional(CONF_NIGHT_MODE_NAME, default=ARMED): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arlo Alarm Control Panels.""" + arlo = hass.data[DATA_ARLO] + + if not arlo.base_stations: + return + + home_mode_name = config.get(CONF_HOME_MODE_NAME) + away_mode_name = config.get(CONF_AWAY_MODE_NAME) + night_mode_name = config.get(CONF_NIGHT_MODE_NAME) + base_stations = [] + for base_station in arlo.base_stations: + base_stations.append(ArloBaseStation(base_station, home_mode_name, + away_mode_name, night_mode_name)) + add_entities(base_stations, True) + + +class ArloBaseStation(AlarmControlPanel): + """Representation of an Arlo Alarm Control Panel.""" + + def __init__(self, data, home_mode_name, away_mode_name, night_mode_name): + """Initialize the alarm control panel.""" + self._base_station = data + self._home_mode_name = home_mode_name + self._away_mode_name = away_mode_name + self._night_mode_name = night_mode_name + self._state = None + + @property + def icon(self): + """Return icon.""" + return ICON + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) + mode = self._base_station.mode + if mode: + self._state = self._get_state_from_mode(mode) + else: + self._state = None + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._base_station.mode = DISARMED + + async def async_alarm_arm_away(self, code=None): + """Send arm away command. Uses custom mode.""" + self._base_station.mode = self._away_mode_name + + async def async_alarm_arm_home(self, code=None): + """Send arm home command. Uses custom mode.""" + self._base_station.mode = self._home_mode_name + + async def async_alarm_arm_night(self, code=None): + """Send arm night command. Uses custom mode.""" + self._base_station.mode = self._night_mode_name + + @property + def name(self): + """Return the name of the base station.""" + return self._base_station.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._base_station.device_id + } + + def _get_state_from_mode(self, mode): + """Convert Arlo mode to Home Assistant state.""" + if mode == ARMED: + return STATE_ALARM_ARMED_AWAY + if mode == DISARMED: + return STATE_ALARM_DISARMED + if mode == self._home_mode_name: + return STATE_ALARM_ARMED_HOME + if mode == self._away_mode_name: + return STATE_ALARM_ARMED_AWAY + if mode == self._night_mode_name: + return STATE_ALARM_ARMED_NIGHT + return mode diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arlo/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/camera.py new file mode 100644 index 00000000000000..d56616218e7690 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/camera.py @@ -0,0 +1,169 @@ +""" +Support for Netgear Arlo IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.arlo/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.components.arlo import ( + DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +ARLO_MODE_ARMED = 'armed' +ARLO_MODE_DISARMED = 'disarmed' + +ATTR_BRIGHTNESS = 'brightness' +ATTR_FLIPPED = 'flipped' +ATTR_MIRRORED = 'mirrored' +ATTR_MOTION = 'motion_detection_sensitivity' +ATTR_POWERSAVE = 'power_save_mode' +ATTR_SIGNAL_STRENGTH = 'signal_strength' +ATTR_UNSEEN_VIDEOS = 'unseen_videos' +ATTR_LAST_REFRESH = 'last_refresh' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['arlo', 'ffmpeg'] + +POWERSAVE_MODE_MAPPING = { + 1: 'best_battery_life', + 2: 'optimized', + 3: 'best_video' +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an Arlo IP Camera.""" + arlo = hass.data[DATA_ARLO] + + cameras = [] + for camera in arlo.cameras: + cameras.append(ArloCam(hass, camera, config)) + + add_entities(cameras) + + +class ArloCam(Camera): + """An implementation of a Netgear Arlo IP camera.""" + + def __init__(self, hass, camera, device_info): + """Initialize an Arlo camera.""" + super().__init__() + self._camera = camera + self._name = self._camera.name + self._motion_status = False + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_refresh = None + self.attrs = {} + + def camera_image(self): + """Return a still image response from the camera.""" + return self._camera.last_image_from_cache + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state() + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + video = self._camera.last_video + if not video: + error_msg = \ + 'Video not found for {0}. Is it older than {1} days?'.format( + self.name, self._camera.min_days_vdo_cache) + _LOGGER.error(error_msg) + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera( + video.video_url, extra_cmd=self._ffmpeg_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + name: value for name, value in ( + (ATTR_BATTERY_LEVEL, self._camera.battery_level), + (ATTR_BRIGHTNESS, self._camera.brightness), + (ATTR_FLIPPED, self._camera.flip_state), + (ATTR_MIRRORED, self._camera.mirror_state), + (ATTR_MOTION, self._camera.motion_detection_sensitivity), + (ATTR_POWERSAVE, POWERSAVE_MODE_MAPPING.get( + self._camera.powersave_mode)), + (ATTR_SIGNAL_STRENGTH, self._camera.signal_strength), + (ATTR_UNSEEN_VIDEOS, self._camera.unseen_videos), + ) if value is not None + } + + @property + def model(self): + """Return the camera model.""" + return self._camera.model_id + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_BRAND + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_status + + def set_base_station_mode(self, mode): + """Set the mode in the base station.""" + # Get the list of base stations identified by library + base_stations = self.hass.data[DATA_ARLO].base_stations + + # Some Arlo cameras does not have base station + # So check if there is base station detected first + # if yes, then choose the primary base station + # Set the mode on the chosen base station + if base_stations: + primary_base_station = base_stations[0] + primary_base_station.mode = mode + + def enable_motion_detection(self): + """Enable the Motion detection in base station (Arm).""" + self._motion_status = True + self.set_base_station_mode(ARLO_MODE_ARMED) + + def disable_motion_detection(self): + """Disable the motion detection in base station (Disarm).""" + self._motion_status = False + self.set_base_station_mode(ARLO_MODE_DISARMED) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/arlo/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/sensor.py new file mode 100644 index 00000000000000..be940cc4f51039 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/arlo/sensor.py @@ -0,0 +1,191 @@ +""" +This component provides HA sensor for Netgear Arlo IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.arlo/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.components.arlo import ( + CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['arlo'] + +# sensor_type [ description, unit, icon ] +SENSOR_TYPES = { + 'last_capture': ['Last', None, 'run-fast'], + 'total_cameras': ['Arlo Cameras', None, 'video'], + 'captured_today': ['Captured Today', None, 'file-video'], + 'battery_level': ['Battery Level', '%', 'battery-50'], + 'signal_strength': ['Signal Strength', None, 'signal'], + 'temperature': ['Temperature', TEMP_CELSIUS, 'thermometer'], + 'humidity': ['Humidity', '%', 'water-percent'], + 'air_quality': ['Air Quality', 'ppm', 'biohazard'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an Arlo IP sensor.""" + arlo = hass.data.get(DATA_ARLO) + if not arlo: + return + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'total_cameras': + sensors.append(ArloSensor( + SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + else: + for camera in arlo.cameras: + if sensor_type in ('temperature', 'humidity', 'air_quality'): + continue + + name = '{0} {1}'.format( + SENSOR_TYPES[sensor_type][0], camera.name) + sensors.append(ArloSensor(name, camera, sensor_type)) + + for base_station in arlo.base_stations: + if sensor_type in ('temperature', 'humidity', 'air_quality') \ + and base_station.model_id == 'ABC1000': + name = '{0} {1}'.format( + SENSOR_TYPES[sensor_type][0], base_station.name) + sensors.append(ArloSensor(name, base_station, sensor_type)) + + add_entities(sensors, True) + + +class ArloSensor(Entity): + """An implementation of a Netgear Arlo IP sensor.""" + + def __init__(self, name, device, sensor_type): + """Initialize an Arlo sensor.""" + _LOGGER.debug('ArloSensor created for %s', name) + self._name = name + self._data = device + self._sensor_type = sensor_type + self._state = None + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @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.""" + if self._sensor_type == 'battery_level' and self._state is not None: + return icon_for_battery_level(battery_level=int(self._state), + charging=False) + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + @property + def device_class(self): + """Return the device class of the sensor.""" + if self._sensor_type == 'temperature': + return DEVICE_CLASS_TEMPERATURE + if self._sensor_type == 'humidity': + return DEVICE_CLASS_HUMIDITY + return None + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Updating Arlo sensor %s", self.name) + if self._sensor_type == 'total_cameras': + self._state = len(self._data.cameras) + + elif self._sensor_type == 'captured_today': + self._state = len(self._data.captured_today) + + elif self._sensor_type == 'last_capture': + try: + video = self._data.last_video + self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") + except (AttributeError, IndexError): + error_msg = \ + 'Video not found for {0}. Older than {1} days?'.format( + self.name, self._data.min_days_vdo_cache) + _LOGGER.debug(error_msg) + self._state = None + + elif self._sensor_type == 'battery_level': + try: + self._state = self._data.battery_level + except TypeError: + self._state = None + + elif self._sensor_type == 'signal_strength': + try: + self._state = self._data.signal_strength + except TypeError: + self._state = None + + elif self._sensor_type == 'temperature': + try: + self._state = self._data.ambient_temperature + except TypeError: + self._state = None + + elif self._sensor_type == 'humidity': + try: + self._state = self._data.ambient_humidity + except TypeError: + self._state = None + + elif self._sensor_type == 'air_quality': + try: + self._state = self._data.ambient_air_quality + except TypeError: + self._state = None + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attrs['brand'] = DEFAULT_BRAND + + if self._sensor_type != 'total_cameras': + attrs['model'] = self._data.model_id + + return attrs diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/asterisk_mbox/__init__.py new file mode 100644 index 00000000000000..406774d5fadbec --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/asterisk_mbox/__init__.py @@ -0,0 +1,122 @@ +""" +Support for Asterisk Voicemail interface. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/asterisk_mbox/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, dispatcher_connect) + +REQUIREMENTS = ['asterisk_mbox==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'asterisk_mbox' + +SIGNAL_DISCOVER_PLATFORM = "asterisk_mbox.discover_platform" +SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' +SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_CDR_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_CDR_REQUEST = 'asterisk_mbox.message_request' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PORT): cv.port, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up for the Asterisk Voicemail box.""" + conf = config.get(DOMAIN) + + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + password = conf.get(CONF_PASSWORD) + + hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) + + return True + + +class AsteriskData: + """Store Asterisk mailbox data.""" + + def __init__(self, hass, host, port, password, config): + """Init the Asterisk data object.""" + from asterisk_mbox import Client as asteriskClient + self.hass = hass + self.config = config + self.messages = None + self.cdr = None + + dispatcher_connect( + self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) + dispatcher_connect( + self.hass, SIGNAL_CDR_REQUEST, self._request_cdr) + dispatcher_connect( + self.hass, SIGNAL_DISCOVER_PLATFORM, self._discover_platform) + # Only connect after signal connection to ensure we don't miss any + self.client = asteriskClient(host, port, password, self.handle_data) + + @callback + def _discover_platform(self, component): + _LOGGER.debug("Adding mailbox %s", component) + self.hass.async_create_task(discovery.async_load_platform( + self.hass, "mailbox", component, {}, self.config)) + + @callback + def handle_data(self, command, msg): + """Handle changes to the mailbox.""" + from asterisk_mbox.commands import (CMD_MESSAGE_LIST, + CMD_MESSAGE_CDR_AVAILABLE, + CMD_MESSAGE_CDR) + + if command == CMD_MESSAGE_LIST: + _LOGGER.debug("AsteriskVM sent updated message list: Len %d", + len(msg)) + old_messages = self.messages + self.messages = sorted( + msg, key=lambda item: item['info']['origtime'], reverse=True) + if not isinstance(old_messages, list): + async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, + DOMAIN) + async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, + self.messages) + elif command == CMD_MESSAGE_CDR: + _LOGGER.debug("AsteriskVM sent updated CDR list: Len %d", + len(msg.get('entries', []))) + self.cdr = msg['entries'] + async_dispatcher_send(self.hass, SIGNAL_CDR_UPDATE, self.cdr) + elif command == CMD_MESSAGE_CDR_AVAILABLE: + if not isinstance(self.cdr, list): + _LOGGER.debug("AsteriskVM adding CDR platform") + self.cdr = [] + async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, + "asterisk_cdr") + async_dispatcher_send(self.hass, SIGNAL_CDR_REQUEST) + else: + _LOGGER.debug("AsteriskVM sent unknown message '%d' len: %d", + command, len(msg)) + + @callback + def _request_messages(self): + """Handle changes to the mailbox.""" + _LOGGER.debug("Requesting message list") + self.client.messages() + + @callback + def _request_cdr(self): + """Handle changes to the CDR.""" + _LOGGER.debug("Requesting CDR list") + self.client.get_cdr() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant-0.88.0.dev0/homeassistant/components/asterisk_mbox/mailbox.py new file mode 100644 index 00000000000000..087018084f2c78 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/asterisk_mbox/mailbox.py @@ -0,0 +1,75 @@ +""" +Asterisk Voicemail interface. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/mailbox.asteriskvm/ +""" +import logging + +from homeassistant.components.asterisk_mbox import DOMAIN as ASTERISK_DOMAIN +from homeassistant.components.mailbox import ( + CONTENT_TYPE_MPEG, Mailbox, StreamError) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['asterisk_mbox'] + +SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' +SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' + + +async def async_get_handler(hass, config, discovery_info=None): + """Set up the Asterix VM platform.""" + return AsteriskMailbox(hass, ASTERISK_DOMAIN) + + +class AsteriskMailbox(Mailbox): + """Asterisk VM Sensor.""" + + def __init__(self, hass, name): + """Initialize Asterisk mailbox.""" + super().__init__(hass, name) + async_dispatcher_connect( + self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback) + + @callback + def _update_callback(self, msg): + """Update the message count in HA, if needed.""" + self.async_update() + + @property + def media_type(self): + """Return the supported media type.""" + return CONTENT_TYPE_MPEG + + @property + def can_delete(self): + """Return if messages can be deleted.""" + return True + + @property + def has_media(self): + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid): + """Return the media blob for the msgid.""" + from asterisk_mbox import ServerError + client = self.hass.data[ASTERISK_DOMAIN].client + try: + return client.mp3(msgid, sync=True) + except ServerError as err: + raise StreamError(err) + + async def async_get_messages(self): + """Return a list of the current messages.""" + return self.hass.data[ASTERISK_DOMAIN].messages + + def async_delete(self, msgid): + """Delete the specified messages.""" + client = self.hass.data[ASTERISK_DOMAIN].client + _LOGGER.info("Deleting: %s", msgid) + client.delete(msgid) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/asuswrt.py b/homeassistant-0.88.0.dev0/homeassistant/components/asuswrt.py new file mode 100644 index 00000000000000..0069b3c0d73e9e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/asuswrt.py @@ -0,0 +1,73 @@ +""" +Support for ASUSWRT devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/asuswrt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, + CONF_PROTOCOL) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['aioasuswrt==1.1.20'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "asuswrt" +DATA_ASUSWRT = DOMAIN + +CONF_PUB_KEY = 'pub_key' +CONF_SSH_KEY = 'ssh_key' +CONF_REQUIRE_IP = 'require_ip' +DEFAULT_SSH_PORT = 22 +SECRET_GROUP = 'Password or SSH Key' +CONF_SENSORS = 'sensors' +SENSOR_TYPES = ['upload_speed', 'download_speed', 'download', 'upload'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), + vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), + vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, + vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, + vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile, + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)]), + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the asuswrt component.""" + from aioasuswrt.asuswrt import AsusWrt + conf = config[DOMAIN] + + api = AsusWrt(conf[CONF_HOST], conf.get(CONF_PORT), + conf.get(CONF_PROTOCOL) == 'telnet', + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ''), + conf.get('ssh_key', conf.get('pub_key', '')), + conf.get(CONF_MODE), conf.get(CONF_REQUIRE_IP)) + + await api.connection.async_connect() + if not api.is_connected: + _LOGGER.error("Unable to setup asuswrt component") + return False + + hass.data[DATA_ASUSWRT] = api + + hass.async_create_task(async_load_platform( + hass, 'sensor', DOMAIN, config[DOMAIN].get(CONF_SENSORS), config)) + hass.async_create_task(async_load_platform( + hass, 'device_tracker', DOMAIN, {}, config)) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/august/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/august/__init__.py new file mode 100644 index 00000000000000..2073f680e00bd7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/august/__init__.py @@ -0,0 +1,355 @@ +""" +Support for August devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/august/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import RequestException + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +_CONFIGURING = {} + +REQUIREMENTS = ['py-august==0.7.0'] + +DEFAULT_TIMEOUT = 10 +ACTIVITY_FETCH_LIMIT = 10 +ACTIVITY_INITIAL_FETCH_LIMIT = 20 + +CONF_LOGIN_METHOD = 'login_method' +CONF_INSTALL_ID = 'install_id' + +NOTIFICATION_ID = 'august_notification' +NOTIFICATION_TITLE = "August Setup" + +AUGUST_CONFIG_FILE = '.august.conf' + +DATA_AUGUST = 'august' +DOMAIN = 'august' +DEFAULT_ENTITY_NAMESPACE = 'august' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) +LOGIN_METHODS = ['phone', 'email'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_INSTALL_ID): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + +AUGUST_COMPONENTS = [ + 'camera', 'binary_sensor', 'lock' +] + + +def request_configuration(hass, config, api, authenticator): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + def august_configuration_callback(data): + """Run when the configuration callback is called.""" + from august.authenticator import ValidationResult + + result = authenticator.validate_verification_code( + data.get('verification_code')) + + if result == ValidationResult.INVALID_VERIFICATION_CODE: + configurator.notify_errors(_CONFIGURING[DOMAIN], + "Invalid verification code") + elif result == ValidationResult.VALIDATED: + setup_august(hass, config, api, authenticator) + + if DOMAIN not in _CONFIGURING: + authenticator.send_verification_code() + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + login_method = conf.get(CONF_LOGIN_METHOD) + + _CONFIGURING[DOMAIN] = configurator.request_config( + NOTIFICATION_TITLE, + august_configuration_callback, + description="Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), + submit_caption='Verify', + fields=[{ + 'id': 'verification_code', + 'name': "Verification code", + 'type': 'string'}] + ) + + +def setup_august(hass, config, api, authenticator): + """Set up the August component.""" + from august.authenticator import AuthenticationState + + authentication = None + try: + authentication = authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + + hass.components.persistent_notification.create( + "Error: {}
" + "You will need to restart hass after fixing." + "".format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + state = authentication.state + + if state == AuthenticationState.AUTHENTICATED: + if DOMAIN in _CONFIGURING: + hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + + hass.data[DATA_AUGUST] = AugustData( + hass, api, authentication.access_token) + + for component in AUGUST_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + if state == AuthenticationState.BAD_PASSWORD: + _LOGGER.error("Invalid password provided") + return False + if state == AuthenticationState.REQUIRES_VALIDATION: + request_configuration(hass, config, api, authenticator) + return True + + return False + + +def setup(hass, config): + """Set up the August component.""" + from august.api import Api + from august.authenticator import Authenticator + from requests import Session + + conf = config[DOMAIN] + api_http_session = None + try: + api_http_session = Session() + except RequestException as ex: + _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) + + api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) + + authenticator = Authenticator( + api, + conf.get(CONF_LOGIN_METHOD), + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + install_id=conf.get(CONF_INSTALL_ID), + access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + + def close_http_session(event): + """Close API sessions used to connect to August.""" + _LOGGER.debug("Closing August HTTP sessions") + if api_http_session: + try: + api_http_session.close() + except RequestException: + pass + + _LOGGER.debug("August HTTP session closed.") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) + _LOGGER.debug("Registered for HASS stop event") + + return setup_august(hass, config, api, authenticator) + + +class AugustData: + """August data object.""" + + def __init__(self, hass, api, access_token): + """Init August data object.""" + self._hass = hass + self._api = api + self._access_token = access_token + self._doorbells = self._api.get_doorbells(self._access_token) or [] + self._locks = self._api.get_operable_locks(self._access_token) or [] + self._house_ids = [d.house_id for d in self._doorbells + self._locks] + + self._doorbell_detail_by_id = {} + self._lock_status_by_id = {} + self._lock_detail_by_id = {} + self._door_state_by_id = {} + self._activities_by_id = {} + + @property + def house_ids(self): + """Return a list of house_ids.""" + return self._house_ids + + @property + def doorbells(self): + """Return a list of doorbells.""" + return self._doorbells + + @property + def locks(self): + """Return a list of locks.""" + return self._locks + + def get_device_activities(self, device_id, *activity_types): + """Return a list of activities.""" + _LOGGER.debug("Getting device activities") + self._update_device_activities() + + activities = self._activities_by_id.get(device_id, []) + if activity_types: + return [a for a in activities if a.activity_type in activity_types] + return activities + + def get_latest_device_activity(self, device_id, *activity_types): + """Return latest activity.""" + activities = self.get_device_activities(device_id, *activity_types) + return next(iter(activities or []), None) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + """Update data object with latest from August API.""" + _LOGGER.debug("Start retrieving device activities") + for house_id in self.house_ids: + _LOGGER.debug("Updating device activity for house id %s", + house_id) + + activities = self._api.get_house_activities(self._access_token, + house_id, + limit=limit) + + device_ids = {a.device_id for a in activities} + for device_id in device_ids: + self._activities_by_id[device_id] = [a for a in activities if + a.device_id == device_id] + _LOGGER.debug("Completed retrieving device activities") + + def get_doorbell_detail(self, doorbell_id): + """Return doorbell detail.""" + self._update_doorbells() + return self._doorbell_detail_by_id.get(doorbell_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doorbells(self): + detail_by_id = {} + + _LOGGER.debug("Start retrieving doorbell details") + for doorbell in self._doorbells: + _LOGGER.debug("Updating doorbell status for %s", + doorbell.device_name) + try: + detail_by_id[doorbell.device_id] =\ + self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve doorbell" + " status for %s. %s", doorbell.device_name, ex) + detail_by_id[doorbell.device_id] = None + except Exception: + detail_by_id[doorbell.device_id] = None + raise + + _LOGGER.debug("Completed retrieving doorbell details") + self._doorbell_detail_by_id = detail_by_id + + def get_lock_status(self, lock_id): + """Return status if the door is locked or unlocked. + + This is status for the lock itself. + """ + self._update_locks() + return self._lock_status_by_id.get(lock_id) + + def get_lock_detail(self, lock_id): + """Return lock detail.""" + self._update_locks() + return self._lock_detail_by_id.get(lock_id) + + def get_door_state(self, lock_id): + """Return status if the door is open or closed. + + This is the status from the door sensor. + """ + self._update_doors() + return self._door_state_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doors(self): + state_by_id = {} + + _LOGGER.debug("Start retrieving door status") + for lock in self._locks: + _LOGGER.debug("Updating door status for %s", + lock.device_name) + + try: + state_by_id[lock.device_id] = self._api.get_lock_door_status( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " status for %s. %s", lock.device_name, ex) + state_by_id[lock.device_id] = None + except Exception: + state_by_id[lock.device_id] = None + raise + + _LOGGER.debug("Completed retrieving door status") + self._door_state_by_id = state_by_id + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_locks(self): + status_by_id = {} + detail_by_id = {} + + _LOGGER.debug("Start retrieving locks status") + for lock in self._locks: + _LOGGER.debug("Updating lock status for %s", + lock.device_name) + try: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " status for %s. %s", lock.device_name, ex) + status_by_id[lock.device_id] = None + except Exception: + status_by_id[lock.device_id] = None + raise + + try: + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " details for %s. %s", lock.device_name, ex) + detail_by_id[lock.device_id] = None + except Exception: + detail_by_id[lock.device_id] = None + raise + + _LOGGER.debug("Completed retrieving locks status") + self._lock_status_by_id = status_by_id + self._lock_detail_by_id = detail_by_id + + def lock(self, device_id): + """Lock the device.""" + return self._api.lock(self._access_token, device_id) + + def unlock(self, device_id): + """Unlock the device.""" + return self._api.unlock(self._access_token, device_id) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/august/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/august/binary_sensor.py new file mode 100644 index 00000000000000..4116a791b01cd5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/august/binary_sensor.py @@ -0,0 +1,184 @@ +""" +Support for August binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.august/ +""" +import logging +from datetime import timedelta, datetime + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.binary_sensor import (BinarySensorDevice) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_door_state(data, lock): + """Get the latest state of the DoorSense sensor.""" + return data.get_door_state(lock.device_id) + + +def _retrieve_online_state(data, doorbell): + """Get the latest state of the sensor.""" + detail = data.get_doorbell_detail(doorbell.device_id) + if detail is None: + return None + + return detail.is_online + + +def _retrieve_motion_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_MOTION, + ActivityType.DOORBELL_DING]) + + +def _retrieve_ding_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_DING]) + + +def _activity_time_based_state(data, doorbell, activity_types): + """Get the latest state of the sensor.""" + latest = data.get_latest_device_activity(doorbell.device_id, + *activity_types) + + if latest is not None: + start = latest.activity_start_time + end = latest.activity_end_time + timedelta(seconds=30) + return start <= datetime.now() <= end + return None + + +# Sensor types: Name, device_class, state_provider +SENSOR_TYPES_DOOR = { + 'door_open': ['Open', 'door', _retrieve_door_state], +} + +SENSOR_TYPES_DOORBELL = { + 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], + 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], + 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the August binary sensors.""" + data = hass.data[DATA_AUGUST] + devices = [] + + from august.lock import LockDoorStatus + for door in data.locks: + for sensor_type in SENSOR_TYPES_DOOR: + state_provider = SENSOR_TYPES_DOOR[sensor_type][2] + if state_provider(data, door) is LockDoorStatus.UNKNOWN: + _LOGGER.debug( + "Not adding sensor class %s for lock %s ", + SENSOR_TYPES_DOOR[sensor_type][1], door.device_name + ) + continue + + _LOGGER.debug( + "Adding sensor class %s for %s", + SENSOR_TYPES_DOOR[sensor_type][1], door.device_name + ) + devices.append(AugustDoorBinarySensor(data, sensor_type, door)) + + for doorbell in data.doorbells: + for sensor_type in SENSOR_TYPES_DOORBELL: + _LOGGER.debug("Adding doorbell sensor class %s for %s", + SENSOR_TYPES_DOORBELL[sensor_type][1], + doorbell.device_name) + devices.append( + AugustDoorbellBinarySensor(data, sensor_type, + doorbell) + ) + + add_entities(devices, True) + + +class AugustDoorBinarySensor(BinarySensorDevice): + """Representation of an August Door binary sensor.""" + + def __init__(self, data, sensor_type, door): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._door = door + self._state = None + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES_DOOR[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._door.device_name, + SENSOR_TYPES_DOOR[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] + self._state = state_provider(self._data, self._door) + self._available = self._state is not None + + from august.lock import LockDoorStatus + self._state = self._state == LockDoorStatus.OPEN + + +class AugustDoorbellBinarySensor(BinarySensorDevice): + """Representation of an August binary sensor.""" + + def __init__(self, data, sensor_type, doorbell): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._doorbell = doorbell + self._state = None + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._doorbell.device_name, + SENSOR_TYPES_DOORBELL[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] + self._state = state_provider(self._data, self._doorbell) + self._available = self._state is not None diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/august/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/august/camera.py new file mode 100644 index 00000000000000..dcce5e13588c11 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/august/camera.py @@ -0,0 +1,76 @@ +""" +Support for August camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.august/ +""" +from datetime import timedelta + +import requests + +from homeassistant.components.august import DATA_AUGUST, DEFAULT_TIMEOUT +from homeassistant.components.camera import Camera + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up August cameras.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) + + add_entities(devices, True) + + +class AugustCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, doorbell, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._doorbell = doorbell + self._timeout = timeout + self._image_url = None + self._image_content = None + + @property + def name(self): + """Return the name of this device.""" + return self._doorbell.device_name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._doorbell.has_subscription + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return True + + @property + def brand(self): + """Return the camera brand.""" + return 'August' + + @property + def model(self): + """Return the camera model.""" + return 'Doorbell' + + def camera_image(self): + """Return bytes of camera image.""" + latest = self._data.get_doorbell_detail(self._doorbell.device_id) + + if self._image_url is not latest.image_url: + self._image_url = latest.image_url + self._image_content = requests.get(self._image_url, + timeout=self._timeout).content + + return self._image_content diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/august/lock.py b/homeassistant-0.88.0.dev0/homeassistant/components/august/lock.py new file mode 100644 index 00000000000000..ce6792ceb39d39 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/august/lock.py @@ -0,0 +1,97 @@ +""" +Support for August lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.august/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up August locks.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for lock in data.locks: + _LOGGER.debug("Adding lock for %s", lock.device_name) + devices.append(AugustLock(data, lock)) + + add_entities(devices, True) + + +class AugustLock(LockDevice): + """Representation of an August lock.""" + + def __init__(self, data, lock): + """Initialize the lock.""" + self._data = data + self._lock = lock + self._lock_status = None + self._lock_detail = None + self._changed_by = None + self._available = False + + def lock(self, **kwargs): + """Lock the device.""" + self._data.lock(self._lock.device_id) + + def unlock(self, **kwargs): + """Unlock the device.""" + self._data.unlock(self._lock.device_id) + + def update(self): + """Get the latest state of the sensor.""" + self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._available = self._lock_status is not None + + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + + from august.activity import ActivityType + activity = self._data.get_latest_device_activity( + self._lock.device_id, + ActivityType.LOCK_OPERATION) + + if activity is not None: + self._changed_by = activity.operated_by + + @property + def name(self): + """Return the name of this device.""" + return self._lock.device_name + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def is_locked(self): + """Return true if device is on.""" + from august.lock import LockStatus + return self._lock_status is LockStatus.LOCKED + + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + if self._lock_detail is None: + return None + + return { + ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ar.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ar.json new file mode 100644 index 00000000000000..1ef902e6fe206f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ar.json @@ -0,0 +1,7 @@ +{ + "mfa_setup": { + "totp": { + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ca.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ca.json new file mode 100644 index 00000000000000..e5ece421a0b886 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ca.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles." + }, + "error": { + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho." + }, + "step": { + "init": { + "description": "Selecciona un dels serveis de notificaci\u00f3:", + "title": "Configuraci\u00f3 d'una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" + }, + "setup": { + "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdueix-la a continuaci\u00f3:", + "title": "Verificaci\u00f3 de la configuraci\u00f3" + } + }, + "title": "Contrasenya d'un sol \u00fas del servei de notificacions" + }, + "totp": { + "error": { + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho. Si obtens aquest error repetidament, assegura't que la data i hora de Home Assistant siguin correctes i acurades." + }, + "step": { + "init": { + "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escaneja el codi QR amb la teva aplicaci\u00f3 de verificaci\u00f3. Si no en tens cap, et recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdueix el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si tens problemes per escanejar el codi QR, fes una configuraci\u00f3 manual amb el codi **`{code}`**.", + "title": "Configura la verificaci\u00f3 en dos passos utilitzant TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/cs.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/cs.json new file mode 100644 index 00000000000000..da234c3dd5dd57 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/cs.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u017d\u00e1dn\u00e9 oznamovac\u00ed slu\u017eby nejsou k dispozici." + }, + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu." + }, + "step": { + "init": { + "description": "Vyberte pros\u00edm jednu z oznamovac\u00edch slu\u017eeb:", + "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify" + }, + "setup": { + "description": "Jednor\u00e1zov\u00e9 heslo bylo odesl\u00e1no prost\u0159ednictv\u00edm **notify.{notify_service}**. Zadejte jej n\u00ed\u017ee:", + "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed" + } + } + }, + "totp": { + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny." + }, + "step": { + "init": { + "description": "Chcete-li aktivovat dvoufaktorovou autentizaci pomoc\u00ed jednor\u00e1zov\u00fdch hesel zalo\u017een\u00fdch na \u010dase, na\u010dt\u011bte k\u00f3d QR pomoc\u00ed va\u0161\u00ed autentiza\u010dn\u00ed aplikace. Pokud ji nem\u00e1te, doporu\u010dujeme bu\u010f [Google Authenticator](https://support.google.com/accounts/answer/1066447) nebo [Authy](https://authy.com/). \n\n {qr_code} \n \n Po skenov\u00e1n\u00ed k\u00f3du zadejte \u0161estcifern\u00fd k\u00f3d z aplikace a ov\u011b\u0159te nastaven\u00ed. Pokud m\u00e1te probl\u00e9my se skenov\u00e1n\u00edm k\u00f3du QR, prove\u010fte ru\u010dn\u00ed nastaven\u00ed s k\u00f3dem **`{code}`**.", + "title": "Nastavte dvoufaktorovou autentizaci pomoc\u00ed TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/de.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/de.json new file mode 100644 index 00000000000000..06da3cde1a132b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/de.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Keine Benachrichtigungsdienste verf\u00fcgbar." + }, + "error": { + "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut." + }, + "step": { + "init": { + "description": "Bitte w\u00e4hlen Sie einen der Benachrichtigungsdienste:", + "title": "Einmal Passwort f\u00fcr Notify einrichten" + }, + "setup": { + "description": "Ein Einmal-Passwort wurde per **notify.{notify_service}** gesendet. Bitte geben Sie es unten ein:", + "title": "\u00dcberpr\u00fcfe das Setup" + } + }, + "title": "Benachrichtig f\u00fcr One-Time Password" + }, + "totp": { + "error": { + "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn du diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist." + }, + "step": { + "init": { + "description": "Um die Zwei-Faktor-Authentifizierung mit zeitbasierten Einmalpassw\u00f6rtern zu aktivieren, scanne den QR-Code mit Ihrer Authentifizierungs-App. Wenn du keine hast, empfehlen wir entweder [Google Authenticator] (https://support.google.com/accounts/answer/1066447) oder [Authy] (https://authy.com/). \n\n {qr_code} \n \nNachdem du den Code gescannt hast, gebe den sechsstelligen Code aus der App ein, um das Setup zu \u00fcberpr\u00fcfen. Wenn es Probleme beim Scannen des QR-Codes gibt, f\u00fchre ein manuelles Setup mit dem Code ** ` {code} ` ** durch.", + "title": "Richte die Zwei-Faktor-Authentifizierung mit TOTP ein" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/en.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/en.json new file mode 100644 index 00000000000000..66c0e92d9b5a64 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/en.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No notification services available." + }, + "error": { + "invalid_code": "Invalid code, please try again." + }, + "step": { + "init": { + "description": "Please select one of the notification services:", + "title": "Set up one-time password delivered by notify component" + }, + "setup": { + "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:", + "title": "Verify setup" + } + }, + "title": "Notify One-Time Password" + }, + "totp": { + "error": { + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." + }, + "step": { + "init": { + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.", + "title": "Set up two-factor authentication using TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/es-419.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/es-419.json new file mode 100644 index 00000000000000..6caa9d49993c56 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/es-419.json @@ -0,0 +1,12 @@ +{ + "mfa_setup": { + "totp": { + "step": { + "init": { + "title": "Configurar la autenticaci\u00f3n de dos factores mediante TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/es.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/es.json new file mode 100644 index 00000000000000..bfec5cd9274374 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/es.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hay servicios de notificaci\u00f3n disponibles." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo." + }, + "step": { + "init": { + "description": "Seleccione uno de los servicios de notificaci\u00f3n:", + "title": "Configure una contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n" + }, + "setup": { + "description": "Se ha enviado una contrase\u00f1a de un solo uso a trav\u00e9s de ** notificar. {notify_service} **. Por favor introd\u00facela a continuaci\u00f3n:", + "title": "Verificar la configuraci\u00f3n" + } + }, + "title": "Notificar la contrase\u00f1a de un solo uso" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, por favor aseg\u00farate de que el reloj de tu Home Assistant es correcto." + }, + "step": { + "init": { + "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.", + "title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/et.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/et.json new file mode 100644 index 00000000000000..290f4ee12a98ed --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/et.json @@ -0,0 +1,7 @@ +{ + "mfa_setup": { + "totp": { + "title": "" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/fr.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/fr.json new file mode 100644 index 00000000000000..cf0a1888495a40 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/fr.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Aucun service de notification disponible." + }, + "error": { + "invalid_code": "Code invalide. Veuillez essayer \u00e0 nouveau." + }, + "step": { + "init": { + "description": "Veuillez s\u00e9lectionner l'un des services de notification:", + "title": "Configurer un mot de passe \u00e0 usage unique d\u00e9livr\u00e9 par le composant notify" + }, + "setup": { + "description": "Un mot de passe unique a \u00e9t\u00e9 envoy\u00e9 par **notify.{notify_service}**. Veuillez le saisir ci-dessous :", + "title": "V\u00e9rifier la configuration" + } + }, + "title": "Notifier un mot de passe unique" + }, + "totp": { + "error": { + "invalid_code": "Code invalide. Veuillez essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte." + }, + "step": { + "init": { + "description": "Pour activer l'authentification \u00e0 deux facteurs \u00e0 l'aide de mots de passe \u00e0 utilisation unique bas\u00e9s sur l'heure, num\u00e9risez le code QR avec votre application d'authentification. Si vous n'en avez pas, nous vous recommandons d'utiliser [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ou [Authy] (https://authy.com/). \n\n {qr_code} \n \n Apr\u00e8s avoir num\u00e9ris\u00e9 le code, entrez le code \u00e0 six chiffres de votre application pour v\u00e9rifier la configuration. Si vous rencontrez des probl\u00e8mes lors de l\u2019analyse du code QR, effectuez une configuration manuelle avec le code ** ` {code} ` **.", + "title": "Configurer une authentification \u00e0 deux facteurs \u00e0 l'aide de TOTP" + } + }, + "title": "TOTP (Mot de passe \u00e0 utilisation unique bas\u00e9 sur le temps)" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/he.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/he.json new file mode 100644 index 00000000000000..bc1826d4d79757 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/he.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." + }, + "error": { + "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." + }, + "step": { + "init": { + "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify", + "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify" + }, + "setup": { + "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4" + } + }, + "title": "\u05dc\u05d4\u05d5\u05d3\u05d9\u05e2 \u200b\u200b\u05e2\u05dc \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea" + }, + "totp": { + "error": { + "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05de\u05e7\u05d1\u05dc \u05d0\u05ea \u05d4\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d4\u05d6\u05d5 \u05d1\u05d0\u05d5\u05e4\u05df \u05e2\u05e7\u05d1\u05d9, \u05d5\u05d3\u05d0 \u05e9\u05d4\u05e9\u05e2\u05d5\u05df \u05e9\u05dc \u05de\u05e2\u05e8\u05db\u05ea \u05d4 - Home Assistant \u05e9\u05dc\u05da \u05de\u05d3\u05d5\u05d9\u05e7." + }, + "step": { + "init": { + "description": "\u05db\u05d3\u05d9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05d5\u05ea \u05de\u05d1\u05d5\u05e1\u05e1\u05d5\u05ea \u05d6\u05de\u05df, \u05e1\u05e8\u05d5\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 QR \u05e2\u05dd \u05d9\u05d9\u05e9\u05d5\u05dd \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05da. \u05d0\u05dd \u05d0\u05d9\u05df \u05dc\u05da \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4, \u05d0\u05e0\u05d5 \u05de\u05de\u05dc\u05d9\u05e6\u05d9\u05dd \u05e2\u05dc [Google Authenticator] (https://support.google.com/accounts/answer/1066447) \u05d0\u05d5 [Authy] (https://authy.com/). \n\n {qr_code} \n \n \u05dc\u05d0\u05d7\u05e8 \u05e1\u05e8\u05d9\u05e7\u05ea \u05d4\u05e7\u05d5\u05d3, \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d0\u05de\u05ea \u05d0\u05ea \u05d4\u05d4\u05d2\u05d3\u05e8\u05d4. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05e0\u05ea\u05e7\u05dc \u05d1\u05d1\u05e2\u05d9\u05d5\u05ea \u05d1\u05e1\u05e8\u05d9\u05e7\u05ea \u05e7\u05d5\u05d3 QR, \u05d1\u05e6\u05e2 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d9\u05d3\u05e0\u05d9\u05ea \u05e2\u05dd \u05e7\u05d5\u05d3 **`{code}`**.", + "title": "\u05d4\u05d2\u05d3\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/hu.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/hu.json new file mode 100644 index 00000000000000..0a3a3c5882066c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/hu.json @@ -0,0 +1,32 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nincs el\u00e9rhet\u0151 \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s." + }, + "error": { + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra." + }, + "step": { + "init": { + "description": "V\u00e1lassz \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1st:" + }, + "setup": { + "title": "Be\u00e1ll\u00edt\u00e1s ellen\u0151rz\u00e9se" + } + } + }, + "totp": { + "error": { + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r." + }, + "step": { + "init": { + "description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.", + "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/id.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/id.json new file mode 100644 index 00000000000000..f6a22386f99bfb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/id.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Kode salah, coba lagi. Jika Anda mendapatkan kesalahan ini secara konsisten, pastikan jam pada sistem Home Assistant anda akurat." + }, + "step": { + "init": { + "description": "Untuk mengaktifkan otentikasi dua faktor menggunakan password satu kali berbasis waktu, pindai kode QR dengan aplikasi otentikasi Anda. Jika Anda tidak memilikinya, kami menyarankan [Google Authenticator] (https://support.google.com/accounts/answer/1066447) atau [Authy] (https://authy.com/). \n\n {qr_code} \n \n Setelah memindai kode, masukkan kode enam digit dari aplikasi Anda untuk memverifikasi pengaturan. Jika Anda mengalami masalah saat memindai kode QR, lakukan pengaturan manual dengan kode ** ` {code} ` **.", + "title": "Siapkan otentikasi dua faktor menggunakan TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/it.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/it.json new file mode 100644 index 00000000000000..25dad4c1aeb83f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/it.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nessun servizio di notifica disponibile." + }, + "error": { + "invalid_code": "Codice non valido, per favore riprovare." + }, + "step": { + "init": { + "description": "Selezionare uno dei servizi di notifica:" + }, + "setup": { + "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:", + "title": "Verifica l'installazione" + } + }, + "title": "Notifica la Password monouso" + }, + "totp": { + "error": { + "invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato." + }, + "step": { + "init": { + "description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.", + "title": "Imposta l'autenticazione a due fattori usando TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ko.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ko.json new file mode 100644 index 00000000000000..7efc50d534cb21 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ko.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc54c\ub9bc \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "error": { + "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "init": { + "description": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:", + "title": "\uc54c\ub9bc \uad6c\uc131\uc694\uc18c\uac00 \uc81c\uacf5\ud558\ub294 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc124\uc815" + }, + "setup": { + "description": "**notify.{notify_service}** \uc5d0\uc11c \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \ubcf4\ub0c8\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc758 \uacf5\ub780\uc5d0 \uc785\ub825\ud574 \uc8fc\uc138\uc694:", + "title": "\uc124\uc815 \ud655\uc778" + } + }, + "title": "\uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc54c\ub9bc" + }, + "totp": { + "error": { + "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \uc774 \uc624\ub958\uac00 \uc9c0\uc18d\uc801\uc73c\ub85c \ubc1c\uc0dd\ud55c\ub2e4\uba74 Home Assistant \uc758 \uc2dc\uac04\uc124\uc815\uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\ubcf4\uc138\uc694." + }, + "step": { + "init": { + "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574 \uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.", + "title": "TOTP \ub97c \uc0ac\uc6a9\ud558\uc5ec 2\ub2e8\uacc4 \uc778\uc99d \uad6c\uc131" + } + }, + "title": "TOTP (\uc2dc\uac04 \uae30\ubc18 OTP)" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/lb.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/lb.json new file mode 100644 index 00000000000000..12ced930446f4e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/lb.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Keen Notifikatioun's D\u00e9ngscht disponibel." + }, + "error": { + "invalid_code": "Ong\u00ebltege Code, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "init": { + "description": "Wielt w.e.g. een Notifikatioun's D\u00e9ngscht aus:", + "title": "Eemolegt Passwuert ariichte wat vun engem Notifikatioun's Komponente versch\u00e9ckt g\u00ebtt" + }, + "setup": { + "description": "Een eemolegt Passwuert ass vun **notify.{notify_service}** gesch\u00e9ckt ginn. Gitt et w.e.g hei \u00ebnnen dr\u00ebnner an:", + "title": "Astellungen iwwerpr\u00e9iwen" + } + }, + "title": "Eemolegt Passwuert Notifikatioun" + }, + "totp": { + "error": { + "invalid_code": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol. Falls d\u00ebse Feeler Message \u00ebmmer er\u00ebm optr\u00ebtt dann iwwerpr\u00e9ift op d'Z\u00e4it vum Home Assistant System richteg ass." + }, + "step": { + "init": { + "description": "Fir d'Zwee-Faktor-Authentifikatioun m\u00ebttels engem Z\u00e4it bas\u00e9ierten eemolege Passwuert z'aktiv\u00e9ieren, scannt de QR Code mat enger Authentifikatioun's App.\nFalls dir keng hutt, recommand\u00e9iere mir entweder [Google Authenticator](https://support.google.com/accounts/answer/1066447) oder [Authy](https://authy.com/).\n\n{qr_code}\n\nNodeems de Code gescannt ass, gitt de sechs stellege Code vun der App a fir d'Konfiguratioun z'iwwerpr\u00e9iwen. Am Fall vu Problemer fir de QR Code ze scannen, gitt de folgende Code **`{code}`** a fir ee manuelle Setup.", + "title": "Zwee Faktor Authentifikatioun mat TOTP konfigur\u00e9ieren" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/nl.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/nl.json new file mode 100644 index 00000000000000..9ec8006507b824 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/nl.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Geen meldingsservices beschikbaar." + }, + "error": { + "invalid_code": "Ongeldige code, probeer opnieuw." + }, + "step": { + "init": { + "description": "Selecteer een van de meldingsdiensten:", + "title": "Stel een \u00e9\u00e9nmalig wachtwoord in dat wordt afgegeven door een meldingscomponent" + }, + "setup": { + "description": "Een \u00e9\u00e9nmalig wachtwoord is verzonden via **notify. {notify_service}**. Voer het hieronder in:", + "title": "Controleer de instellingen" + } + }, + "title": "Eenmalig wachtwoord melden" + }, + "totp": { + "error": { + "invalid_code": "Ongeldige code, probeer het opnieuw. Als u deze fout blijft krijgen, controleer dan of de klok van uw Home Assistant systeem correct is ingesteld." + }, + "step": { + "init": { + "description": "Voor het activeren van twee-factor-authenticatie via tijdgebonden eenmalige wachtwoorden: scan de QR code met uw authenticatie-app. Als u nog geen app heeft, adviseren we [Google Authenticator (https://support.google.com/accounts/answer/1066447) of [Authy](https://authy.com/).\n\n{qr_code}\n\nNa het scannen van de code voert u de zescijferige code uit uw app in om de instelling te controleren. Als u problemen heeft met het scannen van de QR-code, voert u een handmatige configuratie uit met code **`{code}`**.", + "title": "Configureer twee-factor-authenticatie via TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/nn.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/nn.json new file mode 100644 index 00000000000000..24d756f938bc51 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/nn.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Dersom du heile tida f\u00e5r denne feilen, m\u00e5 du s\u00f8rge for at klokka p\u00e5 Home Assistant-systemet ditt er n\u00f8yaktig." + }, + "step": { + "init": { + "description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte eingangspassord, skann QR-koden med autentiseringsappen din. Dersom du ikkje har ein, vil vi r\u00e5de deg til \u00e5 bruke anten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har skanna koda, skriv du inn den sekssifra koda fr\u00e5 appen din for \u00e5 stadfeste oppsettet. Dersom du har problemer med \u00e5 skanne QR-koda, gjer du eit manuelt oppsett med kode ** ` {code} ` **.", + "title": "Konfigurer to-faktor-autentisering ved hjelp av TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/no.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/no.json new file mode 100644 index 00000000000000..48b5db8a3b6063 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/no.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ingen varslingstjenester er tilgjengelig." + }, + "error": { + "invalid_code": "Ugyldig kode, vennligst pr\u00f8v igjen." + }, + "step": { + "init": { + "description": "Vennligst velg en av varslingstjenestene:", + "title": "Sett opp engangspassord levert av varsel komponent" + }, + "setup": { + "description": "Et engangspassord har blitt sendt via **notify.{notify_service}**. Vennligst skriv det inn nedenfor:", + "title": "Bekreft oppsettet" + } + }, + "title": "Varsle engangspassord" + }, + "totp": { + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Hvis du f\u00e5r denne feilen konsekvent, m\u00e5 du s\u00f8rge for at klokken p\u00e5 Home Assistant systemet er riktig." + }, + "step": { + "init": { + "description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte engangspassord, skann QR-koden med autentiseringsappen din. Hvis du ikke har en, kan vi anbefale enten [Google Authenticator](https://support.google.com/accounts/answer/1066447) eller [Authy](https://authy.com/). \n\n {qr_code} \n \nEtter at du har skannet koden, skriver du inn den seks-sifrede koden fra appen din for \u00e5 kontrollere oppsettet. Dersom du har problemer med \u00e5 skanne QR-koden kan du taste inn f\u00f8lgende kode manuelt: **`{code}`**.", + "title": "Konfigurer tofaktorautentisering ved hjelp av TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pl.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pl.json new file mode 100644 index 00000000000000..f0e9f7b71ea449 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pl.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Brak dost\u0119pnych us\u0142ug powiadamiania." + }, + "error": { + "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie." + }, + "step": { + "init": { + "description": "Prosz\u0119 wybra\u0107 jedn\u0105 us\u0142ug\u0119 powiadamiania:", + "title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144" + }, + "setup": { + "description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez **notify.{notify_service}**. Wpisz je poni\u017cej:", + "title": "Sprawd\u017a konfiguracj\u0119" + } + }, + "title": "Powiadomienie z has\u0142em jednorazowym" + }, + "totp": { + "error": { + "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie. Je\u015bli b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, upewnij si\u0119, \u017ce czas zegara systemu Home Assistant jest prawid\u0142owy." + }, + "step": { + "init": { + "description": "Aby aktywowa\u0107 uwierzytelnianie dwusk\u0142adnikowe przy u\u017cyciu jednorazowych hase\u0142 opartych na czasie, zeskanuj kod QR za pomoc\u0105 aplikacji uwierzytelniaj\u0105cej. Je\u015bli jej nie masz, polecamy [Google Authenticator](https://support.google.com/accounts/answer/1066447) lub [Authy](https://authy.com/).\n\n{qr_code} \n \nPo zeskanowaniu kodu wprowad\u017a sze\u015bciocyfrowy kod z aplikacji, aby zweryfikowa\u0107 konfiguracj\u0119. Je\u015bli masz problemy z zeskanowaniem kodu QR, wykonaj r\u0119czn\u0105 konfiguracj\u0119 z kodem **`{code}`**.", + "title": "Skonfiguruj uwierzytelnianie dwusk\u0142adnikowe za pomoc\u0105 hase\u0142 jednorazowych opartych na czasie" + } + }, + "title": "Has\u0142a jednorazowe oparte na czasie" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pt-BR.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pt-BR.json new file mode 100644 index 00000000000000..faf854153b08f5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "mfa_setup": { + "notify": { + "step": { + "setup": { + "title": "Verificar a configura\u00e7\u00e3o" + } + } + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente. Se voc\u00ea obtiver este erro de forma consistente, certifique-se de que o rel\u00f3gio do sistema Home Assistant esteja correto." + }, + "step": { + "init": { + "title": "Configure a autentica\u00e7\u00e3o de dois fatores usando o TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pt.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pt.json new file mode 100644 index 00000000000000..e25fe3139a4b5f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/pt.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nenhum servi\u00e7o de notifica\u00e7\u00e3o dispon\u00edvel." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente." + }, + "step": { + "init": { + "description": "Por favor, selecione um dos servi\u00e7os de notifica\u00e7\u00e3o:", + "title": "Configurar uma palavra-passe entregue pela componente de notifica\u00e7\u00e3o" + }, + "setup": { + "description": "Foi enviada uma palavra-passe atrav\u00e9s de **notify.{notify_service}**. Por favor, insira-a:", + "title": "Verificar a configura\u00e7\u00e3o" + } + }, + "title": "Notificar palavra-passe de uso \u00fanico" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor, tente novamente. Se receber este erro constantemente, por favor, certifique-se de que o rel\u00f3gio do sistema que hospeda o Home Assistant \u00e9 preciso." + }, + "step": { + "init": { + "description": "Para ativar a autentica\u00e7\u00e3o com dois fatores utilizando palavras-passe de uso \u00fanico (OTP), ler o c\u00f3digo QR com a sua aplica\u00e7\u00e3o de autentica\u00e7\u00e3o. Se n\u00e3o tiver uma, recomendamos [Google Authenticator](https://support.google.com/accounts/answer/1066447) ou [Authy](https://authy.com/).\n\n{qr_code}\n\nDepois de ler o c\u00f3digo, introduza o c\u00f3digo de seis d\u00edgitos fornecido pela sua aplica\u00e7\u00e3o para verificar a configura\u00e7\u00e3o. Se tiver problemas a ler o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo **`{code}`**.", + "title": "Configurar autentica\u00e7\u00e3o com dois fatores usando TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ro.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ro.json new file mode 100644 index 00000000000000..19f9ec10c73aaa --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ro.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nu sunt disponibile servicii de notificare." + }, + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou." + }, + "step": { + "init": { + "description": "Selecta\u021bi unul dintre serviciile de notificare:", + "title": "Configura\u021bi o parol\u0103 unic\u0103 livrat\u0103 de o component\u0103 de notificare" + }, + "setup": { + "description": "O parol\u0103 unic\u0103 a fost trimis\u0103 prin **notify.{notify_service}**. Introduce\u021bi parola mai jos:", + "title": "Verifica\u021bi configurarea" + } + }, + "title": "Notifica\u021bi o parol\u0103 unic\u0103" + }, + "totp": { + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou. Dac\u0103 primi\u021bi aceast\u0103 eroare \u00een mod consecvent, asigura\u021bi-v\u0103 c\u0103 ceasul sistemului dvs. Home Assistant este corect." + }, + "step": { + "init": { + "title": "Configura\u021bi autentificarea cu doi factori utiliz\u00e2nd TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ru.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ru.json new file mode 100644 index 00000000000000..edf136bd7f353e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/ru.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439." + }, + "error": { + "invalid_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043e\u0434, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443." + }, + "step": { + "init": { + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u0443 \u0438\u0437 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439:", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0438 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439 \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439" + }, + "setup": { + "description": "\u041e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d \u0447\u0435\u0440\u0435\u0437 **notify.{notify_service}**. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0435\u0433\u043e \u043d\u0438\u0436\u0435:", + "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443" + } + }, + "title": "\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439" + }, + "totp": { + "error": { + "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0432\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f." + }, + "step": { + "init": { + "description": "\u0427\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043e\u0442\u0441\u043a\u0430\u043d\u0438\u0440\u0443\u0439\u0442\u0435 QR-\u043a\u043e\u0434 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0433\u043e \u043d\u0435\u0442, \u043c\u044b \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043b\u0438\u0431\u043e [Google Authenticator](https://support.google.com/accounts/answer/1066447), \u043b\u0438\u0431\u043e [Authy](https://authy.com/). \n\n {qr_code} \n \n\u041f\u043e\u0441\u043b\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f QR-\u043a\u043e\u0434\u0430 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u043a\u043e\u0434 \u0438\u0437 \u0432\u0430\u0448\u0435\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c QR-\u043a\u043e\u0434\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043e\u0434\u0430 **`{code}`**.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/sl.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/sl.json new file mode 100644 index 00000000000000..f70bb81e7003cb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/sl.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Storitve obve\u0161\u010danja niso na voljo." + }, + "error": { + "invalid_code": "Neveljavna koda, poskusite znova." + }, + "step": { + "init": { + "description": "Izberite eno od storitev obve\u0161\u010danja:", + "title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento" + }, + "setup": { + "description": "Enkratno geslo je poslal **notify.{notify_service} **. Prosimo, vnesite ga spodaj:", + "title": "Preverite nastavitev" + } + }, + "title": "Obvesti Enkratno Geslo" + }, + "totp": { + "error": { + "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistanta to\u010dna." + }, + "step": { + "init": { + "description": "\u010ce \u017eelite aktivirati preverjanje pristnosti dveh faktorjev z enkratnimi gesli, ki temeljijo na \u010dasu, skenirajte kodo QR s svojo aplikacijo za preverjanje pristnosti. \u010ce je nimate, priporo\u010damo bodisi [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ali [Authy] (https://authy.com/). \n\n {qr_code} \n \n Po skeniranju kode vnesite \u0161estmestno kodo iz aplikacije, da preverite nastavitev. \u010ce imate te\u017eave pri skeniranju kode QR, naredite ro\u010dno nastavitev s kodo ** ` {code} ` **.", + "title": "Nastavite dvofaktorsko avtentifikacijo s pomo\u010djo TOTP-ja" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/sv.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/sv.json new file mode 100644 index 00000000000000..9246a88c512356 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/sv.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Inga tillg\u00e4ngliga meddelande tj\u00e4nster." + }, + "error": { + "invalid_code": "Ogiltig kod, var god f\u00f6rs\u00f6k igen." + }, + "step": { + "init": { + "description": "Var god v\u00e4lj en av notifieringstj\u00e4nsterna:", + "title": "Konfigurera ett eng\u00e5ngsl\u00f6senord levererat genom notifieringskomponenten" + }, + "setup": { + "description": "Ett eng\u00e5ngsl\u00f6senord har skickats av **notify.{notify_service}**. V\u00e4nligen ange det nedan:", + "title": "Verifiera inst\u00e4llningen" + } + }, + "title": "Meddela eng\u00e5ngsl\u00f6senord" + }, + "totp": { + "error": { + "invalid_code": "Ogiltig kod, f\u00f6rs\u00f6k igen. Om du flera g\u00e5nger i rad f\u00e5r detta fel, se till att klockan i din Home Assistant \u00e4r korrekt inst\u00e4lld." + }, + "step": { + "init": { + "description": "F\u00f6r att aktivera tv\u00e5faktorsautentisering som anv\u00e4nder tidsbaserade eng\u00e5ngsl\u00f6senord, skanna QR-koden med din autentiseringsapp. Om du inte har en, rekommenderar vi antingen [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n{qr_code} \n\nN\u00e4r du har skannat koden anger du den sexsiffriga koden fr\u00e5n din app f\u00f6r att verifiera inst\u00e4llningen. Om du har problem med att skanna QR-koden, g\u00f6r en manuell inst\u00e4llning med kod ** ` {code} ` **.", + "title": "St\u00e4ll in tv\u00e5faktorsautentisering med TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/uk.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/uk.json new file mode 100644 index 00000000000000..f826075078e7b5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/uk.json @@ -0,0 +1,14 @@ +{ + "mfa_setup": { + "notify": { + "error": { + "invalid_code": "\u041d\u0435\u0432\u0456\u0440\u043d\u0438\u0439 \u043a\u043e\u0434, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437." + }, + "step": { + "setup": { + "title": "\u041f\u0435\u0440\u0435\u0432\u0456\u0440\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/zh-Hans.json new file mode 100644 index 00000000000000..1cb311f016fe6d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/zh-Hans.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u6ca1\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52a1\u3002" + }, + "error": { + "invalid_code": "\u4ee3\u7801\u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u8bf7\u4ece\u4e0b\u9762\u9009\u62e9\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a", + "title": "\u8bbe\u7f6e\u7531\u901a\u77e5\u7ec4\u4ef6\u4f20\u9012\u7684\u4e00\u6b21\u6027\u5bc6\u7801" + }, + "setup": { + "description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}** \u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165\uff1a", + "title": "\u9a8c\u8bc1\u8bbe\u7f6e" + } + }, + "title": "\u4e00\u6b21\u6027\u5bc6\u7801\u901a\u77e5" + }, + "totp": { + "error": { + "invalid_code": "\u53e3\u4ee4\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165\u3002\u5982\u679c\u9519\u8bef\u53cd\u590d\u51fa\u73b0\uff0c\u8bf7\u786e\u4fdd Home Assistant \u7cfb\u7edf\u7684\u65f6\u95f4\u51c6\u786e\u65e0\u8bef\u3002" + }, + "step": { + "init": { + "description": "\u8981\u6fc0\u6d3b\u57fa\u4e8e\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u7684\u53cc\u91cd\u8ba4\u8bc1\uff0c\u8bf7\u7528\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\u626b\u63cf\u4ee5\u4e0b\u4e8c\u7ef4\u7801\u3002\u5982\u679c\u60a8\u8fd8\u6ca1\u6709\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\uff0c\u63a8\u8350\u4f7f\u7528 [Google \u8eab\u4efd\u9a8c\u8bc1\u5668](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u626b\u63cf\u4e8c\u7ef4\u7801\u4ee5\u540e\uff0c\u8f93\u5165\u5e94\u7528\u4e0a\u7684\u516d\u4f4d\u6570\u5b57\u53e3\u4ee4\u6765\u9a8c\u8bc1\u914d\u7f6e\u3002\u5982\u679c\u5728\u626b\u63cf\u4e8c\u7ef4\u7801\u65f6\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u4f7f\u7528\u4ee3\u7801 **`{code}`** \u624b\u52a8\u914d\u7f6e\u3002", + "title": "\u7528\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u8bbe\u7f6e\u53cc\u91cd\u8ba4\u8bc1" + } + }, + "title": "\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/zh-Hant.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/zh-Hant.json new file mode 100644 index 00000000000000..b7a26f5079c6c4 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/.translations/zh-Hant.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u6c92\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52d9\u3002" + }, + "error": { + "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u8acb\u9078\u64c7\u4e00\u9805\u901a\u77e5\u670d\u52d9\uff1a", + "title": "\u8a2d\u5b9a\u4e00\u6b21\u6027\u5bc6\u78bc\u50b3\u9001\u7d44\u4ef6" + }, + "setup": { + "description": "\u4e00\u6b21\u6027\u5bc6\u78bc\u5df2\u900f\u904e **notify.{notify_service}** \u50b3\u9001\u3002\u8acb\u65bc\u4e0b\u65b9\u8f38\u5165\uff1a", + "title": "\u9a57\u8b49\u8a2d\u5b9a" + } + }, + "title": "\u901a\u77e5\u4e00\u6b21\u6027\u5bc6\u78bc" + }, + "totp": { + "error": { + "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002\u5047\u5982\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5148\u78ba\u5b9a\u60a8\u7684 Home Assistant \u7cfb\u7d71\u4e0a\u7684\u6642\u9593\u8a2d\u5b9a\u6b63\u78ba\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u6b32\u555f\u7528\u4e00\u6b21\u6027\u4e14\u5177\u6642\u6548\u6027\u7684\u5bc6\u78bc\u4e4b\u5169\u6b65\u9a5f\u9a57\u8b49\u529f\u80fd\uff0c\u8acb\u4f7f\u7528\u60a8\u7684\u9a57\u8b49 App \u6383\u7784\u4e0b\u65b9\u7684 QR code \u3002\u5018\u82e5\u60a8\u5c1a\u672a\u5b89\u88dd\u4efb\u4f55 App\uff0c\u63a8\u85a6\u60a8\u4f7f\u7528 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u65bc\u641c\u5c0b\u4e4b\u5f8c\uff0c\u8f38\u5165 App \u4e2d\u7684\u516d\u4f4d\u6578\u5b57\u9032\u884c\u8a2d\u5b9a\u9a57\u8b49\u3002\u5047\u5982\u641c\u5c0b\u51fa\u73fe\u554f\u984c\uff0c\u8acb\u624b\u52d5\u8f38\u5165\u4ee5\u4e0b\u9a57\u8b49\u78bc **`{code}`**\u3002", + "title": "\u4f7f\u7528 TOTP \u8a2d\u5b9a\u5169\u6b65\u9a5f\u9a57\u8b49" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/auth/__init__.py new file mode 100644 index 00000000000000..2e74961d11b981 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/__init__.py @@ -0,0 +1,527 @@ +"""Component to allow users to login and get tokens. + +# POST /auth/token + +This is an OAuth2 endpoint for granting tokens. We currently support the grant +types "authorization_code" and "refresh_token". Because we follow the OAuth2 +spec, data should be send in formatted as x-www-form-urlencoded. Examples will +be in JSON as it's more readable. + +## Grant type authorization_code + +Exchange the authorization code retrieved from the login flow for tokens. + +{ + "client_id": "https://hassbian.local:8123/", + "grant_type": "authorization_code", + "code": "411ee2f916e648d691e937ae9344681e" +} + +Return value will be the access and refresh tokens. The access token will have +a limited expiration. New access tokens can be requested using the refresh +token. + +{ + "access_token": "ABCDEFGH", + "expires_in": 1800, + "refresh_token": "IJKLMNOPQRST", + "token_type": "Bearer" +} + +## Grant type refresh_token + +Request a new access token using a refresh token. + +{ + "client_id": "https://hassbian.local:8123/", + "grant_type": "refresh_token", + "refresh_token": "IJKLMNOPQRST" +} + +Return value will be a new access token. The access token will have +a limited expiration. + +{ + "access_token": "ABCDEFGH", + "expires_in": 1800, + "token_type": "Bearer" +} + +## Revoking a refresh token + +It is also possible to revoke a refresh token and all access tokens that have +ever been granted by that refresh token. Response code will ALWAYS be 200. + +{ + "token": "IJKLMNOPQRST", + "action": "revoke" +} + +# Websocket API + +## Get current user + +Send websocket command `auth/current_user` will return current user of the +active websocket connection. + +{ + "id": 10, + "type": "auth/current_user", +} + +The result payload likes + +{ + "id": 10, + "type": "result", + "success": true, + "result": { + "id": "USER_ID", + "name": "John Doe", + "is_owner': true, + "credentials": [ + { + "auth_provider_type": "homeassistant", + "auth_provider_id": null + } + ], + "mfa_modules": [ + { + "id": "totp", + "name": "TOTP", + "enabled": true, + } + ] + } +} + +## Create a long-lived access token + +Send websocket command `auth/long_lived_access_token` will create +a long-lived access token for current user. Access token will not be saved in +Home Assistant. User need to record the token in secure place. + +{ + "id": 11, + "type": "auth/long_lived_access_token", + "client_name": "GPS Logger", + "lifespan": 365 +} + +Result will be a long-lived access token: + +{ + "id": 11, + "type": "result", + "success": true, + "result": "ABCDEFGH" +} + +""" +import logging +import uuid +from datetime import timedelta + +from aiohttp import web +import voluptuous as vol + +from homeassistant.auth.models import User, Credentials, \ + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN +from homeassistant.components import websocket_api +from homeassistant.components.http import KEY_REAL_IP +from homeassistant.components.http.auth import async_sign_path +from homeassistant.components.http.ban import log_invalid_auth +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.core import callback, HomeAssistant +from homeassistant.util import dt as dt_util + +from . import indieauth +from . import login_flow +from . import mfa_setup_flow + +DOMAIN = 'auth' +DEPENDENCIES = ['http'] + +WS_TYPE_CURRENT_USER = 'auth/current_user' +SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CURRENT_USER, +}) + +WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token' +SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + vol.Required('lifespan'): int, # days + vol.Required('client_name'): str, + vol.Optional('client_icon'): str, + }) + +WS_TYPE_REFRESH_TOKENS = 'auth/refresh_tokens' +SCHEMA_WS_REFRESH_TOKENS = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_REFRESH_TOKENS, + }) + +WS_TYPE_DELETE_REFRESH_TOKEN = 'auth/delete_refresh_token' +SCHEMA_WS_DELETE_REFRESH_TOKEN = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE_REFRESH_TOKEN, + vol.Required('refresh_token_id'): str, + }) + +WS_TYPE_SIGN_PATH = 'auth/sign_path' +SCHEMA_WS_SIGN_PATH = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SIGN_PATH, + vol.Required('path'): str, + vol.Optional('expires', default=30): int, + }) + +RESULT_TYPE_CREDENTIALS = 'credentials' +RESULT_TYPE_USER = 'user' + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Component to allow users to login.""" + store_result, retrieve_result = _create_auth_code_store() + + hass.http.register_view(TokenView(retrieve_result)) + hass.http.register_view(LinkUserView(retrieve_result)) + + hass.components.websocket_api.async_register_command( + WS_TYPE_CURRENT_USER, websocket_current_user, + SCHEMA_WS_CURRENT_USER + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + websocket_create_long_lived_access_token, + SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_REFRESH_TOKENS, + websocket_refresh_tokens, + SCHEMA_WS_REFRESH_TOKENS + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE_REFRESH_TOKEN, + websocket_delete_refresh_token, + SCHEMA_WS_DELETE_REFRESH_TOKEN + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_SIGN_PATH, + websocket_sign_path, + SCHEMA_WS_SIGN_PATH + ) + + await login_flow.async_setup(hass, store_result) + await mfa_setup_flow.async_setup(hass) + + return True + + +class TokenView(HomeAssistantView): + """View to issue or revoke tokens.""" + + url = '/auth/token' + name = 'api:auth:token' + requires_auth = False + cors_allowed = True + + def __init__(self, retrieve_user): + """Initialize the token view.""" + self._retrieve_user = retrieve_user + + @log_invalid_auth + async def post(self, request): + """Grant a token.""" + hass = request.app['hass'] + data = await request.post() + + grant_type = data.get('grant_type') + + # IndieAuth 6.3.5 + # The revocation endpoint is the same as the token endpoint. + # The revocation request includes an additional parameter, + # action=revoke. + if data.get('action') == 'revoke': + return await self._async_handle_revoke_token(hass, data) + + if grant_type == 'authorization_code': + return await self._async_handle_auth_code( + hass, data, str(request[KEY_REAL_IP])) + + if grant_type == 'refresh_token': + return await self._async_handle_refresh_token( + hass, data, str(request[KEY_REAL_IP])) + + return self.json({ + 'error': 'unsupported_grant_type', + }, status_code=400) + + async def _async_handle_revoke_token(self, hass, data): + """Handle revoke token request.""" + # OAuth 2.0 Token Revocation [RFC7009] + # 2.2 The authorization server responds with HTTP status code 200 + # if the token has been revoked successfully or if the client + # submitted an invalid token. + token = data.get('token') + + if token is None: + return web.Response(status=200) + + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + + if refresh_token is None: + return web.Response(status=200) + + await hass.auth.async_remove_refresh_token(refresh_token) + return web.Response(status=200) + + async def _async_handle_auth_code(self, hass, data, remote_addr): + """Handle authorization code request.""" + client_id = data.get('client_id') + if client_id is None or not indieauth.verify_client_id(client_id): + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid client id', + }, status_code=400) + + code = data.get('code') + + if code is None: + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid code', + }, status_code=400) + + user = self._retrieve_user(client_id, RESULT_TYPE_USER, code) + + if user is None or not isinstance(user, User): + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid code', + }, status_code=400) + + # refresh user + user = await hass.auth.async_get_user(user.id) + + if not user.is_active: + return self.json({ + 'error': 'access_denied', + 'error_description': 'User is not active', + }, status_code=403) + + refresh_token = await hass.auth.async_create_refresh_token(user, + client_id) + access_token = hass.auth.async_create_access_token( + refresh_token, remote_addr) + + return self.json({ + 'access_token': access_token, + 'token_type': 'Bearer', + 'refresh_token': refresh_token.token, + 'expires_in': + int(refresh_token.access_token_expiration.total_seconds()), + }) + + async def _async_handle_refresh_token(self, hass, data, remote_addr): + """Handle authorization code request.""" + client_id = data.get('client_id') + if client_id is not None and not indieauth.verify_client_id(client_id): + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid client id', + }, status_code=400) + + token = data.get('refresh_token') + + if token is None: + return self.json({ + 'error': 'invalid_request', + }, status_code=400) + + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + + if refresh_token is None: + return self.json({ + 'error': 'invalid_grant', + }, status_code=400) + + if refresh_token.client_id != client_id: + return self.json({ + 'error': 'invalid_request', + }, status_code=400) + + access_token = hass.auth.async_create_access_token( + refresh_token, remote_addr) + + return self.json({ + 'access_token': access_token, + 'token_type': 'Bearer', + 'expires_in': + int(refresh_token.access_token_expiration.total_seconds()), + }) + + +class LinkUserView(HomeAssistantView): + """View to link existing users to new credentials.""" + + url = '/auth/link_user' + name = 'api:auth:link_user' + + def __init__(self, retrieve_credentials): + """Initialize the link user view.""" + self._retrieve_credentials = retrieve_credentials + + @RequestDataValidator(vol.Schema({ + 'code': str, + 'client_id': str, + })) + async def post(self, request, data): + """Link a user.""" + hass = request.app['hass'] + user = request['hass_user'] + + credentials = self._retrieve_credentials( + data['client_id'], RESULT_TYPE_CREDENTIALS, data['code']) + + if credentials is None: + return self.json_message('Invalid code', status_code=400) + + await hass.auth.async_link_user(user, credentials) + return self.json_message('User linked') + + +@callback +def _create_auth_code_store(): + """Create an in memory store.""" + temp_results = {} + + @callback + def store_result(client_id, result): + """Store flow result and return a code to retrieve it.""" + if isinstance(result, User): + result_type = RESULT_TYPE_USER + elif isinstance(result, Credentials): + result_type = RESULT_TYPE_CREDENTIALS + else: + raise ValueError('result has to be either User or Credentials') + + code = uuid.uuid4().hex + temp_results[(client_id, result_type, code)] = \ + (dt_util.utcnow(), result_type, result) + return code + + @callback + def retrieve_result(client_id, result_type, code): + """Retrieve flow result.""" + key = (client_id, result_type, code) + + if key not in temp_results: + return None + + created, _, result = temp_results.pop(key) + + # OAuth 4.2.1 + # The authorization code MUST expire shortly after it is issued to + # mitigate the risk of leaks. A maximum authorization code lifetime of + # 10 minutes is RECOMMENDED. + if dt_util.utcnow() - created < timedelta(minutes=10): + return result + + return None + + return store_result, retrieve_result + + +@websocket_api.ws_require_user() +@websocket_api.async_response +async def websocket_current_user( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Return the current user.""" + user = connection.user + enabled_modules = await hass.auth.async_get_enabled_mfa(user) + + connection.send_message( + websocket_api.result_message(msg['id'], { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'credentials': [{'auth_provider_type': c.auth_provider_type, + 'auth_provider_id': c.auth_provider_id} + for c in user.credentials], + 'mfa_modules': [{ + 'id': module.id, + 'name': module.name, + 'enabled': module.id in enabled_modules, + } for module in hass.auth.auth_mfa_modules], + })) + + +@websocket_api.ws_require_user() +@websocket_api.async_response +async def websocket_create_long_lived_access_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Create or a long-lived access token.""" + refresh_token = await hass.auth.async_create_refresh_token( + connection.user, + client_name=msg['client_name'], + client_icon=msg.get('client_icon'), + token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=msg['lifespan'])) + + access_token = hass.auth.async_create_access_token( + refresh_token) + + connection.send_message( + websocket_api.result_message(msg['id'], access_token)) + + +@websocket_api.ws_require_user() +@callback +def websocket_refresh_tokens( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Return metadata of users refresh tokens.""" + current_id = connection.refresh_token_id + connection.send_message(websocket_api.result_message(msg['id'], [{ + 'id': refresh.id, + 'client_id': refresh.client_id, + 'client_name': refresh.client_name, + 'client_icon': refresh.client_icon, + 'type': refresh.token_type, + 'created_at': refresh.created_at, + 'is_current': refresh.id == current_id, + 'last_used_at': refresh.last_used_at, + 'last_used_ip': refresh.last_used_ip, + } for refresh in connection.user.refresh_tokens.values()])) + + +@websocket_api.ws_require_user() +@websocket_api.async_response +async def websocket_delete_refresh_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Handle a delete refresh token request.""" + refresh_token = connection.user.refresh_tokens.get(msg['refresh_token_id']) + + if refresh_token is None: + return websocket_api.error_message( + msg['id'], 'invalid_token_id', 'Received invalid token') + + await hass.auth.async_remove_refresh_token(refresh_token) + + connection.send_message( + websocket_api.result_message(msg['id'], {})) + + +@websocket_api.ws_require_user() +@callback +def websocket_sign_path( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Handle a sign path request.""" + connection.send_message(websocket_api.result_message(msg['id'], { + 'path': async_sign_path(hass, connection.refresh_token_id, msg['path'], + timedelta(seconds=msg['expires'])) + })) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/indieauth.py b/homeassistant-0.88.0.dev0/homeassistant/components/auth/indieauth.py new file mode 100644 index 00000000000000..30432a612a4deb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/indieauth.py @@ -0,0 +1,180 @@ +"""Helpers to resolve client ID/secret.""" +import asyncio +from ipaddress import ip_address +from html.parser import HTMLParser +from urllib.parse import urlparse, urljoin + +import aiohttp +from aiohttp.client_exceptions import ClientError + +from homeassistant.util.network import is_local + + +async def verify_redirect_uri(hass, client_id, redirect_uri): + """Verify that the client and redirect uri match.""" + try: + client_id_parts = _parse_client_id(client_id) + except ValueError: + return False + + redirect_parts = _parse_url(redirect_uri) + + # Verify redirect url and client url have same scheme and domain. + is_valid = ( + client_id_parts.scheme == redirect_parts.scheme and + client_id_parts.netloc == redirect_parts.netloc + ) + + if is_valid: + return True + + # IndieAuth 4.2.2 allows for redirect_uri to be on different domain + # but needs to be specified in link tag when fetching `client_id`. + redirect_uris = await fetch_redirect_uris(hass, client_id) + return redirect_uri in redirect_uris + + +class LinkTagParser(HTMLParser): + """Parser to find link tags.""" + + def __init__(self, rel): + """Initialize a link tag parser.""" + super().__init__() + self.rel = rel + self.found = [] + + def handle_starttag(self, tag, attrs): + """Handle finding a start tag.""" + if tag != 'link': + return + + attrs = dict(attrs) + + if attrs.get('rel') == self.rel: + self.found.append(attrs.get('href')) + + +async def fetch_redirect_uris(hass, url): + """Find link tag with redirect_uri values. + + IndieAuth 4.2.2 + + The client SHOULD publish one or more tags or Link HTTP headers with + a rel attribute of redirect_uri at the client_id URL. + + We limit to the first 10kB of the page. + + We do not implement extracting redirect uris from headers. + """ + parser = LinkTagParser('redirect_uri') + chunks = 0 + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=5) as resp: + async for data in resp.content.iter_chunked(1024): + parser.feed(data.decode()) + chunks += 1 + + if chunks == 10: + break + + except (asyncio.TimeoutError, ClientError): + pass + + # Authorization endpoints verifying that a redirect_uri is allowed for use + # by a client MUST look for an exact match of the given redirect_uri in the + # request against the list of redirect_uris discovered after resolving any + # relative URLs. + return [urljoin(url, found) for found in parser.found] + + +def verify_client_id(client_id): + """Verify that the client id is valid.""" + try: + _parse_client_id(client_id) + return True + except ValueError: + return False + + +def _parse_url(url): + """Parse a url in parts and canonicalize according to IndieAuth.""" + parts = urlparse(url) + + # Canonicalize a url according to IndieAuth 3.2. + + # SHOULD convert the hostname to lowercase + parts = parts._replace(netloc=parts.netloc.lower()) + + # If a URL with no path component is ever encountered, + # it MUST be treated as if it had the path /. + if parts.path == '': + parts = parts._replace(path='/') + + return parts + + +def _parse_client_id(client_id): + """Test if client id is a valid URL according to IndieAuth section 3.2. + + https://indieauth.spec.indieweb.org/#client-identifier + """ + parts = _parse_url(client_id) + + # Client identifier URLs + # MUST have either an https or http scheme + if parts.scheme not in ('http', 'https'): + raise ValueError() + + # MUST contain a path component + # Handled by url canonicalization. + + # MUST NOT contain single-dot or double-dot path segments + if any(segment in ('.', '..') for segment in parts.path.split('/')): + raise ValueError( + 'Client ID cannot contain single-dot or double-dot path segments') + + # MUST NOT contain a fragment component + if parts.fragment != '': + raise ValueError('Client ID cannot contain a fragment') + + # MUST NOT contain a username or password component + if parts.username is not None: + raise ValueError('Client ID cannot contain username') + + if parts.password is not None: + raise ValueError('Client ID cannot contain password') + + # MAY contain a port + try: + # parts raises ValueError when port cannot be parsed as int + parts.port + except ValueError: + raise ValueError('Client ID contains invalid port') + + # Additionally, hostnames + # MUST be domain names or a loopback interface and + # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1 + # or IPv6 [::1] + + # We are not goint to follow the spec here. We are going to allow + # any internal network IP to be used inside a client id. + + address = None + + try: + netloc = parts.netloc + + # Strip the [, ] from ipv6 addresses before parsing + if netloc[0] == '[' and netloc[-1] == ']': + netloc = netloc[1:-1] + + address = ip_address(netloc) + except ValueError: + # Not an ip address + pass + + if address is None or is_local(address): + return parts + + raise ValueError('Hostname should be a domain name or local IP address') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/login_flow.py b/homeassistant-0.88.0.dev0/homeassistant/components/auth/login_flow.py new file mode 100644 index 00000000000000..3a51cf8066f783 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/login_flow.py @@ -0,0 +1,247 @@ +"""HTTP views handle login flow. + +# GET /auth/providers + +Return a list of auth providers. Example: + +[ + { + "name": "Local", + "id": null, + "type": "local_provider", + } +] + + +# POST /auth/login_flow + +Create a login flow. Will return the first step of the flow. + +Pass in parameter 'client_id' and 'redirect_url' validate by indieauth. + +Pass in parameter 'handler' to specify the auth provider to use. Auth providers +are identified by type and id. + +And optional parameter 'type' has to set as 'link_user' if login flow used for +link credential to exist user. Default 'type' is 'authorize'. + +{ + "client_id": "https://hassbian.local:8123/", + "handler": ["local_provider", null], + "redirect_url": "https://hassbian.local:8123/", + "type': "authorize" +} + +Return value will be a step in a data entry flow. See the docs for data entry +flow for details. + +{ + "data_schema": [ + {"name": "username", "type": "string"}, + {"name": "password", "type": "string"} + ], + "errors": {}, + "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", + "handler": ["insecure_example", null], + "step_id": "init", + "type": "form" +} + + +# POST /auth/login_flow/{flow_id} + +Progress the flow. Most flows will be 1 page, but could optionally add extra +login challenges, like TFA. Once the flow has finished, the returned step will +have type "create_entry" and "result" key will contain an authorization code. +The authorization code associated with an authorized user by default, it will +associate with an credential if "type" set to "link_user" in +"/auth/login_flow" + +{ + "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", + "handler": ["insecure_example", null], + "result": "411ee2f916e648d691e937ae9344681e", + "title": "Example", + "type": "create_entry", + "version": 1 +} +""" +from aiohttp import web +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.http import KEY_REAL_IP +from homeassistant.components.http.ban import process_wrong_login, \ + log_invalid_auth +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.view import HomeAssistantView +from . import indieauth + + +async def async_setup(hass, store_result): + """Component to allow users to login.""" + hass.http.register_view(AuthProvidersView) + hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow)) + hass.http.register_view( + LoginFlowResourceView(hass.auth.login_flow, store_result)) + + +class AuthProvidersView(HomeAssistantView): + """View to get available auth providers.""" + + url = '/auth/providers' + name = 'api:auth:providers' + requires_auth = False + + async def get(self, request): + """Get available auth providers.""" + hass = request.app['hass'] + + if not hass.components.onboarding.async_is_onboarded(): + return self.json_message( + message='Onboarding not finished', + status_code=400, + message_code='onboarding_required' + ) + + return self.json([{ + 'name': provider.name, + 'id': provider.id, + 'type': provider.type, + } for provider in hass.auth.auth_providers]) + + +def _prepare_result_json(result): + """Convert result to JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + data.pop('result') + data.pop('data') + return data + + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class LoginFlowIndexView(HomeAssistantView): + """View to create a config flow.""" + + url = '/auth/login_flow' + name = 'api:auth:login_flow' + requires_auth = False + + def __init__(self, flow_mgr): + """Initialize the flow manager index view.""" + self._flow_mgr = flow_mgr + + async def get(self, request): + """Do not allow index of flows in progress.""" + return web.Response(status=405) + + @RequestDataValidator(vol.Schema({ + vol.Required('client_id'): str, + vol.Required('handler'): vol.Any(str, list), + vol.Required('redirect_uri'): str, + vol.Optional('type', default='authorize'): str, + })) + @log_invalid_auth + async def post(self, request, data): + """Create a new login flow.""" + if not await indieauth.verify_redirect_uri( + request.app['hass'], data['client_id'], data['redirect_uri']): + return self.json_message('invalid client id or redirect uri', 400) + + if isinstance(data['handler'], list): + handler = tuple(data['handler']) + else: + handler = data['handler'] + + try: + result = await self._flow_mgr.async_init( + handler, context={ + 'ip_address': request[KEY_REAL_IP], + 'credential_only': data.get('type') == 'link_user', + }) + except data_entry_flow.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except data_entry_flow.UnknownStep: + return self.json_message('Handler does not support init', 400) + + return self.json(_prepare_result_json(result)) + + +class LoginFlowResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + url = '/auth/login_flow/{flow_id}' + name = 'api:auth:login_flow:resource' + requires_auth = False + + def __init__(self, flow_mgr, store_result): + """Initialize the login flow resource view.""" + self._flow_mgr = flow_mgr + self._store_result = store_result + + async def get(self, request): + """Do not allow getting status of a flow in progress.""" + return self.json_message('Invalid flow specified', 404) + + @RequestDataValidator(vol.Schema({ + 'client_id': str + }, extra=vol.ALLOW_EXTRA)) + @log_invalid_auth + async def post(self, request, flow_id, data): + """Handle progressing a login flow request.""" + client_id = data.pop('client_id') + + if not indieauth.verify_client_id(client_id): + return self.json_message('Invalid client id', 400) + + try: + # do not allow change ip during login flow + for flow in self._flow_mgr.async_progress(): + if (flow['flow_id'] == flow_id and + flow['context']['ip_address'] != + request.get(KEY_REAL_IP)): + return self.json_message('IP address changed', 400) + + result = await self._flow_mgr.async_configure(flow_id, data) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + except vol.Invalid: + return self.json_message('User input malformed', 400) + + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + # @log_invalid_auth does not work here since it returns HTTP 200 + # need manually log failed login attempts + if (result.get('errors') is not None and + result['errors'].get('base') in ['invalid_auth', + 'invalid_code']): + await process_wrong_login(request) + return self.json(_prepare_result_json(result)) + + result.pop('data') + result['result'] = self._store_result(client_id, result['result']) + + return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant-0.88.0.dev0/homeassistant/components/auth/mfa_setup_flow.py new file mode 100644 index 00000000000000..121d95aede3ecd --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/mfa_setup_flow.py @@ -0,0 +1,134 @@ +"""Helpers to setup multi-factor auth module.""" +import logging + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components import websocket_api +from homeassistant.core import callback, HomeAssistant + +WS_TYPE_SETUP_MFA = 'auth/setup_mfa' +SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SETUP_MFA, + vol.Exclusive('mfa_module_id', 'module_or_flow_id'): str, + vol.Exclusive('flow_id', 'module_or_flow_id'): str, + vol.Optional('user_input'): object, +}) + +WS_TYPE_DEPOSE_MFA = 'auth/depose_mfa' +SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DEPOSE_MFA, + vol.Required('mfa_module_id'): str, +}) + +DATA_SETUP_FLOW_MGR = 'auth_mfa_setup_flow_manager' + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass): + """Init mfa setup flow manager.""" + async def _async_create_setup_flow(handler, context, data): + """Create a setup flow. hanlder is a mfa module.""" + mfa_module = hass.auth.get_auth_mfa_module(handler) + if mfa_module is None: + raise ValueError('Mfa module {} is not found'.format(handler)) + + user_id = data.pop('user_id') + return await mfa_module.async_setup_flow(user_id) + + async def _async_finish_setup_flow(flow, flow_result): + _LOGGER.debug('flow_result: %s', flow_result) + return flow_result + + hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager( + hass, _async_create_setup_flow, _async_finish_setup_flow) + + hass.components.websocket_api.async_register_command( + WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA) + + hass.components.websocket_api.async_register_command( + WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_setup_mfa( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Return a setup flow for mfa auth module.""" + async def async_setup_flow(msg): + """Return a setup flow for mfa auth module.""" + flow_manager = hass.data[DATA_SETUP_FLOW_MGR] + + flow_id = msg.get('flow_id') + if flow_id is not None: + result = await flow_manager.async_configure( + flow_id, msg.get('user_input')) + connection.send_message( + websocket_api.result_message( + msg['id'], _prepare_result_json(result))) + return + + mfa_module_id = msg.get('mfa_module_id') + mfa_module = hass.auth.get_auth_mfa_module(mfa_module_id) + if mfa_module is None: + connection.send_message(websocket_api.error_message( + msg['id'], 'no_module', + 'MFA module {} is not found'.format(mfa_module_id))) + return + + result = await flow_manager.async_init( + mfa_module_id, data={'user_id': connection.user.id}) + + connection.send_message( + websocket_api.result_message( + msg['id'], _prepare_result_json(result))) + + hass.async_create_task(async_setup_flow(msg)) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_depose_mfa( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Remove user from mfa module.""" + async def async_depose(msg): + """Remove user from mfa auth module.""" + mfa_module_id = msg['mfa_module_id'] + try: + await hass.auth.async_disable_user_mfa( + connection.user, msg['mfa_module_id']) + except ValueError as err: + connection.send_message(websocket_api.error_message( + msg['id'], 'disable_failed', + 'Cannot disable MFA Module {}: {}'.format( + mfa_module_id, err))) + return + + connection.send_message( + websocket_api.result_message( + msg['id'], 'done')) + + hass.async_create_task(async_depose(msg)) + + +def _prepare_result_json(result): + """Convert result to JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + return data + + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/auth/strings.json b/homeassistant-0.88.0.dev0/homeassistant/components/auth/strings.json new file mode 100644 index 00000000000000..57f5ed659b08df --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/auth/strings.json @@ -0,0 +1,35 @@ +{ + "mfa_setup":{ + "totp": { + "title": "TOTP", + "step": { + "init": { + "title": "Set up two-factor authentication using TOTP", + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." + } + }, + "error": { + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." + } + }, + "notify": { + "title": "Notify One-Time Password", + "step": { + "init": { + "title": "Set up one-time password delivered by notify component", + "description": "Please select one of the notification services:" + }, + "setup": { + "title": "Verify setup", + "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:" + } + }, + "abort": { + "no_available_service": "No notification services available." + }, + "error": { + "invalid_code": "Invalid code, please try again." + } + } + } +} diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/__init__.py new file mode 100644 index 00000000000000..836901cde303e7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/__init__.py @@ -0,0 +1,442 @@ +""" +Allow to set up simple automation rules via the config file. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/automation/ +""" +import asyncio +from functools import partial +import importlib +import logging + +import voluptuous as vol + +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.core import CoreState +from homeassistant.loader import bind_hass +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID, + EVENT_AUTOMATION_TRIGGERED, ATTR_NAME) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import extract_domain_configs, script, condition +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.dt import utcnow +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'automation' +DEPENDENCIES = ['group'] +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +GROUP_NAME_ALL_AUTOMATIONS = 'all automations' + +CONF_ALIAS = 'alias' +CONF_HIDE_ENTITY = 'hide_entity' + +CONF_CONDITION = 'condition' +CONF_ACTION = 'action' +CONF_TRIGGER = 'trigger' +CONF_CONDITION_TYPE = 'condition_type' +CONF_INITIAL_STATE = 'initial_state' + +CONDITION_USE_TRIGGER_VALUES = 'use_trigger_values' +CONDITION_TYPE_AND = 'and' +CONDITION_TYPE_OR = 'or' + +DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND +DEFAULT_HIDE_ENTITY = False +DEFAULT_INITIAL_STATE = True + +ATTR_LAST_TRIGGERED = 'last_triggered' +ATTR_VARIABLES = 'variables' +SERVICE_TRIGGER = 'trigger' + +_LOGGER = logging.getLogger(__name__) + + +def _platform_validator(config): + """Validate it is a valid platform.""" + try: + platform = importlib.import_module( + 'homeassistant.components.automation.{}'.format( + config[CONF_PLATFORM])) + except ImportError: + raise vol.Invalid('Invalid platform specified') from None + + return platform.TRIGGER_SCHEMA(config) + + +_TRIGGER_SCHEMA = vol.All( + cv.ensure_list, + [ + vol.All( + vol.Schema({ + vol.Required(CONF_PLATFORM): str + }, extra=vol.ALLOW_EXTRA), + _platform_validator + ), + ] +) + +_CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) + +PLATFORM_SCHEMA = vol.Schema({ + # str on purpose + CONF_ID: str, + CONF_ALIAS: cv.string, + vol.Optional(CONF_INITIAL_STATE): cv.boolean, + vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, + vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, +}) + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +TRIGGER_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_VARIABLES, default={}): dict, +}) + +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + + +@bind_hass +def is_on(hass, entity_id): + """ + Return true if specified automation entity_id is on. + + Async friendly. + """ + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass, config): + """Set up the automation.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, + group_name=GROUP_NAME_ALL_AUTOMATIONS) + + await _async_process_config(hass, config, component) + + async def trigger_service_handler(service_call): + """Handle automation triggers.""" + tasks = [] + for entity in component.async_extract_from_service(service_call): + tasks.append(entity.async_trigger( + service_call.data.get(ATTR_VARIABLES), + skip_condition=True, + context=service_call.context)) + + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + async def turn_onoff_service_handler(service_call): + """Handle automation turn on/off service calls.""" + tasks = [] + method = 'async_{}'.format(service_call.service) + for entity in component.async_extract_from_service(service_call): + tasks.append(getattr(entity, method)()) + + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + async def toggle_service_handler(service_call): + """Handle automation toggle service calls.""" + tasks = [] + for entity in component.async_extract_from_service(service_call): + if entity.is_on: + tasks.append(entity.async_turn_off()) + else: + tasks.append(entity.async_turn_on()) + + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + async def reload_service_handler(service_call): + """Remove all automations and load new ones from config.""" + conf = await component.async_prepare_reload() + if conf is None: + return + await _async_process_config(hass, conf, component) + + hass.services.async_register( + DOMAIN, SERVICE_TRIGGER, trigger_service_handler, + schema=TRIGGER_SERVICE_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, reload_service_handler, + schema=RELOAD_SERVICE_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_TOGGLE, toggle_service_handler, + schema=SERVICE_SCHEMA) + + for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF): + hass.services.async_register( + DOMAIN, service, turn_onoff_service_handler, + schema=SERVICE_SCHEMA) + + return True + + +class AutomationEntity(ToggleEntity, RestoreEntity): + """Entity to show status of entity.""" + + def __init__(self, automation_id, name, async_attach_triggers, cond_func, + async_action, hidden, initial_state): + """Initialize an automation entity.""" + self._id = automation_id + self._name = name + self._async_attach_triggers = async_attach_triggers + self._async_detach_triggers = None + self._cond_func = cond_func + self._async_action = async_action + self._last_triggered = None + self._hidden = hidden + self._initial_state = initial_state + + @property + def name(self): + """Name of the automation.""" + return self._name + + @property + def should_poll(self): + """No polling needed for automation entities.""" + return False + + @property + def state_attributes(self): + """Return the entity state attributes.""" + return { + ATTR_LAST_TRIGGERED: self._last_triggered + } + + @property + def hidden(self) -> bool: + """Return True if the automation entity should be hidden from UIs.""" + return self._hidden + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._async_detach_triggers is not None + + async def async_added_to_hass(self) -> None: + """Startup with initial state or previous state.""" + await super().async_added_to_hass() + if self._initial_state is not None: + enable_automation = self._initial_state + _LOGGER.debug("Automation %s initial state %s from config " + "initial_state", self.entity_id, enable_automation) + else: + state = await self.async_get_last_state() + if state: + enable_automation = state.state == STATE_ON + self._last_triggered = state.attributes.get('last_triggered') + _LOGGER.debug("Automation %s initial state %s from recorder " + "last state %s", self.entity_id, + enable_automation, state) + else: + enable_automation = DEFAULT_INITIAL_STATE + _LOGGER.debug("Automation %s initial state %s from default " + "initial state", self.entity_id, + enable_automation) + + if not enable_automation: + return + + # HomeAssistant is starting up + if self.hass.state == CoreState.not_running: + async def async_enable_automation(event): + """Start automation on startup.""" + await self.async_enable() + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_enable_automation) + + # HomeAssistant is running + else: + await self.async_enable() + + async def async_turn_on(self, **kwargs) -> None: + """Turn the entity on and update the state.""" + if self.is_on: + return + + await self.async_enable() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if not self.is_on: + return + + self._async_detach_triggers() + self._async_detach_triggers = None + await self.async_update_ha_state() + + async def async_trigger(self, variables, skip_condition=False, + context=None): + """Trigger automation. + + This method is a coroutine. + """ + if skip_condition or self._cond_func(variables): + self.async_set_context(context) + self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { + ATTR_NAME: self._name, + ATTR_ENTITY_ID: self.entity_id, + }, context=context) + await self._async_action(self.entity_id, variables, context) + self._last_triggered = utcnow() + await self.async_update_ha_state() + + async def async_will_remove_from_hass(self): + """Remove listeners when removing automation from HASS.""" + await super().async_will_remove_from_hass() + await self.async_turn_off() + + async def async_enable(self): + """Enable this automation entity. + + This method is a coroutine. + """ + if self.is_on: + return + + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger) + await self.async_update_ha_state() + + @property + def device_state_attributes(self): + """Return automation attributes.""" + if self._id is None: + return None + + return { + CONF_ID: self._id + } + + +async def _async_process_config(hass, config, component): + """Process config and add automations. + + This method is a coroutine. + """ + entities = [] + + for config_key in extract_domain_configs(config, DOMAIN): + conf = config[config_key] + + for list_no, config_block in enumerate(conf): + automation_id = config_block.get(CONF_ID) + name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, + list_no) + + hidden = config_block[CONF_HIDE_ENTITY] + initial_state = config_block.get(CONF_INITIAL_STATE) + + action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), + name) + + if CONF_CONDITION in config_block: + cond_func = _async_process_if(hass, config, config_block) + + if cond_func is None: + continue + else: + def cond_func(variables): + """Condition will always pass.""" + return True + + async_attach_triggers = partial( + _async_process_trigger, hass, config, + config_block.get(CONF_TRIGGER, []), name + ) + entity = AutomationEntity( + automation_id, name, async_attach_triggers, cond_func, action, + hidden, initial_state) + + entities.append(entity) + + if entities: + await component.async_add_entities(entities) + + +def _async_get_action(hass, config, name): + """Return an action based on a configuration.""" + script_obj = script.Script(hass, config, name) + + async def action(entity_id, variables, context): + """Execute an action.""" + _LOGGER.info('Executing %s', name) + + try: + await script_obj.async_run(variables, context) + except Exception as err: # pylint: disable=broad-except + script_obj.async_log_exception( + _LOGGER, + 'Error while executing automation {}'.format(entity_id), err) + + return action + + +def _async_process_if(hass, config, p_config): + """Process if checks.""" + if_configs = p_config.get(CONF_CONDITION) + + checks = [] + for if_config in if_configs: + try: + checks.append(condition.async_from_config(if_config, False)) + except HomeAssistantError as ex: + _LOGGER.warning('Invalid condition: %s', ex) + return None + + def if_action(variables=None): + """AND all conditions.""" + return all(check(hass, variables) for check in checks) + + return if_action + + +async def _async_process_trigger(hass, config, trigger_configs, name, action): + """Set up the triggers. + + This method is a coroutine. + """ + removes = [] + info = { + 'name': name + } + + for conf in trigger_configs: + platform = await async_prepare_setup_platform( + hass, config, DOMAIN, conf.get(CONF_PLATFORM)) + + if platform is None: + return None + + remove = await platform.async_trigger(hass, conf, action, info) + + if not remove: + _LOGGER.error("Error setting up trigger %s", name) + continue + + _LOGGER.info("Initialized trigger %s", name) + removes.append(remove) + + if not removes: + return None + + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/event.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/event.py new file mode 100644 index 00000000000000..ec47479eac8492 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/event.py @@ -0,0 +1,53 @@ +""" +Offer event listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#event-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import config_validation as cv + +CONF_EVENT_TYPE = 'event_type' +CONF_EVENT_DATA = 'event_data' + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'event', + vol.Required(CONF_EVENT_TYPE): cv.string, + vol.Optional(CONF_EVENT_DATA): dict, +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for events based on configuration.""" + event_type = config.get(CONF_EVENT_TYPE) + event_data_schema = vol.Schema( + config.get(CONF_EVENT_DATA), + extra=vol.ALLOW_EXTRA) if config.get(CONF_EVENT_DATA) else None + + @callback + def handle_event(event): + """Listen for events and calls the action when data matches.""" + if event_data_schema: + # Check that the event data matches the configured + # schema if one was provided + try: + event_data_schema(event.data) + except vol.Invalid: + # If event data doesn't match requested schema, skip event + return + + hass.async_run_job(action({ + 'trigger': { + 'platform': 'event', + 'event': event, + }, + }, context=event.context)) + + return hass.bus.async_listen(event_type, handle_event) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/geo_location.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/geo_location.py new file mode 100644 index 00000000000000..33ef00da38068c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/geo_location.py @@ -0,0 +1,74 @@ +""" +Offer geolocation automation rules. + +For more details about this automation trigger, please refer to the +documentation at +https://home-assistant.io/docs/automation/trigger/#geolocation-trigger +""" +import voluptuous as vol + +from homeassistant.components.geo_location import DOMAIN +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE, EVENT_STATE_CHANGED) +from homeassistant.helpers import ( + condition, config_validation as cv) +from homeassistant.helpers.config_validation import entity_domain + +EVENT_ENTER = 'enter' +EVENT_LEAVE = 'leave' +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'geo_location', + vol.Required(CONF_SOURCE): cv.string, + vol.Required(CONF_ZONE): entity_domain('zone'), + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): + vol.Any(EVENT_ENTER, EVENT_LEAVE), +}) + + +def source_match(state, source): + """Check if the state matches the provided source.""" + return state and state.attributes.get('source') == source + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + source = config.get(CONF_SOURCE).lower() + zone_entity_id = config.get(CONF_ZONE) + trigger_event = config.get(CONF_EVENT) + + @callback + def state_change_listener(event): + """Handle specific state changes.""" + # Skip if the event is not a geo_location entity. + if not event.data.get('entity_id').startswith(DOMAIN): + return + # Skip if the event's source does not match the trigger's source. + from_state = event.data.get('old_state') + to_state = event.data.get('new_state') + if not source_match(from_state, source) \ + and not source_match(to_state, source): + return + + zone_state = hass.states.get(zone_entity_id) + from_match = condition.zone(hass, zone_state, from_state) + to_match = condition.zone(hass, zone_state, to_state) + + # pylint: disable=too-many-boolean-expressions + if trigger_event == EVENT_ENTER and not from_match and to_match or \ + trigger_event == EVENT_LEAVE and from_match and not to_match: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'geo_location', + 'source': source, + 'entity_id': event.data.get('entity_id'), + 'from_state': from_state, + 'to_state': to_state, + 'zone': zone_state, + 'event': trigger_event, + }, + }, context=event.context)) + + return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/homeassistant.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/homeassistant.py new file mode 100644 index 00000000000000..6d7a44291c99ae --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/homeassistant.py @@ -0,0 +1,53 @@ +""" +Offer Home Assistant core automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/components/automation/#homeassistant-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback, CoreState +from homeassistant.const import ( + CONF_PLATFORM, CONF_EVENT, EVENT_HOMEASSISTANT_STOP) + +EVENT_START = 'start' +EVENT_SHUTDOWN = 'shutdown' +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'homeassistant', + vol.Required(CONF_EVENT): vol.Any(EVENT_START, EVENT_SHUTDOWN), +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for events based on configuration.""" + event = config.get(CONF_EVENT) + + if event == EVENT_SHUTDOWN: + @callback + def hass_shutdown(event): + """Execute when Home Assistant is shutting down.""" + hass.async_run_job(action({ + 'trigger': { + 'platform': 'homeassistant', + 'event': event, + }, + }, context=event.context)) + + return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + hass_shutdown) + + # Automation are enabled while hass is starting up, fire right away + # Check state because a config reload shouldn't trigger it. + if hass.state == CoreState.starting: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'homeassistant', + 'event': event, + }, + })) + + return lambda: None diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/litejet.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/litejet.py new file mode 100644 index 00000000000000..70e01174078a7b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/litejet.py @@ -0,0 +1,98 @@ +""" +Trigger an automation when a LiteJet switch is released. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/automation.litejet/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_PLATFORM +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import track_point_in_utc_time + +DEPENDENCIES = ['litejet'] + +_LOGGER = logging.getLogger(__name__) + +CONF_NUMBER = 'number' +CONF_HELD_MORE_THAN = 'held_more_than' +CONF_HELD_LESS_THAN = 'held_less_than' + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'litejet', + vol.Required(CONF_NUMBER): cv.positive_int, + vol.Optional(CONF_HELD_MORE_THAN): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_HELD_LESS_THAN): + vol.All(cv.time_period, cv.positive_timedelta) +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for events based on configuration.""" + number = config.get(CONF_NUMBER) + held_more_than = config.get(CONF_HELD_MORE_THAN) + held_less_than = config.get(CONF_HELD_LESS_THAN) + pressed_time = None + cancel_pressed_more_than = None + + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, { + 'trigger': { + CONF_PLATFORM: 'litejet', + CONF_NUMBER: number, + CONF_HELD_MORE_THAN: held_more_than, + CONF_HELD_LESS_THAN: held_less_than + }, + }) + + # held_more_than and held_less_than: trigger on released (if in time range) + # held_more_than: trigger after pressed with calculation + # held_less_than: trigger on released with calculation + # neither: trigger on pressed + + @callback + def pressed_more_than_satisfied(now): + """Handle the LiteJet's switch's button pressed >= held_more_than.""" + call_action() + + def pressed(): + """Handle the press of the LiteJet switch's button.""" + nonlocal cancel_pressed_more_than, pressed_time + nonlocal held_less_than, held_more_than + pressed_time = dt_util.utcnow() + if held_more_than is None and held_less_than is None: + hass.add_job(call_action) + if held_more_than is not None and held_less_than is None: + cancel_pressed_more_than = track_point_in_utc_time( + hass, + pressed_more_than_satisfied, + dt_util.utcnow() + held_more_than) + + def released(): + """Handle the release of the LiteJet switch's button.""" + nonlocal cancel_pressed_more_than, pressed_time + nonlocal held_less_than, held_more_than + # pylint: disable=not-callable + if cancel_pressed_more_than is not None: + cancel_pressed_more_than() + cancel_pressed_more_than = None + held_time = dt_util.utcnow() - pressed_time + if held_less_than is not None and held_time < held_less_than: + if held_more_than is None or held_time > held_more_than: + hass.add_job(call_action) + + hass.data['litejet_system'].on_switch_pressed(number, pressed) + hass.data['litejet_system'].on_switch_released(number, released) + + def async_remove(): + """Remove all subscriptions used for this trigger.""" + return + + return async_remove diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/mqtt.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/mqtt.py new file mode 100644 index 00000000000000..1fa0d540610578 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/mqtt.py @@ -0,0 +1,58 @@ +""" +Offer MQTT listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#mqtt-trigger +""" +import json + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components import mqtt +from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['mqtt'] + +CONF_ENCODING = 'encoding' +CONF_TOPIC = 'topic' +DEFAULT_ENCODING = 'utf-8' + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): mqtt.DOMAIN, + vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + topic = config[CONF_TOPIC] + payload = config.get(CONF_PAYLOAD) + encoding = config[CONF_ENCODING] or None + + @callback + def mqtt_automation_listener(msg_topic, msg_payload, qos): + """Listen for MQTT messages.""" + if payload is None or payload == msg_payload: + data = { + 'platform': 'mqtt', + 'topic': msg_topic, + 'payload': msg_payload, + 'qos': qos, + } + + try: + data['payload_json'] = json.loads(msg_payload) + except ValueError: + pass + + hass.async_run_job(action, { + 'trigger': data + }) + + remove = await mqtt.async_subscribe( + hass, topic, mqtt_automation_listener, encoding=encoding) + return remove diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/numeric_state.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/numeric_state.py new file mode 100644 index 00000000000000..aa51e6310269f9 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/numeric_state.py @@ -0,0 +1,103 @@ +""" +Offer numeric state listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#numeric-state-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, + CONF_BELOW, CONF_ABOVE, CONF_FOR) +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) +from homeassistant.helpers import condition, config_validation as cv + +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) + +_LOGGER = logging.getLogger(__name__) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + entity_id = config.get(CONF_ENTITY_ID) + below = config.get(CONF_BELOW) + above = config.get(CONF_ABOVE) + time_delta = config.get(CONF_FOR) + value_template = config.get(CONF_VALUE_TEMPLATE) + unsub_track_same = {} + entities_triggered = set() + + if value_template is not None: + value_template.hass = hass + + @callback + def check_numeric_state(entity, from_s, to_s): + """Return True if criteria are now met.""" + if to_s is None: + return False + + variables = { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + } + } + return condition.async_numeric_state( + hass, to_s, below, above, value_template, variables) + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action({ + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + }, context=to_s.context)) + + matching = check_numeric_state(entity, from_s, to_s) + + if not matching: + entities_triggered.discard(entity) + elif entity not in entities_triggered: + entities_triggered.add(entity) + + if time_delta: + unsub_track_same[entity] = async_track_same_state( + hass, time_delta, call_action, entity_ids=entity_id, + async_check_same_func=check_numeric_state) + else: + call_action() + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + for async_remove in unsub_track_same.values(): + async_remove() + unsub_track_same.clear() + + return async_remove diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/automation/services.yaml new file mode 100644 index 00000000000000..90f660367069c4 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/services.yaml @@ -0,0 +1,32 @@ +# Describes the format for available automation services + +turn_on: + description: Enable an automation. + fields: + entity_id: + description: Name of the automation to turn on. + example: 'automation.notify_home' + +turn_off: + description: Disable an automation. + fields: + entity_id: + description: Name of the automation to turn off. + example: 'automation.notify_home' + +toggle: + description: Toggle an automation. + fields: + entity_id: + description: Name of the automation to toggle on/off. + example: 'automation.notify_home' + +trigger: + description: Trigger the action of an automation. + fields: + entity_id: + description: Name of the automation to trigger. + example: 'automation.notify_home' + +reload: + description: Reload the automation configuration. diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/state.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/state.py new file mode 100644 index 00000000000000..4e47026d8d16d7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/state.py @@ -0,0 +1,79 @@ +""" +Offer state listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#state-trigger +""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) +import homeassistant.helpers.config_validation as cv + +CONF_ENTITY_ID = 'entity_id' +CONF_FROM = 'from' +CONF_TO = 'to' + +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): str, + vol.Optional(CONF_TO): str, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.key_dependency(CONF_FOR, CONF_TO)) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + entity_id = config.get(CONF_ENTITY_ID) + from_state = config.get(CONF_FROM, MATCH_ALL) + to_state = config.get(CONF_TO, MATCH_ALL) + time_delta = config.get(CONF_FOR) + match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) + unsub_track_same = {} + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action({ + 'trigger': { + 'platform': 'state', + 'entity_id': entity, + 'from_state': from_s, + 'to_state': to_s, + 'for': time_delta, + } + }, context=to_s.context)) + + # Ignore changes to state attributes if from/to is in use + if (not match_all and from_s is not None and to_s is not None and + from_s.state == to_s.state): + return + + if not time_delta: + call_action() + return + + unsub_track_same[entity] = async_track_same_state( + hass, time_delta, call_action, + lambda _, _2, to_state: to_state.state == to_s.state, + entity_ids=entity_id) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener, from_state, to_state) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + for async_remove in unsub_track_same.values(): + async_remove() + unsub_track_same.clear() + + return async_remove diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/sun.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/sun.py new file mode 100644 index 00000000000000..509195689a15ae --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/sun.py @@ -0,0 +1,45 @@ +""" +Offer sun based automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#sun-trigger +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_OFFSET, CONF_PLATFORM, SUN_EVENT_SUNRISE) +from homeassistant.helpers.event import async_track_sunrise, async_track_sunset +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'sun', + vol.Required(CONF_EVENT): cv.sun_event, + vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for events based on configuration.""" + event = config.get(CONF_EVENT) + offset = config.get(CONF_OFFSET) + + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, { + 'trigger': { + 'platform': 'sun', + 'event': event, + 'offset': offset, + }, + }) + + if event == SUN_EVENT_SUNRISE: + return async_track_sunrise(hass, call_action, offset) + return async_track_sunset(hass, call_action, offset) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/template.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/template.py new file mode 100644 index 00000000000000..347b3f94e7da39 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/template.py @@ -0,0 +1,42 @@ +""" +Offer template automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#template-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM +from homeassistant.helpers.event import async_track_template +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'template', + vol.Required(CONF_VALUE_TEMPLATE): cv.template, +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + value_template = config.get(CONF_VALUE_TEMPLATE) + value_template.hass = hass + + @callback + def template_listener(entity_id, from_s, to_s): + """Listen for state changes and calls action.""" + hass.async_run_job(action({ + 'trigger': { + 'platform': 'template', + 'entity_id': entity_id, + 'from_state': from_s, + 'to_state': to_s, + }, + }, context=to_s.context)) + + return async_track_template(hass, value_template, template_listener) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/time.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/time.py new file mode 100644 index 00000000000000..d57e190490fe90 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/time.py @@ -0,0 +1,40 @@ +""" +Offer time listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#time-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_AT, CONF_PLATFORM +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_change + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'time', + vol.Required(CONF_AT): cv.time, +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + at_time = config.get(CONF_AT) + hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second + + @callback + def time_automation_listener(now): + """Listen for time changes and calls action.""" + hass.async_run_job(action, { + 'trigger': { + 'platform': 'time', + 'now': now, + }, + }) + + return async_track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/time_pattern.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/time_pattern.py new file mode 100644 index 00000000000000..8b6e907f7b8d1e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/time_pattern.py @@ -0,0 +1,53 @@ +""" +Offer time listening automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#time-trigger +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_change + +CONF_HOURS = 'hours' +CONF_MINUTES = 'minutes' +CONF_SECONDS = 'seconds' + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'time_pattern', + CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), +}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS)) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + hours = config.get(CONF_HOURS) + minutes = config.get(CONF_MINUTES) + seconds = config.get(CONF_SECONDS) + + # If larger units are specified, default the smaller units to zero + if minutes is None and hours is not None: + minutes = 0 + if seconds is None and minutes is not None: + seconds = 0 + + @callback + def time_automation_listener(now): + """Listen for time changes and calls action.""" + hass.async_run_job(action, { + 'trigger': { + 'platform': 'time_pattern', + 'now': now, + }, + }) + + return async_track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/webhook.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/webhook.py new file mode 100644 index 00000000000000..f4afc8a601af24 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/webhook.py @@ -0,0 +1,56 @@ +""" +Offer webhook triggered automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#webhook-trigger +""" +from functools import partial +import logging + +from aiohttp import hdrs +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN as AUTOMATION_DOMAIN + +DEPENDENCIES = ('webhook',) + +_LOGGER = logging.getLogger(__name__) + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'webhook', + vol.Required(CONF_WEBHOOK_ID): cv.string, +}) + + +async def _handle_webhook(action, hass, webhook_id, request): + """Handle incoming webhook.""" + result = { + 'platform': 'webhook', + 'webhook_id': webhook_id, + } + + if 'json' in request.headers.get(hdrs.CONTENT_TYPE, ''): + result['json'] = await request.json() + else: + result['data'] = await request.post() + + hass.async_run_job(action, {'trigger': result}) + + +async def async_trigger(hass, config, action, automation_info): + """Trigger based on incoming webhooks.""" + webhook_id = config.get(CONF_WEBHOOK_ID) + hass.components.webhook.async_register( + AUTOMATION_DOMAIN, automation_info['name'], + webhook_id, partial(_handle_webhook, action)) + + @callback + def unregister(): + """Unregister webhook.""" + hass.components.webhook.async_unregister(webhook_id) + + return unregister diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/automation/zone.py b/homeassistant-0.88.0.dev0/homeassistant/components/automation/zone.py new file mode 100644 index 00000000000000..0c3c0941a9e318 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/automation/zone.py @@ -0,0 +1,64 @@ +""" +Offer zone automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/docs/automation/trigger/#zone-trigger +""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_ENTITY_ID, CONF_ZONE, MATCH_ALL, CONF_PLATFORM) +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers import ( + condition, config_validation as cv, location) + +EVENT_ENTER = 'enter' +EVENT_LEAVE = 'leave' +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'zone', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ZONE): cv.entity_id, + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): + vol.Any(EVENT_ENTER, EVENT_LEAVE), +}) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + entity_id = config.get(CONF_ENTITY_ID) + zone_entity_id = config.get(CONF_ZONE) + event = config.get(CONF_EVENT) + + @callback + def zone_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + if from_s and not location.has_location(from_s) or \ + not location.has_location(to_s): + return + + zone_state = hass.states.get(zone_entity_id) + if from_s: + from_match = condition.zone(hass, zone_state, from_s) + else: + from_match = False + to_match = condition.zone(hass, zone_state, to_s) + + # pylint: disable=too-many-boolean-expressions + if event == EVENT_ENTER and not from_match and to_match or \ + event == EVENT_LEAVE and from_match and not to_match: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'zone', + 'entity_id': entity, + 'from_state': from_s, + 'to_state': to_s, + 'zone': zone_state, + 'event': event, + }, + }, context=to_s.context)) + + return async_track_state_change(hass, entity_id, zone_automation_listener, + MATCH_ALL, MATCH_ALL) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/axis/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/axis/__init__.py new file mode 100644 index 00000000000000..fd2e603445c387 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/axis/__init__.py @@ -0,0 +1,267 @@ +""" +Support for Axis devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/axis/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_AXIS +from homeassistant.const import ( + ATTR_LOCATION, CONF_EVENT, CONF_HOST, CONF_INCLUDE, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.util.json import load_json, save_json + +REQUIREMENTS = ['axis==16'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'axis' +CONFIG_FILE = 'axis.conf' + +EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', + 'daynight', 'tampering', 'input'] + +PLATFORMS = ['camera'] + +AXIS_INCLUDE = EVENT_TYPES + PLATFORMS + +AXIS_DEFAULT_HOST = '192.168.0.90' +AXIS_DEFAULT_USERNAME = 'root' +AXIS_DEFAULT_PASSWORD = 'pass' +DEFAULT_PORT = 80 + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_INCLUDE): + vol.All(cv.ensure_list, [vol.In(AXIS_INCLUDE)]), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(ATTR_LOCATION, default=''): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys(DEVICE_SCHEMA), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_VAPIX_CALL = 'vapix_call' +SERVICE_VAPIX_CALL_RESPONSE = 'vapix_call_response' +SERVICE_CGI = 'cgi' +SERVICE_ACTION = 'action' +SERVICE_PARAM = 'param' +SERVICE_DEFAULT_CGI = 'param.cgi' +SERVICE_DEFAULT_ACTION = 'update' + +SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(SERVICE_PARAM): cv.string, + vol.Optional(SERVICE_CGI, default=SERVICE_DEFAULT_CGI): cv.string, + vol.Optional(SERVICE_ACTION, default=SERVICE_DEFAULT_ACTION): cv.string, +}) + + +def request_configuration(hass, config, name, host, serialnumber): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + def configuration_callback(callback_data): + """Call when configuration is submitted.""" + if CONF_INCLUDE not in callback_data: + configurator.notify_errors( + request_id, "Functionality mandatory.") + return False + + callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() + callback_data[CONF_HOST] = host + + if CONF_NAME not in callback_data: + callback_data[CONF_NAME] = name + + try: + device_config = DEVICE_SCHEMA(callback_data) + except vol.Invalid: + configurator.notify_errors( + request_id, "Bad input, please check spelling.") + return False + + if setup_device(hass, config, device_config): + config_file = load_json(hass.config.path(CONFIG_FILE)) + config_file[serialnumber] = dict(device_config) + save_json(hass.config.path(CONFIG_FILE), config_file) + configurator.request_done(request_id) + else: + configurator.notify_errors( + request_id, "Failed to register, please try again.") + return False + + title = '{} ({})'.format(name, host) + request_id = configurator.request_config( + title, configuration_callback, + description='Functionality: ' + str(AXIS_INCLUDE), + entity_picture="/static/images/logo_axis.png", + link_name='Axis platform documentation', + link_url='https://home-assistant.io/components/axis/', + submit_caption="Confirm", + fields=[ + {'id': CONF_NAME, + 'name': "Device name", + 'type': 'text'}, + {'id': CONF_USERNAME, + 'name': "User name", + 'type': 'text'}, + {'id': CONF_PASSWORD, + 'name': 'Password', + 'type': 'password'}, + {'id': CONF_INCLUDE, + 'name': "Device functionality (space separated list)", + 'type': 'text'}, + {'id': ATTR_LOCATION, + 'name': "Physical location of device (optional)", + 'type': 'text'}, + {'id': CONF_PORT, + 'name': "HTTP port (default=80)", + 'type': 'number'}, + {'id': CONF_TRIGGER_TIME, + 'name': "Sensor update interval (optional)", + 'type': 'number'}, + ] + ) + + +def setup(hass, config): + """Set up for Axis devices.""" + hass.data[DOMAIN] = {} + + def _shutdown(call): + """Stop the event stream on shutdown.""" + for serialnumber, device in hass.data[DOMAIN].items(): + _LOGGER.info("Stopping event stream for %s.", serialnumber) + device.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + def axis_device_discovered(service, discovery_info): + """Call when axis devices has been found.""" + host = discovery_info[CONF_HOST] + name = discovery_info['hostname'] + serialnumber = discovery_info['properties']['macaddress'] + + if serialnumber not in hass.data[DOMAIN]: + config_file = load_json(hass.config.path(CONFIG_FILE)) + if serialnumber in config_file: + # Device config previously saved to file + try: + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = host + except vol.Invalid as err: + _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) + return False + if not setup_device(hass, config, device_config): + _LOGGER.error( + "Couldn't set up %s", device_config[CONF_NAME]) + else: + # New device, create configuration request for UI + request_configuration(hass, config, name, host, serialnumber) + else: + # Device already registered, but on a different IP + device = hass.data[DOMAIN][serialnumber] + device.config.host = host + dispatcher_send(hass, DOMAIN + '_' + device.name + '_new_ip', host) + + # Register discovery service + discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) + + if DOMAIN in config: + for device in config[DOMAIN]: + device_config = config[DOMAIN][device] + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn't set up %s", device_config[CONF_NAME]) + + def vapix_service(call): + """Service to send a message.""" + for device in hass.data[DOMAIN].values(): + if device.name == call.data[CONF_NAME]: + response = device.vapix.do_request( + call.data[SERVICE_CGI], + call.data[SERVICE_ACTION], + call.data[SERVICE_PARAM]) + hass.bus.fire(SERVICE_VAPIX_CALL_RESPONSE, response) + return True + _LOGGER.info("Couldn't find device %s", call.data[CONF_NAME]) + return False + + # Register service with Home Assistant. + hass.services.register( + DOMAIN, SERVICE_VAPIX_CALL, vapix_service, schema=SERVICE_SCHEMA) + return True + + +def setup_device(hass, config, device_config): + """Set up an Axis device.""" + import axis + + def signal_callback(action, event): + """Call to configure events when initialized on event stream.""" + if action == 'add': + event_config = { + CONF_EVENT: event, + CONF_NAME: device_config[CONF_NAME], + ATTR_LOCATION: device_config[ATTR_LOCATION], + CONF_TRIGGER_TIME: device_config[CONF_TRIGGER_TIME] + } + component = event.event_platform + discovery.load_platform( + hass, component, DOMAIN, event_config, config) + + event_types = [ + event + for event in device_config[CONF_INCLUDE] + if event in EVENT_TYPES + ] + + device = axis.AxisDevice( + loop=hass.loop, host=device_config[CONF_HOST], + username=device_config[CONF_USERNAME], + password=device_config[CONF_PASSWORD], + port=device_config[CONF_PORT], web_proto='http', + event_types=event_types, signal=signal_callback) + + try: + hass.data[DOMAIN][device.vapix.serial_number] = device + + except axis.Unauthorized: + _LOGGER.error("Credentials for %s are faulty", + device_config[CONF_HOST]) + return False + + except axis.RequestError: + return False + + device.name = device_config[CONF_NAME] + + for component in device_config[CONF_INCLUDE]: + if component == 'camera': + camera_config = { + CONF_NAME: device_config[CONF_NAME], + CONF_HOST: device_config[CONF_HOST], + CONF_PORT: device_config[CONF_PORT], + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD] + } + discovery.load_platform( + hass, component, DOMAIN, camera_config, config) + + if event_types: + hass.add_job(device.start) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/axis/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/axis/binary_sensor.py new file mode 100644 index 00000000000000..671bbc730d0faf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/axis/binary_sensor.py @@ -0,0 +1,91 @@ +""" +Support for Axis binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.axis/ +""" +from datetime import timedelta +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ( + ATTR_LOCATION, CONF_EVENT, CONF_NAME, CONF_TRIGGER_TIME) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +DEPENDENCIES = ['axis'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Axis binary devices.""" + add_entities([AxisBinarySensor(discovery_info)], True) + + +class AxisBinarySensor(BinarySensorDevice): + """Representation of a binary Axis event.""" + + def __init__(self, event_config): + """Initialize the Axis binary sensor.""" + self.axis_event = event_config[CONF_EVENT] + self.device_name = event_config[CONF_NAME] + self.location = event_config[ATTR_LOCATION] + self.delay = event_config[CONF_TRIGGER_TIME] + self.remove_timer = None + + async def async_added_to_hass(self): + """Subscribe sensors events.""" + self.axis_event.callback = self._update_callback + + def _update_callback(self): + """Update the sensor's state, if needed.""" + if self.remove_timer is not None: + self.remove_timer() + self.remove_timer = None + + if self.delay == 0 or self.is_on: + self.schedule_update_ha_state() + else: # Run timer to delay updating the state + @callback + def _delay_update(now): + """Timer callback for sensor update.""" + _LOGGER.debug("%s called delayed (%s sec) update", + self.name, self.delay) + self.async_schedule_update_ha_state() + self.remove_timer = None + + self.remove_timer = async_track_point_in_utc_time( + self.hass, _delay_update, + utcnow() + timedelta(seconds=self.delay)) + + @property + def is_on(self): + """Return true if event is active.""" + return self.axis_event.is_tripped + + @property + def name(self): + """Return the name of the event.""" + return '{}_{}_{}'.format( + self.device_name, self.axis_event.event_type, self.axis_event.id) + + @property + def device_class(self): + """Return the class of the event.""" + return self.axis_event.event_class + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the event.""" + attr = {} + + attr[ATTR_LOCATION] = self.location + + return attr diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/axis/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/axis/camera.py new file mode 100644 index 00000000000000..9846ae85fb2d16 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/axis/camera.py @@ -0,0 +1,63 @@ +""" +Support for Axis camera streaming. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.axis/ +""" +import logging + +from homeassistant.components.camera.mjpeg import ( + CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging) +from homeassistant.const import ( + CONF_AUTHENTICATION, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.helpers.dispatcher import dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'axis' +DEPENDENCIES = [DOMAIN] + + +def _get_image_url(host, port, mode): + """Set the URL to get the image.""" + if mode == 'mjpeg': + return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) + if mode == 'single': + return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Axis camera.""" + filter_urllib3_logging() + + camera_config = { + CONF_NAME: discovery_info[CONF_NAME], + CONF_USERNAME: discovery_info[CONF_USERNAME], + CONF_PASSWORD: discovery_info[CONF_PASSWORD], + CONF_MJPEG_URL: _get_image_url( + discovery_info[CONF_HOST], str(discovery_info[CONF_PORT]), + 'mjpeg'), + CONF_STILL_IMAGE_URL: _get_image_url( + discovery_info[CONF_HOST], str(discovery_info[CONF_PORT]), + 'single'), + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + } + add_entities([AxisCamera( + hass, camera_config, str(discovery_info[CONF_PORT]))]) + + +class AxisCamera(MjpegCamera): + """Representation of a Axis camera.""" + + def __init__(self, hass, config, port): + """Initialize Axis Communications camera component.""" + super().__init__(config) + self.port = port + dispatcher_connect( + hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) + + def _new_ip(self, host): + """Set new IP for video stream.""" + self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') + self._still_image_url = _get_image_url(host, self.port, 'single') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/axis/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/axis/services.yaml new file mode 100644 index 00000000000000..03db5ce7af8a43 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/axis/services.yaml @@ -0,0 +1,15 @@ +vapix_call: + description: Configure device using Vapix parameter management. + fields: + name: + description: Name of device to Configure. [Required] + example: M1065-W + cgi: + description: Which cgi to call on device. [Optional] Default is 'param.cgi' + example: 'applications/control.cgi' + action: + description: What type of call. [Optional] Default is 'update' + example: 'start' + param: + description: What parameter to operate on. [Required] + example: 'package=VideoMotionDetection' \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/__init__.py new file mode 100644 index 00000000000000..e3f327f1d5cf1d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/__init__.py @@ -0,0 +1,71 @@ +""" +Support for controlling GPIO pins of a Beaglebone Black. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/bbb_gpio/ +""" +import logging + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['Adafruit_BBIO==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'bbb_gpio' + + +def setup(hass, config): + """Set up the BeagleBone Black GPIO component.""" + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + + def cleanup_gpio(event): + """Stuff to do before stopping.""" + GPIO.cleanup() + + def prepare_gpio(event): + """Stuff to do when home assistant starts.""" + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio) + return True + + +def setup_output(pin): + """Set up a GPIO as output.""" + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + GPIO.setup(pin, GPIO.OUT) + + +def setup_input(pin, pull_mode): + """Set up a GPIO as input.""" + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + GPIO.setup(pin, GPIO.IN, + GPIO.PUD_DOWN if pull_mode == 'DOWN' + else GPIO.PUD_UP) + + +def write_output(pin, value): + """Write a value to a GPIO.""" + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + GPIO.output(pin, value) + + +def read_input(pin): + """Read a value from a GPIO.""" + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + return GPIO.input(pin) is GPIO.HIGH + + +def edge_detect(pin, event_callback, bounce): + """Add detection for RISING and FALLING events.""" + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + GPIO.add_event_detect( + pin, GPIO.BOTH, callback=event_callback, bouncetime=bounce) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/binary_sensor.py new file mode 100644 index 00000000000000..8968b680369838 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/binary_sensor.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 + +from homeassistant.components import 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_entities, discovery_info=None): + """Set up 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_entities(binary_sensors) + + +class BBBGPIOBinarySensor(BinarySensorDevice): + """Representation of 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-0.88.0.dev0/homeassistant/components/bbb_gpio/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/switch.py new file mode 100644 index 00000000000000..9e120beb442684 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bbb_gpio/switch.py @@ -0,0 +1,89 @@ +""" +Allows to configure a switch using BeagleBone Black GPIO. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.bbb_gpio/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.components import bbb_gpio +from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME) +from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bbb_gpio'] + +CONF_PINS = 'pins' +CONF_INITIAL = 'initial' +CONF_INVERT_LOGIC = 'invert_logic' + +PIN_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=False): cv.boolean, + vol.Optional(CONF_INVERT_LOGIC, default=False): cv.boolean, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PINS, default={}): + vol.Schema({cv.string: PIN_SCHEMA}), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BeagleBone Black GPIO devices.""" + pins = config.get(CONF_PINS) + + switches = [] + for pin, params in pins.items(): + switches.append(BBBGPIOSwitch(pin, params)) + add_entities(switches) + + +class BBBGPIOSwitch(ToggleEntity): + """Representation of a BeagleBone Black GPIO.""" + + def __init__(self, pin, params): + """Initialize the pin.""" + self._pin = pin + self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME + self._state = params.get(CONF_INITIAL) + self._invert_logic = params.get(CONF_INVERT_LOGIC) + + bbb_gpio.setup_output(self._pin) + + if self._state is False: + bbb_gpio.write_output(self._pin, 1 if self._invert_logic else 0) + else: + bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1) + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + bbb_gpio.write_output(self._pin, 1 if self._invert_logic else 0) + self._state = False + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/__init__.py new file mode 100644 index 00000000000000..7b2da21ff6a686 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/__init__.py @@ -0,0 +1,86 @@ +""" +Component to interface with binary sensors. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor/ +""" + +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.const import (STATE_ON, STATE_OFF) +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa + +DOMAIN = 'binary_sensor' +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + '.{}' +DEVICE_CLASSES = [ + 'battery', # On means low, Off means normal + 'cold', # On means cold, Off means normal + 'connectivity', # On means connected, Off means disconnected + 'door', # On means open, Off means closed + 'garage_door', # On means open, Off means closed + 'gas', # On means gas detected, Off means no gas (clear) + 'heat', # On means hot, Off means normal + 'light', # On means light detected, Off means no light + 'lock', # On means open (unlocked), Off means closed (locked) + 'moisture', # On means wet, Off means dry + 'motion', # On means motion detected, Off means no motion (clear) + 'moving', # On means moving, Off means not moving (stopped) + 'occupancy', # On means occupied, Off means not occupied (clear) + 'opening', # On means open, Off means closed + 'plug', # On means plugged in, Off means unplugged + 'power', # On means power detected, Off means no power + 'presence', # On means home, Off means away + 'problem', # On means problem detected, Off means no problem (OK) + 'safety', # On means unsafe, Off means safe + 'smoke', # On means smoke detected, Off means no smoke (clear) + 'sound', # On means sound detected, Off means no sound (clear) + 'vibration', # On means vibration detected, Off means no vibration + 'window', # On means open, Off means closed +] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + + +async def async_setup(hass, config): + """Track states and offer events for binary sensors.""" + component = hass.data[DOMAIN] = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + + await component.async_setup(config) + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class BinarySensorDevice(Entity): + """Represent a binary sensor.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return None + + @property + def state(self): + """Return the state of the binary sensor.""" + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return None diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/arest.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/arest.py new file mode 100644 index 00000000000000..b70620df3e21b2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/arest.py @@ -0,0 +1,109 @@ +""" +Support for an exposed aREST RESTful API of a device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.arest/ +""" +import logging +from datetime import timedelta + +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) +from homeassistant.const import ( + CONF_RESOURCE, CONF_PIN, CONF_NAME, CONF_DEVICE_CLASS) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the aREST binary sensor.""" + resource = config.get(CONF_RESOURCE) + pin = config.get(CONF_PIN) + device_class = config.get(CONF_DEVICE_CLASS) + + try: + response = requests.get(resource, timeout=10).json() + except requests.exceptions.MissingSchema: + _LOGGER.error("Missing resource or schema in configuration. " + "Add http:// to your URL") + return False + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device at %s", resource) + return False + + arest = ArestData(resource, pin) + + add_entities([ArestBinarySensor( + arest, resource, config.get(CONF_NAME, response[CONF_NAME]), + device_class, pin)], True) + + +class ArestBinarySensor(BinarySensorDevice): + """Implement an aREST binary sensor for a pin.""" + + def __init__(self, arest, resource, name, device_class, pin): + """Initialize the aREST device.""" + self.arest = arest + self._resource = resource + self._name = name + self._device_class = device_class + self._pin = pin + + if self._pin is not None: + request = requests.get( + '{}/mode/{}/i'.format(self._resource, self._pin), timeout=10) + if request.status_code != 200: + _LOGGER.error("Can't set mode of %s", self._resource) + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return bool(self.arest.data.get('state')) + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + def update(self): + """Get the latest data from aREST API.""" + self.arest.update() + + +class ArestData: + """Class for handling the data retrieval for pins.""" + + def __init__(self, resource, pin): + """Initialize the aREST data object.""" + self._resource = resource + self._pin = pin + self.data = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from aREST device.""" + try: + response = requests.get('{}/digital/{}'.format( + self._resource, self._pin), timeout=10) + self.data = {'state': response.json()['return_value']} + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to device '%s'", self._resource) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/aurora.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/aurora.py new file mode 100644 index 00000000000000..04b402722b218f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/aurora.py @@ -0,0 +1,149 @@ +""" +Support for aurora forecast data sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.aurora/ +""" +from datetime import timedelta +import logging + +from aiohttp.hdrs import USER_AGENT +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric" \ + "Administration" +CONF_THRESHOLD = 'forecast_threshold' + +DEFAULT_DEVICE_CLASS = 'visible' +DEFAULT_NAME = 'Aurora Visibility' +DEFAULT_THRESHOLD = 75 + +HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +URL = "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_THRESHOLD, default=DEFAULT_THRESHOLD): cv.positive_int, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the aurora sensor.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Lat. or long. not set in Home Assistant config") + return False + + name = config.get(CONF_NAME) + threshold = config.get(CONF_THRESHOLD) + + try: + aurora_data = AuroraData( + hass.config.latitude, hass.config.longitude, threshold) + aurora_data.update() + except requests.exceptions.HTTPError as error: + _LOGGER.error( + "Connection to aurora forecast service failed: %s", error) + return False + + add_entities([AuroraSensor(aurora_data, name)], True) + + +class AuroraSensor(BinarySensorDevice): + """Implementation of an aurora sensor.""" + + def __init__(self, aurora_data, name): + """Initialize the sensor.""" + self.aurora_data = aurora_data + self._name = name + + @property + def name(self): + """Return the name of the sensor.""" + return '{}'.format(self._name) + + @property + def is_on(self): + """Return true if aurora is visible.""" + return self.aurora_data.is_visible if self.aurora_data else False + + @property + def device_class(self): + """Return the class of this device.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + if self.aurora_data: + attrs['visibility_level'] = self.aurora_data.visibility_level + attrs['message'] = self.aurora_data.is_visible_text + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + return attrs + + def update(self): + """Get the latest data from Aurora API and updates the states.""" + self.aurora_data.update() + + +class AuroraData: + """Get aurora forecast.""" + + def __init__(self, latitude, longitude, threshold): + """Initialize the data object.""" + self.latitude = latitude + self.longitude = longitude + self.number_of_latitude_intervals = 513 + self.number_of_longitude_intervals = 1024 + self.headers = {USER_AGENT: HA_USER_AGENT} + self.threshold = int(threshold) + self.is_visible = None + self.is_visible_text = None + self.visibility_level = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the Aurora service.""" + try: + self.visibility_level = self.get_aurora_forecast() + if int(self.visibility_level) > self.threshold: + self.is_visible = True + self.is_visible_text = "visible!" + else: + self.is_visible = False + self.is_visible_text = "nothing's out" + + except requests.exceptions.HTTPError as error: + _LOGGER.error( + "Connection to aurora forecast service failed: %s", error) + return False + + def get_aurora_forecast(self): + """Get forecast data and parse for given long/lat.""" + raw_data = requests.get(URL, headers=self.headers, timeout=5).text + forecast_table = [ + row.strip(" ").split(" ") + for row in raw_data.split("\n") + if not row.startswith("#") + ] + + # Convert lat and long for data points in table + converted_latitude = round((self.latitude / 180) + * self.number_of_latitude_intervals) + converted_longitude = round((self.longitude / 360) + * self.number_of_longitude_intervals) + + return forecast_table[converted_latitude][converted_longitude] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/bayesian.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/bayesian.py new file mode 100644 index 00000000000000..f7802f0f29de89 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/bayesian.py @@ -0,0 +1,216 @@ +""" +Use Bayesian Inference to trigger a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bayesian/ +""" +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +ATTR_OBSERVATIONS = 'observations' +ATTR_PROBABILITY = 'probability' +ATTR_PROBABILITY_THRESHOLD = 'probability_threshold' + +CONF_OBSERVATIONS = 'observations' +CONF_PRIOR = 'prior' +CONF_PROBABILITY_THRESHOLD = 'probability_threshold' +CONF_P_GIVEN_F = 'prob_given_false' +CONF_P_GIVEN_T = 'prob_given_true' +CONF_TO_STATE = 'to_state' + +DEFAULT_NAME = "Bayesian Binary Sensor" +DEFAULT_PROBABILITY_THRESHOLD = 0.5 + +NUMERIC_STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Required(CONF_OBSERVATIONS): + vol.Schema(vol.All(cv.ensure_list, + [vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA)])), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD, + default=DEFAULT_PROBABILITY_THRESHOLD): vol.Coerce(float), +}) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + + probability = numerator / denominator + return probability + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Bayesian Binary sensor.""" + name = config.get(CONF_NAME) + observations = config.get(CONF_OBSERVATIONS) + prior = config.get(CONF_PRIOR) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_entities([ + BayesianBinarySensor( + name, prior, observations, probability_threshold, device_class) + ], True) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, + device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + to_observe = set(obs['entity_id'] for obs in self._observations) + + self.entity_obs = dict.fromkeys(to_observe, []) + + for ind, obs in enumerate(self._observations): + obs['id'] = ind + self.entity_obs[obs['entity_id']].append(obs) + + self.watchers = { + 'numeric_state': self._process_numeric_state, + 'state': self._process_state + } + + async def async_added_to_hass(self): + """Call when entity about to be added.""" + @callback + def async_threshold_sensor_state_listener(entity, old_state, + new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs_list = self.entity_obs[entity] + + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) + + prior = self.prior + for obs in self.current_obs.values(): + prior = update_probability( + prior, obs['prob_true'], obs['prob_false']) + self.probability = prior + + self.hass.async_add_job(self.async_update_ha_state, True) + + entities = [obs['entity_id'] for obs in self._observations] + async_track_state_change( + self.hass, entities, async_threshold_sensor_state_listener) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + obs_id = entity_observation['id'] + + if should_trigger: + prob_true = entity_observation['prob_given_true'] + prob_false = entity_observation.get( + 'prob_given_false', 1 - prob_true) + + self.current_obs[obs_id] = { + 'prob_true': prob_true, + 'prob_false': prob_false + } + + else: + self.current_obs.pop(obs_id, None) + + def _process_numeric_state(self, entity_observation): + """Add entity to current_obs if numeric state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.async_numeric_state( + self.hass, entity, + entity_observation.get('below'), + entity_observation.get('above'), None, entity_observation) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get('to_state')) + + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_OBSERVATIONS: [val for val in self.current_obs.values()], + ATTR_PROBABILITY: round(self.probability, 2), + ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, + } + + async def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability >= self._probability_threshold) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/command_line.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/command_line.py new file mode 100644 index 00000000000000..a3f1595787d166 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/command_line.py @@ -0,0 +1,102 @@ +""" +Support for custom shell commands to retrieve values. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.command_line/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA) +from homeassistant.components.sensor.command_line import CommandSensorData +from homeassistant.const import ( + CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_NAME, CONF_VALUE_TEMPLATE, + CONF_COMMAND, CONF_DEVICE_CLASS) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Binary Command Sensor' +DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_OFF = 'OFF' + +SCAN_INTERVAL = timedelta(seconds=60) + +CONF_COMMAND_TIMEOUT = 'command_timeout' +DEFAULT_TIMEOUT = 15 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional( + CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Command line Binary Sensor.""" + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + payload_off = config.get(CONF_PAYLOAD_OFF) + payload_on = config.get(CONF_PAYLOAD_ON) + device_class = config.get(CONF_DEVICE_CLASS) + value_template = config.get(CONF_VALUE_TEMPLATE) + command_timeout = config.get(CONF_COMMAND_TIMEOUT) + if value_template is not None: + value_template.hass = hass + data = CommandSensorData(hass, command, command_timeout) + + add_entities([CommandBinarySensor( + hass, data, name, device_class, payload_on, payload_off, + value_template)], True) + + +class CommandBinarySensor(BinarySensorDevice): + """Representation of a command line binary sensor.""" + + def __init__(self, hass, data, name, device_class, payload_on, + payload_off, value_template): + """Initialize the Command line binary sensor.""" + self._hass = hass + self.data = data + self._name = name + self._device_class = device_class + self._state = False + self._payload_on = payload_on + self._payload_off = payload_off + self._value_template = value_template + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @ property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + def update(self): + """Get the latest data and updates the state.""" + self.data.update() + value = self.data.value + + if self._value_template is not None: + value = self._value_template.render_with_possible_json_value( + value, False) + if value == self._payload_on: + self._state = True + elif value == self._payload_off: + self._state = False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/concord232.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/concord232.py new file mode 100644 index 00000000000000..26f35d60305825 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/concord232.py @@ -0,0 +1,141 @@ +""" +Support for exposing Concord232 elements as sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.concord232/ +""" +import datetime +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES) +from homeassistant.const import (CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['concord232==0.15'] + +_LOGGER = logging.getLogger(__name__) + +CONF_EXCLUDE_ZONES = 'exclude_zones' +CONF_ZONE_TYPES = 'zone_types' + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'Alarm' +DEFAULT_PORT = '5007' +DEFAULT_SSL = False + +SCAN_INTERVAL = datetime.timedelta(seconds=10) + +ZONE_TYPES_SCHEMA = vol.Schema({ + cv.positive_int: vol.In(DEVICE_CLASSES), +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_EXCLUDE_ZONES, default=[]): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Concord232 binary sensor platform.""" + from concord232 import client as concord232_client + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + exclude = config.get(CONF_EXCLUDE_ZONES) + zone_types = config.get(CONF_ZONE_TYPES) + sensors = [] + + try: + _LOGGER.debug("Initializing client") + client = concord232_client.Client('http://{}:{}'.format(host, port)) + client.zones = client.list_zones() + client.last_zone_update = datetime.datetime.now() + + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) + return False + + # The order of zones returned by client.list_zones() can vary. + # When the zones are not named, this can result in the same entity + # name mapping to different sensors in an unpredictable way. Sort + # the zones by zone number to prevent this. + + client.zones.sort(key=lambda zone: zone['number']) + + for zone in client.zones: + _LOGGER.info("Loading Zone found: %s", zone['name']) + if zone['number'] not in exclude: + sensors.append( + Concord232ZoneSensor( + hass, client, zone, zone_types.get( + zone['number'], get_opening_type(zone)) + ) + ) + + add_entities(sensors, True) + + +def get_opening_type(zone): + """Return the result of the type guessing from name.""" + if 'MOTION' in zone['name']: + return 'motion' + if 'KEY' in zone['name']: + return 'safety' + if 'SMOKE' in zone['name']: + return 'smoke' + if 'WATER' in zone['name']: + return 'water' + return 'opening' + + +class Concord232ZoneSensor(BinarySensorDevice): + """Representation of a Concord232 zone as a sensor.""" + + def __init__(self, hass, client, zone, zone_type): + """Initialize the Concord232 binary sensor.""" + self._hass = hass + self._client = client + self._zone = zone + self._number = zone['number'] + self._zone_type = zone_type + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @property + def should_poll(self): + """No polling needed.""" + return True + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._zone['name'] + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + # True means "faulted" or "open" or "abnormal state" + return bool(self._zone['state'] != 'Normal') + + def update(self): + """Get updated stats from API.""" + last_update = datetime.datetime.now() - self._client.last_zone_update + _LOGGER.debug("Zone: %s ", self._zone) + if last_update > datetime.timedelta(seconds=1): + self._client.zones = self._client.list_zones() + self._client.last_zone_update = datetime.datetime.now() + _LOGGER.debug("Updated from zone: %s", self._zone['name']) + + if hasattr(self._client, 'zones'): + self._zone = next((x for x in self._client.zones + if x['number'] == self._number), None) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/demo.py new file mode 100644 index 00000000000000..d656b79e8ed2e6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/demo.py @@ -0,0 +1,45 @@ +""" +Demo platform that has two fake binary sensors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo binary sensor platform.""" + add_entities([ + DemoBinarySensor('Basement Floor Wet', False, 'moisture'), + DemoBinarySensor('Movement Backyard', True, 'motion'), + ]) + + +class DemoBinarySensor(BinarySensorDevice): + """representation of a Demo binary sensor.""" + + def __init__(self, name, state, device_class): + """Initialize the demo sensor.""" + self._name = name + self._state = state + self._sensor_type = device_class + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type + + @property + def should_poll(self): + """No polling needed for a demo binary sensor.""" + return False + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ffmpeg_motion.py new file mode 100644 index 00000000000000..d0e597e13c01a4 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -0,0 +1,120 @@ +""" +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_motion/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.ffmpeg import ( + FFmpegBase, DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS, + CONF_INITIAL_STATE) +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RESET = 'reset' +CONF_CHANGES = 'changes' +CONF_REPEAT = 'repeat' +CONF_REPEAT_TIME = 'repeat_time' + +DEFAULT_NAME = 'FFmpeg Motion' +DEFAULT_INIT_STATE = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + 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_RESET, default=10): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_CHANGES, default=10): + vol.All(vol.Coerce(float), vol.Range(min=0, max=99)), + vol.Inclusive(CONF_REPEAT, 'repeat'): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Inclusive(CONF_REPEAT_TIME, 'repeat'): + vol.All(vol.Coerce(int), vol.Range(min=1)), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the FFmpeg binary motion sensor.""" + manager = hass.data[DATA_FFMPEG] + entity = FFmpegMotion(hass, manager, config) + async_add_entities([entity]) + + +class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): + """A binary sensor which use FFmpeg for noise detection.""" + + def __init__(self, config): + """Init for the binary sensor noise detection.""" + super().__init__(config.get(CONF_INITIAL_STATE)) + + self._state = False + self._config = config + self._name = config.get(CONF_NAME) + + @callback + def _async_callback(self, state): + """HA-FFmpeg callback for noise detection.""" + self._state = state + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + +class FFmpegMotion(FFmpegBinarySensor): + """A binary sensor which use FFmpeg for noise detection.""" + + def __init__(self, hass, manager, config): + """Initialize FFmpeg motion binary sensor.""" + from haffmpeg import SensorMotion + + super().__init__(config) + self.ffmpeg = SensorMotion( + manager.binary, hass.loop, self._async_callback) + + async def _async_start_ffmpeg(self, entity_ids): + """Start a FFmpeg instance. + + This method is a coroutine. + """ + if entity_ids is not None and self.entity_id not in entity_ids: + return + + # init config + self.ffmpeg.set_options( + time_reset=self._config.get(CONF_RESET), + time_repeat=self._config.get(CONF_REPEAT_TIME, 0), + repeat=self._config.get(CONF_REPEAT, 0), + changes=self._config.get(CONF_CHANGES), + ) + + # run + await self.ffmpeg.open_sensor( + input_source=self._config.get(CONF_INPUT), + extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), + ) + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return 'motion' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ffmpeg_noise.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ffmpeg_noise.py new file mode 100644 index 00000000000000..3c2397d692bac0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ffmpeg_noise.py @@ -0,0 +1,87 @@ +""" +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_noise/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA +from homeassistant.components.binary_sensor.ffmpeg_motion import ( + FFmpegBinarySensor) +from homeassistant.components.ffmpeg import ( + DATA_FFMPEG, CONF_INPUT, CONF_OUTPUT, CONF_EXTRA_ARGUMENTS, + CONF_INITIAL_STATE) +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +CONF_PEAK = 'peak' +CONF_DURATION = 'duration' +CONF_RESET = 'reset' + +DEFAULT_NAME = 'FFmpeg Noise' +DEFAULT_INIT_STATE = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + 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, + vol.Optional(CONF_PEAK, default=-30): vol.Coerce(int), + vol.Optional(CONF_DURATION, default=1): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_RESET, default=10): + vol.All(vol.Coerce(int), vol.Range(min=1)), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the FFmpeg noise binary sensor.""" + manager = hass.data[DATA_FFMPEG] + entity = FFmpegNoise(hass, manager, config) + async_add_entities([entity]) + + +class FFmpegNoise(FFmpegBinarySensor): + """A binary sensor which use FFmpeg for noise detection.""" + + def __init__(self, hass, manager, config): + """Initialize FFmpeg noise binary sensor.""" + from haffmpeg import SensorNoise + + super().__init__(config) + self.ffmpeg = SensorNoise( + manager.binary, hass.loop, self._async_callback) + + async def _async_start_ffmpeg(self, entity_ids): + """Start a FFmpeg instance. + + This method is a coroutine. + """ + if entity_ids is not None and self.entity_id not in entity_ids: + return + + self.ffmpeg.set_options( + time_duration=self._config.get(CONF_DURATION), + time_reset=self._config.get(CONF_RESET), + peak=self._config.get(CONF_PEAK), + ) + + await 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 + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return 'sound' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/flic.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/flic.py new file mode 100644 index 00000000000000..baf1d469b28b8b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/flic.py @@ -0,0 +1,242 @@ +""" +Support to use flic buttons as a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.flic/ +""" +import logging +import threading + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_DISCOVERY, CONF_TIMEOUT, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) + +REQUIREMENTS = ['pyflic-homeassistant==0.4.dev0'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 3 + +CLICK_TYPE_SINGLE = 'single' +CLICK_TYPE_DOUBLE = 'double' +CLICK_TYPE_HOLD = 'hold' +CLICK_TYPES = [CLICK_TYPE_SINGLE, CLICK_TYPE_DOUBLE, CLICK_TYPE_HOLD] + +CONF_IGNORED_CLICK_TYPES = 'ignored_click_types' + +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 5551 + +EVENT_NAME = 'flic_click' +EVENT_DATA_NAME = 'button_name' +EVENT_DATA_ADDRESS = 'button_address' +EVENT_DATA_TYPE = 'click_type' +EVENT_DATA_QUEUED_TIME = 'queued_time' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_IGNORED_CLICK_TYPES): + vol.All(cv.ensure_list, [vol.In(CLICK_TYPES)]) +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the flic platform.""" + import pyflic + + # Initialize flic client responsible for + # connecting to buttons and retrieving events + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + discovery = config.get(CONF_DISCOVERY) + + try: + client = pyflic.FlicClient(host, port) + except ConnectionRefusedError: + _LOGGER.error("Failed to connect to flic server") + return + + def new_button_callback(address): + """Set up newly verified button as device in Home Assistant.""" + setup_button(hass, config, add_entities, client, address) + + client.on_new_verified_button = new_button_callback + if discovery: + start_scanning(config, add_entities, client) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: client.close()) + + # Start the pyflic event handling thread + threading.Thread(target=client.handle_events).start() + + def get_info_callback(items): + """Add entities for already verified buttons.""" + addresses = items['bd_addr_of_verified_buttons'] or [] + for address in addresses: + setup_button(hass, config, add_entities, client, address) + + # Get addresses of already verified buttons + client.get_info(get_info_callback) + + +def start_scanning(config, add_entities, client): + """Start a new flic client for scanning and connecting to new buttons.""" + import pyflic + + scan_wizard = pyflic.ScanWizard() + + def scan_completed_callback(scan_wizard, result, address, name): + """Restart scan wizard to constantly check for new buttons.""" + if result == pyflic.ScanWizardResult.WizardSuccess: + _LOGGER.info("Found new button %s", address) + elif result != pyflic.ScanWizardResult.WizardFailedTimeout: + _LOGGER.warning( + "Failed to connect to button %s. Reason: %s", address, result) + + # Restart scan wizard + start_scanning(config, add_entities, client) + + scan_wizard.on_completed = scan_completed_callback + client.add_scan_wizard(scan_wizard) + + +def setup_button(hass, config, add_entities, client, address): + """Set up a single button device.""" + timeout = config.get(CONF_TIMEOUT) + ignored_click_types = config.get(CONF_IGNORED_CLICK_TYPES) + button = FlicButton(hass, client, address, timeout, ignored_click_types) + _LOGGER.info("Connected to button %s", address) + + add_entities([button]) + + +class FlicButton(BinarySensorDevice): + """Representation of a flic button.""" + + def __init__(self, hass, client, address, timeout, ignored_click_types): + """Initialize the flic button.""" + import pyflic + + self._hass = hass + self._address = address + self._timeout = timeout + self._is_down = False + self._ignored_click_types = ignored_click_types or [] + self._hass_click_types = { + pyflic.ClickType.ButtonClick: CLICK_TYPE_SINGLE, + pyflic.ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE, + pyflic.ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE, + pyflic.ClickType.ButtonHold: CLICK_TYPE_HOLD, + } + + self._channel = self._create_channel() + client.add_connection_channel(self._channel) + + def _create_channel(self): + """Create a new connection channel to the button.""" + import pyflic + + channel = pyflic.ButtonConnectionChannel(self._address) + channel.on_button_up_or_down = self._on_up_down + + # If all types of clicks should be ignored, skip registering callbacks + if set(self._ignored_click_types) == set(CLICK_TYPES): + return channel + + if CLICK_TYPE_DOUBLE in self._ignored_click_types: + # Listen to all but double click type events + channel.on_button_click_or_hold = self._on_click + elif CLICK_TYPE_HOLD in self._ignored_click_types: + # Listen to all but hold click type events + channel.on_button_single_or_double_click = self._on_click + else: + # Listen to all click type events + channel.on_button_single_or_double_click_or_hold = self._on_click + + return channel + + @property + def name(self): + """Return the name of the device.""" + return 'flic_{}'.format(self.address.replace(':', '')) + + @property + def address(self): + """Return the bluetooth address of the device.""" + return self._address + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._is_down + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return {'address': self.address} + + def _queued_event_check(self, click_type, time_diff): + """Generate a log message and returns true if timeout exceeded.""" + time_string = "{:d} {}".format( + time_diff, 'second' if time_diff == 1 else 'seconds') + + if time_diff > self._timeout: + _LOGGER.warning( + "Queued %s dropped for %s. Time in queue was %s", + click_type, self.address, time_string) + return True + _LOGGER.info( + "Queued %s allowed for %s. Time in queue was %s", + click_type, self.address, time_string) + return False + + def _on_up_down(self, channel, click_type, was_queued, time_diff): + """Update device state, if event was not queued.""" + import pyflic + + if was_queued and self._queued_event_check(click_type, time_diff): + return + + self._is_down = click_type == pyflic.ClickType.ButtonDown + self.schedule_update_ha_state() + + def _on_click(self, channel, click_type, was_queued, time_diff): + """Fire click event, if event was not queued.""" + # Return if click event was queued beyond allowed timeout + if was_queued and self._queued_event_check(click_type, time_diff): + return + + # Return if click event is in ignored click types + hass_click_type = self._hass_click_types[click_type] + if hass_click_type in self._ignored_click_types: + return + + self._hass.bus.fire(EVENT_NAME, { + EVENT_DATA_NAME: self.name, + EVENT_DATA_ADDRESS: self.address, + EVENT_DATA_QUEUED_TIME: time_diff, + EVENT_DATA_TYPE: hass_click_type + }) + + def _connection_status_changed( + self, channel, connection_status, disconnect_reason): + """Remove device, if button disconnects.""" + import pyflic + + if connection_status == pyflic.ConnectionStatus.Disconnected: + _LOGGER.warning("Button (%s) disconnected. Reason: %s", + self.address, disconnect_reason) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/hikvision.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/hikvision.py new file mode 100644 index 00000000000000..57618ca2652a81 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/hikvision.py @@ -0,0 +1,280 @@ +""" +Support for Hikvision event stream events represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.hikvision/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.util.dt import utcnow +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, + ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) + +REQUIREMENTS = ['pyhik==0.1.9'] +_LOGGER = logging.getLogger(__name__) + +CONF_IGNORED = 'ignored' +CONF_DELAY = 'delay' + +DEFAULT_PORT = 80 +DEFAULT_IGNORED = False +DEFAULT_DELAY = 0 + +ATTR_DELAY = 'delay' + +DEVICE_CLASS_MAP = { + 'Motion': 'motion', + 'Line Crossing': 'motion', + 'Field Detection': 'motion', + 'Video Loss': None, + 'Tamper Detection': 'motion', + 'Shelter Alarm': None, + 'Disk Full': None, + 'Disk Error': None, + 'Net Interface Broken': 'connectivity', + 'IP Conflict': 'connectivity', + 'Illegal Access': None, + 'Video Mismatch': None, + 'Bad Video': None, + 'PIR Alarm': 'motion', + 'Face Detection': 'motion', + 'Scene Change Detection': 'motion', + 'I/O': None, + 'Unattended Baggage': 'motion', + 'Attended Baggage': 'motion', + 'Recording Failure': None, +} + +CUSTOMIZE_SCHEMA = vol.Schema({ + vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int + }) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CUSTOMIZE, default={}): + vol.Schema({cv.string: CUSTOMIZE_SCHEMA}), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Hikvision binary sensor devices.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + customize = config.get(CONF_CUSTOMIZE) + + if config.get(CONF_SSL): + protocol = 'https' + else: + protocol = 'http' + + url = '{}://{}'.format(protocol, host) + + data = HikvisionData(hass, url, port, name, username, password) + + if data.sensors is None: + _LOGGER.error("Hikvision event stream has no data, unable to set up") + return False + + entities = [] + + for sensor, channel_list in data.sensors.items(): + for channel in channel_list: + # Build sensor name, then parse customize config. + if data.type == 'NVR': + sensor_name = '{}_{}'.format( + sensor.replace(' ', '_'), channel[1]) + else: + sensor_name = sensor.replace(' ', '_') + + custom = customize.get(sensor_name.lower(), {}) + ignore = custom.get(CONF_IGNORED) + delay = custom.get(CONF_DELAY) + + _LOGGER.debug("Entity: %s - %s, Options - Ignore: %s, Delay: %s", + data.name, sensor_name, ignore, delay) + if not ignore: + entities.append(HikvisionBinarySensor( + hass, sensor, channel[1], data, delay)) + + add_entities(entities) + + +class HikvisionData: + """Hikvision device event stream object.""" + + def __init__(self, hass, url, port, name, username, password): + """Initialize the data object.""" + from pyhik.hikvision import HikCamera + self._url = url + self._port = port + self._name = name + self._username = username + self._password = password + + # Establish camera + self.camdata = HikCamera( + self._url, self._port, self._username, self._password) + + if self._name is None: + self._name = self.camdata.get_name + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik) + + def stop_hik(self, event): + """Shutdown Hikvision subscriptions and subscription thread on exit.""" + self.camdata.disconnect() + + def start_hik(self, event): + """Start Hikvision event stream thread.""" + self.camdata.start_stream() + + @property + def sensors(self): + """Return list of available sensors and their states.""" + return self.camdata.current_event_states + + @property + def cam_id(self): + """Return device id.""" + return self.camdata.get_id + + @property + def name(self): + """Return device name.""" + return self._name + + @property + def type(self): + """Return device type.""" + return self.camdata.get_type + + def get_attributes(self, sensor, channel): + """Return attribute list for sensor/channel.""" + return self.camdata.fetch_attributes(sensor, channel) + + +class HikvisionBinarySensor(BinarySensorDevice): + """Representation of a Hikvision binary sensor.""" + + def __init__(self, hass, sensor, channel, cam, delay): + """Initialize the binary_sensor.""" + self._hass = hass + self._cam = cam + self._sensor = sensor + self._channel = channel + + if self._cam.type == 'NVR': + self._name = '{} {} {}'.format(self._cam.name, sensor, channel) + else: + self._name = '{} {}'.format(self._cam.name, sensor) + + self._id = '{}.{}.{}'.format(self._cam.cam_id, sensor, channel) + + if delay is None: + self._delay = 0 + else: + self._delay = delay + + self._timer = None + + # Register callback function with pyHik + self._cam.camdata.add_update_callback(self._update_callback, self._id) + + def _sensor_state(self): + """Extract sensor state.""" + return self._cam.get_attributes(self._sensor, self._channel)[0] + + def _sensor_last_update(self): + """Extract sensor last update time.""" + return self._cam.get_attributes(self._sensor, self._channel)[3] + + @property + def name(self): + """Return the name of the Hikvision sensor.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._sensor_state() + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + try: + return DEVICE_CLASS_MAP[self._sensor] + except KeyError: + # Sensor must be unknown to us, add as generic + return None + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + attr[ATTR_LAST_TRIP_TIME] = self._sensor_last_update() + + if self._delay != 0: + attr[ATTR_DELAY] = self._delay + + return attr + + def _update_callback(self, msg): + """Update the sensor's state, if needed.""" + _LOGGER.debug('Callback signal from: %s', msg) + + if self._delay > 0 and not self.is_on: + # Set timer to wait until updating the state + def _delay_update(now): + """Timer callback for sensor update.""" + _LOGGER.debug("%s Called delayed (%ssec) update", + self._name, self._delay) + self.schedule_update_ha_state() + self._timer = None + + if self._timer is not None: + self._timer() + self._timer = None + + self._timer = track_point_in_utc_time( + self._hass, _delay_update, + utcnow() + timedelta(seconds=self._delay)) + + elif self._delay > 0 and self.is_on: + # For delayed sensors kill any callbacks on true events and update + if self._timer is not None: + self._timer() + self._timer = None + + self.schedule_update_ha_state() + + else: + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/iss.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/iss.py new file mode 100644 index 00000000000000..b986c51ddaa14d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/iss.py @@ -0,0 +1,129 @@ +""" +Support for International Space Station data sensor. + +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 + +import requests +import voluptuous as vol + +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, CONF_SHOW_ON_MAP) +from homeassistant.util import Throttle + +REQUIREMENTS = ['pyiss==1.0.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ISS_NEXT_RISE = 'next_rise' +ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space' + +DEFAULT_NAME = 'ISS' +DEFAULT_DEVICE_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_entities, discovery_info=None): + """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 + + try: + iss_data = IssData(hass.config.latitude, hass.config.longitude) + iss_data.update() + except requests.exceptions.HTTPError as error: + _LOGGER.error(error) + return False + + name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) + + add_entities([IssBinarySensor(iss_data, name, show_on_map)], True) + + +class IssBinarySensor(BinarySensorDevice): + """Implementation of the ISS binary sensor.""" + + def __init__(self, iss_data, name, show): + """Initialize the sensor.""" + self.iss_data = iss_data + self._state = None + self._name = name + self._show_on_map = show + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + 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 device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes.""" + 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.iss_data.update() + + +class IssData: + """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 API.""" + import pyiss + + try: + iss = pyiss.ISS() + 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() + self.position = iss.current_location() + except requests.exceptions.HTTPError as error: + _LOGGER.error(error) + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/mystrom.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/mystrom.py new file mode 100644 index 00000000000000..4927be27eb303f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/mystrom.py @@ -0,0 +1,94 @@ +""" +Support for the myStrom buttons. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.mystrom/ +""" +import logging + +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up myStrom Binary Sensor.""" + hass.http.register_view(MyStromView(async_add_entities)) + + return True + + +class MyStromView(HomeAssistantView): + """View to handle requests from myStrom buttons.""" + + url = '/api/mystrom' + name = 'api:mystrom' + supported_actions = ['single', 'double', 'long', 'touch'] + + def __init__(self, add_entities): + """Initialize the myStrom URL endpoint.""" + self.buttons = {} + self.add_entities = add_entities + + async def get(self, request): + """Handle the GET request received from a myStrom button.""" + res = await self._handle(request.app['hass'], request.query) + return res + + async def _handle(self, hass, data): + """Handle requests to the myStrom endpoint.""" + button_action = next(( + parameter for parameter in data + if parameter in self.supported_actions), None) + + if button_action is None: + _LOGGER.error( + "Received unidentified message from myStrom button: %s", data) + return ("Received unidentified message: {}".format(data), + HTTP_UNPROCESSABLE_ENTITY) + + button_id = data[button_action] + entity_id = '{}.{}_{}'.format(DOMAIN, button_id, button_action) + if entity_id not in self.buttons: + _LOGGER.info("New myStrom button/action detected: %s/%s", + button_id, button_action) + self.buttons[entity_id] = MyStromBinarySensor( + '{}_{}'.format(button_id, button_action)) + self.add_entities([self.buttons[entity_id]]) + else: + new_state = self.buttons[entity_id].state == 'off' + self.buttons[entity_id].async_on_update(new_state) + + +class MyStromBinarySensor(BinarySensorDevice): + """Representation of a myStrom button.""" + + def __init__(self, button_id): + """Initialize the myStrom Binary sensor.""" + self._button_id = button_id + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._button_id + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + def async_on_update(self, value): + """Receive an update.""" + self._state = value + self.async_schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ness_alarm.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ness_alarm.py new file mode 100644 index 00000000000000..9f1479efd6983f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ness_alarm.py @@ -0,0 +1,81 @@ +""" +Support for Ness D8X/D16X zone states - represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.ness_alarm/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.ness_alarm import ( + CONF_ZONES, CONF_ZONE_TYPE, CONF_ZONE_NAME, CONF_ZONE_ID, + SIGNAL_ZONE_CHANGED, ZoneChangedData) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['ness_alarm'] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Ness Alarm binary sensor devices.""" + if not discovery_info: + return + + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + + for zone_config in configured_zones: + zone_type = zone_config[CONF_ZONE_TYPE] + zone_name = zone_config[CONF_ZONE_NAME] + zone_id = zone_config[CONF_ZONE_ID] + device = NessZoneBinarySensor(zone_id=zone_id, name=zone_name, + zone_type=zone_type) + devices.append(device) + + async_add_entities(devices) + + +class NessZoneBinarySensor(BinarySensorDevice): + """Representation of an Ness alarm zone as a binary sensor.""" + + def __init__(self, zone_id, name, zone_type): + """Initialize the binary_sensor.""" + self._zone_id = zone_id + self._name = name + self._type = zone_type + self._state = 0 + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ZONE_CHANGED, self._handle_zone_change) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._type + + @callback + def _handle_zone_change(self, data: ZoneChangedData): + """Handle zone state update.""" + if self._zone_id == data.zone_id: + self._state = data.state + self.async_schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/nx584.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/nx584.py new file mode 100644 index 00000000000000..2929acc27095f8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/nx584.py @@ -0,0 +1,148 @@ +""" +Support for exposing NX584 elements as sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.nx584/ +""" +import logging +import threading +import time + +import requests +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES, BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pynx584==0.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_EXCLUDE_ZONES = 'exclude_zones' +CONF_ZONE_TYPES = 'zone_types' + +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = '5007' +DEFAULT_SSL = False + +ZONE_TYPES_SCHEMA = vol.Schema({ + cv.positive_int: vol.In(DEVICE_CLASSES), +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_EXCLUDE_ZONES, default=[]): + vol.All(cv.ensure_list, [cv.positive_int]), + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the NX584 binary sensor platform.""" + from nx584 import client as nx584_client + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + exclude = config.get(CONF_EXCLUDE_ZONES) + zone_types = config.get(CONF_ZONE_TYPES) + + try: + client = nx584_client.Client('http://{}:{}'.format(host, port)) + zones = client.list_zones() + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to NX584: %s", str(ex)) + return False + + version = [int(v) for v in client.get_version().split('.')] + if version < [1, 1]: + _LOGGER.error("NX584 is too old to use for sensors (>=0.2 required)") + return False + + zone_sensors = { + zone['number']: NX584ZoneSensor( + zone, + zone_types.get(zone['number'], 'opening')) + for zone in zones + if zone['number'] not in exclude} + if zone_sensors: + add_entities(zone_sensors.values()) + watcher = NX584Watcher(client, zone_sensors) + watcher.start() + else: + _LOGGER.warning("No zones found on NX584") + return True + + +class NX584ZoneSensor(BinarySensorDevice): + """Representation of a NX584 zone as a sensor.""" + + def __init__(self, zone, zone_type): + """Initialize the nx594 binary sensor.""" + self._zone = zone + self._zone_type = zone_type + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._zone['name'] + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + # True means "faulted" or "open" or "abnormal state" + return self._zone['state'] + + +class NX584Watcher(threading.Thread): + """Event listener thread to process NX584 events.""" + + def __init__(self, client, zone_sensors): + """Initialize NX584 watcher thread.""" + super(NX584Watcher, self).__init__() + self.daemon = True + self._client = client + self._zone_sensors = zone_sensors + + def _process_zone_event(self, event): + zone = event['zone'] + zone_sensor = self._zone_sensors.get(zone) + # pylint: disable=protected-access + if not zone_sensor: + return + zone_sensor._zone['state'] = event['zone_state'] + zone_sensor.schedule_update_ha_state() + + def _process_events(self, events): + for event in events: + if event.get('type') == 'zone_status': + self._process_zone_event(event) + + def _run(self): + """Throw away any existing events so we don't replay history.""" + self._client.get_events() + while True: + events = self._client.get_events() + if events: + self._process_events(events) + + def run(self): + """Run the watcher.""" + while True: + try: + self._run() + except requests.exceptions.ConnectionError: + _LOGGER.error("Failed to reach NX584 server") + time.sleep(10) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ping.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ping.py new file mode 100644 index 00000000000000..4c597dd63e1e34 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ping.py @@ -0,0 +1,153 @@ +""" +Tracks the latency of a host by sending ICMP echo requests (ping). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.ping/ +""" +import logging +import subprocess +import re +import sys +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_HOST + +_LOGGER = logging.getLogger(__name__) + +ATTR_ROUND_TRIP_TIME_AVG = 'round_trip_time_avg' +ATTR_ROUND_TRIP_TIME_MAX = 'round_trip_time_max' +ATTR_ROUND_TRIP_TIME_MDEV = 'round_trip_time_mdev' +ATTR_ROUND_TRIP_TIME_MIN = 'round_trip_time_min' + +CONF_PING_COUNT = 'count' + +DEFAULT_NAME = 'Ping Binary sensor' +DEFAULT_PING_COUNT = 5 +DEFAULT_DEVICE_CLASS = 'connectivity' + +SCAN_INTERVAL = timedelta(minutes=5) + +PING_MATCHER = re.compile( + r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)') + +PING_MATCHER_BUSYBOX = re.compile( + r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)') + +WIN32_PING_MATCHER = re.compile( + r'(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms') + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ping Binary sensor.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + count = config.get(CONF_PING_COUNT) + + add_entities([PingBinarySensor(name, PingData(host, count))], True) + + +class PingBinarySensor(BinarySensorDevice): + """Representation of a Ping Binary sensor.""" + + def __init__(self, name, ping): + """Initialize the Ping Binary sensor.""" + self._name = name + self.ping = ping + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.ping.available + + @property + def device_state_attributes(self): + """Return the state attributes of the ICMP checo request.""" + if self.ping.data is not False: + return { + ATTR_ROUND_TRIP_TIME_AVG: self.ping.data['avg'], + ATTR_ROUND_TRIP_TIME_MAX: self.ping.data['max'], + ATTR_ROUND_TRIP_TIME_MDEV: self.ping.data['mdev'], + ATTR_ROUND_TRIP_TIME_MIN: self.ping.data['min'], + } + + def update(self): + """Get the latest data.""" + self.ping.update() + + +class PingData: + """The Class for handling the data retrieval.""" + + def __init__(self, host, count): + """Initialize the data object.""" + self._ip_address = host + self._count = count + self.data = {} + self.available = False + + if sys.platform == 'win32': + self._ping_cmd = [ + 'ping', '-n', str(self._count), '-w', '1000', self._ip_address] + else: + self._ping_cmd = [ + 'ping', '-n', '-q', '-c', str(self._count), '-W1', + self._ip_address] + + def ping(self): + """Send ICMP echo request and return details if success.""" + pinger = subprocess.Popen( + self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + out = pinger.communicate() + _LOGGER.debug("Output is %s", str(out)) + if sys.platform == 'win32': + match = WIN32_PING_MATCHER.search(str(out).split('\n')[-1]) + rtt_min, rtt_avg, rtt_max = match.groups() + return { + 'min': rtt_min, + 'avg': rtt_avg, + 'max': rtt_max, + 'mdev': ''} + if 'max/' not in str(out): + match = PING_MATCHER_BUSYBOX.search(str(out).split('\n')[-1]) + rtt_min, rtt_avg, rtt_max = match.groups() + return { + 'min': rtt_min, + 'avg': rtt_avg, + 'max': rtt_max, + 'mdev': ''} + match = PING_MATCHER.search(str(out).split('\n')[-1]) + rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() + return { + 'min': rtt_min, + 'avg': rtt_avg, + 'max': rtt_max, + 'mdev': rtt_mdev} + except (subprocess.CalledProcessError, AttributeError): + return False + + def update(self): + """Retrieve the latest details from the host.""" + self.data = self.ping() + self.available = bool(self.data) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/random.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/random.py new file mode 100644 index 00000000000000..9bdc57c6e468d3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/random.py @@ -0,0 +1,62 @@ +""" +Support for showing random states. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.random/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_DEVICE_CLASS + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Random Binary Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Random binary sensor.""" + name = config.get(CONF_NAME) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_entities([RandomSensor(name, device_class)], True) + + +class RandomSensor(BinarySensorDevice): + """Representation of a Random binary sensor.""" + + def __init__(self, name, device_class): + """Initialize the Random binary sensor.""" + self._name = name + self._device_class = device_class + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + async def async_update(self): + """Get new state and update the sensor's state.""" + from random import getrandbits + self._state = bool(getrandbits(1)) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/rest.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/rest.py new file mode 100644 index 00000000000000..ac82ab126fd407 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/rest.py @@ -0,0 +1,127 @@ +""" +Support for RESTful binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rest/ +""" +import logging + +import voluptuous as vol +from requests.auth import HTTPBasicAuth, HTTPDigestAuth + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA) +from homeassistant.components.sensor.rest import RestData +from homeassistant.const import ( + CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, + CONF_VERIFY_SSL, CONF_USERNAME, CONF_PASSWORD, + CONF_HEADERS, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, CONF_DEVICE_CLASS) +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_METHOD = 'GET' +DEFAULT_NAME = 'REST Binary Sensor' +DEFAULT_VERIFY_SSL = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_AUTHENTICATION): + vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), + vol.Optional(CONF_HEADERS): {cv.string: cv.string}, + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET']), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the REST binary sensor.""" + name = config.get(CONF_NAME) + resource = config.get(CONF_RESOURCE) + method = config.get(CONF_METHOD) + payload = config.get(CONF_PAYLOAD) + verify_ssl = config.get(CONF_VERIFY_SSL) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + headers = config.get(CONF_HEADERS) + device_class = config.get(CONF_DEVICE_CLASS) + value_template = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + + if username and password: + if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = HTTPDigestAuth(username, password) + else: + auth = HTTPBasicAuth(username, password) + else: + auth = None + + rest = RestData(method, resource, auth, headers, payload, verify_ssl) + rest.update() + if rest.data is None: + raise PlatformNotReady + + # No need to update the sensor now because it will determine its state + # based in the rest resource that has just been retrieved. + add_entities([RestBinarySensor( + hass, rest, name, device_class, value_template)]) + + +class RestBinarySensor(BinarySensorDevice): + """Representation of a REST binary sensor.""" + + def __init__(self, hass, rest, name, device_class, value_template): + """Initialize a REST binary sensor.""" + self._hass = hass + self.rest = rest + self._name = name + self._device_class = device_class + self._state = False + self._previous_data = None + self._value_template = value_template + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def available(self): + """Return the availability of this sensor.""" + return self.rest.data is not None + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + if self.rest.data is None: + return False + + response = self.rest.data + + if self._value_template is not None: + response = self._value_template.\ + async_render_with_possible_json_value(self.rest.data, False) + + try: + return bool(int(response)) + except ValueError: + return {'true': True, 'on': True, 'open': True, + 'yes': True}.get(response.lower(), False) + + def update(self): + """Get the latest data from REST API and updates the state.""" + self.rest.update() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/rflink.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/rflink.py new file mode 100644 index 00000000000000..73b912d62dae94 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/rflink.py @@ -0,0 +1,105 @@ +""" +Support for Rflink binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rflink/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.rflink import ( + CONF_ALIASES, CONF_DEVICES, RflinkDevice) +from homeassistant.const import ( + CONF_FORCE_UPDATE, CONF_NAME, CONF_DEVICE_CLASS) +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.event as evt + +CONF_OFF_DELAY = 'off_delay' +DEFAULT_FORCE_UPDATE = False + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): + cv.boolean, + vol.Optional(CONF_OFF_DELAY): cv.positive_int, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + }) + }, +}, extra=vol.ALLOW_EXTRA) + + +def devices_from_config(domain_config): + """Parse configuration and add Rflink sensor devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device = RflinkBinarySensor(device_id, **config) + devices.append(device) + + return devices + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Rflink platform.""" + async_add_entities(devices_from_config(config)) + + +class RflinkBinarySensor(RflinkDevice, BinarySensorDevice): + """Representation of an Rflink binary sensor.""" + + def __init__(self, device_id, device_class=None, + force_update=None, off_delay=None, + **kwargs): + """Handle sensor specific args and super init.""" + self._state = None + self._device_class = device_class + self._force_update = force_update + self._off_delay = off_delay + self._delay_listener = None + super().__init__(device_id, **kwargs) + + def _handle_event(self, event): + """Domain specific event handler.""" + command = event['command'] + if command == 'on': + self._state = True + elif command == 'off': + self._state = False + + if (self._state and self._off_delay is not None): + def off_delay_listener(now): + """Switch device off after a delay.""" + self._delay_listener = None + self._state = False + self.async_schedule_update_ha_state() + + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = evt.async_call_later( + self.hass, self._off_delay, off_delay_listener) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def force_update(self): + """Force update.""" + return self._force_update diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ring.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ring.py new file mode 100644 index 00000000000000..5a65917f40b73b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/ring.py @@ -0,0 +1,123 @@ +""" +This component provides HA sensor support for Ring Door Bell/Chimes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.ring/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.ring import ( + CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING) + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) + +DEPENDENCIES = ['ring'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) + +# Sensor types: Name, category, device_class +SENSOR_TYPES = { + 'ding': ['Ding', ['doorbell'], 'occupancy'], + 'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a sensor for a Ring device.""" + ring = hass.data[DATA_RING] + + sensors = [] + for device in ring.doorbells: # ring.doorbells is doing I/O + for sensor_type in config[CONF_MONITORED_CONDITIONS]: + if 'doorbell' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingBinarySensor(hass, + device, + sensor_type)) + + for device in ring.stickup_cams: # ring.stickup_cams is doing I/O + for sensor_type in config[CONF_MONITORED_CONDITIONS]: + if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingBinarySensor(hass, + device, + sensor_type)) + add_entities(sensors, True) + return True + + +class RingBinarySensor(BinarySensorDevice): + """A binary sensor implementation for Ring device.""" + + def __init__(self, hass, data, sensor_type): + """Initialize a sensor for Ring device.""" + super(RingBinarySensor, self).__init__() + self._sensor_type = sensor_type + self._data = data + self._name = "{0} {1}".format(self._data.name, + SENSOR_TYPES.get(self._sensor_type)[0]) + self._device_class = SENSOR_TYPES.get(self._sensor_type)[2] + self._state = None + self._unique_id = '{}-{}'.format(self._data.id, self._sensor_type) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + + attrs['device_id'] = self._data.id + attrs['firmware'] = self._data.firmware + attrs['timezone'] = self._data.timezone + + if self._data.alert and self._data.alert_expires_at: + attrs['expires_at'] = self._data.alert_expires_at + attrs['state'] = self._data.alert.get('state') + + return attrs + + def update(self): + """Get the latest data and updates the state.""" + self._data.check_alerts() + + if self._data.alert: + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True + else: + self._state = False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/sleepiq.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/sleepiq.py new file mode 100644 index 00000000000000..808eda4967db28 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/sleepiq.py @@ -0,0 +1,52 @@ +""" +Support for SleepIQ sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.sleepiq/ +""" +from homeassistant.components import sleepiq +from homeassistant.components.binary_sensor import BinarySensorDevice + +DEPENDENCIES = ['sleepiq'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SleepIQ sensors.""" + if discovery_info is None: + return + + data = sleepiq.DATA + data.update() + + dev = list() + for bed_id, _ in data.beds.items(): + for side in sleepiq.SIDES: + dev.append(IsInBedBinarySensor(data, bed_id, side)) + add_entities(dev) + + +class IsInBedBinarySensor(sleepiq.SleepIQSensor, BinarySensorDevice): + """Implementation of a SleepIQ presence sensor.""" + + def __init__(self, sleepiq_data, bed_id, side): + """Initialize the sensor.""" + sleepiq.SleepIQSensor.__init__(self, sleepiq_data, bed_id, side) + self.type = sleepiq.IS_IN_BED + self._state = None + self._name = sleepiq.SENSOR_TYPES[self.type] + self.update() + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state is True + + @property + def device_class(self): + """Return the class of this sensor.""" + return "occupancy" + + def update(self): + """Get the latest data from SleepIQ and updates the states.""" + sleepiq.SleepIQSensor.update(self) + self._state = self.side.is_in_bed diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/spc.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/spc.py new file mode 100644 index 00000000000000..baa25266804ad3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/spc.py @@ -0,0 +1,80 @@ +""" +Support for Vanderbilt (formerly Siemens) SPC alarm systems. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.spc/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import callback +from homeassistant.components.spc import (DATA_API, SIGNAL_UPDATE_SENSOR) + +_LOGGER = logging.getLogger(__name__) + + +def _get_device_class(zone_type): + from pyspcwebgw.const import ZoneType + return { + ZoneType.ALARM: 'motion', + ZoneType.ENTRY_EXIT: 'opening', + ZoneType.FIRE: 'smoke', + }.get(zone_type) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the SPC binary sensor.""" + if discovery_info is None: + return + api = hass.data[DATA_API] + async_add_entities([SpcBinarySensor(zone) + for zone in api.zones.values() + if _get_device_class(zone.type)]) + + +class SpcBinarySensor(BinarySensorDevice): + """Representation of a sensor based on a SPC zone.""" + + def __init__(self, zone): + """Initialize the sensor device.""" + self._zone = zone + + async def async_added_to_hass(self): + """Call for adding new entities.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SENSOR.format(self._zone.id), + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def name(self): + """Return the name of the device.""" + return self._zone.name + + @property + def is_on(self): + """Whether the device is switched on.""" + from pyspcwebgw.const import ZoneInput + return self._zone.input == ZoneInput.OPEN + + @property + def hidden(self) -> bool: + """Whether the device is hidden by default.""" + # These type of sensors are probably mainly used for automations + return True + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the device class.""" + return _get_device_class(self._zone.type) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/tapsaff.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/tapsaff.py new file mode 100644 index 00000000000000..1978a127c17e16 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/tapsaff.py @@ -0,0 +1,86 @@ +""" +Support for Taps Affs. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tapsaff/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['tapsaff==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_LOCATION = 'location' + +DEFAULT_NAME = 'Taps Aff' + +SCAN_INTERVAL = timedelta(minutes=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_LOCATION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Taps Aff binary sensor.""" + name = config.get(CONF_NAME) + location = config.get(CONF_LOCATION) + + taps_aff_data = TapsAffData(location) + + add_entities([TapsAffSensor(taps_aff_data, name)], True) + + +class TapsAffSensor(BinarySensorDevice): + """Implementation of a Taps Aff binary sensor.""" + + def __init__(self, taps_aff_data, name): + """Initialize the Taps Aff sensor.""" + self.data = taps_aff_data + self._name = name + + @property + def name(self): + """Return the name of the sensor.""" + return '{}'.format(self._name) + + @property + def is_on(self): + """Return true if taps aff.""" + return self.data.is_taps_aff + + def update(self): + """Get the latest data.""" + self.data.update() + + +class TapsAffData: + """Class for handling the data retrieval for pins.""" + + def __init__(self, location): + """Initialize the data object.""" + from tapsaff import TapsAff + + self._is_taps_aff = None + self.taps_aff = TapsAff(location) + + @property + def is_taps_aff(self): + """Return true if taps aff.""" + return self._is_taps_aff + + def update(self): + """Get the latest data from the Taps Aff API and updates the states.""" + try: + self._is_taps_aff = self.taps_aff.is_taps_aff + except RuntimeError: + _LOGGER.error("Update failed. Check configured location") diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/tcp.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/tcp.py new file mode 100644 index 00000000000000..764b6829c91b75 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/tcp.py @@ -0,0 +1,31 @@ +""" +Provides a binary sensor which gets its values from a TCP socket. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tcp/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.sensor.tcp import ( + TcpSensor, CONF_VALUE_ON, PLATFORM_SCHEMA) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the TCP binary sensor.""" + add_entities([TcpBinarySensor(hass, config)]) + + +class TcpBinarySensor(BinarySensorDevice, TcpSensor): + """A binary sensor which is on when its state == CONF_VALUE_ON.""" + + required = (CONF_VALUE_ON,) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state == self._config[CONF_VALUE_ON] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/template.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/template.py new file mode 100644 index 00000000000000..605ab24a264170 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/template.py @@ -0,0 +1,253 @@ +""" +Support for exposing a templated binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.template/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + DEVICE_CLASSES_SCHEMA) +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, + CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, + CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, MATCH_ALL) +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) + +_LOGGER = logging.getLogger(__name__) + +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up template binary sensors.""" + sensors = [] + + for device, device_config in config[CONF_SENSORS].items(): + value_template = device_config[CONF_VALUE_TEMPLATE] + icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) + entity_ids = set() + manual_entity_ids = device_config.get(ATTR_ENTITY_ID) + + invalid_templates = [] + + for tpl_name, template in ( + (CONF_VALUE_TEMPLATE, value_template), + (CONF_ICON_TEMPLATE, icon_template), + (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template), + ): + if template is None: + continue + template.hass = hass + + if manual_entity_ids is not None: + continue + + template_entity_ids = template.extract_entities() + if template_entity_ids == MATCH_ALL: + entity_ids = MATCH_ALL + # Cut off _template from name + invalid_templates.append(tpl_name[:-9]) + elif entity_ids != MATCH_ALL: + entity_ids |= set(template_entity_ids) + + if manual_entity_ids is not None: + entity_ids = manual_entity_ids + elif entity_ids != MATCH_ALL: + entity_ids = list(entity_ids) + + if invalid_templates: + _LOGGER.warning( + 'Template binary sensor %s has no entity ids configured to' + ' track nor were we able to extract the entities to track' + ' from the %s template(s). This entity will only be able' + ' to be updated manually.', + device, ', '.join(invalid_templates)) + + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) + + sensors.append( + BinarySensorTemplate( + hass, device, friendly_name, device_class, value_template, + icon_template, entity_picture_template, entity_ids, + delay_on, delay_off) + ) + if not sensors: + _LOGGER.error("No sensors added") + return False + + async_add_entities(sensors) + return True + + +class BinarySensorTemplate(BinarySensorDevice): + """A virtual binary sensor that triggers from another sensor.""" + + def __init__(self, hass, device, friendly_name, device_class, + value_template, icon_template, entity_picture_template, + entity_ids, delay_on, delay_off): + """Initialize the Template binary sensor.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device, hass=hass) + self._name = friendly_name + self._device_class = device_class + self._template = value_template + self._state = None + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._icon = None + self._entity_picture = None + self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def template_bsensor_state_listener(entity, old_state, new_state): + """Handle the target device state changes.""" + self.async_check_state() + + @callback + def template_bsensor_startup(event): + """Update template on startup.""" + if self._entities != MATCH_ALL: + # Track state change only for valid templates + async_track_state_change( + self.hass, self._entities, template_bsensor_state_listener) + + self.async_check_state() + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_bsensor_startup) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def entity_picture(self): + """Return the entity_picture to use in the frontend, if any.""" + return self._entity_picture + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def should_poll(self): + """No polling needed.""" + return False + + @callback + def _async_render(self): + """Get the state of template.""" + state = None + try: + state = (self._template.async_render().lower() == 'true') + except TemplateError as ex: + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning("Could not render template %s, " + "the state is unknown", self._name) + return + _LOGGER.error("Could not render template %s: %s", self._name, ex) + + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + + try: + setattr(self, property_name, template.async_render()) + except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) + else: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) + return state + + return state + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.async_schedule_update_ha_state() + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, period, set_state, entity_ids=self._entities, + async_check_same_func=lambda *args: self._async_render() == state) + + async def async_update(self): + """Force update of the state from the template.""" + self.async_check_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/threshold.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/threshold.py new file mode 100644 index 00000000000000..0dadf3a61fdc63 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/threshold.py @@ -0,0 +1,187 @@ +""" +Support for monitoring if a sensor value is below/above a threshold. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.threshold/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + STATE_UNKNOWN) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +ATTR_HYSTERESIS = 'hysteresis' +ATTR_LOWER = 'lower' +ATTR_POSITION = 'position' +ATTR_SENSOR_VALUE = 'sensor_value' +ATTR_TYPE = 'type' +ATTR_UPPER = 'upper' + +CONF_HYSTERESIS = 'hysteresis' +CONF_LOWER = 'lower' +CONF_UPPER = 'upper' + +DEFAULT_NAME = 'Threshold' +DEFAULT_HYSTERESIS = 0.0 + +POSITION_ABOVE = 'above' +POSITION_BELOW = 'below' +POSITION_IN_RANGE = 'in_range' +POSITION_UNKNOWN = 'unknown' + +TYPE_LOWER = 'lower' +TYPE_RANGE = 'range' +TYPE_UPPER = 'upper' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): + vol.Coerce(float), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Threshold sensor.""" + entity_id = config.get(CONF_ENTITY_ID) + name = config.get(CONF_NAME) + lower = config.get(CONF_LOWER) + upper = config.get(CONF_UPPER) + hysteresis = config.get(CONF_HYSTERESIS) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_entities([ThresholdSensor( + hass, entity_id, name, lower, upper, hysteresis, device_class)], True) + + +class ThresholdSensor(BinarySensorDevice): + """Representation of a Threshold sensor.""" + + def __init__(self, hass, entity_id, name, lower, upper, hysteresis, + device_class): + """Initialize the Threshold sensor.""" + self._hass = hass + self._entity_id = entity_id + self._name = name + self._threshold_lower = lower + self._threshold_upper = upper + self._hysteresis = hysteresis + self._device_class = device_class + + self._state_position = None + self._state = False + self.sensor_value = None + + @callback + def async_threshold_sensor_state_listener( + entity, old_state, new_state): + """Handle sensor state changes.""" + try: + self.sensor_value = None if new_state.state == STATE_UNKNOWN \ + else float(new_state.state) + except (ValueError, TypeError): + self.sensor_value = None + _LOGGER.warning("State is not numerical") + + hass.async_add_job(self.async_update_ha_state, True) + + async_track_state_change( + hass, entity_id, async_threshold_sensor_state_listener) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def threshold_type(self): + """Return the type of threshold this sensor represents.""" + if self._threshold_lower is not None and \ + self._threshold_upper is not None: + return TYPE_RANGE + if self._threshold_lower is not None: + return TYPE_LOWER + if self._threshold_upper is not None: + return TYPE_UPPER + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ENTITY_ID: self._entity_id, + ATTR_HYSTERESIS: self._hysteresis, + ATTR_LOWER: self._threshold_lower, + ATTR_POSITION: self._state_position, + ATTR_SENSOR_VALUE: self.sensor_value, + ATTR_TYPE: self.threshold_type, + ATTR_UPPER: self._threshold_upper, + } + + async def async_update(self): + """Get the latest data and updates the states.""" + def below(threshold): + """Determine if the sensor value is below a threshold.""" + return self.sensor_value < (threshold - self._hysteresis) + + def above(threshold): + """Determine if the sensor value is above a threshold.""" + return self.sensor_value > (threshold + self._hysteresis) + + if self.sensor_value is None: + self._state_position = POSITION_UNKNOWN + self._state = False + + elif self.threshold_type == TYPE_LOWER: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = True + elif above(self._threshold_lower): + self._state_position = POSITION_ABOVE + self._state = False + + elif self.threshold_type == TYPE_UPPER: + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = True + elif below(self._threshold_upper): + self._state_position = POSITION_BELOW + self._state = False + + elif self.threshold_type == TYPE_RANGE: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = False + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = False + elif above(self._threshold_lower) and below(self._threshold_upper): + self._state_position = POSITION_IN_RANGE + self._state = True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/trend.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/trend.py new file mode 100644 index 00000000000000..494c3154b848d6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/trend.py @@ -0,0 +1,191 @@ +""" +A sensor that monitors trends in other components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.trend/ +""" +from collections import deque +import logging +import math + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, CONF_ENTITY_ID, + CONF_FRIENDLY_NAME, STATE_UNKNOWN, CONF_SENSORS) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import utcnow + +REQUIREMENTS = ['numpy==1.16.0'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ATTRIBUTE = 'attribute' +ATTR_GRADIENT = 'gradient' +ATTR_MIN_GRADIENT = 'min_gradient' +ATTR_INVERT = 'invert' +ATTR_SAMPLE_DURATION = 'sample_duration' +ATTR_SAMPLE_COUNT = 'sample_count' + +CONF_ATTRIBUTE = 'attribute' +CONF_INVERT = 'invert' +CONF_MAX_SAMPLES = 'max_samples' +CONF_MIN_GRADIENT = 'min_gradient' +CONF_SAMPLE_DURATION = 'sample_duration' + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the trend sensors.""" + sensors = [] + + for device_id, device_config in config[CONF_SENSORS].items(): + entity_id = device_config[ATTR_ENTITY_ID] + attribute = device_config.get(CONF_ATTRIBUTE) + device_class = device_config.get(CONF_DEVICE_CLASS) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id) + invert = device_config[CONF_INVERT] + max_samples = device_config[CONF_MAX_SAMPLES] + min_gradient = device_config[CONF_MIN_GRADIENT] + sample_duration = device_config[CONF_SAMPLE_DURATION] + + sensors.append( + SensorTrend( + hass, device_id, friendly_name, entity_id, attribute, + device_class, invert, max_samples, min_gradient, + sample_duration) + ) + if not sensors: + _LOGGER.error("No sensors added") + return + add_entities(sensors) + + +class SensorTrend(BinarySensorDevice): + """Representation of a trend Sensor.""" + + def __init__(self, hass, device_id, friendly_name, entity_id, + attribute, device_class, invert, max_samples, + min_gradient, sample_duration): + """Initialize the sensor.""" + self._hass = hass + self.entity_id = generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._name = friendly_name + self._entity_id = entity_id + self._attribute = attribute + self._device_class = device_class + self._invert = invert + self._sample_duration = sample_duration + self._min_gradient = min_gradient + self._gradient = None + self._state = None + self.samples = deque(maxlen=max_samples) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ENTITY_ID: self._entity_id, + ATTR_FRIENDLY_NAME: self._name, + ATTR_GRADIENT: self._gradient, + ATTR_INVERT: self._invert, + ATTR_MIN_GRADIENT: self._min_gradient, + ATTR_SAMPLE_COUNT: len(self.samples), + ATTR_SAMPLE_DURATION: self._sample_duration, + } + + @property + def should_poll(self): + """No polling needed.""" + return False + + async def async_added_to_hass(self): + """Complete device setup after being added to hass.""" + @callback + def trend_sensor_state_listener(entity, old_state, new_state): + """Handle state changes on the observed device.""" + try: + if self._attribute: + state = new_state.attributes.get(self._attribute) + else: + state = new_state.state + if state != STATE_UNKNOWN: + sample = (utcnow().timestamp(), float(state)) + self.samples.append(sample) + self.async_schedule_update_ha_state(True) + except (ValueError, TypeError) as ex: + _LOGGER.error(ex) + + async_track_state_change( + self.hass, self._entity_id, + trend_sensor_state_listener) + + async def async_update(self): + """Get the latest data and update the states.""" + # Remove outdated samples + if self._sample_duration > 0: + cutoff = utcnow().timestamp() - self._sample_duration + while self.samples and self.samples[0][0] < cutoff: + self.samples.popleft() + + if len(self.samples) < 2: + return + + # Calculate gradient of linear trend + await self.hass.async_add_job(self._calculate_gradient) + + # Update state + self._state = ( + abs(self._gradient) > abs(self._min_gradient) and + math.copysign(self._gradient, self._min_gradient) == self._gradient + ) + + if self._invert: + self._state = not self._state + + def _calculate_gradient(self): + """Compute the linear trend gradient of the current samples. + + This need run inside executor. + """ + import numpy as np + timestamps = np.array([t for t, _ in self.samples]) + values = np.array([s for _, s in self.samples]) + coeffs = np.polyfit(timestamps, values, 1) + self._gradient = coeffs[0] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/uptimerobot.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/uptimerobot.py new file mode 100644 index 00000000000000..dbb83e53e9fd11 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/uptimerobot.py @@ -0,0 +1,92 @@ +""" +A platform that to monitor Uptime Robot monitors. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/binary_sensor.uptimerobot/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyuptimerobot==0.0.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TARGET = 'target' + +CONF_ATTRIBUTION = "Data provided by Uptime Robot" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Uptime Robot binary_sensors.""" + from pyuptimerobot import UptimeRobot + + up_robot = UptimeRobot() + api_key = config.get(CONF_API_KEY) + monitors = up_robot.getMonitors(api_key) + + devices = [] + if not monitors or monitors.get('stat') != 'ok': + _LOGGER.error("Error connecting to Uptime Robot") + return + + for monitor in monitors['monitors']: + devices.append(UptimeRobotBinarySensor( + api_key, up_robot, monitor['id'], monitor['friendly_name'], + monitor['url'])) + + add_entities(devices, True) + + +class UptimeRobotBinarySensor(BinarySensorDevice): + """Representation of a Uptime Robot binary sensor.""" + + def __init__(self, api_key, up_robot, monitor_id, name, target): + """Initialize Uptime Robot the binary sensor.""" + self._api_key = api_key + self._monitor_id = str(monitor_id) + self._name = name + self._target = target + self._up_robot = up_robot + self._state = None + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def device_state_attributes(self): + """Return the state attributes of the binary sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_TARGET: self._target, + } + + def update(self): + """Get the latest state of the binary sensor.""" + monitor = self._up_robot.getMonitors(self._api_key, self._monitor_id) + if not monitor or monitor.get('stat') != 'ok': + _LOGGER.warning("Failed to get new state") + return + status = monitor['monitors'][0]['status'] + self._state = 1 if status == 2 else 0 diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/vultr.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/vultr.py new file mode 100644 index 00000000000000..149a6c282901ee --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/vultr.py @@ -0,0 +1,103 @@ +""" +Support for monitoring the state of Vultr subscriptions (VPS). + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.vultr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_AUTO_BACKUPS, ATTR_ALLOWED_BANDWIDTH, + ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, ATTR_SUBSCRIPTION_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_DISK, + ATTR_COST_PER_MONTH, ATTR_OS, ATTR_REGION, ATTR_VCPUS, DATA_VULTR) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_CLASS = 'power' +DEFAULT_NAME = 'Vultr {}' +DEPENDENCIES = ['vultr'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Vultr subscription (server) binary sensor.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return + + add_entities([VultrBinarySensor(vultr, subscription, name)], True) + + +class VultrBinarySensor(BinarySensorDevice): + """Representation of a Vultr subscription sensor.""" + + def __init__(self, vultr, subscription, name): + """Initialize a new Vultr binary sensor.""" + self._vultr = vultr + self._name = name + + self.subscription = subscription + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + try: + return self._name.format(self.data['label']) + except (KeyError, TypeError): + return self._name + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.data['power_status'] == 'running' + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the Vultr subscription.""" + return { + ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'), + ATTR_AUTO_BACKUPS: self.data.get('auto_backups'), + ATTR_COST_PER_MONTH: self.data.get('cost_per_month'), + ATTR_CREATED_AT: self.data.get('date_created'), + ATTR_DISK: self.data.get('disk'), + ATTR_IPV4_ADDRESS: self.data.get('main_ip'), + ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'), + ATTR_MEMORY: self.data.get('ram'), + ATTR_OS: self.data.get('os'), + ATTR_REGION: self.data.get('location'), + ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'), + ATTR_SUBSCRIPTION_NAME: self.data.get('label'), + ATTR_VCPUS: self.data.get('vcpu_count') + } + + def update(self): + """Update state of sensor.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/workday.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/workday.py new file mode 100644 index 00000000000000..551ca835e783f6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/workday.py @@ -0,0 +1,180 @@ +""" +Sensor to indicate whether the current day is a workday. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.workday/ +""" +import logging +from datetime import datetime, timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.components.binary_sensor import BinarySensorDevice +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['holidays==0.9.9'] + +_LOGGER = logging.getLogger(__name__) + +# List of all countries currently supported by holidays +# There seems to be no way to get the list out at runtime +ALL_COUNTRIES = [ + 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', + 'Brazil', 'BR', 'Belarus', 'BY', 'Belgium', 'BE', + 'Canada', 'CA', 'Colombia', 'CO', 'Croatia', 'HR', 'Czech', 'CZ', + 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', + 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', + 'Honduras', 'HUD', + 'India', 'IND', 'Ireland', 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', + 'Mexico', 'MX', 'Netherlands', 'NL', 'NewZealand', 'NZ', + 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', + 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK', + 'South Africa', 'ZA', 'Spain', 'ES', 'Sweden', 'SE', 'Switzerland', 'CH', + 'Ukraine', 'UA', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', +] + +ALLOWED_DAYS = WEEKDAYS + ['holiday'] + +CONF_COUNTRY = 'country' +CONF_PROVINCE = 'province' +CONF_WORKDAYS = 'workdays' +CONF_EXCLUDES = 'excludes' +CONF_OFFSET = 'days_offset' + +# By default, Monday - Friday are workdays +DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] +# By default, public holidays, Saturdays and Sundays are excluded from workdays +DEFAULT_EXCLUDES = ['sat', 'sun', 'holiday'] +DEFAULT_NAME = 'Workday Sensor' +DEFAULT_OFFSET = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), + vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): + vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), + vol.Optional(CONF_PROVINCE): cv.string, + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): + vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Workday sensor.""" + import holidays + + sensor_name = config.get(CONF_NAME) + country = config.get(CONF_COUNTRY) + province = config.get(CONF_PROVINCE) + workdays = config.get(CONF_WORKDAYS) + excludes = config.get(CONF_EXCLUDES) + days_offset = config.get(CONF_OFFSET) + + year = (get_date(datetime.today()) + timedelta(days=days_offset)).year + obj_holidays = getattr(holidays, country)(years=year) + + if province: + # 'state' and 'prov' are not interchangeable, so need to make + # sure we use the right one + if (hasattr(obj_holidays, 'PROVINCES') and + province in obj_holidays.PROVINCES): + obj_holidays = getattr(holidays, country)( + prov=province, years=year) + elif (hasattr(obj_holidays, 'STATES') and + province in obj_holidays.STATES): + obj_holidays = getattr(holidays, country)( + state=province, years=year) + else: + _LOGGER.error("There is no province/state %s in country %s", + province, country) + return + + _LOGGER.debug("Found the following holidays for your configuration:") + for date, name in sorted(obj_holidays.items()): + _LOGGER.debug("%s %s", date, name) + + add_entities([IsWorkdaySensor( + obj_holidays, workdays, excludes, days_offset, sensor_name)], True) + + +def day_to_string(day): + """Convert day index 0 - 7 to string.""" + try: + return ALLOWED_DAYS[day] + except IndexError: + return None + + +def get_date(date): + """Return date. Needed for testing.""" + return date + + +class IsWorkdaySensor(BinarySensorDevice): + """Implementation of a Workday sensor.""" + + def __init__(self, obj_holidays, workdays, excludes, days_offset, name): + """Initialize the Workday sensor.""" + self._name = name + self._obj_holidays = obj_holidays + self._workdays = workdays + self._excludes = excludes + self._days_offset = days_offset + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the device.""" + return self._state + + def is_include(self, day, now): + """Check if given day is in the includes list.""" + if day in self._workdays: + return True + if 'holiday' in self._workdays and now in self._obj_holidays: + return True + + return False + + def is_exclude(self, day, now): + """Check if given day is in the excludes list.""" + if day in self._excludes: + return True + if 'holiday' in self._excludes and now in self._obj_holidays: + return True + + return False + + @property + def state_attributes(self): + """Return the attributes of the entity.""" + # return self._attributes + return { + CONF_WORKDAYS: self._workdays, + CONF_EXCLUDES: self._excludes, + CONF_OFFSET: self._days_offset + } + + async def async_update(self): + """Get date and look whether it is a holiday.""" + # Default is no workday + self._state = False + + # Get iso day of the week (1 = Monday, 7 = Sunday) + date = get_date(datetime.today()) + timedelta(days=self._days_offset) + day = date.isoweekday() - 1 + day_of_week = day_to_string(day) + + if self.is_include(day_of_week, date): + self._state = True + + if self.is_exclude(day_of_week, date): + self._state = False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/zwave.py b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/zwave.py new file mode 100644 index 00000000000000..ca07986976d0dd --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/binary_sensor/zwave.py @@ -0,0 +1,111 @@ +""" +Interfaces with Z-Wave sensors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/binary_sensor.zwave/ +""" +import logging +import datetime +import homeassistant.util.dt as dt_util +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import track_point_in_time +from homeassistant.components import zwave +from homeassistant.components.zwave import workaround +from homeassistant.components.binary_sensor import ( + DOMAIN, + BinarySensorDevice) + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = [] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave binary sensors from Config Entry.""" + @callback + def async_add_binary_sensor(binary_sensor): + """Add Z-Wave binary sensor.""" + async_add_entities([binary_sensor]) + + async_dispatcher_connect(hass, 'zwave_new_binary_sensor', + async_add_binary_sensor) + + +def get_device(values, **kwargs): + """Create Z-Wave entity device.""" + device_mapping = workaround.get_device_mapping(values.primary) + if device_mapping == workaround.WORKAROUND_NO_OFF_EVENT: + return ZWaveTriggerSensor(values, "motion") + + if workaround.get_device_component_mapping(values.primary) == DOMAIN: + return ZWaveBinarySensor(values, None) + + if values.primary.command_class == zwave.const.COMMAND_CLASS_SENSOR_BINARY: + return ZWaveBinarySensor(values, None) + return None + + +class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): + """Representation of a binary sensor within Z-Wave.""" + + def __init__(self, values, device_class): + """Initialize the sensor.""" + zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + self._sensor_type = device_class + self._state = self.values.primary.data + + def update_properties(self): + """Handle data changes for node values.""" + self._state = self.values.primary.data + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._sensor_type + + +class ZWaveTriggerSensor(ZWaveBinarySensor): + """Representation of a stateless sensor within Z-Wave.""" + + def __init__(self, values, device_class): + """Initialize the sensor.""" + super(ZWaveTriggerSensor, self).__init__(values, device_class) + # Set default off delay to 60 sec + self.re_arm_sec = 60 + self.invalidate_after = None + + def update_properties(self): + """Handle value changes for this entity's node.""" + self._state = self.values.primary.data + _LOGGER.debug('off_delay=%s', self.values.off_delay) + # Set re_arm_sec if off_delay is provided from the sensor + if self.values.off_delay: + _LOGGER.debug('off_delay.data=%s', self.values.off_delay.data) + self.re_arm_sec = self.values.off_delay.data * 8 + # only allow this value to be true for re_arm secs + if not self.hass: + return + + self.invalidate_after = dt_util.utcnow() + datetime.timedelta( + seconds=self.re_arm_sec) + track_point_in_time( + self.hass, self.async_update_ha_state, + self.invalidate_after) + + @property + def is_on(self): + """Return true if movement has happened within the rearm time.""" + return self._state and \ + (self.invalidate_after is None or + self.invalidate_after > dt_util.utcnow()) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/blink/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/blink/__init__.py new file mode 100644 index 00000000000000..82815d11a6e861 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/blink/__init__.py @@ -0,0 +1,159 @@ +""" +Support for Blink Home Camera System. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/blink/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers import ( + config_validation as cv, discovery) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_SCAN_INTERVAL, + CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, + CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) + +REQUIREMENTS = ['blinkpy==0.12.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'blink' +BLINK_DATA = 'blink' + +CONF_CAMERA = 'camera' +CONF_ALARM_CONTROL_PANEL = 'alarm_control_panel' + +DEFAULT_BRAND = 'Blink' +DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" +SIGNAL_UPDATE_BLINK = "blink_update" + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) + +TYPE_CAMERA_ARMED = 'motion_enabled' +TYPE_MOTION_DETECTED = 'motion_detected' +TYPE_TEMPERATURE = 'temperature' +TYPE_BATTERY = 'battery' +TYPE_WIFI_STRENGTH = 'wifi_strength' + +SERVICE_REFRESH = 'blink_update' +SERVICE_TRIGGER = 'trigger_camera' +SERVICE_SAVE_VIDEO = 'save_video' + +BINARY_SENSORS = { + TYPE_CAMERA_ARMED: ['Camera Armed', 'mdi:verified'], + TYPE_MOTION_DETECTED: ['Motion Detected', 'mdi:run-fast'], +} + +SENSORS = { + TYPE_TEMPERATURE: ['Temperature', TEMP_FAHRENHEIT, 'mdi:thermometer'], + TYPE_BATTERY: ['Battery', '%', 'mdi:battery-80'], + TYPE_WIFI_STRENGTH: ['Wifi Signal', 'bars', 'mdi:wifi-strength-2'], +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]) +}) + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +SERVICE_TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string +}) + +SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_FILENAME): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_BINARY_SENSORS, default={}): + BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + }) + }, + extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up Blink System.""" + from blinkpy import blinkpy + conf = config[BLINK_DATA] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + scan_interval = conf[CONF_SCAN_INTERVAL] + hass.data[BLINK_DATA] = blinkpy.Blink(username=username, + password=password) + hass.data[BLINK_DATA].refresh_rate = scan_interval.total_seconds() + hass.data[BLINK_DATA].start() + + platforms = [ + ('alarm_control_panel', {}), + ('binary_sensor', conf[CONF_BINARY_SENSORS]), + ('camera', {}), + ('sensor', conf[CONF_SENSORS]), + ] + + for component, schema in platforms: + discovery.load_platform(hass, component, DOMAIN, schema, config) + + def trigger_camera(call): + """Trigger a camera.""" + cameras = hass.data[BLINK_DATA].cameras + name = call.data[CONF_NAME] + if name in cameras: + cameras[name].snap_picture() + hass.data[BLINK_DATA].refresh(force_cache=True) + + def blink_refresh(event_time): + """Call blink to refresh info.""" + hass.data[BLINK_DATA].refresh(force_cache=True) + + async def async_save_video(call): + """Call save video service handler.""" + await async_handle_save_video_service(hass, call) + + hass.services.register(DOMAIN, SERVICE_REFRESH, blink_refresh) + hass.services.register(DOMAIN, + SERVICE_TRIGGER, + trigger_camera, + schema=SERVICE_TRIGGER_SCHEMA) + hass.services.register(DOMAIN, + SERVICE_SAVE_VIDEO, + async_save_video, + schema=SERVICE_SAVE_VIDEO_SCHEMA) + return True + + +async def async_handle_save_video_service(hass, call): + """Handle save video service calls.""" + camera_name = call.data[CONF_NAME] + video_path = call.data[CONF_FILENAME] + if not hass.config.is_allowed_path(video_path): + _LOGGER.error( + "Can't write %s, no access to path!", video_path) + return + + def _write_video(camera_name, video_path): + """Call video write.""" + all_cameras = hass.data[BLINK_DATA].cameras + if camera_name in all_cameras: + all_cameras[camera_name].video_to_file(video_path) + + try: + await hass.async_add_executor_job( + _write_video, camera_name, video_path) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/blink/alarm_control_panel.py b/homeassistant-0.88.0.dev0/homeassistant/components/blink/alarm_control_panel.py new file mode 100644 index 00000000000000..77267fd7516c8a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/blink/alarm_control_panel.py @@ -0,0 +1,91 @@ +""" +Support for Blink Alarm Control Panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.blink/ +""" +import logging + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.blink import ( + BLINK_DATA, DEFAULT_ATTRIBUTION) +from homeassistant.const import ( + ATTR_ATTRIBUTION, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['blink'] + +ICON = 'mdi:security' + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Arlo Alarm Control Panels.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + + sync_modules = [] + for sync_name, sync_module in data.sync.items(): + sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) + add_entities(sync_modules, True) + + +class BlinkSyncModule(AlarmControlPanel): + """Representation of a Blink Alarm Control Panel.""" + + def __init__(self, data, name, sync): + """Initialize the alarm control panel.""" + self.data = data + self.sync = sync + self._name = name + self._state = None + + @property + def unique_id(self): + """Return the unique id for the sync module.""" + return self.sync.serial + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the panel.""" + return "{} {}".format(BLINK_DATA, self._name) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = self.sync.attributes + attr['network_info'] = self.data.networks + attr['associated_cameras'] = list(self.sync.cameras.keys()) + attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION + return attr + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) + self.data.refresh() + mode = self.sync.arm + if mode: + self._state = STATE_ALARM_ARMED_AWAY + else: + self._state = STATE_ALARM_DISARMED + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self.sync.arm = False + self.sync.refresh() + + def alarm_arm_away(self, code=None): + """Send arm command.""" + self.sync.arm = True + self.sync.refresh() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/blink/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/blink/binary_sensor.py new file mode 100644 index 00000000000000..cd558f0368487d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/blink/binary_sensor.py @@ -0,0 +1,54 @@ +""" +Support for Blink system camera control. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.blink. +""" +from homeassistant.components.blink import BLINK_DATA, BINARY_SENSORS +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['blink'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the blink binary sensors.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + + devs = [] + for camera in data.cameras: + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + devs.append(BlinkBinarySensor(data, camera, sensor_type)) + add_entities(devs, True) + + +class BlinkBinarySensor(BinarySensorDevice): + """Representation of a Blink binary sensor.""" + + def __init__(self, data, camera, sensor_type): + """Initialize the sensor.""" + self.data = data + self._type = sensor_type + name, icon = BINARY_SENSORS[sensor_type] + self._name = "{} {} {}".format(BLINK_DATA, camera, name) + self._icon = icon + self._camera = data.cameras[camera] + self._state = None + self._unique_id = "{}-{}".format(self._camera.serial, self._type) + + @property + def name(self): + """Return the name of the blink sensor.""" + return self._name + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def update(self): + """Update sensor state.""" + self.data.refresh() + self._state = self._camera.attributes[self._type] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/blink/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/blink/camera.py new file mode 100644 index 00000000000000..e904791445630a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/blink/camera.py @@ -0,0 +1,82 @@ +""" +Support for Blink system camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.blink/ +""" +import logging + +from homeassistant.components.blink import BLINK_DATA, DEFAULT_BRAND +from homeassistant.components.camera import Camera + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['blink'] + +ATTR_VIDEO_CLIP = 'video' +ATTR_IMAGE = 'image' + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Blink Camera.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + devs = [] + for name, camera in data.cameras.items(): + devs.append(BlinkCamera(data, name, camera)) + + add_entities(devs) + + +class BlinkCamera(Camera): + """An implementation of a Blink Camera.""" + + def __init__(self, data, name, camera): + """Initialize a camera.""" + super().__init__() + self.data = data + self._name = "{} {}".format(BLINK_DATA, name) + self._camera = camera + self._unique_id = "{}-camera".format(camera.serial) + self.response = None + self.current_image = None + self.last_image = None + _LOGGER.debug("Initialized blink camera %s", self._name) + + @property + def name(self): + """Return the camera name.""" + return self._name + + @property + def unique_id(self): + """Return the unique camera id.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return the camera attributes.""" + return self._camera.attributes + + def enable_motion_detection(self): + """Enable motion detection for the camera.""" + self._camera.set_motion_detect(True) + + def disable_motion_detection(self): + """Disable motion detection for the camera.""" + self._camera.set_motion_detect(False) + + @property + def motion_detection_enabled(self): + """Return the state of the camera.""" + return self._camera.motion_enabled + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_BRAND + + def camera_image(self): + """Return a still image response from the camera.""" + return self._camera.image_from_cache.content diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/blink/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/blink/sensor.py new file mode 100644 index 00000000000000..a1a07cb2a7326f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/blink/sensor.py @@ -0,0 +1,85 @@ +""" +Support for Blink system camera sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.blink/ +""" +import logging + +from homeassistant.components.blink import BLINK_DATA, SENSORS +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_MONITORED_CONDITIONS + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['blink'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Blink sensor.""" + if discovery_info is None: + return + data = hass.data[BLINK_DATA] + devs = [] + for camera in data.cameras: + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + devs.append(BlinkSensor(data, camera, sensor_type)) + + add_entities(devs, True) + + +class BlinkSensor(Entity): + """A Blink camera sensor.""" + + def __init__(self, data, camera, sensor_type): + """Initialize sensors from Blink camera.""" + name, units, icon = SENSORS[sensor_type] + self._name = "{} {} {}".format( + BLINK_DATA, camera, name) + self._camera_name = name + self._type = sensor_type + self.data = data + self._camera = data.cameras[camera] + self._state = None + self._unit_of_measurement = units + self._icon = icon + self._unique_id = "{}-{}".format(self._camera.serial, self._type) + self._sensor_key = self._type + if self._type == 'temperature': + self._sensor_key = 'temperature_calibrated' + + @property + def name(self): + """Return the name of the camera.""" + return self._name + + @property + def unique_id(self): + """Return the unique id for the camera sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the sensor.""" + return self._icon + + @property + def state(self): + """Return the camera's current state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Retrieve sensor data from the camera.""" + self.data.refresh() + try: + self._state = self._camera.attributes[self._sensor_key] + except KeyError: + self._state = None + _LOGGER.error( + "%s not a valid camera attribute. Did the API change?", + self._sensor_key) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/blink/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/blink/services.yaml new file mode 100644 index 00000000000000..fc042b0d5986cc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/blink/services.yaml @@ -0,0 +1,21 @@ +# Describes the format for available Blink services + +blink_update: + description: Force a refresh. + +trigger_camera: + description: Request named camera to take new image. + fields: + name: + description: Name of camera to take new image. + example: 'Living Room' + +save_video: + description: Save last recorded video clip to local file. + fields: + name: + description: Name of camera to grab video from. + example: 'Living Room' + filename: + description: Filename to writable path (directory may need to be included in whitelist_dirs in config) + example: '/tmp/video.mp4' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/__init__.py new file mode 100644 index 00000000000000..55fb15c686d852 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/__init__.py @@ -0,0 +1,80 @@ +""" +Support for BloomSky weather station. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/bloomsky/ +""" +from datetime import timedelta +import logging + +from aiohttp.hdrs import AUTHORIZATION +import requests +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +BLOOMSKY = None +BLOOMSKY_TYPE = ['camera', 'binary_sensor', 'sensor'] + +DOMAIN = 'bloomsky' + +# The BloomSky only updates every 5-8 minutes as per the API spec so there's +# no point in polling the API more frequently +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the BloomSky component.""" + api_key = config[DOMAIN][CONF_API_KEY] + + global BLOOMSKY + try: + BLOOMSKY = BloomSky(api_key) + except RuntimeError: + return False + + for component in BLOOMSKY_TYPE: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class BloomSky: + """Handle all communication with the BloomSky API.""" + + # API documentation at http://weatherlution.com/bloomsky-api/ + API_URL = 'http://api.bloomsky.com/api/skydata' + + def __init__(self, api_key): + """Initialize the BookSky.""" + self._api_key = api_key + self.devices = {} + _LOGGER.debug("Initial BloomSky device load...") + self.refresh_devices() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_devices(self): + """Use the API to retrieve a list of devices.""" + _LOGGER.debug("Fetching BloomSky update") + response = requests.get( + self.API_URL, headers={AUTHORIZATION: self._api_key}, timeout=10) + if response.status_code == 401: + raise RuntimeError("Invalid API_KEY") + elif response.status_code != 200: + _LOGGER.error("Invalid HTTP response: %s", response.status_code) + return + # Create dictionary keyed off of the device unique id + self.devices.update({ + device['DeviceID']: device for device in response.json() + }) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/binary_sensor.py new file mode 100644 index 00000000000000..971941f4dd66e1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/binary_sensor.py @@ -0,0 +1,80 @@ +""" +Support the binary sensors of a BloomSky weather station. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bloomsky/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bloomsky'] + +SENSOR_TYPES = { + 'Rain': 'moisture', + 'Night': None, +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available BloomSky weather binary sensors.""" + bloomsky = hass.components.bloomsky + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) + + for device in bloomsky.BLOOMSKY.devices.values(): + for variable in sensors: + add_entities( + [BloomSkySensor(bloomsky.BLOOMSKY, device, variable)], True) + + +class BloomSkySensor(BinarySensorDevice): + """Representation of a single binary sensor in a BloomSky device.""" + + def __init__(self, bs, device, sensor_name): + """Initialize a BloomSky binary sensor.""" + self._bloomsky = bs + self._device_id = device['DeviceID'] + self._sensor_name = sensor_name + self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._state = None + self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of the BloomSky device and this sensor.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return SENSOR_TYPES.get(self._sensor_name) + + @property + def is_on(self): + """Return true if binary sensor is on.""" + return self._state + + def update(self): + """Request an update from the BloomSky API.""" + self._bloomsky.refresh_devices() + + self._state = \ + self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/camera.py new file mode 100644 index 00000000000000..1c9266ca3a7240 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/camera.py @@ -0,0 +1,64 @@ +""" +Support for a camera of a BloomSky weather station. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/camera.bloomsky/ +""" +import logging + +import requests + +from homeassistant.components.camera import Camera + +DEPENDENCIES = ['bloomsky'] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up access to BloomSky cameras.""" + bloomsky = hass.components.bloomsky + for device in bloomsky.BLOOMSKY.devices.values(): + add_entities([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) + + +class BloomSkyCamera(Camera): + """Representation of the images published from the BloomSky's camera.""" + + def __init__(self, bs, device): + """Initialize access to the BloomSky camera images.""" + super(BloomSkyCamera, self).__init__() + self._name = device['DeviceName'] + self._id = device['DeviceID'] + self._bloomsky = bs + self._url = "" + self._last_url = "" + # last_image will store images as they are downloaded so that the + # frequent updates in home-assistant don't keep poking the server + # to download the same image over and over. + self._last_image = "" + self._logger = logging.getLogger(__name__) + + def camera_image(self): + """Update the camera's image if it has changed.""" + try: + self._url = self._bloomsky.devices[self._id]['Data']['ImageURL'] + self._bloomsky.refresh_devices() + # If the URL hasn't changed then the image hasn't changed. + if self._url != self._last_url: + response = requests.get(self._url, timeout=10) + self._last_url = self._url + self._last_image = response.content + except requests.exceptions.RequestException as error: + self._logger.error("Error getting bloomsky image: %s", error) + return None + + return self._last_image + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def name(self): + """Return the name of this BloomSky device.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/sensor.py new file mode 100644 index 00000000000000..02cd456107ff3d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bloomsky/sensor.py @@ -0,0 +1,98 @@ +""" +Support the sensor of a BloomSky weather station. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.bloomsky/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bloomsky'] + +# These are the available sensors +SENSOR_TYPES = ['Temperature', + 'Humidity', + 'Pressure', + 'Luminance', + 'UVIndex', + 'Voltage'] + +# Sensor units - these do not currently align with the API documentation +SENSOR_UNITS = {'Temperature': TEMP_FAHRENHEIT, + 'Humidity': '%', + 'Pressure': 'inHg', + 'Luminance': 'cd/m²', + 'Voltage': 'mV'} + +# Which sensors to format numerically +FORMAT_NUMBERS = ['Temperature', 'Pressure', 'Voltage'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available BloomSky weather sensors.""" + bloomsky = hass.components.bloomsky + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) + + for device in bloomsky.BLOOMSKY.devices.values(): + for variable in sensors: + add_entities( + [BloomSkySensor(bloomsky.BLOOMSKY, device, variable)], True) + + +class BloomSkySensor(Entity): + """Representation of a single sensor in a BloomSky device.""" + + def __init__(self, bs, device, sensor_name): + """Initialize a BloomSky sensor.""" + self._bloomsky = bs + self._device_id = device['DeviceID'] + self._sensor_name = sensor_name + self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._state = None + self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of the BloomSky device and this sensor.""" + return self._name + + @property + def state(self): + """Return the current state, eg. value, of this sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the sensor units.""" + return SENSOR_UNITS.get(self._sensor_name, None) + + def update(self): + """Request an update from the BloomSky API.""" + self._bloomsky.refresh_devices() + + state = \ + self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] + + if self._sensor_name in FORMAT_NUMBERS: + self._state = '{0:.2f}'.format(state) + else: + self._state = state diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000000..40f2b91045afda --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/__init__.py @@ -0,0 +1,160 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/bmw_connected_drive/ +""" +import datetime +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) +from homeassistant.helpers import discovery +from homeassistant.helpers.event import track_utc_time_change +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['bimmer_connected==0.5.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'bmw_connected_drive' +CONF_REGION = 'region' +CONF_READ_ONLY = 'read_only' +ATTR_VIN = 'vin' + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_REGION): vol.Any('north_america', 'china', + 'rest_of_world'), + vol.Optional(CONF_READ_ONLY, default=False): cv.boolean, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: ACCOUNT_SCHEMA + }, +}, extra=vol.ALLOW_EXTRA) + +SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_VIN): cv.string, +}) + + +BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor'] +UPDATE_INTERVAL = 5 # in minutes + +SERVICE_UPDATE_STATE = 'update_state' + +_SERVICE_MAP = { + 'light_flash': 'trigger_remote_light_flash', + 'sound_horn': 'trigger_remote_horn', + 'activate_air_conditioning': 'trigger_remote_air_conditioning', +} + + +def setup(hass, config: dict): + """Set up the BMW connected drive components.""" + accounts = [] + for name, account_config in config[DOMAIN].items(): + accounts.append(setup_account(account_config, hass, name)) + + hass.data[DOMAIN] = accounts + + def _update_all(call) -> None: + """Update all BMW accounts.""" + for cd_account in hass.data[DOMAIN]: + cd_account.update() + + # Service to manually trigger updates for all accounts. + hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all) + + _update_all(None) + + for component in BMW_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +def setup_account(account_config: dict, hass, name: str) \ + -> 'BMWConnectedDriveAccount': + """Set up a new BMWConnectedDriveAccount based on the config.""" + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + region = account_config[CONF_REGION] + read_only = account_config[CONF_READ_ONLY] + + _LOGGER.debug('Adding new account %s', name) + cd_account = BMWConnectedDriveAccount(username, password, region, name, + read_only) + + def execute_service(call): + """Execute a service for a vehicle. + + This must be a member function as we need access to the cd_account + object here. + """ + vin = call.data[ATTR_VIN] + vehicle = cd_account.account.get_vehicle(vin) + if not vehicle: + _LOGGER.error('Could not find a vehicle for VIN "%s"!', vin) + return + function_name = _SERVICE_MAP[call.service] + function_call = getattr(vehicle.remote_services, function_name) + function_call() + if not read_only: + # register the remote services + for service in _SERVICE_MAP: + hass.services.register( + DOMAIN, service, + execute_service, + schema=SERVICE_SCHEMA) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + now = datetime.datetime.now() + track_utc_time_change( + hass, cd_account.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second) + + return cd_account + + +class BMWConnectedDriveAccount: + """Representation of a BMW vehicle.""" + + def __init__(self, username: str, password: str, region_str: str, + name: str, read_only) -> None: + """Constructor.""" + from bimmer_connected.account import ConnectedDriveAccount + from bimmer_connected.country_selector import get_region_from_name + + region = get_region_from_name(region_str) + + self.read_only = read_only + self.account = ConnectedDriveAccount(username, password, region) + self.name = name + self._update_listeners = [] + + def update(self, *_): + """Update the state of all vehicles. + + Notify all listeners about the update. + """ + _LOGGER.debug('Updating vehicle state for account %s, ' + 'notifying %d listeners', + self.name, len(self._update_listeners)) + try: + self.account.update_vehicle_states() + for listener in self._update_listeners: + listener() + except IOError as exception: + _LOGGER.error('Error updating the vehicle state.') + _LOGGER.exception(exception) + + def add_update_listener(self, listener): + """Add a listener for update notifications.""" + self._update_listeners.append(listener) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/binary_sensor.py new file mode 100644 index 00000000000000..f8855b2e28b633 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -0,0 +1,203 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.const import LENGTH_KILOMETERS + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + 'lids': ['Doors', 'opening'], + 'windows': ['Windows', 'opening'], + 'door_lock_state': ['Door lock state', 'safety'], + 'lights_parking': ['Parking lights', 'light'], + 'condition_based_services': ['Condition based services', 'problem'], + 'check_control_messages': ['Control messages', 'problem'] +} + +SENSOR_TYPES_ELEC = { + 'charging_status': ['Charging status', 'power'], + 'connection_status': ['Connection status', 'plug'] +} + +SENSOR_TYPES_ELEC.update(SENSOR_TYPES) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BMW sensors.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + devices = [] + for account in accounts: + for vehicle in account.account.vehicles: + if vehicle.has_hv_battery: + _LOGGER.debug('BMW with a high voltage battery') + for key, value in sorted(SENSOR_TYPES_ELEC.items()): + device = BMWConnectedDriveSensor(account, vehicle, key, + value[0], value[1]) + devices.append(device) + elif vehicle.has_internal_combustion_engine: + _LOGGER.debug('BMW with an internal combustion engine') + for key, value in sorted(SENSOR_TYPES.items()): + device = BMWConnectedDriveSensor(account, vehicle, key, + value[0], value[1]) + devices.append(device) + add_entities(devices, True) + + +class BMWConnectedDriveSensor(BinarySensorDevice): + """Representation of a BMW vehicle binary sensor.""" + + def __init__(self, account, vehicle, attribute: str, sensor_name, + device_class): + """Constructor.""" + self._account = account + self._vehicle = vehicle + self._attribute = attribute + self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) + self._sensor_name = sensor_name + self._device_class = device_class + self._state = None + + @property + def should_poll(self) -> bool: + """Return False. + + Data update is triggered from BMWConnectedDriveEntity. + """ + return False + + @property + def unique_id(self): + """Return the unique ID of the binary sensor.""" + return self._unique_id + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of the binary sensor.""" + vehicle_state = self._vehicle.state + result = { + 'car': self._vehicle.name + } + + if self._attribute == 'lids': + for lid in vehicle_state.lids: + result[lid.name] = lid.state.value + elif self._attribute == 'windows': + for window in vehicle_state.windows: + result[window.name] = window.state.value + elif self._attribute == 'door_lock_state': + result['door_lock_state'] = vehicle_state.door_lock_state.value + result['last_update_reason'] = vehicle_state.last_update_reason + elif self._attribute == 'lights_parking': + result['lights_parking'] = vehicle_state.parking_lights.value + elif self._attribute == 'condition_based_services': + for report in vehicle_state.condition_based_services: + result.update( + self._format_cbs_report(report)) + elif self._attribute == 'check_control_messages': + check_control_messages = vehicle_state.check_control_messages + if not check_control_messages: + result['check_control_messages'] = 'OK' + else: + cbs_list = [] + for message in check_control_messages: + cbs_list.append(message['ccmDescriptionShort']) + result['check_control_messages'] = cbs_list + elif self._attribute == 'charging_status': + result['charging_status'] = vehicle_state.charging_status.value + # pylint: disable=protected-access + result['last_charging_end_result'] = \ + vehicle_state._attributes['lastChargingEndResult'] + if self._attribute == 'connection_status': + # pylint: disable=protected-access + result['connection_status'] = \ + vehicle_state._attributes['connectionStatus'] + + return sorted(result.items()) + + def update(self): + """Read new state data from the library.""" + from bimmer_connected.state import LockState + from bimmer_connected.state import ChargingState + vehicle_state = self._vehicle.state + + # device class opening: On means open, Off means closed + if self._attribute == 'lids': + _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) + self._state = not vehicle_state.all_lids_closed + if self._attribute == 'windows': + self._state = not vehicle_state.all_windows_closed + # device class safety: On means unsafe, Off means safe + if self._attribute == 'door_lock_state': + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + self._state = vehicle_state.door_lock_state not in \ + [LockState.LOCKED, LockState.SECURED] + # device class light: On means light detected, Off means no light + if self._attribute == 'lights_parking': + self._state = vehicle_state.are_parking_lights_on + # device class problem: On means problem detected, Off means no problem + if self._attribute == 'condition_based_services': + self._state = not vehicle_state.are_all_cbs_ok + if self._attribute == 'check_control_messages': + self._state = vehicle_state.has_check_control_messages + # device class power: On means power detected, Off means no power + if self._attribute == 'charging_status': + self._state = vehicle_state.charging_status in \ + [ChargingState.CHARGING] + # device class plug: On means device is plugged in, + # Off means device is unplugged + if self._attribute == 'connection_status': + # pylint: disable=protected-access + self._state = (vehicle_state._attributes['connectionStatus'] == + 'CONNECTED') + + def _format_cbs_report(self, report): + result = {} + service_type = report.service_type.lower().replace('_', ' ') + result['{} status'.format(service_type)] = report.state.value + if report.due_date is not None: + result['{} date'.format(service_type)] = \ + report.due_date.strftime('%Y-%m-%d') + if report.due_distance is not None: + distance = round(self.hass.config.units.length( + report.due_distance, LENGTH_KILOMETERS)) + result['{} distance'.format(service_type)] = '{} {}'.format( + distance, self.hass.config.units.length_unit) + return result + + def update_callback(self): + """Schedule a state update.""" + self.schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Add callback after being added to hass. + + Show latest data after startup. + """ + self._account.add_update_listener(self.update_callback) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/device_tracker.py new file mode 100644 index 00000000000000..02a12653180705 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -0,0 +1,58 @@ +"""Device tracker for BMW Connected Drive vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN \ + as BMW_DOMAIN +from homeassistant.util import slugify + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the BMW tracker.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + for account in accounts: + for vehicle in account.account.vehicles: + tracker = BMWDeviceTracker(see, vehicle) + account.add_update_listener(tracker.update) + tracker.update() + return True + + +class BMWDeviceTracker: + """BMW Connected Drive device tracker.""" + + def __init__(self, see, vehicle): + """Initialize the Tracker.""" + self._see = see + self.vehicle = vehicle + + def update(self) -> None: + """Update the device info. + + Only update the state in home assistant if tracking in + the car is enabled. + """ + dev_id = slugify(self.vehicle.name) + + if not self.vehicle.state.is_vehicle_tracking_enabled: + _LOGGER.debug('Tracking is disabled for vehicle %s', dev_id) + return + + _LOGGER.debug('Updating %s', dev_id) + attrs = { + 'vin': self.vehicle.vin, + } + self._see( + dev_id=dev_id, host_name=self.vehicle.name, + gps=self.vehicle.state.gps_position, attributes=attrs, + icon='mdi:car' + ) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/lock.py new file mode 100644 index 00000000000000..b13665610d80c4 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/lock.py @@ -0,0 +1,118 @@ +""" +Support for BMW cars with BMW ConnectedDrive. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/lock.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.lock import LockDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BMW Connected Drive lock.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + devices = [] + for account in accounts: + if not account.read_only: + for vehicle in account.account.vehicles: + device = BMWLock(account, vehicle, 'lock', 'BMW lock') + devices.append(device) + add_entities(devices, True) + + +class BMWLock(LockDevice): + """Representation of a BMW vehicle lock.""" + + def __init__(self, account, vehicle, attribute: str, sensor_name): + """Initialize the lock.""" + self._account = account + self._vehicle = vehicle + self._attribute = attribute + self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) + self._sensor_name = sensor_name + self._state = None + + @property + def should_poll(self): + """Do not poll this class. + + Updates are triggered from BMWConnectedDriveAccount. + """ + return False + + @property + def unique_id(self): + """Return the unique ID of the lock.""" + return self._unique_id + + @property + def name(self): + """Return the name of the lock.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the lock.""" + vehicle_state = self._vehicle.state + return { + 'car': self._vehicle.name, + 'door_lock_state': vehicle_state.door_lock_state.value + } + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + def lock(self, **kwargs): + """Lock the car.""" + _LOGGER.debug("%s: locking doors", self._vehicle.name) + # Optimistic state set here because it takes some time before the + # update callback response + self._state = STATE_LOCKED + self.schedule_update_ha_state() + self._vehicle.remote_services.trigger_remote_door_lock() + + def unlock(self, **kwargs): + """Unlock the car.""" + _LOGGER.debug("%s: unlocking doors", self._vehicle.name) + # Optimistic state set here because it takes some time before the + # update callback response + self._state = STATE_UNLOCKED + self.schedule_update_ha_state() + self._vehicle.remote_services.trigger_remote_door_unlock() + + def update(self): + """Update state of the lock.""" + from bimmer_connected.state import LockState + + _LOGGER.debug("%s: updating data for %s", self._vehicle.name, + self._attribute) + vehicle_state = self._vehicle.state + + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + self._state = STATE_LOCKED \ + if vehicle_state.door_lock_state \ + in [LockState.LOCKED, LockState.SECURED] \ + else STATE_UNLOCKED + + def update_callback(self): + """Schedule a state update.""" + self.schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Add callback after being added to hass. + + Show latest data after startup. + """ + self._account.add_update_listener(self.update_callback) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/sensor.py new file mode 100644 index 00000000000000..a7ee5724d19980 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/sensor.py @@ -0,0 +1,163 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.const import (CONF_UNIT_SYSTEM_IMPERIAL, VOLUME_LITERS, + VOLUME_GALLONS, LENGTH_KILOMETERS, + LENGTH_MILES) + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TO_HA_METRIC = { + 'mileage': ['mdi:speedometer', LENGTH_KILOMETERS], + 'remaining_range_total': ['mdi:ruler', LENGTH_KILOMETERS], + 'remaining_range_electric': ['mdi:ruler', LENGTH_KILOMETERS], + 'remaining_range_fuel': ['mdi:ruler', LENGTH_KILOMETERS], + 'max_range_electric': ['mdi:ruler', LENGTH_KILOMETERS], + 'remaining_fuel': ['mdi:gas-station', VOLUME_LITERS], + 'charging_time_remaining': ['mdi:update', 'h'], + 'charging_status': ['mdi:battery-charging', None] +} + +ATTR_TO_HA_IMPERIAL = { + 'mileage': ['mdi:speedometer', LENGTH_MILES], + 'remaining_range_total': ['mdi:ruler', LENGTH_MILES], + 'remaining_range_electric': ['mdi:ruler', LENGTH_MILES], + 'remaining_range_fuel': ['mdi:ruler', LENGTH_MILES], + 'max_range_electric': ['mdi:ruler', LENGTH_MILES], + 'remaining_fuel': ['mdi:gas-station', VOLUME_GALLONS], + 'charging_time_remaining': ['mdi:update', 'h'], + 'charging_status': ['mdi:battery-charging', None] +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the BMW sensors.""" + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + attribute_info = ATTR_TO_HA_IMPERIAL + else: + attribute_info = ATTR_TO_HA_METRIC + + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + devices = [] + for account in accounts: + for vehicle in account.account.vehicles: + for attribute_name in vehicle.drive_train_attributes: + device = BMWConnectedDriveSensor(account, vehicle, + attribute_name, + attribute_info) + devices.append(device) + device = BMWConnectedDriveSensor(account, vehicle, 'mileage', + attribute_info) + devices.append(device) + add_entities(devices, True) + + +class BMWConnectedDriveSensor(Entity): + """Representation of a BMW vehicle sensor.""" + + def __init__(self, account, vehicle, attribute: str, attribute_info): + """Constructor.""" + self._vehicle = vehicle + self._account = account + self._attribute = attribute + self._state = None + self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) + self._attribute_info = attribute_info + + @property + def should_poll(self) -> bool: + """Return False. + + Data update is triggered from BMWConnectedDriveEntity. + """ + return False + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + from bimmer_connected.state import ChargingState + vehicle_state = self._vehicle.state + charging_state = vehicle_state.charging_status in [ + ChargingState.CHARGING] + + if self._attribute == 'charging_level_hv': + return icon_for_battery_level( + battery_level=vehicle_state.charging_level_hv, + charging=charging_state) + icon, _ = self._attribute_info.get(self._attribute, [None, None]) + return icon + + @property + def state(self): + """Return the state of the sensor. + + The return type of this call depends on the attribute that + is configured. + """ + return self._state + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement.""" + _, unit = self._attribute_info.get(self._attribute, [None, None]) + return unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'car': self._vehicle.name + } + + def update(self) -> None: + """Read new state data from the library.""" + _LOGGER.debug('Updating %s', self._vehicle.name) + vehicle_state = self._vehicle.state + if self._attribute == 'charging_status': + self._state = getattr(vehicle_state, self._attribute).value + elif self.unit_of_measurement == VOLUME_GALLONS: + value = getattr(vehicle_state, self._attribute) + value_converted = self.hass.config.units.volume(value, + VOLUME_LITERS) + self._state = round(value_converted) + elif self.unit_of_measurement == LENGTH_MILES: + value = getattr(vehicle_state, self._attribute) + value_converted = self.hass.config.units.length(value, + LENGTH_KILOMETERS) + self._state = round(value_converted) + else: + self._state = getattr(vehicle_state, self._attribute) + + def update_callback(self): + """Schedule a state update.""" + self.schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Add callback after being added to hass. + + Show latest data after startup. + """ + self._account.add_update_listener(self.update_callback) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/services.yaml new file mode 100644 index 00000000000000..b9605429a8efa0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/bmw_connected_drive/services.yaml @@ -0,0 +1,42 @@ +# Describes the format for available services for bmw_connected_drive +# +# The services related to locking/unlocking are implemented in the lock +# component to avoid redundancy. + +light_flash: + description: > + Flash the lights of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +sound_horn: + description: > + Sound the horn of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +activate_air_conditioning: + description: > + Start the air conditioning of the vehicle. What exactly is started here + depends on the type of vehicle. It might range from just ventilation over + auxiliary heating to real air conditioning. The vehicle is identified via + the vin (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +update_state: + description: > + Fetch the last state of the vehicles of all your accounts from the BMW + server. This does *not* trigger an update from the vehicle, it just gets + the data from the BMW servers. This service does not require any attributes. diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/browser.py b/homeassistant-0.88.0.dev0/homeassistant/components/browser.py new file mode 100644 index 00000000000000..041a0f9cdc6476 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/browser.py @@ -0,0 +1,30 @@ +""" +Provides functionality to launch a web browser on the host machine. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/browser/ +""" +import voluptuous as vol + +DOMAIN = "browser" +SERVICE_BROWSE_URL = "browse_url" + +ATTR_URL = 'url' +ATTR_URL_DEFAULT = 'https://www.google.com' + +SERVICE_BROWSE_URL_SCHEMA = vol.Schema({ + # pylint: disable=no-value-for-parameter + vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(), +}) + + +def setup(hass, config): + """Listen for browse_url events.""" + import webbrowser + + hass.services.register(DOMAIN, SERVICE_BROWSE_URL, + lambda service: + webbrowser.open(service.data[ATTR_URL]), + schema=SERVICE_BROWSE_URL_SCHEMA) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/calendar/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/__init__.py new file mode 100644 index 00000000000000..9d105fb02d0ffe --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/__init__.py @@ -0,0 +1,246 @@ +""" +Support for Google Calendar event device sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar/ +""" +import logging +from datetime import timedelta +import re + +from aiohttp import web + +from homeassistant.components.google import ( + CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.config_validation import time_period_str +from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.components import http + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'calendar' + +DEPENDENCIES = ['http'] + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SCAN_INTERVAL = timedelta(seconds=60) + + +async def async_setup(hass, config): + """Track states and offer events for calendars.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN) + + hass.http.register_view(CalendarListView(component)) + hass.http.register_view(CalendarEventView(component)) + + # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 + # await hass.components.frontend.async_register_built_in_panel( + # 'calendar', 'calendar', 'hass:calendar') + + await component.async_setup(config) + return True + + +DEFAULT_CONF_TRACK_NEW = True +DEFAULT_CONF_OFFSET = '!!' + + +def get_date(date): + """Get the dateTime from date or dateTime as a local.""" + if 'date' in date: + return dt.start_of_local_day(dt.dt.datetime.combine( + dt.parse_date(date['date']), dt.dt.time.min)) + return dt.as_local(dt.parse_datetime(date['dateTime'])) + + +class CalendarEventDevice(Entity): + """A calendar event device.""" + + # Classes overloading this must set data to an object + # with an update() method + data = None + + def __init__(self, hass, data): + """Create the Calendar Event Device.""" + self._name = data.get(CONF_NAME) + self.dev_id = data.get(CONF_DEVICE_ID) + self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) + self.entity_id = generate_entity_id( + ENTITY_ID_FORMAT, self.dev_id, hass=hass) + + self._cal_data = { + 'all_day': False, + 'offset_time': dt.dt.timedelta(), + 'message': '', + 'start': None, + 'end': None, + 'location': '', + 'description': '', + } + + self.update() + + def offset_reached(self): + """Have we reached the offset time specified in the event title.""" + if self._cal_data['start'] is None or \ + self._cal_data['offset_time'] == dt.dt.timedelta(): + return False + + return self._cal_data['start'] + self._cal_data['offset_time'] <= \ + dt.now(self._cal_data['start'].tzinfo) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + start = self._cal_data.get('start', None) + end = self._cal_data.get('end', None) + start = start.strftime(DATE_STR_FORMAT) if start is not None else None + end = end.strftime(DATE_STR_FORMAT) if end is not None else None + + return { + 'message': self._cal_data.get('message', ''), + 'all_day': self._cal_data.get('all_day', False), + 'offset_reached': self.offset_reached(), + 'start_time': start, + 'end_time': end, + 'location': self._cal_data.get('location', None), + 'description': self._cal_data.get('description', None), + } + + @property + def state(self): + """Return the state of the calendar event.""" + start = self._cal_data.get('start', None) + end = self._cal_data.get('end', None) + if start is None or end is None: + return STATE_OFF + + now = dt.now() + + if start <= now < end: + return STATE_ON + + if now >= end: + self.cleanup() + + return STATE_OFF + + def cleanup(self): + """Cleanup any start/end listeners that were setup.""" + self._cal_data = { + 'all_day': False, + 'offset_time': 0, + 'message': '', + 'start': None, + 'end': None, + 'location': None, + 'description': None + } + + def update(self): + """Search for the next event.""" + if not self.data or not self.data.update(): + # update cached, don't do anything + return + + if not self.data.event: + # we have no event to work on, make sure we're clean + self.cleanup() + return + + start = get_date(self.data.event['start']) + end = get_date(self.data.event['end']) + + summary = self.data.event.get('summary', '') + + # check if we have an offset tag in the message + # time is HH:MM or MM + reg = '{}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)'.format(self._offset) + search = re.search(reg, summary) + if search and search.group(1): + time = search.group(1) + if ':' not in time: + if time[0] == '+' or time[0] == '-': + time = '{}0:{}'.format(time[0], time[1:]) + else: + time = '0:{}'.format(time) + + offset_time = time_period_str(time) + summary = (summary[:search.start()] + summary[search.end():]) \ + .strip() + else: + offset_time = dt.dt.timedelta() # default it + + # cleanup the string so we don't have a bunch of double+ spaces + self._cal_data['message'] = re.sub(' +', '', summary).strip() + self._cal_data['offset_time'] = offset_time + self._cal_data['location'] = self.data.event.get('location', '') + self._cal_data['description'] = self.data.event.get('description', '') + self._cal_data['start'] = start + self._cal_data['end'] = end + self._cal_data['all_day'] = 'date' in self.data.event['start'] + + +class CalendarEventView(http.HomeAssistantView): + """View to retrieve calendar content.""" + + url = '/api/calendars/{entity_id}' + name = 'api:calendars:calendar' + + def __init__(self, component): + """Initialize calendar view.""" + self.component = component + + async def get(self, request, entity_id): + """Return calendar events.""" + entity = self.component.get_entity(entity_id) + start = request.query.get('start') + end = request.query.get('end') + if None in (start, end, entity): + return web.Response(status=400) + try: + start_date = dt.parse_datetime(start) + end_date = dt.parse_datetime(end) + except (ValueError, AttributeError): + return web.Response(status=400) + event_list = await entity.async_get_events( + request.app['hass'], start_date, end_date) + return self.json(event_list) + + +class CalendarListView(http.HomeAssistantView): + """View to retrieve calendar list.""" + + url = '/api/calendars' + name = "api:calendars" + + def __init__(self, component): + """Initialize calendar view.""" + self.component = component + + async def get(self, request): + """Retrieve calendar list.""" + get_state = request.app['hass'].states.get + calendar_list = [] + + for entity in self.component.entities: + state = get_state(entity.entity_id) + calendar_list.append({ + "name": state.name, + "entity_id": entity.entity_id, + }) + + return self.json(sorted(calendar_list, key=lambda x: x['name'])) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/calendar/caldav.py b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/caldav.py new file mode 100644 index 00000000000000..cb8874a817c5cc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/caldav.py @@ -0,0 +1,266 @@ +""" +Support for WebDav Calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.caldav/ +""" +from datetime import datetime, timedelta +import logging +import re + +import voluptuous as vol + +from homeassistant.components.calendar import ( + PLATFORM_SCHEMA, CalendarEventDevice, get_date) +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle, dt + +REQUIREMENTS = ['caldav==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' +CONF_CALENDARS = 'calendars' +CONF_CUSTOM_CALENDARS = 'custom_calendars' +CONF_CALENDAR = 'calendar' +CONF_SEARCH = 'search' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + # pylint: disable=no-value-for-parameter + vol.Required(CONF_URL): vol.Url(), + vol.Optional(CONF_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + cv.string + ])), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SEARCH): cv.string, + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_entities, disc_info=None): + """Set up the WebDav Calendar platform.""" + import caldav + + url = config.get(CONF_URL) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + client = caldav.DAVClient(url, None, username, password) + + calendars = client.principal().calendars() + + calendar_devices = [] + for calendar in list(calendars): + # If a calendar name was given in the configuration, + # ignore all the others + if (config.get(CONF_CALENDARS) + and calendar.name not in config.get(CONF_CALENDARS)): + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering rules + for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): + # Check that the base calendar matches + if cust_calendar.get(CONF_CALENDAR) != calendar.name: + continue + + device_data = { + CONF_NAME: cust_calendar.get(CONF_NAME), + CONF_DEVICE_ID: "{} {}".format( + cust_calendar.get(CONF_CALENDAR), + cust_calendar.get(CONF_NAME)), + } + + calendar_devices.append( + WebDavCalendarEventDevice( + hass, device_data, calendar, True, + cust_calendar.get(CONF_SEARCH))) + + # Create a default calendar if there was no custom one + if not config.get(CONF_CUSTOM_CALENDARS): + device_data = { + CONF_NAME: calendar.name, + CONF_DEVICE_ID: calendar.name, + } + calendar_devices.append( + WebDavCalendarEventDevice(hass, device_data, calendar) + ) + + add_entities(calendar_devices) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, hass, device_data, calendar, all_day=False, + search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + super().__init__(hass, device_data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + return attributes + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) + + +class WebDavCalendarData: + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, calendar, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + self.calendar = calendar + self.include_all_day = include_all_day + self.search = search + self.event = None + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + # Get event list from the current calendar + vevent_list = await hass.async_add_job(self.calendar.date_search, + start_date, end_date) + event_list = [] + for event in vevent_list: + vevent = event.instance.vevent + uid = None + if hasattr(vevent, 'uid'): + uid = vevent.uid.value + data = { + "uid": uid, + "title": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(self.get_end_date(vevent)), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description"), + } + + data['start'] = get_date(data['start']).isoformat() + data['end'] = get_date(data['end']).isoformat() + + event_list.append(data) + + return event_list + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = self.calendar.date_search( + dt.start_of_local_day(), + dt.start_of_local_day() + timedelta(days=1) + ) + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + results.sort(key=lambda x: self.to_datetime( + x.instance.vevent.dtstart.value + )) + + vevent = next(( + event.instance.vevent for event in results + if (self.is_matching(event.instance.vevent, self.search) + and (not self.is_all_day(event.instance.vevent) + or self.include_all_day) + and not self.is_over(event.instance.vevent))), None) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(results), self.calendar.name) + self.event = None + return True + + # Populate the entity attributes with the event values + self.event = { + "summary": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(self.get_end_date(vevent)), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description") + } + return True + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter criteria.""" + if search is None: + return True + + pattern = re.compile(search) + return (hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value)) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt.now() >= WebDavCalendarData.to_datetime( + WebDavCalendarData.get_end_date(vevent) + ) + + @staticmethod + def get_hass_date(obj): + """Return if the event matches.""" + if isinstance(obj, datetime): + return {"dateTime": obj.isoformat()} + + return {"date": obj.isoformat()} + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return obj + return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + + else: + enddate = obj.dtstart.value + timedelta(days=1) + + return enddate diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/calendar/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/demo.py new file mode 100644 index 00000000000000..bf9d4abeb58475 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/demo.py @@ -0,0 +1,98 @@ +""" +Demo platform that has two fake binary sensors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import copy + +import homeassistant.util.dt as dt_util +from homeassistant.components.calendar import CalendarEventDevice, get_date +from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo Calendar platform.""" + calendar_data_future = DemoGoogleCalendarDataFuture() + calendar_data_current = DemoGoogleCalendarDataCurrent() + add_entities([ + DemoGoogleCalendar(hass, calendar_data_future, { + CONF_NAME: 'Calendar 1', + CONF_DEVICE_ID: 'calendar_1', + }), + + DemoGoogleCalendar(hass, calendar_data_current, { + CONF_NAME: 'Calendar 2', + CONF_DEVICE_ID: 'calendar_2', + }), + ]) + + +class DemoGoogleCalendarData: + """Representation of a Demo Calendar element.""" + + event = {} + + # pylint: disable=no-self-use + def update(self): + """Return true so entity knows we have new data.""" + return True + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + event = copy.copy(self.event) + event['title'] = event['summary'] + event['start'] = get_date(event['start']).isoformat() + event['end'] = get_date(event['end']).isoformat() + return [event] + + +class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): + """Representation of a Demo Calendar for a future event.""" + + def __init__(self): + """Set the event to a future event.""" + one_hour_from_now = dt_util.now() \ + + dt_util.dt.timedelta(minutes=30) + self.event = { + 'start': { + 'dateTime': one_hour_from_now.isoformat() + }, + 'end': { + 'dateTime': (one_hour_from_now + dt_util.dt. + timedelta(minutes=60)).isoformat() + }, + 'summary': 'Future Event', + } + + +class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): + """Representation of a Demo Calendar for a current event.""" + + def __init__(self): + """Set the event data.""" + middle_of_event = dt_util.now() \ + - dt_util.dt.timedelta(minutes=30) + self.event = { + 'start': { + 'dateTime': middle_of_event.isoformat() + }, + 'end': { + 'dateTime': (middle_of_event + dt_util.dt. + timedelta(minutes=60)).isoformat() + }, + 'summary': 'Current Event', + } + + +class DemoGoogleCalendar(CalendarEventDevice): + """Representation of a Demo Calendar element.""" + + def __init__(self, hass, calendar_data, data): + """Initialize Google Calendar but without the API calls.""" + self.data = calendar_data + super().__init__(hass, data) + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/calendar/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000000..ebf0c7b1591ab0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/services.yaml @@ -0,0 +1,26 @@ +# Describes the format for available calendar services + +todoist_new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). + example: 2 + due_date_string: + description: The day this task is due, in natural language. + example: "tomorrow" + due_date_lang: + description: The language of due_date_string. + example: "en" + due_date: + description: The day this task is due, in format YYYY-MM-DD. + example: "2018-04-01" diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/calendar/todoist.py b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000000..a0a3457667fbf5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/calendar/todoist.py @@ -0,0 +1,584 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" +from datetime import datetime, timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.calendar import ( + DOMAIN, PLATFORM_SCHEMA, CalendarEventDevice) +from homeassistant.components.google import CONF_DEVICE_ID +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import Throttle, dt + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_LABEL_WHITELIST = 'labels' +CONF_PROJECT_WHITELIST = 'include_projects' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Service Call: When is this task due (in natural language)? +DUE_DATE_STRING = 'due_date_string' +# Service Call: The language of DUE_DATE_STRING +DUE_DATE_LANG = 'due_date_lang' +# Service Call: The available options of DUE_DATE_LANG +DUE_DATE_VALID_LANGS = ['en', 'da', 'pl', 'zh', 'ko', 'de', + 'pt', 'ja', 'it', 'fr', 'sv', 'ru', + 'es', 'nl'] +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'todoist_new_task' + +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), + + vol.Exclusive(DUE_DATE_STRING, 'due_date'): cv.string, + vol.Optional(DUE_DATE_LANG): + vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)), + vol.Exclusive(DUE_DATE, 'due_date'): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Todoist platform.""" + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_entities(project_devices) + + def handle_new_task(call): + """Call when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE_STRING in call.data: + item.update(date_string=call.data[DUE_DATE_STRING]) + + if DUE_DATE_LANG in call.data: + item.update(date_lang=call.data[DUE_DATE_LANG]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData: + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analogous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have an end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + async def async_get_events(self, hass, start_date, end_date): + """Get all tasks in a specific time frame.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + events = [] + time_format = '%a %d %b %Y %H:%M:%S %z' + for task in project_task_data: + due_date = datetime.strptime(task['due_date_utc'], time_format) + if start_date < due_date < end_date: + event = { + 'uid': task['id'], + 'title': task['content'], + 'start': due_date.isoformat(), + 'end': due_date.isoformat(), + 'allDay': True, + } + events.append(event) + return events + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + self._api.reset_state() + self._api.sync() + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + _LOGGER.debug("No data for %s", self._name) + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + _LOGGER.debug("No valid tasks for %s", self._name) + self.event = None + return True + + # Make sure the task collection is reset to prevent an + # infinite collection repeating the same tasks + self.all_project_tasks.clear() + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while project_tasks: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/__init__.py new file mode 100644 index 00000000000000..653d0315ad4e34 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/__init__.py @@ -0,0 +1,501 @@ +""" +Component to interface with cameras. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/camera/ +""" +import asyncio +import base64 +import collections +from contextlib import suppress +from datetime import timedelta +import logging +import hashlib +from random import SystemRandom + +import attr +from aiohttp import web +import async_timeout +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ + SERVICE_TURN_ON +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass +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.components import websocket_api +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'camera' +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ENABLE_MOTION = 'enable_motion_detection' +SERVICE_DISABLE_MOTION = 'disable_motion_detection' +SERVICE_SNAPSHOT = 'snapshot' + +SCAN_INTERVAL = timedelta(seconds=30) +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +ATTR_FILENAME = 'filename' + +STATE_RECORDING = 'recording' +STATE_STREAMING = 'streaming' +STATE_IDLE = 'idle' + +# Bitfield of features supported by the camera entity +SUPPORT_ON_OFF = 1 + +DEFAULT_CONTENT_TYPE = 'image/jpeg' +ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' + +TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) +_RND = SystemRandom() + +FALLBACK_STREAM_INTERVAL = 1 # seconds +MIN_STREAM_INTERVAL = 0.5 # seconds + +CAMERA_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FILENAME): cv.template +}) + +WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail' +SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, + vol.Required('entity_id'): cv.entity_id +}) + + +@attr.s +class Image: + """Represent an image.""" + + content_type = attr.ib(type=str) + content = attr.ib(type=bytes) + + +@bind_hass +async def async_get_image(hass, entity_id, timeout=10): + """Fetch an image from a camera entity.""" + camera = _get_camera_from_entity_id(hass, entity_id) + + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with async_timeout.timeout(timeout, loop=hass.loop): + image = await camera.async_camera_image() + + if image: + return Image(camera.content_type, image) + + raise HomeAssistantError('Unable to get image') + + +@bind_hass +async def async_get_mjpeg_stream(hass, request, entity_id): + """Fetch an mjpeg stream from a camera entity.""" + camera = _get_camera_from_entity_id(hass, entity_id) + + return await camera.handle_async_mjpeg_stream(request) + + +async def async_get_still_stream(request, image_cb, content_type, interval): + """Generate an HTTP MJPEG stream from camera images. + + This method must be run in the event loop. + """ + response = web.StreamResponse() + response.content_type = ('multipart/x-mixed-replace; ' + 'boundary=--frameboundary') + await response.prepare(request) + + async def write_to_mjpeg_stream(img_bytes): + """Write image to stream.""" + await response.write(bytes( + '--frameboundary\r\n' + 'Content-Type: {}\r\n' + 'Content-Length: {}\r\n\r\n'.format( + content_type, len(img_bytes)), + 'utf-8') + img_bytes + b'\r\n') + + last_image = None + + while True: + img_bytes = await image_cb() + if not img_bytes: + break + + if img_bytes != last_image: + await write_to_mjpeg_stream(img_bytes) + + # Chrome seems to always ignore first picture, + # print it twice. + if last_image is None: + await write_to_mjpeg_stream(img_bytes) + last_image = img_bytes + + await asyncio.sleep(interval) + + return response + + +def _get_camera_from_entity_id(hass, entity_id): + """Get camera component from entity_id.""" + component = hass.data.get(DOMAIN) + + if component is None: + raise HomeAssistantError('Camera component not set up') + + camera = component.get_entity(entity_id) + + if camera is None: + raise HomeAssistantError('Camera not found') + + if not camera.is_on: + raise HomeAssistantError('Camera is off') + + return camera + + +async def async_setup(hass, config): + """Set up the camera component.""" + component = hass.data[DOMAIN] = \ + EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + + hass.http.register_view(CameraImageView(component)) + hass.http.register_view(CameraMjpegStream(component)) + hass.components.websocket_api.async_register_command( + WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail, + SCHEMA_WS_CAMERA_THUMBNAIL + ) + + await component.async_setup(config) + + @callback + def update_tokens(time): + """Update tokens of the entities.""" + for entity in component.entities: + entity.async_update_token() + hass.async_create_task(entity.async_update_ha_state()) + + hass.helpers.event.async_track_time_interval( + update_tokens, TOKEN_CHANGE_INTERVAL) + + component.async_register_entity_service( + SERVICE_ENABLE_MOTION, CAMERA_SERVICE_SCHEMA, + 'async_enable_motion_detection' + ) + component.async_register_entity_service( + SERVICE_DISABLE_MOTION, CAMERA_SERVICE_SCHEMA, + 'async_disable_motion_detection' + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA, + 'async_turn_off' + ) + component.async_register_entity_service( + SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA, + 'async_turn_on' + ) + component.async_register_entity_service( + SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, + async_handle_snapshot_service + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class Camera(Entity): + """The base class for camera entities.""" + + def __init__(self): + """Initialize a camera.""" + self.is_streaming = False + self.content_type = DEFAULT_CONTENT_TYPE + self.access_tokens = collections.deque([], 2) + self.async_update_token() + + @property + def should_poll(self): + """No need to poll cameras.""" + return False + + @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_tokens[-1]) + + @property + def supported_features(self): + """Flag supported features.""" + return 0 + + @property + def is_recording(self): + """Return true if the device is recording.""" + return False + + @property + def brand(self): + """Return the camera brand.""" + return None + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return None + + @property + def model(self): + """Return the camera model.""" + return None + + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return 0.5 + + def camera_image(self): + """Return bytes of camera image.""" + raise NotImplementedError() + + @callback + def async_camera_image(self): + """Return bytes of camera image. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.camera_image) + + async def handle_async_still_stream(self, request, interval): + """Generate an HTTP MJPEG stream from camera images. + + This method must be run in the event loop. + """ + return await async_get_still_stream(request, self.async_camera_image, + self.content_type, interval) + + async def handle_async_mjpeg_stream(self, request): + """Serve an HTTP MJPEG stream from the camera. + + This method can be overridden by camera plaforms to proxy + a direct stream from the camera. + This method must be run in the event loop. + """ + return await self.handle_async_still_stream( + request, self.frame_interval) + + @property + def state(self): + """Return the camera state.""" + if self.is_recording: + return STATE_RECORDING + if self.is_streaming: + return STATE_STREAMING + return STATE_IDLE + + @property + def is_on(self): + """Return true if on.""" + return True + + def turn_off(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_off(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_off) + + def turn_on(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_on(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_on) + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + raise NotImplementedError() + + @callback + def async_enable_motion_detection(self): + """Call the job and enable motion detection.""" + return self.hass.async_add_job(self.enable_motion_detection) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + raise NotImplementedError() + + @callback + def async_disable_motion_detection(self): + """Call the job and disable motion detection.""" + return self.hass.async_add_job(self.disable_motion_detection) + + @property + def state_attributes(self): + """Return the camera state attributes.""" + attrs = { + 'access_token': self.access_tokens[-1], + } + + if self.model: + attrs['model_name'] = self.model + + if self.brand: + attrs['brand'] = self.brand + + if self.motion_detection_enabled: + attrs['motion_detection'] = self.motion_detection_enabled + + return attrs + + @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.""" + + requires_auth = False + + def __init__(self, component): + """Initialize a basic camera view.""" + self.component = component + + async def get(self, request, entity_id): + """Start a GET request.""" + camera = self.component.get_entity(entity_id) + + if camera is None: + raise web.HTTPNotFound() + + authenticated = (request[KEY_AUTHENTICATED] or + request.query.get('token') in camera.access_tokens) + + if not authenticated: + raise web.HTTPUnauthorized() + + if not camera.is_on: + _LOGGER.debug('Camera is off.') + raise web.HTTPServiceUnavailable() + + return await self.handle(request, camera) + + async def handle(self, request, camera): + """Handle the camera request.""" + raise NotImplementedError() + + +class CameraImageView(CameraView): + """Camera view to serve an image.""" + + url = '/api/camera_proxy/{entity_id}' + name = 'api:camera:image' + + async def handle(self, request, camera): + """Serve camera image.""" + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with async_timeout.timeout(10, loop=request.app['hass'].loop): + image = await camera.async_camera_image() + + if image: + return web.Response(body=image, + content_type=camera.content_type) + + raise web.HTTPInternalServerError() + + +class CameraMjpegStream(CameraView): + """Camera View to serve an MJPEG stream.""" + + url = '/api/camera_proxy_stream/{entity_id}' + name = 'api:camera:stream' + + async def handle(self, request, camera): + """Serve camera stream, possibly with interval.""" + interval = request.query.get('interval') + if interval is None: + return await camera.handle_async_mjpeg_stream(request) + + try: + # Compose camera stream from stills + interval = float(request.query.get('interval')) + if interval < MIN_STREAM_INTERVAL: + raise ValueError("Stream interval must be be > {}" + .format(MIN_STREAM_INTERVAL)) + return await camera.handle_async_still_stream(request, interval) + except ValueError: + raise web.HTTPBadRequest() + + +@websocket_api.async_response +async def websocket_camera_thumbnail(hass, connection, msg): + """Handle get camera thumbnail websocket command. + + Async friendly. + """ + try: + image = await async_get_image(hass, msg['entity_id']) + connection.send_message(websocket_api.result_message( + msg['id'], { + 'content_type': image.content_type, + 'content': base64.b64encode(image.content).decode('utf-8') + } + )) + except HomeAssistantError: + connection.send_message(websocket_api.error_message( + msg['id'], 'image_fetch_failed', 'Unable to fetch image')) + + +async def async_handle_snapshot_service(camera, service): + """Handle snapshot services calls.""" + hass = camera.hass + filename = service.data[ATTR_FILENAME] + filename.hass = hass + + snapshot_file = filename.async_render( + variables={ATTR_ENTITY_ID: camera}) + + # check if we allow to access to that file + if not hass.config.is_allowed_path(snapshot_file): + _LOGGER.error( + "Can't write %s, no access to path!", snapshot_file) + return + + image = await camera.async_camera_image() + + def _write_image(to_file, image_data): + """Executor helper to write image.""" + with open(to_file, 'wb') as img_file: + img_file.write(image_data) + + try: + await hass.async_add_executor_job( + _write_image, snapshot_file, image) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/canary.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/canary.py new file mode 100644 index 00000000000000..7a83e2da4d135f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/canary.py @@ -0,0 +1,112 @@ +""" +Support for Canary camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.canary/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.util import Throttle + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['canary', 'ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_SESSION_RENEW = timedelta(seconds=90) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + for device in location.devices: + if device.is_online: + devices.append( + CanaryCamera(hass, data, location, device, DEFAULT_TIMEOUT, + config.get(CONF_FFMPEG_ARGUMENTS))) + + add_entities(devices, True) + + +class CanaryCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, hass, data, location, device, timeout, ffmpeg_args): + """Initialize a Canary security camera.""" + super().__init__() + + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = ffmpeg_args + self._data = data + self._location = location + self._device = device + self._timeout = timeout + self._live_stream_session = None + + @property + def name(self): + """Return the name of this device.""" + return self._device.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._location.is_recording + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return not self._location.is_recording + + async def async_camera_image(self): + """Return a still image response from the camera.""" + self.renew_live_stream_session() + + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + image = await asyncio.shield(ffmpeg.get_image( + self._live_stream_session.live_stream_url, + output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + if self._live_stream_session is None: + return + + from haffmpeg import CameraMjpeg + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera( + self._live_stream_session.live_stream_url, + extra_cmd=self._ffmpeg_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() + + @Throttle(MIN_TIME_BETWEEN_SESSION_RENEW) + def renew_live_stream_session(self): + """Renew live stream session.""" + self._live_stream_session = self._data.get_live_stream_session( + self._device) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo.py new file mode 100644 index 00000000000000..f950edb5c6ca79 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo.py @@ -0,0 +1,91 @@ +""" +Demo camera platform that has a fake camera. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import logging +import os + +from homeassistant.components.camera import Camera, SUPPORT_ON_OFF + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Demo camera platform.""" + async_add_entities([ + DemoCamera('Demo camera') + ]) + + +class DemoCamera(Camera): + """The representation of a Demo camera.""" + + def __init__(self, name): + """Initialize demo camera component.""" + super().__init__() + self._name = name + self._motion_status = False + self.is_streaming = True + self._images_index = 0 + + def camera_image(self): + """Return a faked still image response.""" + self._images_index = (self._images_index + 1) % 4 + + image_path = os.path.join( + os.path.dirname(__file__), + 'demo_{}.jpg'.format(self._images_index)) + _LOGGER.debug('Loading camera_image: %s', image_path) + with open(image_path, 'rb') as file: + return file.read() + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def should_poll(self): + """Demo camera doesn't need poll. + + Need explicitly call schedule_update_ha_state() after state changed. + """ + return False + + @property + def supported_features(self): + """Camera support turn on/off features.""" + return SUPPORT_ON_OFF + + @property + def is_on(self): + """Whether camera is on (streaming).""" + return self.is_streaming + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return self._motion_status + + def enable_motion_detection(self): + """Enable the Motion detection in base station (Arm).""" + self._motion_status = True + self.schedule_update_ha_state() + + def disable_motion_detection(self): + """Disable the motion detection in base station (Disarm).""" + self._motion_status = False + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off camera.""" + self.is_streaming = False + self.schedule_update_ha_state() + + def turn_on(self): + """Turn on camera.""" + self.is_streaming = True + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_0.jpg b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_0.jpg new file mode 100644 index 00000000000000..f062b26bad7980 Binary files /dev/null and b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_0.jpg differ diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_1.jpg b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_1.jpg new file mode 100644 index 00000000000000..a349f22b1523ec Binary files /dev/null and b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_1.jpg differ diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_2.jpg b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_2.jpg new file mode 100644 index 00000000000000..e21d7457ebf019 Binary files /dev/null and b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_2.jpg differ diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_3.jpg b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_3.jpg new file mode 100644 index 00000000000000..a349f22b1523ec Binary files /dev/null and b/homeassistant-0.88.0.dev0/homeassistant/components/camera/demo_3.jpg differ diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/familyhub.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/familyhub.py new file mode 100644 index 00000000000000..f3dd8b6d0c9166 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/familyhub.py @@ -0,0 +1,58 @@ +""" +Family Hub camera for Samsung Refrigerators. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/camera.familyhub/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['python-family-hub-local==0.0.2'] + +DEFAULT_NAME = 'FamilyHub Camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Family Hub Camera.""" + from pyfamilyhublocal import FamilyHubCam + address = config.get(CONF_IP_ADDRESS) + name = config.get(CONF_NAME) + + session = async_get_clientsession(hass) + family_hub_cam = FamilyHubCam(address, hass.loop, session) + + async_add_entities([FamilyHubCamera(name, family_hub_cam)], True) + + +class FamilyHubCamera(Camera): + """The representation of a Family Hub camera.""" + + def __init__(self, name, family_hub_cam): + """Initialize camera component.""" + super().__init__() + self._name = name + self.family_hub_cam = family_hub_cam + + async def async_camera_image(self): + """Return a still image response.""" + return await self.family_hub_cam.async_get_cam_image() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/ffmpeg.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/ffmpeg.py new file mode 100644 index 00000000000000..6bd68b05bb589d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/ffmpeg.py @@ -0,0 +1,78 @@ +""" +Support for Cameras with FFmpeg as decoder. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.ffmpeg/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import ( + DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import ( + async_aiohttp_proxy_stream) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['ffmpeg'] +DEFAULT_NAME = 'FFmpeg' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_INPUT): cv.string, + vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up a FFmpeg camera.""" + async_add_entities([FFmpegCamera(hass, config)]) + + +class FFmpegCamera(Camera): + """An implementation of an FFmpeg 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) + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + + image = await asyncio.shield(ffmpeg.get_image( + self._input, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), loop=self.hass.loop) + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + await stream.open_camera( + self._input, extra_cmd=self._extra_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/foscam.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/foscam.py new file mode 100644 index 00000000000000..ceec57f77557cc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/foscam.py @@ -0,0 +1,96 @@ +""" +This component provides basic support for Foscam IP cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.foscam/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['libpyfoscam==1.0'] + +CONF_IP = 'ip' + +DEFAULT_NAME = 'Foscam Camera' +DEFAULT_PORT = 88 + +FOSCAM_COMM_ERROR = -8 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Foscam IP Camera.""" + add_entities([FoscamCam(config)]) + + +class FoscamCam(Camera): + """An implementation of a Foscam IP camera.""" + + def __init__(self, device_info): + """Initialize a Foscam camera.""" + from libpyfoscam import FoscamCamera + + super(FoscamCam, self).__init__() + + ip_address = device_info.get(CONF_IP) + port = device_info.get(CONF_PORT) + self._username = device_info.get(CONF_USERNAME) + self._password = device_info.get(CONF_PASSWORD) + self._name = device_info.get(CONF_NAME) + self._motion_status = False + + self._foscam_session = FoscamCamera( + ip_address, port, self._username, self._password, verbose=False) + + def camera_image(self): + """Return a still image response from the camera.""" + # Send the request to snap a picture and return raw jpg data + # Handle exception if host is not reachable or url failed + result, response = self._foscam_session.snap_picture_2() + if result == FOSCAM_COMM_ERROR: + return None + + return response + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return self._motion_status + + def enable_motion_detection(self): + """Enable motion detection in camera.""" + try: + ret = self._foscam_session.enable_motion_detection() + self._motion_status = ret == FOSCAM_COMM_ERROR + except TypeError: + _LOGGER.debug("Communication problem") + self._motion_status = False + + def disable_motion_detection(self): + """Disable motion detection.""" + try: + ret = self._foscam_session.disable_motion_detection() + self._motion_status = ret == FOSCAM_COMM_ERROR + except TypeError: + _LOGGER.debug("Communication problem") + self._motion_status = False + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/generic.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/generic.py new file mode 100644 index 00000000000000..f89e5ff29c2c40 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/generic.py @@ -0,0 +1,143 @@ +""" +Support for IP Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.generic/ +""" +import asyncio +import logging + +import aiohttp +import async_timeout +import requests +from requests.auth import HTTPDigestAuth +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL) +from homeassistant.exceptions import TemplateError +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, Camera) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv +from homeassistant.util.async_ import run_coroutine_threadsafe + +_LOGGER = logging.getLogger(__name__) + +CONF_CONTENT_TYPE = 'content_type' +CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change' +CONF_STILL_IMAGE_URL = 'still_image_url' +CONF_FRAMERATE = 'framerate' + +DEFAULT_NAME = 'Generic Camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STILL_IMAGE_URL): cv.template, + vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): + vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), + vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, + vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up a generic IP Camera.""" + async_add_entities([GenericCamera(hass, config)]) + + +class GenericCamera(Camera): + """A generic implementation of an IP camera.""" + + def __init__(self, hass, device_info): + """Initialize a generic camera.""" + super().__init__() + self.hass = hass + self._authentication = device_info.get(CONF_AUTHENTICATION) + self._name = device_info.get(CONF_NAME) + self._still_image_url = device_info[CONF_STILL_IMAGE_URL] + self._still_image_url.hass = hass + self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] + self._frame_interval = 1 / device_info[CONF_FRAMERATE] + self.content_type = device_info[CONF_CONTENT_TYPE] + self.verify_ssl = device_info[CONF_VERIFY_SSL] + + username = device_info.get(CONF_USERNAME) + password = device_info.get(CONF_PASSWORD) + + if username and password: + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + self._auth = HTTPDigestAuth(username, password) + else: + self._auth = aiohttp.BasicAuth(username, password=password) + else: + self._auth = None + + self._last_url = None + self._last_image = None + + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return self._frame_interval + + def camera_image(self): + """Return bytes of camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + try: + url = self._still_image_url.async_render() + except TemplateError as err: + _LOGGER.error( + "Error parsing template %s: %s", self._still_image_url, err) + return self._last_image + + if url == self._last_url and self._limit_refetch: + return self._last_image + + # aiohttp don't support DigestAuth yet + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + def fetch(): + """Read image from a URL.""" + try: + response = requests.get(url, timeout=10, auth=self._auth, + verify=self.verify_ssl) + return response.content + except requests.exceptions.RequestException as error: + _LOGGER.error("Error getting camera image: %s", error) + return self._last_image + + self._last_image = await self.hass.async_add_job( + fetch) + # async + else: + try: + websession = async_get_clientsession( + self.hass, verify_ssl=self.verify_ssl) + with async_timeout.timeout(10, loop=self.hass.loop): + response = await websession.get( + url, auth=self._auth) + self._last_image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image") + return self._last_image + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image: %s", err) + return self._last_image + + self._last_url = url + return self._last_image + + @property + def name(self): + """Return the name of this device.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/local_file.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/local_file.py new file mode 100644 index 00000000000000..d306509b76258a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/local_file.py @@ -0,0 +1,100 @@ +""" +Camera that loads a picture from a local file. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.local_file/ +""" +import logging +import mimetypes +import os + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.components.camera import ( + Camera, CAMERA_SERVICE_SCHEMA, DOMAIN, PLATFORM_SCHEMA) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE_PATH = 'file_path' +DEFAULT_NAME = 'Local File' +SERVICE_UPDATE_FILE_PATH = 'local_file_update_file_path' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILE_PATH): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + +CAMERA_SERVICE_UPDATE_FILE_PATH = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(CONF_FILE_PATH): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Camera that works with local files.""" + file_path = config[CONF_FILE_PATH] + camera = LocalFile(config[CONF_NAME], file_path) + + def update_file_path_service(call): + """Update the file path.""" + file_path = call.data.get(CONF_FILE_PATH) + camera.update_file_path(file_path) + return True + + hass.services.register( + DOMAIN, + SERVICE_UPDATE_FILE_PATH, + update_file_path_service, + schema=CAMERA_SERVICE_UPDATE_FILE_PATH) + + add_entities([camera]) + + +class LocalFile(Camera): + """Representation of a local file camera.""" + + def __init__(self, name, file_path): + """Initialize Local File Camera component.""" + super().__init__() + + self._name = name + self.check_file_path_access(file_path) + self._file_path = file_path + # Set content type of local file + content, _ = mimetypes.guess_type(file_path) + if content is not None: + self.content_type = content + + def camera_image(self): + """Return image response.""" + try: + with open(self._file_path, 'rb') as file: + return file.read() + except FileNotFoundError: + _LOGGER.warning("Could not read camera %s image from file: %s", + self._name, self._file_path) + + def check_file_path_access(self, file_path): + """Check that filepath given is readable.""" + if not os.access(file_path, os.R_OK): + _LOGGER.warning("Could not read camera %s image from file: %s", + self._name, file_path) + + def update_file_path(self, file_path): + """Update the file_path.""" + self.check_file_path_access(file_path) + self._file_path = file_path + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def device_state_attributes(self): + """Return the camera state attributes.""" + return { + 'file_path': self._file_path, + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/mjpeg.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/mjpeg.py new file mode 100644 index 00000000000000..0e73dfac0b7e5c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/mjpeg.py @@ -0,0 +1,181 @@ +""" +Support for IP Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.mjpeg/ +""" +import asyncio +import logging +from contextlib import closing + +import aiohttp +import async_timeout +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL) +from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_web) +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' +DEFAULT_VERIFY_SSL = True + +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, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up a MJPEG IP Camera.""" + filter_urllib3_logging() + + if discovery_info: + config = PLATFORM_SCHEMA(discovery_info) + async_add_entities([MjpegCamera(config)]) + + +def filter_urllib3_logging(): + """Filter header errors from urllib3 due to a urllib3 bug.""" + urllib3_logger = logging.getLogger("urllib3.connectionpool") + if not any(isinstance(x, NoHeaderErrorFilter) + for x in urllib3_logger.filters): + urllib3_logger.addFilter( + NoHeaderErrorFilter() + ) + + +def extract_image_from_mjpeg(stream): + """Take in a MJPEG stream object, return the jpg from it.""" + data = b'' + + for chunk in stream: + data += chunk + jpg_end = data.find(b'\xff\xd9') + + if jpg_end == -1: + continue + + jpg_start = data.find(b'\xff\xd8') + + if jpg_start == -1: + continue + + return data[jpg_start:jpg_end + 2] + + +class MjpegCamera(Camera): + """An implementation of an IP camera that is reachable over a URL.""" + + def __init__(self, device_info): + """Initialize a MJPEG camera.""" + super().__init__() + self._name = device_info.get(CONF_NAME) + self._authentication = device_info.get(CONF_AUTHENTICATION) + 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: + if self._authentication == HTTP_BASIC_AUTHENTICATION: + self._auth = aiohttp.BasicAuth( + self._username, password=self._password + ) + self._verify_ssl = device_info.get(CONF_VERIFY_SSL) + + async 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 = await self.hass.async_add_job( + self.camera_image) + return image + + websession = async_get_clientsession( + self.hass, + verify_ssl=self._verify_ssl + ) + try: + with async_timeout.timeout(10, loop=self.hass.loop): + response = await websession.get( + self._still_image_url, auth=self._auth) + + image = await response.read() + return image + + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image") + + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image: %s", err) + + def camera_image(self): + """Return a still image response from the camera.""" + if self._username and self._password: + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + auth = HTTPDigestAuth(self._username, self._password) + else: + auth = HTTPBasicAuth(self._username, self._password) + req = requests.get( + self._mjpeg_url, + auth=auth, + stream=True, + timeout=10, + verify=self._verify_ssl + ) + else: + req = requests.get(self._mjpeg_url, stream=True, timeout=10) + + # https://github.com/PyCQA/pylint/issues/1437 + # pylint: disable=no-member + with closing(req) as response: + return extract_image_from_mjpeg(response.iter_content(102400)) + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + # aiohttp don't support DigestAuth -> Fallback + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + return await super().handle_async_mjpeg_stream(request) + + # connect to stream + websession = async_get_clientsession( + self.hass, + verify_ssl=self._verify_ssl + ) + stream_coro = websession.get(self._mjpeg_url, auth=self._auth) + + return await async_aiohttp_proxy_web(self.hass, request, stream_coro) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + +class NoHeaderErrorFilter(logging.Filter): + """Filter out urllib3 Header Parsing Errors due to a urllib3 bug.""" + + def filter(self, record): + """Filter out Header Parsing Errors.""" + return "Failed to parse headers" not in record.getMessage() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/onvif.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/onvif.py new file mode 100644 index 00000000000000..d1afd39ca7b1ff --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/onvif.py @@ -0,0 +1,231 @@ +""" +Support for ONVIF Cameras with FFmpeg as decoder. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.onvif/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, + ATTR_ENTITY_ID) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, DOMAIN +from homeassistant.components.ffmpeg import ( + DATA_FFMPEG, CONF_EXTRA_ARGUMENTS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import ( + async_aiohttp_proxy_stream) +from homeassistant.helpers.service import extract_entity_ids + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['onvif-py3==0.1.3', + 'suds-py3==1.3.3.0', + 'suds-passworddigest-homeassistant==0.1.2a0.dev0'] +DEPENDENCIES = ['ffmpeg'] +DEFAULT_NAME = 'ONVIF Camera' +DEFAULT_PORT = 5000 +DEFAULT_USERNAME = 'admin' +DEFAULT_PASSWORD = '888888' +DEFAULT_ARGUMENTS = '-q:v 2' +DEFAULT_PROFILE = 0 + +CONF_PROFILE = "profile" + +ATTR_PAN = "pan" +ATTR_TILT = "tilt" +ATTR_ZOOM = "zoom" + +DIR_UP = "UP" +DIR_DOWN = "DOWN" +DIR_LEFT = "LEFT" +DIR_RIGHT = "RIGHT" +ZOOM_OUT = "ZOOM_OUT" +ZOOM_IN = "ZOOM_IN" +PTZ_NONE = "NONE" + +SERVICE_PTZ = "onvif_ptz" + +ONVIF_DATA = "onvif" +ENTITIES = "entities" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, + vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): + vol.All(vol.Coerce(int), vol.Range(min=0)), +}) + +SERVICE_PTZ_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, + ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT, PTZ_NONE]), + ATTR_TILT: vol.In([DIR_UP, DIR_DOWN, PTZ_NONE]), + ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN, PTZ_NONE]) +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a ONVIF camera.""" + def handle_ptz(service): + """Handle PTZ service call.""" + pan = service.data.get(ATTR_PAN, None) + tilt = service.data.get(ATTR_TILT, None) + zoom = service.data.get(ATTR_ZOOM, None) + all_cameras = hass.data[ONVIF_DATA][ENTITIES] + entity_ids = extract_entity_ids(hass, service) + target_cameras = [] + if not entity_ids: + target_cameras = all_cameras + else: + target_cameras = [camera for camera in all_cameras + if camera.entity_id in entity_ids] + for camera in target_cameras: + camera.perform_ptz(pan, tilt, zoom) + + hass.services.register(DOMAIN, SERVICE_PTZ, handle_ptz, + schema=SERVICE_PTZ_SCHEMA) + add_entities([ONVIFHassCamera(hass, config)]) + + +class ONVIFHassCamera(Camera): + """An implementation of an ONVIF camera.""" + + def __init__(self, hass, config): + """Initialize a ONVIF camera.""" + super().__init__() + import onvif + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + self._host = config.get(CONF_HOST) + self._port = config.get(CONF_PORT) + self._name = config.get(CONF_NAME) + self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) + self._profile_index = config.get(CONF_PROFILE) + self._input = None + self._media_service = \ + onvif.ONVIFService('http://{}:{}/onvif/device_service'.format( + self._host, self._port), + self._username, self._password, + '{}/wsdl/media.wsdl'.format(os.path.dirname( + onvif.__file__))) + + self._ptz_service = \ + onvif.ONVIFService('http://{}:{}/onvif/device_service'.format( + self._host, self._port), + self._username, self._password, + '{}/wsdl/ptz.wsdl'.format(os.path.dirname( + onvif.__file__))) + + def obtain_input_uri(self): + """Set the input uri for the camera.""" + from onvif import exceptions + _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s", + self._host, self._port) + + try: + profiles = self._media_service.GetProfiles() + + if self._profile_index >= len(profiles): + _LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d." + " Using the last profile.", + self._name, self._profile_index) + self._profile_index = -1 + + req = self._media_service.create_type('GetStreamUri') + + # pylint: disable=protected-access + req.ProfileToken = profiles[self._profile_index]._token + uri_no_auth = self._media_service.GetStreamUri(req).Uri + uri_for_log = uri_no_auth.replace( + 'rtsp://', 'rtsp://:@', 1) + self._input = uri_no_auth.replace( + 'rtsp://', 'rtsp://{}:{}@'.format(self._username, + self._password), 1) + _LOGGER.debug( + "ONVIF Camera Using the following URL for %s: %s", + self._name, uri_for_log) + # we won't need the media service anymore + self._media_service = None + except exceptions.ONVIFError as err: + _LOGGER.debug("Couldn't setup camera '%s'. Error: %s", + self._name, err) + return + + def perform_ptz(self, pan, tilt, zoom): + """Perform a PTZ action on the camera.""" + from onvif import exceptions + if self._ptz_service: + pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0 + tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0 + zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0 + req = {"Velocity": { + "PanTilt": {"_x": pan_val, "_y": tilt_val}, + "Zoom": {"_x": zoom_val}}} + try: + self._ptz_service.ContinuousMove(req) + except exceptions.ONVIFError as err: + if "Bad Request" in err.reason: + self._ptz_service = None + _LOGGER.debug("Camera '%s' doesn't support PTZ.", + self._name) + else: + _LOGGER.debug("Camera '%s' doesn't support PTZ.", self._name) + + async def async_added_to_hass(self): + """Handle entity addition to hass.""" + if ONVIF_DATA not in self.hass.data: + self.hass.data[ONVIF_DATA] = {} + self.hass.data[ONVIF_DATA][ENTITIES] = [] + self.hass.data[ONVIF_DATA][ENTITIES].append(self) + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + if not self._input: + await self.hass.async_add_job(self.obtain_input_uri) + if not self._input: + return None + + ffmpeg = ImageFrame( + self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop) + + image = await asyncio.shield(ffmpeg.get_image( + self._input, output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + if not self._input: + await self.hass.async_add_job(self.obtain_input_uri) + if not self._input: + return None + + stream = CameraMjpeg(self.hass.data[DATA_FFMPEG].binary, + loop=self.hass.loop) + await stream.open_camera( + self._input, extra_cmd=self._ffmpeg_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/proxy.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/proxy.py new file mode 100644 index 00000000000000..8afd71abc2681b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/proxy.py @@ -0,0 +1,279 @@ +""" +Proxy camera platform that enables image processing of camera data. + +For more details about this platform, please refer to the documentation +https://www.home-assistant.io/components/camera.proxy/ +""" +import asyncio +import logging + +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \ + HTTP_HEADER_HA_AUTH +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.util.async_ import run_coroutine_threadsafe +import homeassistant.util.dt as dt_util +from homeassistant.components.camera import async_get_still_stream + +REQUIREMENTS = ['pillow==5.4.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CACHE_IMAGES = 'cache_images' +CONF_FORCE_RESIZE = 'force_resize' +CONF_IMAGE_QUALITY = 'image_quality' +CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate' +CONF_MAX_IMAGE_WIDTH = 'max_image_width' +CONF_MAX_IMAGE_HEIGHT = 'max_image_height' +CONF_MAX_STREAM_WIDTH = 'max_stream_width' +CONF_MAX_STREAM_HEIGHT = 'max_stream_height' +CONF_IMAGE_TOP = 'image_top' +CONF_IMAGE_LEFT = 'image_left' +CONF_STREAM_QUALITY = 'stream_quality' + +MODE_RESIZE = 'resize' +MODE_CROP = 'crop' + +DEFAULT_BASENAME = "Camera Proxy" +DEFAULT_QUALITY = 75 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_MODE, default=MODE_RESIZE): + vol.In([MODE_RESIZE, MODE_CROP]), + vol.Optional(CONF_IMAGE_QUALITY): int, + vol.Optional(CONF_IMAGE_REFRESH_RATE): float, + vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_MAX_IMAGE_HEIGHT): int, + vol.Optional(CONF_MAX_STREAM_WIDTH): int, + vol.Optional(CONF_MAX_STREAM_HEIGHT): int, + vol.Optional(CONF_IMAGE_LEFT): int, + vol.Optional(CONF_IMAGE_TOP): int, + vol.Optional(CONF_STREAM_QUALITY): int, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Proxy camera platform.""" + async_add_entities([ProxyCamera(hass, config)]) + + +def _precheck_image(image, opts): + """Perform some pre-checks on the given image.""" + from PIL import Image + import io + + if not opts: + raise ValueError() + try: + img = Image.open(io.BytesIO(image)) + except IOError: + _LOGGER.warning("Failed to open image") + raise ValueError() + imgfmt = str(img.format) + if imgfmt not in ('PNG', 'JPEG'): + _LOGGER.warning("Image is of unsupported type: %s", imgfmt) + raise ValueError() + return img + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + try: + img = _precheck_image(image, opts) + except ValueError: + return image + + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width + (old_width, old_height) = img.size + old_size = len(image) + if old_width <= new_width: + if opts.quality is None: + _LOGGER.debug("Image is smaller-than/equal-to requested width") + return image + new_width = old_width + + scale = new_width / float(old_width) + new_height = int((float(old_height)*float(scale))) + + img = img.resize((new_width, new_height), Image.ANTIALIAS) + imgbuf = io.BytesIO() + img.save(imgbuf, 'JPEG', optimize=True, quality=quality) + newimage = imgbuf.getvalue() + if not opts.force_resize and len(newimage) >= old_size: + _LOGGER.debug("Using original image (%d bytes) " + "because resized image (%d bytes) is not smaller", + old_size, len(newimage)) + return image + + _LOGGER.debug( + "Resized image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", + old_width, old_height, old_size, new_width, new_height, len(newimage)) + return newimage + + +def _crop_image(image, opts): + """Crop image.""" + import io + + try: + img = _precheck_image(image, opts) + except ValueError: + return image + + quality = opts.quality or DEFAULT_QUALITY + (old_width, old_height) = img.size + old_size = len(image) + if opts.top is None: + opts.top = 0 + if opts.left is None: + opts.left = 0 + if opts.max_width is None or opts.max_width > old_width - opts.left: + opts.max_width = old_width - opts.left + if opts.max_height is None or opts.max_height > old_height - opts.top: + opts.max_height = old_height - opts.top + + img = img.crop((opts.left, opts.top, + opts.left+opts.max_width, opts.top+opts.max_height)) + imgbuf = io.BytesIO() + img.save(imgbuf, 'JPEG', optimize=True, quality=quality) + newimage = imgbuf.getvalue() + + _LOGGER.debug( + "Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", + old_width, old_height, old_size, opts.max_width, opts.max_height, + len(newimage)) + return newimage + + +class ImageOpts(): + """The representation of image options.""" + + def __init__(self, max_width, max_height, left, top, + quality, force_resize): + """Initialize image options.""" + self.max_width = max_width + self.max_height = max_height + self.left = left + self.top = top + self.quality = quality + self.force_resize = force_resize + + def __bool__(self): + """Bool evaluation rules.""" + return bool(self.max_width or self.quality) + + +class ProxyCamera(Camera): + """The representation of a Proxy camera.""" + + def __init__(self, hass, config): + """Initialize a proxy camera component.""" + super().__init__() + self.hass = hass + self._proxied_camera = config.get(CONF_ENTITY_ID) + self._name = ( + config.get(CONF_NAME) or + "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) + self._image_opts = ImageOpts( + config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_MAX_IMAGE_HEIGHT), + config.get(CONF_IMAGE_LEFT), + config.get(CONF_IMAGE_TOP), + config.get(CONF_IMAGE_QUALITY), + config.get(CONF_FORCE_RESIZE)) + + self._stream_opts = ImageOpts( + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_MAX_STREAM_HEIGHT), + config.get(CONF_IMAGE_LEFT), + config.get(CONF_IMAGE_TOP), + config.get(CONF_STREAM_QUALITY), + True) + + self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) + self._cache_images = bool( + config.get(CONF_IMAGE_REFRESH_RATE) + or config.get(CONF_CACHE_IMAGES)) + self._last_image_time = dt_util.utc_from_timestamp(0) + self._last_image = None + self._headers = ( + {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} + if self.hass.config.api.api_password is not None else None) + self._mode = config.get(CONF_MODE) + + def camera_image(self): + """Return camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + now = dt_util.utcnow() + + if (self._image_refresh_rate and + now < self._last_image_time + + timedelta(seconds=self._image_refresh_rate)): + return self._last_image + + self._last_image_time = now + image = await self.hass.components.camera.async_get_image( + self._proxied_camera) + if not image: + _LOGGER.error("Error getting original camera image") + return self._last_image + + if self._mode == MODE_RESIZE: + job = _resize_image + else: + job = _crop_image + image = await self.hass.async_add_executor_job( + job, image.content, self._image_opts) + + if self._cache_images: + self._last_image = image + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from camera images.""" + if not self._stream_opts: + return await self.hass.components.camera.async_get_mjpeg_stream( + request, self._proxied_camera) + + return await async_get_still_stream( + request, self._async_stream_image, + self.content_type, self.frame_interval) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def _async_stream_image(self): + """Return a still image response from the camera.""" + try: + image = await self.hass.components.camera.async_get_image( + self._proxied_camera) + if not image: + return None + except HomeAssistantError: + raise asyncio.CancelledError() + + if self._mode == MODE_RESIZE: + job = _resize_image + else: + job = _crop_image + return await self.hass.async_add_executor_job( + job, image.content, self._stream_opts) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/push.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/push.py new file mode 100644 index 00000000000000..36c4a3109baba2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/push.py @@ -0,0 +1,182 @@ +""" +Camera platform that receives images through HTTP POST. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/camera.push/ +""" +import logging +import asyncio + +from collections import deque +from datetime import timedelta +import voluptuous as vol +import aiohttp +import async_timeout + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ + STATE_IDLE, STATE_RECORDING, DOMAIN +from homeassistant.core import callback +from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +import homeassistant.util.dt as dt_util + +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + +CONF_BUFFER_SIZE = 'buffer' +CONF_IMAGE_FIELD = 'field' + +DEFAULT_NAME = "Push Camera" + +ATTR_FILENAME = 'filename' +ATTR_LAST_TRIP = 'last_trip' + +PUSH_CAMERA_DATA = 'push_camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, + vol.Required(CONF_WEBHOOK_ID): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Push Camera platform.""" + if PUSH_CAMERA_DATA not in hass.data: + hass.data[PUSH_CAMERA_DATA] = {} + + webhook_id = config.get(CONF_WEBHOOK_ID) + + cameras = [PushCamera(hass, + config[CONF_NAME], + config[CONF_BUFFER_SIZE], + config[CONF_TIMEOUT], + config[CONF_IMAGE_FIELD], + webhook_id)] + + async_add_entities(cameras) + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook POST with image files.""" + try: + with async_timeout.timeout(5, loop=hass.loop): + data = dict(await request.post()) + except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: + _LOGGER.error("Could not get information from POST <%s>", error) + return + + camera = hass.data[PUSH_CAMERA_DATA][webhook_id] + + if camera.image_field not in data: + _LOGGER.warning("Webhook call without POST parameter <%s>", + camera.image_field) + return + + await camera.update_image(data[camera.image_field].file.read(), + data[camera.image_field].filename) + + +class PushCamera(Camera): + """The representation of a Push camera.""" + + def __init__(self, hass, name, buffer_size, timeout, image_field, + webhook_id): + """Initialize push camera component.""" + super().__init__() + self._name = name + self._last_trip = None + self._filename = None + self._expired_listener = None + self._state = STATE_IDLE + self._timeout = timeout + self.queue = deque([], buffer_size) + self._current_image = None + self._image_field = image_field + self.webhook_id = webhook_id + self.webhook_url = \ + hass.components.webhook.async_generate_url(webhook_id) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.data[PUSH_CAMERA_DATA][self.webhook_id] = self + + try: + self.hass.components.webhook.async_register(DOMAIN, + self.name, + self.webhook_id, + handle_webhook) + except ValueError: + _LOGGER.error("In <%s>, webhook_id <%s> already used", + self.name, self.webhook_id) + + @property + def image_field(self): + """HTTP field containing the image file.""" + return self._image_field + + @property + def state(self): + """Return current state of the camera.""" + return self._state + + async def update_image(self, image, filename): + """Update the camera image.""" + if self._state == STATE_IDLE: + self._state = STATE_RECORDING + self._last_trip = dt_util.utcnow() + self.queue.clear() + + self._filename = filename + self.queue.appendleft(image) + + @callback + def reset_state(now): + """Set state to idle after no new images for a period of time.""" + self._state = STATE_IDLE + self._expired_listener = None + _LOGGER.debug("Reset state") + self.async_schedule_update_ha_state() + + if self._expired_listener: + self._expired_listener() + + self._expired_listener = async_track_point_in_utc_time( + self.hass, reset_state, dt_util.utcnow() + self._timeout) + + self.async_schedule_update_ha_state() + + async def async_camera_image(self): + """Return a still image response.""" + if self.queue: + if self._state == STATE_IDLE: + self.queue.rotate(1) + self._current_image = self.queue[0] + + return self._current_image + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + name: value for name, value in ( + (ATTR_LAST_TRIP, self._last_trip), + (ATTR_FILENAME, self._filename), + ) if value is not None + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/ring.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/ring.py new file mode 100644 index 00000000000000..ad351fb59cfb27 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/ring.py @@ -0,0 +1,171 @@ +""" +This component provides support to the Ring Door Bell camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.ring/ +""" +import asyncio +import logging + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.ring import ( + DATA_RING, CONF_ATTRIBUTION, NOTIFICATION_ID) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.util import dt as dt_util + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['ring', 'ffmpeg'] + +FORCE_REFRESH_INTERVAL = timedelta(minutes=45) + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_TITLE = 'Ring Camera Setup' + +SCAN_INTERVAL = timedelta(seconds=90) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Ring Door Bell and StickUp Camera.""" + ring = hass.data[DATA_RING] + + cams = [] + cams_no_plan = [] + for camera in ring.doorbells: + if camera.has_subscription: + cams.append(RingCam(hass, camera, config)) + else: + cams_no_plan.append(camera) + + for camera in ring.stickup_cams: + if camera.has_subscription: + cams.append(RingCam(hass, camera, config)) + else: + cams_no_plan.append(camera) + + # show notification for all cameras without an active subscription + if cams_no_plan: + cameras = str(', '.join([camera.name for camera in cams_no_plan])) + + err_msg = '''A Ring Protect Plan is required for the''' \ + ''' following cameras: {}.'''.format(cameras) + + _LOGGER.error(err_msg) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(err_msg), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + add_entities(cams, True) + return True + + +class RingCam(Camera): + """An implementation of a Ring Door Bell camera.""" + + def __init__(self, hass, camera, device_info): + """Initialize a Ring Door Bell camera.""" + super(RingCam, self).__init__() + self._camera = camera + self._hass = hass + self._name = self._camera.name + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_video_id = self._camera.last_recording_id + self._video_url = self._camera.recording_url(self._last_video_id) + self._utcnow = dt_util.utcnow() + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._camera.id + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._camera.id, + 'firmware': self._camera.firmware, + 'kind': self._camera.kind, + 'timezone': self._camera.timezone, + 'type': self._camera.family, + 'video_url': self._video_url, + } + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + + if self._video_url is None: + return + + image = await asyncio.shield(ffmpeg.get_image( + self._video_url, output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + if self._video_url is None: + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + await stream.open_camera( + self._video_url, extra_cmd=self._ffmpeg_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() + + @property + def should_poll(self): + """Update the image periodically.""" + return True + + def update(self): + """Update camera entity and refresh attributes.""" + _LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url") + + self._camera.update() + self._utcnow = dt_util.utcnow() + + last_recording_id = self._camera.last_recording_id + + if self._last_video_id != last_recording_id or \ + self._utcnow >= self._expires_at: + + _LOGGER.info("Ring DoorBell properties refreshed") + + # update attributes if new video or if URL has expired + self._last_video_id = self._camera.last_recording_id + self._video_url = self._camera.recording_url(self._last_video_id) + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/rpi_camera.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/rpi_camera.py new file mode 100644 index 00000000000000..ba6f5e93304b5f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/rpi_camera.py @@ -0,0 +1,151 @@ +""" +Camera platform that has a Raspberry Pi camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.rpi_camera/ +""" +import os +import subprocess +import logging +import shutil +from tempfile import NamedTemporaryFile + +import voluptuous as vol + +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_FILE_PATH, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_HORIZONTAL_FLIP = 'horizontal_flip' +CONF_IMAGE_HEIGHT = 'image_height' +CONF_IMAGE_QUALITY = 'image_quality' +CONF_IMAGE_ROTATION = 'image_rotation' +CONF_IMAGE_WIDTH = 'image_width' +CONF_TIMELAPSE = 'timelapse' +CONF_VERTICAL_FLIP = 'vertical_flip' + +DEFAULT_HORIZONTAL_FLIP = 0 +DEFAULT_IMAGE_HEIGHT = 480 +DEFAULT_IMAGE_QUALITY = 7 +DEFAULT_IMAGE_ROTATION = 0 +DEFAULT_IMAGE_WIDTH = 640 +DEFAULT_NAME = 'Raspberry Pi Camera' +DEFAULT_TIMELAPSE = 1000 +DEFAULT_VERTICAL_FLIP = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FILE_PATH): cv.isfile, + vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP): + vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), + vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_IMAGE_HEIGHT): + vol.Coerce(int), + vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITY): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + vol.Optional(CONF_IMAGE_ROTATION, default=DEFAULT_IMAGE_ROTATION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=359)), + vol.Optional(CONF_IMAGE_WIDTH, default=DEFAULT_IMAGE_WIDTH): + vol.Coerce(int), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TIMELAPSE, default=1000): vol.Coerce(int), + vol.Optional(CONF_VERTICAL_FLIP, default=DEFAULT_VERTICAL_FLIP): + vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), +}) + + +def kill_raspistill(*args): + """Kill any previously running raspistill process..""" + subprocess.Popen(['killall', 'raspistill'], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Raspberry Camera.""" + if shutil.which("raspistill") is None: + _LOGGER.error("'raspistill' was not found") + return False + + setup_config = ( + { + CONF_NAME: config.get(CONF_NAME), + CONF_IMAGE_WIDTH: config.get(CONF_IMAGE_WIDTH), + CONF_IMAGE_HEIGHT: config.get(CONF_IMAGE_HEIGHT), + CONF_IMAGE_QUALITY: config.get(CONF_IMAGE_QUALITY), + CONF_IMAGE_ROTATION: config.get(CONF_IMAGE_ROTATION), + CONF_TIMELAPSE: config.get(CONF_TIMELAPSE), + CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP), + CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP), + CONF_FILE_PATH: config.get(CONF_FILE_PATH) + } + ) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill) + + file_path = setup_config[CONF_FILE_PATH] + + def delete_temp_file(*args): + """Delete the temporary file to prevent saving multiple temp images. + + Only used when no path is defined + """ + os.remove(file_path) + + # If no file path is defined, use a temporary file + if file_path is None: + temp_file = NamedTemporaryFile(suffix='.jpg', delete=False) + temp_file.close() + file_path = temp_file.name + setup_config[CONF_FILE_PATH] = file_path + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, delete_temp_file) + + # Check whether the file path has been whitelisted + elif not hass.config.is_allowed_path(file_path): + _LOGGER.error("'%s' is not a whitelisted directory", file_path) + return False + + add_entities([RaspberryCamera(setup_config)]) + + +class RaspberryCamera(Camera): + """Representation of a Raspberry Pi camera.""" + + def __init__(self, device_info): + """Initialize Raspberry Pi camera component.""" + super().__init__() + + self._name = device_info[CONF_NAME] + self._config = device_info + + # Kill if there's raspistill instance + kill_raspistill() + + cmd_args = [ + 'raspistill', '--nopreview', '-o', device_info[CONF_FILE_PATH], + '-t', '0', '-w', str(device_info[CONF_IMAGE_WIDTH]), + '-h', str(device_info[CONF_IMAGE_HEIGHT]), + '-tl', str(device_info[CONF_TIMELAPSE]), + '-q', str(device_info[CONF_IMAGE_QUALITY]), + '-rot', str(device_info[CONF_IMAGE_ROTATION]) + ] + if device_info[CONF_HORIZONTAL_FLIP]: + cmd_args.append("-hf") + + if device_info[CONF_VERTICAL_FLIP]: + cmd_args.append("-vf") + + subprocess.Popen(cmd_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT) + + def camera_image(self): + """Return raspistill image response.""" + with open(self._config[CONF_FILE_PATH], 'rb') as file: + return file.read() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/camera/services.yaml new file mode 100644 index 00000000000000..1cae5baf1cfa86 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/services.yaml @@ -0,0 +1,101 @@ +# Describes the format for available camera services + +turn_off: + description: Turn off camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + +turn_on: + description: Turn on camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + +enable_motion_detection: + description: Enable the motion detection in a camera. + fields: + entity_id: + description: Name(s) of entities to enable motion detection. + example: 'camera.living_room_camera' + +disable_motion_detection: + description: Disable the motion detection in a camera. + fields: + entity_id: + description: Name(s) of entities to disable motion detection. + example: 'camera.living_room_camera' + +snapshot: + description: Take a snapshot from a camera. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: 'camera.living_room_camera' + filename: + description: Template of a Filename. Variable is entity_id. + example: '/tmp/snapshot_{{ entity_id }}' + +local_file_update_file_path: + description: Update the file_path for a local_file camera. + fields: + entity_id: + description: Name(s) of entities to update. + example: 'camera.local_file' + file_path: + description: Path to the new image file. + example: '/images/newimage.jpg' + +onvif_ptz: + description: Pan/Tilt/Zoom service for ONVIF camera. + fields: + entity_id: + description: Name(s) of entities to pan, tilt or zoom. + example: 'camera.living_room_camera' + pan: + description: "Direction of pan. Allowed values: LEFT, RIGHT." + example: 'LEFT' + tilt: + description: "Direction of tilt. Allowed values: DOWN, UP." + example: 'DOWN' + zoom: + description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" + example: "ZOOM_IN" + +logi_circle_set_config: + description: Set a configuration property. + fields: + entity_id: + description: Name(s) of entities to apply the operation mode to. + example: "camera.living_room_camera" + mode: + description: "Operation mode. Allowed values: BATTERY_SAVING, LED, PRIVACY_MODE." + example: "PRIVACY_MODE" + value: + description: "Operation value. Allowed values: true, false" + example: true + +logi_circle_livestream_snapshot: + description: Take a snapshot from the camera's livestream. Will wake the camera from sleep if required. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: "camera.living_room_camera" + filename: + description: Template of a Filename. Variable is entity_id. + example: "/tmp/snapshot_{{ entity_id }}.jpg" + +logi_circle_livestream_record: + description: Take a video recording from the camera's livestream. + fields: + entity_id: + description: Name(s) of entities to create recordings from. + example: "camera.living_room_camera" + filename: + description: Template of a Filename. Variable is entity_id. + example: "/tmp/snapshot_{{ entity_id }}.mp4" + duration: + description: Recording duration in seconds. + example: 60 diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/synology.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/synology.py new file mode 100644 index 00000000000000..b094cf98edfbdc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/synology.py @@ -0,0 +1,130 @@ +""" +Support for Synology Surveillance Station Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.synology/ +""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) +from homeassistant.components.camera import ( + Camera, PLATFORM_SCHEMA) +from homeassistant.helpers.aiohttp_client import ( + async_aiohttp_proxy_web, + async_get_clientsession) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['py-synology==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Synology Camera' +DEFAULT_TIMEOUT = 5 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up a Synology IP Camera.""" + verify_ssl = config.get(CONF_VERIFY_SSL) + timeout = config.get(CONF_TIMEOUT) + + try: + from synology.surveillance_station import SurveillanceStation + surveillance = SurveillanceStation( + config.get(CONF_URL), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + verify_ssl=verify_ssl, + timeout=timeout + ) + except (requests.exceptions.RequestException, ValueError): + _LOGGER.exception("Error when initializing SurveillanceStation") + return False + + cameras = surveillance.get_all_cameras() + + # add cameras + devices = [] + for camera in cameras: + if not config.get(CONF_WHITELIST): + device = SynologyCamera(surveillance, camera.camera_id, verify_ssl) + devices.append(device) + + async_add_entities(devices) + + +class SynologyCamera(Camera): + """An implementation of a Synology NAS based IP camera.""" + + def __init__(self, surveillance, camera_id, verify_ssl): + """Initialize a Synology Surveillance Station camera.""" + super().__init__() + self._surveillance = surveillance + self._camera_id = camera_id + self._verify_ssl = verify_ssl + self._camera = self._surveillance.get_camera(camera_id) + self._motion_setting = self._surveillance.get_motion_setting(camera_id) + self.is_streaming = self._camera.is_enabled + + def camera_image(self): + """Return bytes of camera image.""" + return self._surveillance.get_camera_image(self._camera_id) + + async def handle_async_mjpeg_stream(self, request): + """Return a MJPEG stream image response directly from the camera.""" + streaming_url = self._camera.video_stream_url + + websession = async_get_clientsession(self.hass, self._verify_ssl) + stream_coro = websession.get(streaming_url) + + return await async_aiohttp_proxy_web(self.hass, request, stream_coro) + + @property + def name(self): + """Return the name of this device.""" + return self._camera.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._camera.is_recording + + def should_poll(self): + """Update the recording state periodically.""" + return True + + def update(self): + """Update the status of the camera.""" + self._surveillance.update() + self._camera = self._surveillance.get_camera(self._camera.camera_id) + self._motion_setting = self._surveillance.get_motion_setting( + self._camera.camera_id) + self.is_streaming = self._camera.is_enabled + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_setting.is_enabled + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._surveillance.enable_motion_detection(self._camera_id) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._surveillance.disable_motion_detection(self._camera_id) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/uvc.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/uvc.py new file mode 100644 index 00000000000000..50e7c3d8fe2938 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/uvc.py @@ -0,0 +1,204 @@ +""" +Support for Ubiquiti's UVC cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.uvc/ +""" +import logging +import socket + +import requests +import voluptuous as vol + +from homeassistant.const import CONF_PORT, CONF_SSL +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['uvcclient==0.11.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_NVR = 'nvr' +CONF_KEY = 'key' +CONF_PASSWORD = 'password' + +DEFAULT_PASSWORD = 'ubnt' +DEFAULT_PORT = 7080 +DEFAULT_SSL = False + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NVR): cv.string, + vol.Required(CONF_KEY): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Discover cameras on a Unifi NVR.""" + addr = config[CONF_NVR] + key = config[CONF_KEY] + password = config[CONF_PASSWORD] + port = config[CONF_PORT] + ssl = config[CONF_SSL] + + from uvcclient import nvr + try: + # Exceptions may be raised in all method calls to the nvr library. + nvrconn = nvr.UVCRemote(addr, port, key, ssl=ssl) + cameras = nvrconn.index() + + identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid' + # Filter out airCam models, which are not supported in the latest + # version of UnifiVideo and which are EOL by Ubiquiti + cameras = [ + camera for camera in cameras + if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']] + except nvr.NotAuthorized: + _LOGGER.error("Authorization failure while connecting to NVR") + return False + except nvr.NvrError as ex: + _LOGGER.error("NVR refuses to talk to me: %s", str(ex)) + raise PlatformNotReady + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Unable to connect to NVR: %s", str(ex)) + raise PlatformNotReady + + add_entities([UnifiVideoCamera(nvrconn, + camera[identifier], + camera['name'], + password) + for camera in cameras]) + return True + + +class UnifiVideoCamera(Camera): + """A Ubiquiti Unifi Video Camera.""" + + def __init__(self, nvr, uuid, name, password): + """Initialize an Unifi camera.""" + super(UnifiVideoCamera, self).__init__() + self._nvr = nvr + self._uuid = uuid + self._name = name + self._password = password + self.is_streaming = False + self._connect_addr = None + self._camera = None + self._motion_status = False + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def is_recording(self): + """Return true if the camera is recording.""" + caminfo = self._nvr.get_camera(self._uuid) + return caminfo['recordingSettings']['fullTimeRecordEnabled'] + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + caminfo = self._nvr.get_camera(self._uuid) + return caminfo['recordingSettings']['motionRecordEnabled'] + + @property + def brand(self): + """Return the brand of this camera.""" + return 'Ubiquiti' + + @property + def model(self): + """Return the model of this camera.""" + caminfo = self._nvr.get_camera(self._uuid) + return caminfo['model'] + + def _login(self): + """Login to the camera.""" + from uvcclient import camera as uvc_camera + + caminfo = self._nvr.get_camera(self._uuid) + if self._connect_addr: + addrs = [self._connect_addr] + else: + addrs = [caminfo['host'], caminfo['internalHost']] + + if self._nvr.server_version >= (3, 2, 0): + client_cls = uvc_camera.UVCCameraClientV320 + else: + client_cls = uvc_camera.UVCCameraClient + + if caminfo['username'] is None: + caminfo['username'] = 'ubnt' + + camera = None + for addr in addrs: + try: + camera = client_cls( + addr, caminfo['username'], self._password) + camera.login() + _LOGGER.debug("Logged into UVC camera %(name)s via %(addr)s", + dict(name=self._name, addr=addr)) + self._connect_addr = addr + break + except socket.error: + pass + except uvc_camera.CameraConnectError: + pass + except uvc_camera.CameraAuthError: + pass + if not self._connect_addr: + _LOGGER.error("Unable to login to camera") + return None + + self._camera = camera + return True + + def camera_image(self): + """Return the image of this camera.""" + from uvcclient import camera as uvc_camera + if not self._camera: + if not self._login(): + return + + def _get_image(retry=True): + try: + return self._camera.get_snapshot() + except uvc_camera.CameraConnectError: + _LOGGER.error("Unable to contact camera") + except uvc_camera.CameraAuthError: + if retry: + self._login() + return _get_image(retry=False) + _LOGGER.error( + "Unable to log into camera, unable to get snapshot") + raise + + return _get_image() + + def set_motion_detection(self, mode): + """Set motion detection on or off.""" + from uvcclient.nvr import NvrError + if mode is True: + set_mode = 'motion' + else: + set_mode = 'none' + + try: + self._nvr.set_recordmode(self._uuid, set_mode) + self._motion_status = mode + except NvrError as err: + _LOGGER.error("Unable to set recordmode to %s", set_mode) + _LOGGER.debug(err) + + def enable_motion_detection(self): + """Enable motion detection in camera.""" + self.set_motion_detection(True) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self.set_motion_detection(False) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/xeoma.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/xeoma.py new file mode 100644 index 00000000000000..c268c3533e0ab0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/xeoma.py @@ -0,0 +1,118 @@ +""" +Support for Xeoma Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.xeoma/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv + +REQUIREMENTS = ['pyxeoma==1.4.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CAMERAS = 'cameras' +CONF_HIDE = 'hide' +CONF_IMAGE_NAME = 'image_name' +CONF_NEW_VERSION = 'new_version' +CONF_VIEWER_PASSWORD = 'viewer_password' +CONF_VIEWER_USERNAME = 'viewer_username' + +CAMERAS_SCHEMA = vol.Schema({ + vol.Required(CONF_IMAGE_NAME): cv.string, + vol.Optional(CONF_HIDE, default=False): cv.boolean, + vol.Optional(CONF_NAME): cv.string, +}, required=False) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_CAMERAS): + vol.Schema(vol.All(cv.ensure_list, [CAMERAS_SCHEMA])), + vol.Optional(CONF_NEW_VERSION, default=True): cv.boolean, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Discover and setup Xeoma Cameras.""" + from pyxeoma.xeoma import Xeoma, XeomaError + + host = config[CONF_HOST] + login = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + xeoma = Xeoma(host, login, password) + + try: + await xeoma.async_test_connection() + discovered_image_names = await xeoma.async_get_image_names() + discovered_cameras = [ + { + CONF_IMAGE_NAME: image_name, + CONF_HIDE: False, + CONF_NAME: image_name, + CONF_VIEWER_USERNAME: username, + CONF_VIEWER_PASSWORD: pw + + } + for image_name, username, pw in discovered_image_names + ] + + for cam in config.get(CONF_CAMERAS, []): + camera = next( + (dc for dc in discovered_cameras + if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None) + + if camera is not None: + if CONF_NAME in cam: + camera[CONF_NAME] = cam[CONF_NAME] + if CONF_HIDE in cam: + camera[CONF_HIDE] = cam[CONF_HIDE] + + cameras = list(filter(lambda c: not c[CONF_HIDE], discovered_cameras)) + async_add_entities( + [XeomaCamera(xeoma, camera[CONF_IMAGE_NAME], camera[CONF_NAME], + camera[CONF_VIEWER_USERNAME], + camera[CONF_VIEWER_PASSWORD]) for camera in cameras]) + except XeomaError as err: + _LOGGER.error("Error: %s", err.message) + return + + +class XeomaCamera(Camera): + """Implementation of a Xeoma camera.""" + + def __init__(self, xeoma, image, name, username, password): + """Initialize a Xeoma camera.""" + super().__init__() + self._xeoma = xeoma + self._name = name + self._image = image + self._username = username + self._password = password + self._last_image = None + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from pyxeoma.xeoma import XeomaError + try: + image = await self._xeoma.async_get_camera_image( + self._image, self._username, self._password) + self._last_image = image + except XeomaError as err: + _LOGGER.error("Error fetching image: %s", err.message) + + return self._last_image + + @property + def name(self): + """Return the name of this device.""" + return self._name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/xiaomi.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/xiaomi.py new file mode 100644 index 00000000000000..207dd17ed9b318 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/xiaomi.py @@ -0,0 +1,166 @@ +""" +This component provides support for Xiaomi Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.xiaomi/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH, + CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream + +DEPENDENCIES = ['ffmpeg'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_BRAND = 'Xiaomi Home Camera' +DEFAULT_PATH = '/media/mmcblk0p1/record' +DEFAULT_PORT = 21 +DEFAULT_USERNAME = 'root' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +CONF_MODEL = 'model' + +MODEL_YI = 'yi' +MODEL_XIAOFANG = 'xiaofang' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MODEL): vol.Any(MODEL_YI, + MODEL_XIAOFANG), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string +}) + + +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): + """Set up a Xiaomi Camera.""" + _LOGGER.debug('Received configuration for model %s', config[CONF_MODEL]) + async_add_entities([XiaomiCamera(hass, config)]) + + +class XiaomiCamera(Camera): + """Define an implementation of a Xiaomi Camera.""" + + def __init__(self, hass, config): + """Initialize.""" + super().__init__() + self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) + self._last_image = None + self._last_url = None + self._manager = hass.data[DATA_FFMPEG] + self._name = config[CONF_NAME] + self.host = config[CONF_HOST] + self._model = config[CONF_MODEL] + self.port = config[CONF_PORT] + self.path = config[CONF_PATH] + self.user = config[CONF_USERNAME] + self.passwd = config[CONF_PASSWORD] + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_BRAND + + @property + def model(self): + """Return the camera model.""" + return self._model + + def get_latest_video_url(self): + """Retrieve the latest video file from the Xiaomi Camera FTP server.""" + from ftplib import FTP, error_perm + + ftp = FTP(self.host) + try: + ftp.login(self.user, self.passwd) + except error_perm as exc: + _LOGGER.error('Camera login failed: %s', exc) + return False + + try: + ftp.cwd(self.path) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s - %s', self.path, exc) + return False + + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any folders") + return False + + first_dir = latest_dir = dirs[-1] + try: + ftp.cwd(first_dir) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s - %s', first_dir, exc) + return False + + if self._model == MODEL_XIAOFANG: + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any uploaded videos") + return False + + latest_dir = dirs[-1] + ftp.cwd(latest_dir) + + videos = [v for v in ftp.nlst() if '.tmp' not in v] + if not videos: + _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) + return False + + if self._model == MODEL_XIAOFANG: + video = videos[-2] + else: + video = videos[-1] + + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}'.format( + self.user, self.passwd, self.host, self.port, ftp.pwd(), video) + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + url = await self.hass.async_add_job(self.get_latest_video_url) + if url != self._last_url: + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + self._last_image = await asyncio.shield(ffmpeg.get_image( + url, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), loop=self.hass.loop) + self._last_url = url + + return self._last_image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + await stream.open_camera( + self._last_url, extra_cmd=self._extra_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/camera/yi.py b/homeassistant-0.88.0.dev0/homeassistant/components/camera/yi.py new file mode 100644 index 00000000000000..8b5b865ee57ccb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/camera/yi.py @@ -0,0 +1,152 @@ +""" +This component provides support for Xiaomi Cameras (HiSilicon Hi3518e V200). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.yi/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PATH, CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['aioftp==0.12.0'] +DEPENDENCIES = ['ffmpeg'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_BRAND = 'YI Home Camera' +DEFAULT_PASSWORD = '' +DEFAULT_PATH = '/tmp/sd/record' +DEFAULT_PORT = 21 +DEFAULT_USERNAME = 'root' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up a Yi Camera.""" + async_add_entities([YiCamera(hass, config)], True) + + +class YiCamera(Camera): + """Define an implementation of a Yi Camera.""" + + def __init__(self, hass, config): + """Initialize.""" + super().__init__() + self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) + self._last_image = None + self._last_url = None + self._manager = hass.data[DATA_FFMPEG] + self._name = config[CONF_NAME] + self._is_on = True + self.host = config[CONF_HOST] + self.port = config[CONF_PORT] + self.path = config[CONF_PATH] + self.user = config[CONF_USERNAME] + self.passwd = config[CONF_PASSWORD] + + @property + def brand(self): + """Camera brand.""" + return DEFAULT_BRAND + + @property + def is_on(self): + """Determine whether the camera is on.""" + return self._is_on + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def _get_latest_video_url(self): + """Retrieve the latest video file from the customized Yi FTP server.""" + from aioftp import Client, StatusCodeError + + ftp = Client(loop=self.hass.loop) + try: + await ftp.connect(self.host) + await ftp.login(self.user, self.passwd) + except (ConnectionRefusedError, StatusCodeError) as err: + raise PlatformNotReady(err) + + try: + await ftp.change_directory(self.path) + dirs = [] + for path, attrs in await ftp.list(): + if attrs['type'] == 'dir' and '.' not in str(path): + dirs.append(path) + latest_dir = dirs[-1] + await ftp.change_directory(latest_dir) + + videos = [] + for path, _ in await ftp.list(): + videos.append(path) + if not videos: + _LOGGER.info('Video folder "%s" empty; delaying', latest_dir) + return None + + await ftp.quit() + self._is_on = True + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( + self.user, self.passwd, self.host, self.port, self.path, + latest_dir, videos[-1]) + except (ConnectionRefusedError, StatusCodeError) as err: + _LOGGER.error('Error while fetching video: %s', err) + self._is_on = False + return None + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + url = await self._get_latest_video_url() + if url and url != self._last_url: + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + self._last_image = await asyncio.shield( + ffmpeg.get_image( + url, + output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), + loop=self.hass.loop) + self._last_url = url + + return self._last_image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + if not self._is_on: + return + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + await stream.open_camera( + self._last_url, extra_cmd=self._extra_arguments) + + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/canary.py b/homeassistant-0.88.0.dev0/homeassistant/components/canary.py new file mode 100644 index 00000000000000..04c33d83f3db88 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/canary.py @@ -0,0 +1,128 @@ +""" +Support for Canary. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/canary/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import ConnectTimeout, HTTPError + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ['py-canary==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'canary_notification' +NOTIFICATION_TITLE = 'Canary Setup' + +DOMAIN = 'canary' +DATA_CANARY = 'canary' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +CANARY_COMPONENTS = [ + 'alarm_control_panel', 'camera', 'sensor' +] + + +def setup(hass, config): + """Set up the Canary component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + timeout = conf.get(CONF_TIMEOUT) + + try: + hass.data[DATA_CANARY] = CanaryData(username, password, timeout) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Canary service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component in CANARY_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class CanaryData: + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + from canary.api import Api + self._api = Api(username, password, timeout) + + self._locations_by_id = {} + self._readings_by_device_id = {} + self._entries_by_location_id = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Get the latest data from py-canary.""" + for location in self._api.get_locations(): + location_id = location.location_id + + self._locations_by_id[location_id] = location + self._entries_by_location_id[location_id] = self._api.get_entries( + location_id, entry_type="motion", limit=1) + + for device in location.devices: + if device.is_online: + self._readings_by_device_id[device.device_id] = \ + self._api.get_latest_readings(device.device_id) + + @property + def locations(self): + """Return a list of locations.""" + return self._locations_by_id.values() + + def get_motion_entries(self, location_id): + """Return a list of motion entries based on location_id.""" + return self._entries_by_location_id.get(location_id, []) + + def get_location(self, location_id): + """Return a location based on location_id.""" + return self._locations_by_id.get(location_id, []) + + def get_readings(self, device_id): + """Return a list of readings based on device_id.""" + return self._readings_by_device_id.get(device_id, []) + + def get_reading(self, device_id, sensor_type): + """Return reading for device_id and sensor type.""" + readings = self._readings_by_device_id.get(device_id, []) + return next(( + reading.value for reading in readings + if reading.sensor_type == sensor_type), None) + + def set_location_mode(self, location_id, mode_name, is_private=False): + """Set location mode.""" + self._api.set_location_mode(location_id, mode_name, is_private) + self.update(no_throttle=True) + + def get_live_stream_session(self, device): + """Return live stream session.""" + return self._api.get_live_stream_session(device) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ca.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ca.json new file mode 100644 index 00000000000000..26236397dec77e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius de Google Cast a la xarxa.", + "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Google Cast." + }, + "step": { + "confirm": { + "description": "Vols configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/cs.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/cs.json new file mode 100644 index 00000000000000..82f063b365f1ec --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Google Cast.", + "single_instance_allowed": "Pouze jedin\u00e1 konfigurace Google Cast je nezbytn\u00e1." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/da.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/da.json new file mode 100644 index 00000000000000..5d8ab2362377c1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Google Cast enheder kunne findes p\u00e5 netv\u00e6rket.", + "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Google Cast" + }, + "step": { + "confirm": { + "description": "Vil du ops\u00e6tte Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/de.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/de.json new file mode 100644 index 00000000000000..ac1ebbeb23653b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Google Cast Ger\u00e4te im Netzwerk gefunden.", + "single_instance_allowed": "Nur eine einzige Konfiguration von Google Cast ist notwendig." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du Google Cast einrichten?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/en.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/en.json new file mode 100644 index 00000000000000..f908f41e3289e6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Google Cast devices found on the network.", + "single_instance_allowed": "Only a single configuration of Google Cast is necessary." + }, + "step": { + "confirm": { + "description": "Do you want to set up Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/es-419.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/es-419.json new file mode 100644 index 00000000000000..2f8d4982afdd0e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/es-419.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/es.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/es.json new file mode 100644 index 00000000000000..6dc41196af56fd --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos de Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/et.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/et.json new file mode 100644 index 00000000000000..987c54955f20ed --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/et.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/fr.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/fr.json new file mode 100644 index 00000000000000..99feeb3c89837d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun appareil Google Cast trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/he.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/he.json new file mode 100644 index 00000000000000..40d2514b59ce06 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Google Cast \u05d1\u05e8\u05e9\u05ea.", + "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Google Cast \u05e0\u05d7\u05d5\u05e6\u05d4." + }, + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/hu.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/hu.json new file mode 100644 index 00000000000000..66dc4ea8dd8438 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen Google Cast konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Google Cast szolg\u00e1ltat\u00e1st?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/id.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/id.json new file mode 100644 index 00000000000000..86fb32c0844fac --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat Google Cast yang ditemukan pada jaringan.", + "single_instance_allowed": "Hanya satu konfigurasi Google Cast yang diperlukan." + }, + "step": { + "confirm": { + "description": "Apakah Anda ingin menyiapkan Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/it.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/it.json new file mode 100644 index 00000000000000..21c8e60518e2ad --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Google Cast trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Google Cast." + }, + "step": { + "confirm": { + "description": "Vuoi configurare Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ja.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ja.json new file mode 100644 index 00000000000000..25b9c10b2e7435 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306bGoogle Cast\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "confirm": { + "description": "Google Cast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ko.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ko.json new file mode 100644 index 00000000000000..e4472c88cd8e3a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Googgle Cast \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Google Cast \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Google Cast\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/lb.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/lb.json new file mode 100644 index 00000000000000..f1daff8306955c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Google Cast Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Google Cast ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Google Cast konfigur\u00e9iert ginn?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/nl.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/nl.json new file mode 100644 index 00000000000000..91c428770f5fc8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Google Cast-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Google Cast nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Google Cast instellen?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/nn.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/nn.json new file mode 100644 index 00000000000000..7f550155658926 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/nn.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Klar", + "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Google Cast-konfigurasjon." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/no.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/no.json new file mode 100644 index 00000000000000..d36c929e7211b5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Google Cast enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Kun en enkelt konfigurasjon av Google Cast er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 sette opp Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pl.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pl.json new file mode 100644 index 00000000000000..c4399f95defe81 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Google Cast.", + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Google Cast." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pt-BR.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pt-BR.json new file mode 100644 index 00000000000000..bd670d7c72f56e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Google Cast encontrado na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Google Cast?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pt.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pt.json new file mode 100644 index 00000000000000..85d1b14484d174 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Google Cast descoberto na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ro.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ro.json new file mode 100644 index 00000000000000..8a1d19c0ecf56d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ru.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ru.json new file mode 100644 index 00000000000000..da03eae701dd98 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Google Cast \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/sl.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/sl.json new file mode 100644 index 00000000000000..24a7215574dbd9 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/sv.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/sv.json new file mode 100644 index 00000000000000..aea55058d108f7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Google Cast-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Google Cast \u00e4r n\u00f6dv\u00e4ndig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/vi.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/vi.json new file mode 100644 index 00000000000000..2f2982293cfdac --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/vi.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Google Cast n\u00e0o tr\u00ean m\u1ea1ng.", + "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Google Cast l\u00e0 \u0111\u1ee7." + }, + "step": { + "confirm": { + "description": "B\u1ea1n c\u00f3 mu\u1ed1n thi\u1ebft l\u1eadp Google Cast kh\u00f4ng?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/zh-Hans.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/zh-Hans.json new file mode 100644 index 00000000000000..d4f1cf4c1a5907 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Google Cast \u8bbe\u5907\u3002", + "single_instance_allowed": "Google Cast \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "step": { + "confirm": { + "description": "\u60a8\u60f3\u8981\u914d\u7f6e Google Cast \u5417\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/zh-Hant.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/zh-Hant.json new file mode 100644 index 00000000000000..d5383fb1a2bdba --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u88dd\u7f6e\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Google Cast \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Google Cast\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/cast/__init__.py new file mode 100644 index 00000000000000..53f5e704019004 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/__init__.py @@ -0,0 +1,39 @@ +"""Component to embed Google Cast.""" +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + + +DOMAIN = 'cast' +REQUIREMENTS = ['pychromecast==2.1.0'] + + +async def async_setup(hass, config): + """Set up the Cast component.""" + conf = config.get(DOMAIN) + + hass.data[DOMAIN] = conf or {} + + if conf is not None: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Cast from a config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'media_player')) + return True + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + from pychromecast.discovery import discover_chromecasts + + return await hass.async_add_executor_job(discover_chromecasts) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Google Cast', _async_has_devices, + config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/media_player.py b/homeassistant-0.88.0.dev0/homeassistant/components/cast/media_player.py new file mode 100644 index 00000000000000..20a44c0e910e9f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/media_player.py @@ -0,0 +1,681 @@ +""" +Provide functionality to interact with Cast devices on the network. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.cast/ +""" +import asyncio +import logging +import threading +from typing import Optional, Tuple + +import attr +import voluptuous as vol + +from homeassistant.components.cast import DOMAIN as CAST_DOMAIN +from homeassistant.components.media_player import ( + MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) +from homeassistant.const import ( + CONF_HOST, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_PAUSED, + STATE_PLAYING) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util + +DEPENDENCIES = ('cast',) + +_LOGGER = logging.getLogger(__name__) + +CONF_IGNORE_CEC = 'ignore_cec' +CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' + +DEFAULT_PORT = 8009 + +SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY + +# Stores a threading.Lock that is held by the internal pychromecast discovery. +INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running' +# Stores all ChromecastInfo we encountered through discovery or config as a set +# If we find a chromecast with a new host, the old one will be removed again. +KNOWN_CHROMECAST_INFO_KEY = 'cast_known_chromecasts' +# Stores UUIDs of cast devices that were added as entities. Doesn't store +# None UUIDs. +ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' + +# Dispatcher signal fired with a ChromecastInfo every time we discover a new +# Chromecast or receive it through configuration +SIGNAL_CAST_DISCOVERED = 'cast_discovered' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_IGNORE_CEC, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) + + +@attr.s(slots=True, frozen=True) +class ChromecastInfo: + """Class to hold all data about a chromecast for creating connections. + + This also has the same attributes as the mDNS fields by zeroconf. + """ + + host = attr.ib(type=str) + port = attr.ib(type=int) + uuid = attr.ib(type=Optional[str], converter=attr.converters.optional(str), + default=None) # always convert UUID to string if not None + manufacturer = attr.ib(type=str, default='') + model_name = attr.ib(type=str, default='') + friendly_name = attr.ib(type=Optional[str], default=None) + + @property + def is_audio_group(self) -> bool: + """Return if this is an audio group.""" + return self.port != DEFAULT_PORT + + @property + def is_information_complete(self) -> bool: + """Return if all information is filled out.""" + return all(attr.astuple(self)) + + @property + def host_port(self) -> Tuple[str, int]: + """Return the host+port tuple.""" + return self.host, self.port + + +def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: + """Fill out missing attributes of ChromecastInfo using blocking HTTP.""" + if info.is_information_complete or info.is_audio_group: + # We have all information, no need to check HTTP API. Or this is an + # audio group, so checking via HTTP won't give us any new information. + return info + + # Fill out missing information via HTTP dial. + from pychromecast import dial + + http_device_status = dial.get_device_status(info.host) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return info + + return ChromecastInfo( + host=info.host, port=info.port, + uuid=(info.uuid or http_device_status.uuid), + friendly_name=(info.friendly_name or http_device_status.friendly_name), + manufacturer=(info.manufacturer or http_device_status.manufacturer), + model_name=(info.model_name or http_device_status.model_name) + ) + + +def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo): + if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", info) + return + + # Either discovered completely new chromecast or a "moved" one. + info = _fill_out_missing_chromecast_info(info) + _LOGGER.debug("Discovered chromecast %s", info) + + if info.uuid is not None: + # Remove previous cast infos with same uuid from known chromecasts. + same_uuid = set(x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] + if info.uuid == x.uuid) + hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid + + hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info) + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) + + +def _setup_internal_discovery(hass: HomeAssistantType) -> None: + """Set up the pychromecast internal discovery.""" + if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock() + + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): + # Internal discovery is already running + return + + import pychromecast + + def internal_callback(name): + """Handle zeroconf discovery of a new chromecast.""" + mdns = listener.services[name] + _discover_chromecast(hass, ChromecastInfo( + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + )) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery(internal_callback) + + def stop_discovery(event): + """Stop discovery of new chromecasts.""" + _LOGGER.debug("Stopping internal pychromecast discovery.") + pychromecast.stop_discovery(browser) + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) + + +@callback +def _async_create_cast_device(hass: HomeAssistantType, + info: ChromecastInfo): + """Create a CastDevice Entity from the chromecast object. + + Returns None if the cast device has already been added. + """ + if info.uuid is None: + # Found a cast without UUID, we don't store it because we won't be able + # to update it anyway. + return CastDevice(info) + + # Found a cast with UUID + added_casts = hass.data[ADDED_CAST_DEVICES_KEY] + if info.uuid in added_casts: + # Already added this one, the entity will take care of moved hosts + # itself + return None + # -> New cast device + added_casts.add(info.uuid) + return CastDevice(info) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_info=None): + """Set up thet Cast platform. + + Deprecated. + """ + _LOGGER.warning( + 'Setting configuration for Cast via platform is deprecated. ' + 'Configure via Cast component instead.') + await _async_setup_platform( + hass, config, async_add_entities, discovery_info) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Cast from a config entry.""" + config = hass.data[CAST_DOMAIN].get('media_player', {}) + if not isinstance(config, list): + config = [config] + + # no pending task + done, _ = await asyncio.wait([ + _async_setup_platform(hass, cfg, async_add_entities, None) + for cfg in config]) + if any([task.exception() for task in done]): + raise PlatformNotReady + + +async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_info): + """Set up the cast platform.""" + import pychromecast + + # Import CEC IGNORE attributes + pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) + hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) + hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, set()) + + info = None + if discovery_info is not None: + info = ChromecastInfo(host=discovery_info['host'], + port=discovery_info['port']) + elif CONF_HOST in config: + info = ChromecastInfo(host=config[CONF_HOST], + port=DEFAULT_PORT) + + @callback + def async_cast_discovered(discover: ChromecastInfo) -> None: + """Handle discovery of a new chromecast.""" + if info is not None and info.host_port != discover.host_port: + # Not our requested cast device. + return + + cast_device = _async_create_cast_device(hass, discover) + if cast_device is not None: + async_add_entities([cast_device]) + + remove_handler = async_dispatcher_connect( + hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered) + # Re-play the callback for all past chromecasts, store the objects in + # a list to avoid concurrent modification resulting in exception. + for chromecast in list(hass.data[KNOWN_CHROMECAST_INFO_KEY]): + async_cast_discovered(chromecast) + + if info is None or info.is_audio_group: + # If we were a) explicitly told to enable discovery or + # b) have an audio group cast device, we need internal discovery. + hass.async_add_job(_setup_internal_discovery, hass) + else: + info = await hass.async_add_job(_fill_out_missing_chromecast_info, + info) + if info.friendly_name is None: + _LOGGER.debug("Cannot retrieve detail information for chromecast" + " %s, the device may not be online", info) + remove_handler() + raise PlatformNotReady + + hass.async_add_job(_discover_chromecast, hass, info) + + +class CastStatusListener: + """Helper class to handle pychromecast status callbacks. + + Necessary because a CastDevice entity can create a new socket client + and therefore callbacks from multiple chromecast connections can + potentially arrive. This class allows invalidating past chromecast objects. + """ + + def __init__(self, cast_device, chromecast): + """Initialize the status listener.""" + self._cast_device = cast_device + self._valid = True + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener( + self) + chromecast.register_connection_listener(self) + + def new_cast_status(self, cast_status): + """Handle reception of a new CastStatus.""" + if self._valid: + self._cast_device.new_cast_status(cast_status) + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if self._valid: + self._cast_device.new_connection_status(connection_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + self._valid = False + + +class CastDevice(MediaPlayerDevice): + """Representation of a Cast device on the network. + + This class is the holder of the pychromecast.Chromecast object and its + socket client. It therefore handles all reconnects and audio group changing + "elected leader" itself. + """ + + def __init__(self, cast_info): + """Initialize the cast device.""" + self._cast_info = cast_info # type: ChromecastInfo + self._chromecast = None # type: Optional[pychromecast.Chromecast] + self.cast_status = None + self.media_status = None + self.media_status_received = None + self._available = False # type: bool + self._status_listener = None # type: Optional[CastStatusListener] + + async def async_added_to_hass(self): + """Create chromecast object when added to hass.""" + @callback + def async_cast_discovered(discover: ChromecastInfo): + """Handle discovery of new Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + if self._cast_info.uuid != discover.uuid: + # Discovered is not our device. + return + _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) + self.hass.async_create_task(self.async_set_cast_info(discover)) + + async def async_stop(event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() + + async_dispatcher_connect(self.hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) + self.hass.async_create_task(self.async_set_cast_info(self._cast_info)) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect Chromecast object when removed.""" + await self._async_disconnect() + if self._cast_info.uuid is not None: + # Remove the entity from the added casts so that it can dynamically + # be re-added again. + self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid) + + async def async_set_cast_info(self, cast_info): + """Set the cast information and set up the chromecast object.""" + import pychromecast + old_cast_info = self._cast_info + self._cast_info = cast_info + + if self._chromecast is not None: + if old_cast_info.host_port == cast_info.host_port: + _LOGGER.debug("No connection related update: %s", + cast_info.host_port) + return + await self._async_disconnect() + + # pylint: disable=protected-access + _LOGGER.debug("Connecting to cast device %s", cast_info) + chromecast = await self.hass.async_add_job( + pychromecast._get_chromecast_from_host, ( + cast_info.host, cast_info.port, cast_info.uuid, + cast_info.model_name, cast_info.friendly_name + )) + self._chromecast = chromecast + self._status_listener = CastStatusListener(self, chromecast) + # Initialise connection status as connected because we can only + # register the connection listener *after* the initial connection + # attempt. If the initial connection failed, we would never reach + # this code anyway. + self._available = True + self.cast_status = chromecast.status + self.media_status = chromecast.media_controller.status + _LOGGER.debug("Connection successful!") + self.async_schedule_update_ha_state() + + async def _async_disconnect(self): + """Disconnect Chromecast object if it is set.""" + if self._chromecast is None: + # Can't disconnect if not connected. + return + _LOGGER.debug("Disconnecting from chromecast socket.") + self._available = False + self.async_schedule_update_ha_state() + + await self.hass.async_add_job(self._chromecast.disconnect) + + self._invalidate() + + self.async_schedule_update_ha_state() + + def _invalidate(self): + """Invalidate some attributes.""" + self._chromecast = None + self.cast_status = None + self.media_status = None + self.media_status_received = None + if self._status_listener is not None: + self._status_listener.invalidate() + self._status_listener = None + + # ========== Callbacks ========== + def new_cast_status(self, cast_status): + """Handle updates of the cast status.""" + self.cast_status = cast_status + self.schedule_update_ha_state() + + def new_media_status(self, media_status): + """Handle updates of the media status.""" + self.media_status = media_status + self.media_status_received = dt_util.utcnow() + self.schedule_update_ha_state() + + def new_connection_status(self, connection_status): + """Handle updates of connection status.""" + from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, \ + CONNECTION_STATUS_DISCONNECTED + + _LOGGER.debug("Received cast device connection status: %s", + connection_status.status) + if connection_status.status == CONNECTION_STATUS_DISCONNECTED: + self._available = False + self._invalidate() + self.schedule_update_ha_state() + return + + new_available = connection_status.status == CONNECTION_STATUS_CONNECTED + if new_available != self._available: + # Connection status callbacks happen often when disconnected. + # Only update state when availability changed to put less pressure + # on state machine. + _LOGGER.debug("Cast device availability changed: %s", + connection_status.status) + self._available = new_available + self.schedule_update_ha_state() + + # ========== Service Calls ========== + def turn_on(self): + """Turn on the cast device.""" + import pychromecast + + if not self._chromecast.is_idle: + # Already turned on + return + + if self._chromecast.app_id is not None: + # Quit the previous app before starting splash screen + self._chromecast.quit_app() + + # The only way we can turn the Chromecast is on is by launching an app + self._chromecast.play_media(CAST_SPLASH, + pychromecast.STREAM_TYPE_BUFFERED) + + def turn_off(self): + """Turn off the cast device.""" + self._chromecast.quit_app() + + def mute_volume(self, mute): + """Mute the volume.""" + self._chromecast.set_volume_muted(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._chromecast.set_volume(volume) + + def media_play(self): + """Send play command.""" + self._chromecast.media_controller.play() + + def media_pause(self): + """Send pause command.""" + self._chromecast.media_controller.pause() + + def media_stop(self): + """Send stop command.""" + self._chromecast.media_controller.stop() + + def media_previous_track(self): + """Send previous track command.""" + self._chromecast.media_controller.rewind() + + def media_next_track(self): + """Send next track command.""" + self._chromecast.media_controller.skip() + + def media_seek(self, position): + """Seek the media to a specific location.""" + self._chromecast.media_controller.seek(position) + + def play_media(self, media_type, media_id, **kwargs): + """Play media from a URL.""" + self._chromecast.media_controller.play_media(media_id, media_type) + + # ========== Properties ========== + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._cast_info.friendly_name + + @property + def device_info(self): + """Return information about the device.""" + cast_info = self._cast_info + + if cast_info.model_name == "Google Cast Group": + return None + + return { + 'name': cast_info.friendly_name, + 'identifiers': { + (CAST_DOMAIN, cast_info.uuid.replace('-', '')) + }, + 'model': cast_info.model_name, + 'manufacturer': cast_info.manufacturer, + } + + @property + def state(self): + """Return the state of the player.""" + if self.media_status is None: + return None + if self.media_status.player_is_playing: + return STATE_PLAYING + if self.media_status.player_is_paused: + return STATE_PAUSED + if self.media_status.player_is_idle: + return STATE_IDLE + if self._chromecast is not None and self._chromecast.is_idle: + return STATE_OFF + return None + + @property + def available(self): + """Return True if the cast device is connected.""" + return self._available + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.cast_status.volume_level if self.cast_status else None + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.cast_status.volume_muted if self.cast_status else None + + @property + def media_content_id(self): + """Content ID of current playing media.""" + return self.media_status.content_id if self.media_status else None + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self.media_status is None: + return None + if self.media_status.media_is_tvshow: + return MEDIA_TYPE_TVSHOW + if self.media_status.media_is_movie: + return MEDIA_TYPE_MOVIE + if self.media_status.media_is_musictrack: + return MEDIA_TYPE_MUSIC + return None + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.media_status.duration if self.media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.media_status is None: + return None + + images = self.media_status.images + + return images[0].url if images and images[0].url else None + + @property + def media_title(self): + """Title of current playing media.""" + return self.media_status.title if self.media_status else None + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + return self.media_status.artist if self.media_status else None + + @property + def media_album_name(self): + """Album of current playing media (Music track only).""" + return self.media_status.album_name if self.media_status else None + + @property + def media_album_artist(self): + """Album artist of current playing media (Music track only).""" + return self.media_status.album_artist if self.media_status else None + + @property + def media_track(self): + """Track number of current playing media (Music track only).""" + return self.media_status.track if self.media_status else None + + @property + def media_series_title(self): + """Return the title of the series of current playing media.""" + return self.media_status.series_title if self.media_status else None + + @property + def media_season(self): + """Season of current playing media (TV Show only).""" + return self.media_status.season if self.media_status else None + + @property + def media_episode(self): + """Episode of current playing media (TV Show only).""" + return self.media_status.episode if self.media_status else None + + @property + def app_id(self): + """Return the ID of the current running app.""" + return self._chromecast.app_id if self._chromecast else None + + @property + def app_name(self): + """Name of the current running app.""" + return self._chromecast.app_display_name if self._chromecast else None + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_CAST + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self.media_status is None or \ + not (self.media_status.player_is_playing or + self.media_status.player_is_paused or + self.media_status.player_is_idle): + return None + return self.media_status.current_time + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self.media_status_received + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._cast_info.uuid diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cast/strings.json b/homeassistant-0.88.0.dev0/homeassistant/components/cast/strings.json new file mode 100644 index 00000000000000..eecdecbfdf9e5f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cast/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Google Cast", + "step": { + "confirm": { + "title": "Google Cast", + "description": "Do you want to set up Google Cast?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Google Cast is necessary.", + "no_devices_found": "No Google Cast devices found on the network." + } + } +} diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/__init__.py new file mode 100644 index 00000000000000..6d7f9432e39a2f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/__init__.py @@ -0,0 +1,574 @@ +""" +Provides functionality to interact with climate devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/climate/ +""" +from datetime import timedelta +import logging +import functools as ft + +import voluptuous as vol + +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE, + PRECISION_TENTHS) + +DEFAULT_MIN_TEMP = 7 +DEFAULT_MAX_TEMP = 35 +DEFAULT_MIN_HUMITIDY = 30 +DEFAULT_MAX_HUMIDITY = 99 + +DOMAIN = 'climate' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' +SCAN_INTERVAL = timedelta(seconds=60) + +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' + +STATE_HEAT = 'heat' +STATE_COOL = 'cool' +STATE_IDLE = 'idle' +STATE_AUTO = 'auto' +STATE_MANUAL = 'manual' +STATE_DRY = 'dry' +STATE_FAN_ONLY = 'fan_only' +STATE_ECO = 'eco' + +SUPPORT_TARGET_TEMPERATURE = 1 +SUPPORT_TARGET_TEMPERATURE_HIGH = 2 +SUPPORT_TARGET_TEMPERATURE_LOW = 4 +SUPPORT_TARGET_HUMIDITY = 8 +SUPPORT_TARGET_HUMIDITY_HIGH = 16 +SUPPORT_TARGET_HUMIDITY_LOW = 32 +SUPPORT_FAN_MODE = 64 +SUPPORT_OPERATION_MODE = 128 +SUPPORT_HOLD_MODE = 256 +SUPPORT_SWING_MODE = 512 +SUPPORT_AWAY_MODE = 1024 +SUPPORT_AUX_HEAT = 2048 +SUPPORT_ON_OFF = 4096 + +ATTR_CURRENT_TEMPERATURE = 'current_temperature' +ATTR_MAX_TEMP = 'max_temp' +ATTR_MIN_TEMP = 'min_temp' +ATTR_TARGET_TEMP_HIGH = 'target_temp_high' +ATTR_TARGET_TEMP_LOW = 'target_temp_low' +ATTR_TARGET_TEMP_STEP = 'target_temp_step' +ATTR_AWAY_MODE = 'away_mode' +ATTR_AUX_HEAT = 'aux_heat' +ATTR_FAN_MODE = 'fan_mode' +ATTR_FAN_LIST = 'fan_list' +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' +ATTR_SWING_LIST = 'swing_list' + +CONVERTIBLE_ATTRIBUTE = [ + ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_HIGH, +] + +_LOGGER = logging.getLogger(__name__) + +ON_OFF_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_AWAY_MODE): cv.boolean, +}) +SET_AUX_HEAT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_AUX_HEAT): cv.boolean, +}) +SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key( + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW), + { + vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float), + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Optional(ATTR_OPERATION_MODE): cv.string, + } +)) +SET_FAN_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_FAN_MODE): cv.string, +}) +SET_HOLD_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_HOLD_MODE): cv.string, +}) +SET_OPERATION_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_OPERATION_MODE): cv.string, +}) +SET_HUMIDITY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_HUMIDITY): vol.Coerce(float), +}) +SET_SWING_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_SWING_MODE): cv.string, +}) + + +async def async_setup(hass, config): + """Set up climate devices.""" + component = hass.data[DOMAIN] = \ + EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA, + async_service_away_mode + ) + component.async_register_entity_service( + SERVICE_SET_HOLD_MODE, SET_HOLD_MODE_SCHEMA, + 'async_set_hold_mode' + ) + component.async_register_entity_service( + SERVICE_SET_AUX_HEAT, SET_AUX_HEAT_SCHEMA, + async_service_aux_heat + ) + component.async_register_entity_service( + SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, + async_service_temperature_set + ) + component.async_register_entity_service( + SERVICE_SET_HUMIDITY, SET_HUMIDITY_SCHEMA, + 'async_set_humidity' + ) + component.async_register_entity_service( + SERVICE_SET_FAN_MODE, SET_FAN_MODE_SCHEMA, + 'async_set_fan_mode' + ) + component.async_register_entity_service( + SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA, + 'async_set_operation_mode' + ) + component.async_register_entity_service( + SERVICE_SET_SWING_MODE, SET_SWING_MODE_SCHEMA, + 'async_set_swing_mode' + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, + 'async_turn_off' + ) + component.async_register_entity_service( + SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA, + 'async_turn_on' + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class ClimateDevice(Entity): + """Representation of a climate device.""" + + @property + def state(self): + """Return the current state.""" + if self.is_on is False: + return STATE_OFF + if self.current_operation: + return self.current_operation + if self.is_on: + return STATE_ON + return None + + @property + def precision(self): + """Return the precision of the system.""" + if self.hass.config.units.temperature_unit == TEMP_CELSIUS: + return PRECISION_TENTHS + return PRECISION_WHOLE + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = { + ATTR_CURRENT_TEMPERATURE: show_temp( + self.hass, self.current_temperature, self.temperature_unit, + self.precision), + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, + self.precision), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, + self.precision), + ATTR_TEMPERATURE: show_temp( + self.hass, self.target_temperature, self.temperature_unit, + self.precision), + } + + supported_features = self.supported_features + if self.target_temperature_step is not None: + data[ATTR_TARGET_TEMP_STEP] = self.target_temperature_step + + if supported_features & SUPPORT_TARGET_TEMPERATURE_HIGH: + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + self.precision) + + if supported_features & SUPPORT_TARGET_TEMPERATURE_LOW: + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + self.precision) + + if self.current_humidity is not None: + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + + if supported_features & SUPPORT_TARGET_HUMIDITY: + data[ATTR_HUMIDITY] = self.target_humidity + + if supported_features & SUPPORT_TARGET_HUMIDITY_LOW: + data[ATTR_MIN_HUMIDITY] = self.min_humidity + + if supported_features & SUPPORT_TARGET_HUMIDITY_HIGH: + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + if supported_features & SUPPORT_FAN_MODE: + data[ATTR_FAN_MODE] = self.current_fan_mode + if self.fan_list: + data[ATTR_FAN_LIST] = self.fan_list + + if supported_features & SUPPORT_OPERATION_MODE: + data[ATTR_OPERATION_MODE] = self.current_operation + if self.operation_list: + data[ATTR_OPERATION_LIST] = self.operation_list + + if supported_features & SUPPORT_HOLD_MODE: + data[ATTR_HOLD_MODE] = self.current_hold_mode + + if supported_features & SUPPORT_SWING_MODE: + data[ATTR_SWING_MODE] = self.current_swing_mode + if self.swing_list: + data[ATTR_SWING_LIST] = self.swing_list + + if supported_features & SUPPORT_AWAY_MODE: + is_away = self.is_away_mode_on + data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF + + if supported_features & SUPPORT_AUX_HEAT: + is_aux_heat = self.is_aux_heat_on + data[ATTR_AUX_HEAT] = STATE_ON if is_aux_heat else STATE_OFF + + return data + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + raise NotImplementedError + + @property + def current_humidity(self): + """Return the current humidity.""" + return None + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return None + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return None + + @property + def current_temperature(self): + """Return the current temperature.""" + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return None + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return None + + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return None + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return None + + @property + def is_away_mode_on(self): + """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_on(self): + """Return true if on.""" + return None + + @property + def is_aux_heat_on(self): + """Return true if aux heater.""" + return None + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return None + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return None + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return None + + @property + def swing_list(self): + """Return the list of available swing modes.""" + return None + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + raise NotImplementedError() + + def async_set_temperature(self, **kwargs): + """Set new target temperature. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + ft.partial(self.set_temperature, **kwargs)) + + def set_humidity(self, humidity): + """Set new target humidity.""" + raise NotImplementedError() + + def async_set_humidity(self, humidity): + """Set new target humidity. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.set_humidity, humidity) + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + raise NotImplementedError() + + def async_set_fan_mode(self, fan_mode): + """Set new target fan mode. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.set_fan_mode, fan_mode) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + raise NotImplementedError() + + def async_set_operation_mode(self, operation_mode): + """Set new target operation mode. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.set_operation_mode, operation_mode) + + def set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + raise NotImplementedError() + + def async_set_swing_mode(self, swing_mode): + """Set new target swing operation. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.set_swing_mode, swing_mode) + + def turn_away_mode_on(self): + """Turn away mode on.""" + raise NotImplementedError() + + def async_turn_away_mode_on(self): + """Turn away mode on. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_away_mode_on) + + def turn_away_mode_off(self): + """Turn away mode off.""" + raise NotImplementedError() + + def async_turn_away_mode_off(self): + """Turn away mode off. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(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.async_add_job(self.set_hold_mode, hold_mode) + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + raise NotImplementedError() + + def async_turn_aux_heat_on(self): + """Turn auxiliary heater on. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_aux_heat_on) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + raise NotImplementedError() + + def async_turn_aux_heat_off(self): + """Turn auxiliary heater off. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_aux_heat_off) + + def turn_on(self): + """Turn device on.""" + raise NotImplementedError() + + def async_turn_on(self): + """Turn device on. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_on) + + def turn_off(self): + """Turn device off.""" + raise NotImplementedError() + + def async_turn_off(self): + """Turn device off. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_off) + + @property + def supported_features(self): + """Return the list of supported features.""" + raise NotImplementedError() + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert_temperature(DEFAULT_MIN_TEMP, TEMP_CELSIUS, + self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert_temperature(DEFAULT_MAX_TEMP, TEMP_CELSIUS, + self.temperature_unit) + + @property + def min_humidity(self): + """Return the minimum humidity.""" + return DEFAULT_MIN_HUMITIDY + + @property + def max_humidity(self): + """Return the maximum humidity.""" + return DEFAULT_MAX_HUMIDITY + + +async def async_service_away_mode(entity, service): + """Handle away mode service.""" + if service.data[ATTR_AWAY_MODE]: + await entity.async_turn_away_mode_on() + else: + await entity.async_turn_away_mode_off() + + +async def async_service_aux_heat(entity, service): + """Handle aux heat service.""" + if service.data[ATTR_AUX_HEAT]: + await entity.async_turn_aux_heat_on() + else: + await entity.async_turn_aux_heat_off() + + +async def async_service_temperature_set(entity, service): + """Handle set temperature service.""" + hass = entity.hass + kwargs = {} + + for value, temp in service.data.items(): + if value in CONVERTIBLE_ATTRIBUTE: + kwargs[value] = convert_temperature( + temp, + hass.config.units.temperature_unit, + entity.temperature_unit + ) + else: + kwargs[value] = temp + + await entity.async_set_temperature(**kwargs) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/demo.py new file mode 100644 index 00000000000000..bc0b9bd52ee5bc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/demo.py @@ -0,0 +1,251 @@ +""" +Demo platform that offers a fake climate device. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.climate import ( + ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_TARGET_HUMIDITY_HIGH, + SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_AUX_HEAT, SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + SUPPORT_ON_OFF) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE + +SUPPORT_FLAGS = SUPPORT_TARGET_HUMIDITY_LOW | SUPPORT_TARGET_HUMIDITY_HIGH + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo climate devices.""" + add_entities([ + DemoClimate('HeatPump', 68, TEMP_FAHRENHEIT, None, None, 77, + None, None, None, None, 'heat', None, None, + None, True), + DemoClimate('Hvac', 21, TEMP_CELSIUS, True, None, 22, 'On High', + 67, 54, 'Off', 'cool', False, None, None, None), + DemoClimate('Ecobee', None, TEMP_CELSIUS, None, 'home', 23, 'Auto Low', + None, None, 'Auto', 'auto', None, 24, 21, None) + ]) + + +class DemoClimate(ClimateDevice): + """Representation of a demo climate device.""" + + def __init__(self, name, target_temperature, unit_of_measurement, + away, hold, current_temperature, current_fan_mode, + target_humidity, current_humidity, current_swing_mode, + current_operation, aux, target_temp_high, target_temp_low, + is_on): + """Initialize the climate device.""" + self._name = name + self._support_flags = SUPPORT_FLAGS + if target_temperature is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE + if away is not None: + self._support_flags = self._support_flags | SUPPORT_AWAY_MODE + if hold is not None: + self._support_flags = self._support_flags | SUPPORT_HOLD_MODE + if current_fan_mode is not None: + self._support_flags = self._support_flags | SUPPORT_FAN_MODE + if target_humidity is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_HUMIDITY + if current_swing_mode is not None: + self._support_flags = self._support_flags | SUPPORT_SWING_MODE + if current_operation is not None: + self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE + if aux is not None: + self._support_flags = self._support_flags | SUPPORT_AUX_HEAT + if target_temp_high is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE_HIGH + if target_temp_low is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE_LOW + if is_on is not None: + self._support_flags = self._support_flags | SUPPORT_ON_OFF + self._target_temperature = target_temperature + 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 + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = ['On Low', 'On High', 'Auto Low', 'Auto High', 'Off'] + self._operation_list = ['heat', 'cool', 'auto', 'off'] + self._swing_list = ['Auto', '1', '2', '3', 'Off'] + self._target_temperature_high = target_temp_high + self._target_temperature_low = target_temp_low + self._on = is_on + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return self._target_temperature_high + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return self._target_temperature_low + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._current_humidity + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def is_away_mode_on(self): + """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 aux heat is on.""" + return self._aux + + @property + def is_on(self): + """Return true if the device is on.""" + return self._on + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._fan_list + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + if kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None and \ + kwargs.get(ATTR_TARGET_TEMP_LOW) is not None: + self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + self.schedule_update_ha_state() + + def set_humidity(self, humidity): + """Set new target temperature.""" + self._target_humidity = humidity + self.schedule_update_ha_state() + + def set_swing_mode(self, swing_mode): + """Set new target temperature.""" + self._current_swing_mode = swing_mode + self.schedule_update_ha_state() + + def set_fan_mode(self, fan_mode): + """Set new target temperature.""" + self._current_fan_mode = fan_mode + self.schedule_update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set new target temperature.""" + self._current_operation = operation_mode + self.schedule_update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._away = True + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._away = False + self.schedule_update_ha_state() + + def set_hold_mode(self, hold_mode): + """Update hold_mode on.""" + self._hold = hold_mode + self.schedule_update_ha_state() + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._aux = True + self.schedule_update_ha_state() + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._aux = False + self.schedule_update_ha_state() + + def turn_on(self): + """Turn on.""" + self._on = True + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off.""" + self._on = False + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/dyson.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/dyson.py new file mode 100644 index 00000000000000..0b09ec7f0b4c8d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/dyson.py @@ -0,0 +1,176 @@ +""" +Support for Dyson Pure Hot+Cool link fan. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.dyson/ +""" +import logging + +from homeassistant.components.dyson import DYSON_DEVICES +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE + +_LOGGER = logging.getLogger(__name__) + +STATE_DIFFUSE = "Diffuse Mode" +STATE_FOCUS = "Focus Mode" +FAN_LIST = [STATE_FOCUS, STATE_DIFFUSE] +OPERATION_LIST = [STATE_HEAT, STATE_COOL] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dyson fan components.""" + if discovery_info is None: + return + + from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink + # Get Dyson Devices from parent component. + add_devices( + [DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink)] + ) + + +class DysonPureHotCoolLinkDevice(ClimateDevice): + """Representation of a Dyson climate fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + self._current_temp = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, + self.on_message) + + def on_message(self, message): + """Call when new messages received from the climate.""" + from libpurecoollink.dyson_pure_state import DysonPureHotCoolState + + if isinstance(message, DysonPureHotCoolState): + _LOGGER.debug("Message received for climate device %s : %s", + self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the display name of this climate.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._device.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin != 0: + self._current_temp = float("{0:.1f}".format( + temperature_kelvin - 273)) + return self._current_temp + + @property + def target_temperature(self): + """Return the target temperature.""" + heat_target = int(self._device.state.heat_target) / 10 + return int(heat_target - 273) + + @property + def current_humidity(self): + """Return the current humidity.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return None + return self._device.environmental_state.humidity + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + from libpurecoollink.const import HeatMode, HeatState + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return STATE_HEAT + return STATE_IDLE + return STATE_COOL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + @property + def current_fan_mode(self): + """Return the fan setting.""" + from libpurecoollink.const import FocusMode + if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: + return STATE_FOCUS + return STATE_DIFFUSE + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return FAN_LIST + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + target_temp = int(target_temp) + _LOGGER.debug("Set %s temperature %s", self.name, target_temp) + # Limit the target temperature into acceptable range. + target_temp = min(self.max_temp, target_temp) + target_temp = max(self.min_temp, target_temp) + from libpurecoollink.const import HeatTarget, HeatMode + self._device.set_configuration( + heat_target=HeatTarget.celsius(target_temp), + heat_mode=HeatMode.HEAT_ON) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) + from libpurecoollink.const import FocusMode + if fan_mode == STATE_FOCUS: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) + elif fan_mode == STATE_DIFFUSE: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, operation_mode) + from libpurecoollink.const import HeatMode + if operation_mode == STATE_HEAT: + self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) + elif operation_mode == STATE_COOL: + self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 37 diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/ephember.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/ephember.py new file mode 100644 index 00000000000000..cd410cf3be455e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/ephember.py @@ -0,0 +1,206 @@ +""" +Support for the EPH Controls Ember themostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.ephember/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_OFF, + STATE_AUTO, SUPPORT_AUX_HEAT, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + TEMP_CELSIUS, CONF_USERNAME, CONF_PASSWORD, ATTR_TEMPERATURE) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyephember==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago +SCAN_INTERVAL = timedelta(seconds=120) + +OPERATION_LIST = [STATE_AUTO, STATE_HEAT, STATE_OFF] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + +EPH_TO_HA_STATE = { + 'AUTO': STATE_AUTO, + 'ON': STATE_HEAT, + 'OFF': STATE_OFF +} + +HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ephember thermostat.""" + from pyephember.pyephember import EphEmber + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + try: + ember = EphEmber(username, password) + zones = ember.get_zones() + for zone in zones: + add_entities([EphEmberThermostat(ember, zone)]) + except RuntimeError: + _LOGGER.error("Cannot connect to EphEmber") + return + + return + + +class EphEmberThermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + def __init__(self, ember, zone): + """Initialize the thermostat.""" + self._ember = ember + self._zone_name = zone['name'] + self._zone = zone + self._hot_water = zone['isHotWater'] + + @property + def supported_features(self): + """Return the list of supported features.""" + if self._hot_water: + return SUPPORT_AUX_HEAT | SUPPORT_OPERATION_MODE + + return (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_AUX_HEAT | + SUPPORT_OPERATION_MODE) + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._zone_name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._zone['currentTemperature'] + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._zone['targetTemperature'] + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + if self._hot_water: + return None + + return 1 + + @property + def device_state_attributes(self): + """Show Device Attributes.""" + attributes = { + 'currently_active': self._zone['isCurrentlyActive'] + } + return attributes + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + mode = self._ember.get_zone_mode(self._zone_name) + return self.map_mode_eph_hass(mode) + + @property + def operation_list(self): + """Return the supported operations.""" + return OPERATION_LIST + + def set_operation_mode(self, operation_mode): + """Set the operation mode.""" + mode = self.map_mode_hass_eph(operation_mode) + if mode is not None: + self._ember.set_mode_by_name(self._zone_name, mode) + else: + _LOGGER.error("Invalid operation mode provided %s", operation_mode) + + @property + def is_on(self): + """Return current state.""" + if self._zone['isCurrentlyActive']: + return True + + return None + + @property + def is_aux_heat_on(self): + """Return true if aux heater.""" + return self._zone['isBoostActive'] + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._ember.activate_boost_by_name( + self._zone_name, self._zone['targetTemperature']) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._ember.deactivate_boost_by_name(self._zone_name) + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + if self._hot_water: + return + + if temperature == self.target_temperature: + return + + if temperature > self.max_temp or temperature < self.min_temp: + return + + self._ember.set_target_temperture_by_name(self._zone_name, + int(temperature)) + + @property + def min_temp(self): + """Return the minimum temperature.""" + # Hot water temp doesn't support being changed + if self._hot_water: + return self._zone['targetTemperature'] + + return 5 + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._hot_water: + return self._zone['targetTemperature'] + + return 35 + + def update(self): + """Get the latest data.""" + self._zone = self._ember.get_zone(self._zone_name) + + @staticmethod + def map_mode_hass_eph(operation_mode): + """Map from home assistant mode to eph mode.""" + from pyephember.pyephember import ZoneMode + return getattr(ZoneMode, HA_STATE_TO_EPH.get(operation_mode), None) + + @staticmethod + def map_mode_eph_hass(operation_mode): + """Map from eph mode to home assistant mode.""" + return EPH_TO_HA_STATE.get(operation_mode.name, STATE_AUTO) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/eq3btsmart.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/eq3btsmart.py new file mode 100644 index 00000000000000..1eaaaa9d34e81a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/eq3btsmart.py @@ -0,0 +1,202 @@ +""" +Support for eQ-3 Bluetooth Smart thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.eq3btsmart/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + STATE_ON, STATE_OFF, STATE_HEAT, STATE_MANUAL, STATE_ECO, PLATFORM_SCHEMA, + ClimateDevice, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, + SUPPORT_ON_OFF) +from homeassistant.const import ( + CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.45'] + +_LOGGER = logging.getLogger(__name__) + +STATE_BOOST = 'boost' + +ATTR_STATE_WINDOW_OPEN = 'window_open' +ATTR_STATE_VALVE = 'valve' +ATTR_STATE_LOCKED = 'is_locked' +ATTR_STATE_LOW_BAT = 'low_battery' +ATTR_STATE_AWAY_END = 'away_end' + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_MAC): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): + vol.Schema({cv.string: DEVICE_SCHEMA}), +}) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE | SUPPORT_ON_OFF) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the eQ-3 BLE thermostats.""" + devices = [] + + for name, device_cfg in config[CONF_DEVICES].items(): + mac = device_cfg[CONF_MAC] + devices.append(EQ3BTSmartThermostat(mac, name)) + + add_entities(devices) + + +class EQ3BTSmartThermostat(ClimateDevice): + """Representation of an eQ-3 Bluetooth Smart thermostat.""" + + def __init__(self, _mac, _name): + """Initialize the thermostat.""" + # We want to avoid name clash with this module. + import eq3bt as eq3 # pylint: disable=import-error + + self.modes = { + eq3.Mode.Open: STATE_ON, + eq3.Mode.Closed: STATE_OFF, + eq3.Mode.Auto: STATE_HEAT, + eq3.Mode.Manual: STATE_MANUAL, + eq3.Mode.Boost: STATE_BOOST, + eq3.Mode.Away: STATE_ECO, + } + + self.reverse_modes = {v: k for k, v in self.modes.items()} + + self._name = _name + self._thermostat = eq3.Thermostat(_mac) + self._target_temperature = None + self._target_mode = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def available(self) -> bool: + """Return if thermostat is available.""" + return self.current_operation is not None + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return eq3bt's precision 0.5.""" + return PRECISION_HALVES + + @property + def current_temperature(self): + """Can not report temperature, so return target_temperature.""" + return self.target_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._thermostat.target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._target_temperature = temperature + self._thermostat.target_temperature = temperature + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._thermostat.mode < 0: + return None + return self.modes[self._thermostat.mode] + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [x for x in self.modes.values()] + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + self._target_mode = operation_mode + self._thermostat.mode = self.reverse_modes[operation_mode] + + def turn_away_mode_off(self): + """Away mode off turns to AUTO mode.""" + self.set_operation_mode(STATE_HEAT) + + def turn_away_mode_on(self): + """Set away mode on.""" + self.set_operation_mode(STATE_ECO) + + @property + def is_away_mode_on(self): + """Return if we are away.""" + return self.current_operation == STATE_ECO + + def turn_on(self): + """Turn device on.""" + self.set_operation_mode(STATE_HEAT) + + def turn_off(self): + """Turn device off.""" + self.set_operation_mode(STATE_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._thermostat.min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._thermostat.max_temp + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + dev_specific = { + ATTR_STATE_AWAY_END: self._thermostat.away_end, + ATTR_STATE_LOCKED: self._thermostat.locked, + ATTR_STATE_LOW_BAT: self._thermostat.low_battery, + ATTR_STATE_VALVE: self._thermostat.valve_state, + ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open, + } + + return dev_specific + + def update(self): + """Update the data from the thermostat.""" + from bluepy.btle import BTLEException # pylint: disable=import-error + try: + self._thermostat.update() + except BTLEException as ex: + _LOGGER.warning("Updating the state failed: %s", ex) + + if (self._target_temperature and + self._thermostat.target_temperature + != self._target_temperature): + self.set_temperature(temperature=self._target_temperature) + else: + self._target_temperature = None + if (self._target_mode and + self.modes[self._thermostat.mode] != self._target_mode): + self.set_operation_mode(operation_mode=self._target_mode) + else: + self._target_mode = None diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/flexit.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/flexit.py new file mode 100644 index 00000000000000..de74d2facb57b9 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/flexit.py @@ -0,0 +1,157 @@ +""" +Platform for Flexit AC units with CI66 Modbus adapter. + +Example configuration: + +climate: + - platform: flexit + name: Main AC + slave: 21 + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.flexit/ +""" +import logging +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_SLAVE, TEMP_CELSIUS, + ATTR_TEMPERATURE, DEVICE_DEFAULT_NAME) +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_FAN_MODE) +from homeassistant.components import modbus +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyflexit==0.3'] +DEPENDENCIES = ['modbus'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SLAVE): vol.All(int, vol.Range(min=0, max=32)), + vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): cv.string +}) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Flexit Platform.""" + modbus_slave = config.get(CONF_SLAVE, None) + name = config.get(CONF_NAME, None) + add_entities([Flexit(modbus_slave, name)], True) + + +class Flexit(ClimateDevice): + """Representation of a Flexit AC unit.""" + + def __init__(self, modbus_slave, name): + """Initialize the unit.""" + from pyflexit import pyflexit + self._name = name + self._slave = modbus_slave + self._target_temperature = None + self._current_temperature = None + self._current_fan_mode = None + self._current_operation = None + self._fan_list = ['Off', 'Low', 'Medium', 'High'] + self._current_operation = None + self._filter_hours = None + self._filter_alarm = None + self._heat_recovery = None + self._heater_enabled = False + self._heating = None + self._cooling = None + self._alarm = False + self.unit = pyflexit.pyflexit(modbus.HUB, modbus_slave) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def update(self): + """Update unit attributes.""" + if not self.unit.update(): + _LOGGER.warning("Modbus read failed") + + self._target_temperature = self.unit.get_target_temp + self._current_temperature = self.unit.get_temp + self._current_fan_mode =\ + self._fan_list[self.unit.get_fan_speed] + self._filter_hours = self.unit.get_filter_hours + # Mechanical heat recovery, 0-100% + self._heat_recovery = self.unit.get_heat_recovery + # Heater active 0-100% + self._heating = self.unit.get_heating + # Cooling active 0-100% + self._cooling = self.unit.get_cooling + # Filter alarm 0/1 + self._filter_alarm = self.unit.get_filter_alarm + # Heater enabled or not. Does not mean it's necessarily heating + self._heater_enabled = self.unit.get_heater_enabled + # Current operation mode + self._current_operation = self.unit.get_operation + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + 'filter_hours': self._filter_hours, + 'filter_alarm': self._filter_alarm, + 'heat_recovery': self._heat_recovery, + 'heating': self._heating, + 'heater_enabled': self._heater_enabled, + 'cooling': self._cooling + } + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._fan_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.unit.set_temp(self._target_temperature) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + self.unit.set_fan_speed(self._fan_list.index(fan_mode)) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/generic_thermostat.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/generic_thermostat.py new file mode 100644 index 00000000000000..ffab50c989d708 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/generic_thermostat.py @@ -0,0 +1,418 @@ +""" +Adds support for generic thermostat units. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.generic_thermostat/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_IDLE, STATE_AUTO, ClimateDevice, + ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, + SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN, PRECISION_HALVES, + PRECISION_TENTHS, PRECISION_WHOLE) +from homeassistant.helpers import condition +from homeassistant.helpers.event import ( + async_track_state_change, async_track_time_interval) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['switch', 'sensor'] + +DEFAULT_TOLERANCE = 0.3 +DEFAULT_NAME = 'Generic Thermostat' + +CONF_HEATER = 'heater' +CONF_SENSOR = 'target_sensor' +CONF_MIN_TEMP = 'min_temp' +CONF_MAX_TEMP = 'max_temp' +CONF_TARGET_TEMP = 'target_temp' +CONF_AC_MODE = 'ac_mode' +CONF_MIN_DUR = 'min_cycle_duration' +CONF_COLD_TOLERANCE = 'cold_tolerance' +CONF_HOT_TOLERANCE = 'hot_tolerance' +CONF_KEEP_ALIVE = 'keep_alive' +CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' +CONF_AWAY_TEMP = 'away_temp' +CONF_PRECISION = 'precision' +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HEATER): cv.entity_id, + vol.Required(CONF_SENSOR): cv.entity_id, + vol.Optional(CONF_AC_MODE): cv.boolean, + vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( + float), + vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( + float), + vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), + vol.Optional(CONF_KEEP_ALIVE): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_OPERATION_MODE): + vol.In([STATE_AUTO, STATE_OFF]), + vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the generic thermostat platform.""" + name = config.get(CONF_NAME) + heater_entity_id = config.get(CONF_HEATER) + sensor_entity_id = config.get(CONF_SENSOR) + min_temp = config.get(CONF_MIN_TEMP) + max_temp = config.get(CONF_MAX_TEMP) + target_temp = config.get(CONF_TARGET_TEMP) + ac_mode = config.get(CONF_AC_MODE) + min_cycle_duration = config.get(CONF_MIN_DUR) + cold_tolerance = config.get(CONF_COLD_TOLERANCE) + hot_tolerance = config.get(CONF_HOT_TOLERANCE) + keep_alive = config.get(CONF_KEEP_ALIVE) + initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) + away_temp = config.get(CONF_AWAY_TEMP) + precision = config.get(CONF_PRECISION) + + async_add_entities([GenericThermostat( + hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, + target_temp, ac_mode, min_cycle_duration, cold_tolerance, + hot_tolerance, keep_alive, initial_operation_mode, away_temp, + precision)]) + + +class GenericThermostat(ClimateDevice, RestoreEntity): + """Representation of a Generic Thermostat device.""" + + def __init__(self, hass, name, heater_entity_id, sensor_entity_id, + min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, + cold_tolerance, hot_tolerance, keep_alive, + initial_operation_mode, away_temp, precision): + """Initialize the thermostat.""" + self.hass = hass + self._name = name + self.heater_entity_id = heater_entity_id + self.ac_mode = ac_mode + self.min_cycle_duration = min_cycle_duration + self._cold_tolerance = cold_tolerance + self._hot_tolerance = hot_tolerance + self._keep_alive = keep_alive + self._initial_operation_mode = initial_operation_mode + self._saved_target_temp = target_temp if target_temp is not None \ + else away_temp + self._temp_precision = precision + if self.ac_mode: + self._current_operation = STATE_COOL + self._operation_list = [STATE_COOL, STATE_OFF] + else: + self._current_operation = STATE_HEAT + self._operation_list = [STATE_HEAT, STATE_OFF] + if initial_operation_mode == STATE_OFF: + self._enabled = False + self._current_operation = STATE_OFF + else: + self._enabled = True + self._active = False + self._cur_temp = None + self._temp_lock = asyncio.Lock() + self._min_temp = min_temp + self._max_temp = max_temp + self._target_temp = target_temp + self._unit = hass.config.units.temperature_unit + self._support_flags = SUPPORT_FLAGS + if away_temp is not None: + self._support_flags = SUPPORT_FLAGS | SUPPORT_AWAY_MODE + self._away_temp = away_temp + self._is_away = False + + async_track_state_change( + hass, sensor_entity_id, self._async_sensor_changed) + async_track_state_change( + hass, heater_entity_id, self._async_switch_changed) + + if self._keep_alive: + async_track_time_interval( + hass, self._async_control_heating, self._keep_alive) + + sensor_state = hass.states.get(sensor_entity_id) + if sensor_state and sensor_state.state != STATE_UNKNOWN: + self._async_update_temp(sensor_state) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + # Check If we have an old state + old_state = await self.async_get_last_state() + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: + # If we have a previously saved temperature + if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if self.ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning("Undefined target temperature," + "falling back to %s", self._target_temp) + else: + self._target_temp = float( + old_state.attributes[ATTR_TEMPERATURE]) + if old_state.attributes.get(ATTR_AWAY_MODE) is not None: + self._is_away = str( + old_state.attributes[ATTR_AWAY_MODE]) == STATE_ON + if (self._initial_operation_mode is None and + old_state.attributes[ATTR_OPERATION_MODE] is not None): + self._current_operation = \ + old_state.attributes[ATTR_OPERATION_MODE] + self._enabled = self._current_operation != STATE_OFF + + else: + # No previous state, try and restore defaults + if self._target_temp is None: + if self.ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning("No previously saved temperature, setting to %s", + self._target_temp) + + @property + def state(self): + """Return the current state.""" + if self._is_device_active: + return self.current_operation + if self._enabled: + return STATE_IDLE + return STATE_OFF + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def precision(self): + """Return the precision of the system.""" + if self._temp_precision is not None: + return self._temp_precision + return super().precision + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit + + @property + def current_temperature(self): + """Return the sensor temperature.""" + return self._cur_temp + + @property + def current_operation(self): + """Return current operation.""" + return self._current_operation + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + async def async_set_operation_mode(self, operation_mode): + """Set operation mode.""" + if operation_mode == STATE_HEAT: + self._current_operation = STATE_HEAT + self._enabled = True + await self._async_control_heating(force=True) + elif operation_mode == STATE_COOL: + self._current_operation = STATE_COOL + self._enabled = True + await self._async_control_heating(force=True) + elif operation_mode == STATE_OFF: + self._current_operation = STATE_OFF + self._enabled = False + if self._is_device_active: + await self._async_heater_turn_off() + else: + _LOGGER.error("Unrecognized operation mode: %s", operation_mode) + return + # Ensure we update the current operation after changing the mode + self.schedule_update_ha_state() + + async def async_turn_on(self): + """Turn thermostat on.""" + await self.async_set_operation_mode(self.operation_list[0]) + + async def async_turn_off(self): + """Turn thermostat off.""" + await self.async_set_operation_mode(STATE_OFF) + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._target_temp = temperature + await self._async_control_heating(force=True) + await self.async_update_ha_state() + + @property + def min_temp(self): + """Return the minimum temperature.""" + if self._min_temp: + return self._min_temp + + # get default temp from super class + return super().min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._max_temp: + return self._max_temp + + # Get default temp from super class + return super().max_temp + + async def _async_sensor_changed(self, entity_id, old_state, new_state): + """Handle temperature changes.""" + if new_state is None: + return + + self._async_update_temp(new_state) + await self._async_control_heating() + await self.async_update_ha_state() + + @callback + def _async_switch_changed(self, entity_id, old_state, new_state): + """Handle heater switch state changes.""" + if new_state is None: + return + self.async_schedule_update_ha_state() + + @callback + def _async_update_temp(self, state): + """Update thermostat with latest state from sensor.""" + try: + self._cur_temp = float(state.state) + except ValueError as ex: + _LOGGER.error("Unable to update from sensor: %s", ex) + + async def _async_control_heating(self, time=None, force=False): + """Check if we need to turn heating on or off.""" + async with self._temp_lock: + if not self._active and None not in (self._cur_temp, + self._target_temp): + self._active = True + _LOGGER.info("Obtained current and target temperature. " + "Generic thermostat active. %s, %s", + self._cur_temp, self._target_temp) + + if not self._active or not self._enabled: + return + + if not force and time is None: + # If the `force` argument is True, we + # ignore `min_cycle_duration`. + # If the `time` argument is not none, we were invoked for + # keep-alive purposes, and `min_cycle_duration` is irrelevant. + if self.min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state( + self.hass, self.heater_entity_id, current_state, + self.min_cycle_duration) + if not long_enough: + return + + too_cold = \ + self._target_temp - self._cur_temp >= self._cold_tolerance + too_hot = \ + self._cur_temp - self._target_temp >= self._hot_tolerance + if self._is_device_active: + if (self.ac_mode and too_cold) or \ + (not self.ac_mode and too_hot): + _LOGGER.info("Turning off heater %s", + self.heater_entity_id) + await self._async_heater_turn_off() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_heater_turn_on() + else: + if (self.ac_mode and too_hot) or \ + (not self.ac_mode and too_cold): + _LOGGER.info("Turning on heater %s", self.heater_entity_id) + await self._async_heater_turn_on() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_heater_turn_off() + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + return self.hass.states.is_state(self.heater_entity_id, STATE_ON) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + async def _async_heater_turn_on(self): + """Turn heater toggleable device on.""" + data = {ATTR_ENTITY_ID: self.heater_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) + + async def _async_heater_turn_off(self): + """Turn heater toggleable device off.""" + data = {ATTR_ENTITY_ID: self.heater_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._is_away + + async def async_turn_away_mode_on(self): + """Turn away mode on by setting it on away hold indefinitely.""" + if self._is_away: + return + self._is_away = True + self._saved_target_temp = self._target_temp + self._target_temp = self._away_temp + await self._async_control_heating(force=True) + await self.async_update_ha_state() + + async def async_turn_away_mode_off(self): + """Turn away off.""" + if not self._is_away: + return + self._is_away = False + self._target_temp = self._saved_target_temp + await self._async_control_heating(force=True) + await self.async_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/heatmiser.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/heatmiser.py new file mode 100644 index 00000000000000..a03d1567e01aa8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/heatmiser.py @@ -0,0 +1,116 @@ +""" +Support for the PRT Heatmiser themostats using the V3 protocol. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.heatmiser/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_PORT, CONF_NAME, CONF_ID) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['heatmiserV3==0.9.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_IPADDRESS = 'ipaddress' +CONF_TSTATS = 'tstats' + +TSTATS_SCHEMA = vol.Schema({ + vol.Required(CONF_ID): cv.string, + vol.Required(CONF_NAME): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IPADDRESS): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_TSTATS, default={}): + vol.Schema({cv.string: TSTATS_SCHEMA}), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the heatmiser thermostat.""" + from heatmiserV3 import heatmiser, connection + + ipaddress = config.get(CONF_IPADDRESS) + port = str(config.get(CONF_PORT)) + tstats = config.get(CONF_TSTATS) + + serport = connection.connection(ipaddress, port) + serport.open() + + for tstat in tstats.values(): + add_entities([ + HeatmiserV3Thermostat( + heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport) + ]) + + +class HeatmiserV3Thermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + def __init__(self, heatmiser, device, name, serport): + """Initialize the thermostat.""" + self.heatmiser = heatmiser + self.serport = serport + self._current_temperature = None + self._name = name + self._id = device + self.dcb = None + self.update() + self._target_temperature = int(self.dcb.get('roomset')) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self.dcb is not None: + low = self.dcb.get('floortemplow ') + high = self.dcb.get('floortemphigh') + temp = (high * 256 + low) / 10.0 + self._current_temperature = temp + else: + self._current_temperature = None + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self.heatmiser.hmSendAddress( + self._id, + 18, + temperature, + 1, + self.serport) + self._target_temperature = temperature + + def update(self): + """Get the latest data.""" + self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/honeywell.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/honeywell.py new file mode 100644 index 00000000000000..e0f104a84b1955 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/honeywell.py @@ -0,0 +1,428 @@ +""" +Support for Honeywell Round Connected and Honeywell Evohome thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.honeywell/ +""" +import logging +import socket +import datetime + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, ATTR_FAN_MODE, ATTR_FAN_LIST, + ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE) +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + ATTR_TEMPERATURE, CONF_REGION) + +REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_FAN = 'fan' +ATTR_SYSTEM_MODE = 'system_mode' +ATTR_CURRENT_OPERATION = 'equipment_output_status' + +CONF_AWAY_TEMPERATURE = 'away_temperature' +CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' +CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' + +DEFAULT_AWAY_TEMPERATURE = 16 +DEFAULT_COOL_AWAY_TEMPERATURE = 30 +DEFAULT_HEAT_AWAY_TEMPERATURE = 16 +DEFAULT_REGION = 'eu' +REGIONS = ['eu', 'us'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_AWAY_TEMPERATURE, + default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_COOL_AWAY_TEMPERATURE, + default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_HEAT_AWAY_TEMPERATURE, + default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Honeywell thermostat.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + region = config.get(CONF_REGION) + + if region == 'us': + return _setup_us(username, password, config, add_entities) + + return _setup_round(username, password, config, add_entities) + + +def _setup_round(username, password, config, add_entities): + """Set up the rounding function.""" + from evohomeclient import EvohomeClient + + away_temp = config.get(CONF_AWAY_TEMPERATURE) + evo_api = EvohomeClient(username, password) + + try: + zones = evo_api.temperatures(force_refresh=True) + for i, zone in enumerate(zones): + add_entities( + [RoundThermostat(evo_api, zone['id'], i == 0, away_temp)], + True + ) + except socket.error: + _LOGGER.error( + "Connection error logging into the honeywell evohome web service") + return False + return True + + +# config will be used later +def _setup_us(username, password, config, add_entities): + """Set up the user.""" + import somecomfort + + try: + client = somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", username) + return False + except somecomfort.SomeComfortError as ex: + _LOGGER.error("Failed to initialize honeywell client: %s", str(ex)) + return False + + dev_id = config.get('thermostat') + loc_id = config.get('location') + cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE) + heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE) + + add_entities([HoneywellUSThermostat(client, device, cool_away_temp, + heat_away_temp, username, password) + for location in client.locations_by_id.values() + for device in location.devices_by_id.values() + if ((not loc_id or location.locationid == loc_id) and + (not dev_id or device.deviceid == dev_id))]) + return True + + +class RoundThermostat(ClimateDevice): + """Representation of a Honeywell Round Connected thermostat.""" + + def __init__(self, client, zone_id, master, away_temp): + """Initialize the thermostat.""" + self.client = client + self._current_temperature = None + self._target_temperature = None + self._name = 'round connected' + self._id = zone_id + self._master = master + self._is_dhw = False + self._away_temp = away_temp + self._away = False + + @property + def supported_features(self): + """Return the list of supported features.""" + supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE) + if hasattr(self.client, ATTR_SYSTEM_MODE): + supported |= SUPPORT_OPERATION_MODE + return supported + + @property + def name(self): + """Return the name of the honeywell, if any.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._is_dhw: + return None + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self.client.set_temperature(self._name, temperature) + + @property + def current_operation(self) -> str: + """Get the current operation of the system.""" + return getattr(self.client, ATTR_SYSTEM_MODE, None) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away + + def set_operation_mode(self, operation_mode: str) -> None: + """Set the HVAC mode for the thermostat.""" + if hasattr(self.client, ATTR_SYSTEM_MODE): + self.client.system_mode = operation_mode + + def turn_away_mode_on(self): + """Turn away on. + + Honeywell does have a proprietary away mode, but it doesn't really work + the way it should. For example: If you set a temperature manually + it doesn't get overwritten when away mode is switched on. + """ + self._away = True + self.client.set_temperature(self._name, self._away_temp) + + def turn_away_mode_off(self): + """Turn away off.""" + self._away = False + self.client.cancel_temp_override(self._name) + + def update(self): + """Get the latest date.""" + try: + # Only refresh if this is the "master" device, + # others will pick up the cache + for val in self.client.temperatures(force_refresh=self._master): + if val['id'] == self._id: + data = val + + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + + except StopIteration: + _LOGGER.error("Did not receive any temperature data from the " + "evohomeclient API") + return + + self._current_temperature = data['temp'] + self._target_temperature = data['setpoint'] + if data['thermostat'] == 'DOMESTIC_HOT_WATER': + self._name = 'Hot Water' + self._is_dhw = True + else: + self._name = data['name'] + self._is_dhw = False + + # The underlying library doesn't expose the thermostat's mode + # but we can pull it out of the big dictionary of information. + device = self.client.devices[self._id] + self.client.system_mode = device[ + 'thermostat']['changeableValues']['mode'] + + +class HoneywellUSThermostat(ClimateDevice): + """Representation of a Honeywell US Thermostat.""" + + def __init__(self, client, device, cool_away_temp, + heat_away_temp, username, password): + """Initialize the thermostat.""" + self._client = client + self._device = device + self._cool_away_temp = cool_away_temp + self._heat_away_temp = heat_away_temp + self._away = False + self._username = username + self._password = password + + @property + def supported_features(self): + """Return the list of supported features.""" + supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE) + if hasattr(self._device, ATTR_SYSTEM_MODE): + supported |= SUPPORT_OPERATION_MODE + return supported + + @property + def is_fan_on(self): + """Return true if fan is on.""" + return self._device.fan_running + + @property + def name(self): + """Return the name of the honeywell, if any.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return (TEMP_CELSIUS if self._device.temperature_unit == 'C' + else TEMP_FAHRENHEIT) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._device.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._device.system_mode == 'cool': + return self._device.setpoint_cool + return self._device.setpoint_heat + + @property + def current_operation(self) -> str: + """Return current operation ie. heat, cool, idle.""" + oper = getattr(self._device, ATTR_CURRENT_OPERATION, None) + if oper == "off": + oper = "idle" + return oper + + def set_temperature(self, **kwargs): + """Set target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + import somecomfort + try: + # Get current mode + mode = self._device.system_mode + # Set hold if this is not the case + if getattr(self._device, "hold_{}".format(mode)) is False: + # Get next period key + next_period_key = '{}NextPeriod'.format(mode.capitalize()) + # Get next period raw value + next_period = self._device.raw_ui_data.get(next_period_key) + # Get next period time + hour, minute = divmod(next_period * 15, 60) + # Set hold time + setattr(self._device, + "hold_{}".format(mode), + datetime.time(hour, minute)) + # Set temperature + setattr(self._device, + "setpoint_{}".format(mode), + temperature) + except somecomfort.SomeComfortError: + _LOGGER.error("Temperature %.1f out of range", temperature) + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + import somecomfort + data = { + ATTR_FAN: (self.is_fan_on and 'running' or 'idle'), + ATTR_FAN_MODE: self._device.fan_mode, + ATTR_OPERATION_MODE: self._device.system_mode, + } + data[ATTR_FAN_LIST] = somecomfort.FAN_MODES + data[ATTR_OPERATION_LIST] = somecomfort.SYSTEM_MODES + return data + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away + + def turn_away_mode_on(self): + """Turn away on. + + Somecomfort does have a proprietary away mode, but it doesn't really + work the way it should. For example: If you set a temperature manually + it doesn't get overwritten when away mode is switched on. + """ + self._away = True + import somecomfort + try: + # Get current mode + mode = self._device.system_mode + except somecomfort.SomeComfortError: + _LOGGER.error('Can not get system mode') + return + try: + + # Set permanent hold + setattr(self._device, + "hold_{}".format(mode), + True) + # Set temperature + setattr(self._device, + "setpoint_{}".format(mode), + getattr(self, "_{}_away_temp".format(mode))) + except somecomfort.SomeComfortError: + _LOGGER.error('Temperature %.1f out of range', + getattr(self, "_{}_away_temp".format(mode))) + + def turn_away_mode_off(self): + """Turn away off.""" + self._away = False + import somecomfort + try: + # Disabling all hold modes + self._device.hold_cool = False + self._device.hold_heat = False + except somecomfort.SomeComfortError: + _LOGGER.error('Can not stop hold mode') + + def set_operation_mode(self, operation_mode: str) -> None: + """Set the system mode (Cool, Heat, etc).""" + if hasattr(self._device, ATTR_SYSTEM_MODE): + self._device.system_mode = operation_mode + + def update(self): + """Update the state.""" + import somecomfort + retries = 3 + while retries > 0: + try: + self._device.refresh() + break + except (somecomfort.client.APIRateLimited, OSError, + requests.exceptions.ReadTimeout) as exp: + retries -= 1 + if retries == 0: + raise exp + if not self._retry(): + raise exp + _LOGGER.error( + "SomeComfort update failed, Retrying - Error: %s", exp) + + def _retry(self): + """Recreate a new somecomfort client. + + When we got an error, the best way to be sure that the next query + will succeed, is to recreate a new somecomfort client. + """ + import somecomfort + try: + self._client = somecomfort.SomeComfort( + self._username, self._password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", + self._username) + return False + except somecomfort.SomeComfortError as ex: + _LOGGER.error("Failed to initialize honeywell client: %s", + str(ex)) + return False + + devices = [device + for location in self._client.locations_by_id.values() + for device in location.devices_by_id.values() + if device.name == self._device.name] + + if len(devices) != 1: + _LOGGER.error("Failed to find device %s", self._device.name) + return False + + self._device = devices[0] + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/melissa.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/melissa.py new file mode 100644 index 00000000000000..25beedfe0dd29b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/melissa.py @@ -0,0 +1,258 @@ +""" +Support for Melissa Climate A/C. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.melissa/ +""" +import logging + +from homeassistant.components.climate import ( + ClimateDevice, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_ON_OFF, STATE_AUTO, STATE_HEAT, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, SUPPORT_FAN_MODE +) +from homeassistant.components.fan import SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH +from homeassistant.components.melissa import DATA_MELISSA +from homeassistant.const import ( + TEMP_CELSIUS, STATE_ON, STATE_OFF, STATE_IDLE, ATTR_TEMPERATURE, + PRECISION_WHOLE +) + +DEPENDENCIES = ['melissa'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | + SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE) + +OP_MODES = [ + STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT +] + +FAN_MODES = [ + STATE_AUTO, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM +] + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Iterate through and add all Melissa devices.""" + api = hass.data[DATA_MELISSA] + devices = (await api.async_fetch_devices()).values() + + all_devices = [] + + for device in devices: + if device['type'] == 'melissa': + all_devices.append(MelissaClimate( + api, device['serial_number'], device)) + + async_add_entities(all_devices) + + +class MelissaClimate(ClimateDevice): + """Representation of a Melissa Climate device.""" + + def __init__(self, api, serial_number, init_data): + """Initialize the climate device.""" + self._name = init_data['name'] + self._api = api + self._serial_number = serial_number + self._data = init_data['controller_log'] + self._state = None + self._cur_settings = None + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._name + + @property + def is_on(self): + """Return current state.""" + if self._cur_settings is not None: + return self._cur_settings[self._api.STATE] in ( + self._api.STATE_ON, self._api.STATE_IDLE) + return None + + @property + def current_fan_mode(self): + """Return the current fan mode.""" + if self._cur_settings is not None: + return self.melissa_fan_to_hass( + self._cur_settings[self._api.FAN]) + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._data: + return self._data[self._api.TEMP] + + @property + def current_humidity(self): + """Return the current humidity value.""" + if self._data: + return self._data[self._api.HUMIDITY] + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return PRECISION_WHOLE + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._cur_settings is not None: + return self.melissa_op_to_hass( + self._cur_settings[self._api.MODE]) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OP_MODES + + @property + def fan_list(self): + """List of available fan modes.""" + return FAN_MODES + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._cur_settings is None: + return None + return self._cur_settings[self._api.TEMP] + + @property + def state(self): + """Return current state.""" + if self._cur_settings is not None: + return self.melissa_state_to_hass( + self._cur_settings[self._api.STATE]) + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def min_temp(self): + """Return the minimum supported temperature for the thermostat.""" + return 16 + + @property + def max_temp(self): + """Return the maximum supported temperature for the thermostat.""" + return 30 + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + await self.async_send({self._api.TEMP: temp}) + + async def async_set_fan_mode(self, fan_mode): + """Set fan mode.""" + melissa_fan_mode = self.hass_fan_to_melissa(fan_mode) + await self.async_send({self._api.FAN: melissa_fan_mode}) + + async def async_set_operation_mode(self, operation_mode): + """Set operation mode.""" + mode = self.hass_mode_to_melissa(operation_mode) + await self.async_send({self._api.MODE: mode}) + + async def async_turn_on(self): + """Turn on device.""" + await self.async_send({self._api.STATE: self._api.STATE_ON}) + + async def async_turn_off(self): + """Turn off device.""" + await self.async_send({self._api.STATE: self._api.STATE_OFF}) + + async def async_send(self, value): + """Send action to service.""" + try: + old_value = self._cur_settings.copy() + self._cur_settings.update(value) + except AttributeError: + old_value = None + if not await self._api.async_send( + self._serial_number, self._cur_settings): + self._cur_settings = old_value + + async def async_update(self): + """Get latest data from Melissa.""" + try: + self._data = (await self._api.async_status(cached=True))[ + self._serial_number] + self._cur_settings = (await self._api.async_cur_settings( + self._serial_number + ))['controller']['_relation']['command_log'] + except KeyError: + _LOGGER.warning( + 'Unable to update entity %s', self.entity_id) + + def melissa_state_to_hass(self, state): + """Translate Melissa states to hass states.""" + if state == self._api.STATE_ON: + return STATE_ON + if state == self._api.STATE_OFF: + return STATE_OFF + if state == self._api.STATE_IDLE: + return STATE_IDLE + return None + + def melissa_op_to_hass(self, mode): + """Translate Melissa modes to hass states.""" + if mode == self._api.MODE_HEAT: + return STATE_HEAT + if mode == self._api.MODE_COOL: + return STATE_COOL + if mode == self._api.MODE_DRY: + return STATE_DRY + if mode == self._api.MODE_FAN: + return STATE_FAN_ONLY + _LOGGER.warning( + "Operation mode %s could not be mapped to hass", mode) + return None + + def melissa_fan_to_hass(self, fan): + """Translate Melissa fan modes to hass modes.""" + if fan == self._api.FAN_AUTO: + return STATE_AUTO + if fan == self._api.FAN_LOW: + return SPEED_LOW + if fan == self._api.FAN_MEDIUM: + return SPEED_MEDIUM + if fan == self._api.FAN_HIGH: + return SPEED_HIGH + _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) + return None + + def hass_mode_to_melissa(self, mode): + """Translate hass states to melissa modes.""" + if mode == STATE_HEAT: + return self._api.MODE_HEAT + if mode == STATE_COOL: + return self._api.MODE_COOL + if mode == STATE_DRY: + return self._api.MODE_DRY + if mode == STATE_FAN_ONLY: + return self._api.MODE_FAN + _LOGGER.warning("Melissa have no setting for %s mode", mode) + + def hass_fan_to_melissa(self, fan): + """Translate hass fan modes to melissa modes.""" + if fan == STATE_AUTO: + return self._api.FAN_AUTO + if fan == SPEED_LOW: + return self._api.FAN_LOW + if fan == SPEED_MEDIUM: + return self._api.FAN_MEDIUM + if fan == SPEED_HIGH: + return self._api.FAN_HIGH + _LOGGER.warning("Melissa have no setting for %s fan mode", fan) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/mill.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/mill.py new file mode 100644 index 00000000000000..b735927cb8085b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/mill.py @@ -0,0 +1,223 @@ +""" +Support for mill wifi-enabled home heaters. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.mill/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, DOMAIN, PLATFORM_SCHEMA, STATE_HEAT, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, + SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE) +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, + STATE_ON, STATE_OFF, TEMP_CELSIUS) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['millheater==0.3.4'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_AWAY_TEMP = 'away_temp' +ATTR_COMFORT_TEMP = 'comfort_temp' +ATTR_ROOM_NAME = 'room_name' +ATTR_SLEEP_TEMP = 'sleep_temp' +MAX_TEMP = 35 +MIN_TEMP = 5 +SERVICE_SET_ROOM_TEMP = 'mill_set_room_temperature' + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_FAN_MODE) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + +SET_ROOM_TEMP_SCHEMA = vol.Schema({ + vol.Required(ATTR_ROOM_NAME): cv.string, + vol.Optional(ATTR_AWAY_TEMP): cv.positive_int, + vol.Optional(ATTR_COMFORT_TEMP): cv.positive_int, + vol.Optional(ATTR_SLEEP_TEMP): cv.positive_int, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Mill heater.""" + from mill import Mill + mill_data_connection = Mill(config[CONF_USERNAME], + config[CONF_PASSWORD], + websession=async_get_clientsession(hass)) + if not await mill_data_connection.connect(): + _LOGGER.error("Failed to connect to Mill") + return + + await mill_data_connection.find_all_heaters() + + dev = [] + for heater in mill_data_connection.heaters.values(): + dev.append(MillHeater(heater, mill_data_connection)) + async_add_entities(dev) + + async def set_room_temp(service): + """Set room temp.""" + room_name = service.data.get(ATTR_ROOM_NAME) + sleep_temp = service.data.get(ATTR_SLEEP_TEMP) + comfort_temp = service.data.get(ATTR_COMFORT_TEMP) + away_temp = service.data.get(ATTR_AWAY_TEMP) + await mill_data_connection.set_room_temperatures_by_name(room_name, + sleep_temp, + comfort_temp, + away_temp) + + hass.services.async_register(DOMAIN, SERVICE_SET_ROOM_TEMP, + set_room_temp, schema=SET_ROOM_TEMP_SCHEMA) + + +class MillHeater(ClimateDevice): + """Representation of a Mill Thermostat device.""" + + def __init__(self, heater, mill_data_connection): + """Initialize the thermostat.""" + self._heater = heater + self._conn = mill_data_connection + + @property + def supported_features(self): + """Return the list of supported features.""" + if self._heater.is_gen1: + return SUPPORT_FLAGS + return SUPPORT_FLAGS | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE + + @property + def available(self): + """Return True if entity is available.""" + return self._heater.available + + @property + def unique_id(self): + """Return a unique ID.""" + return self._heater.device_id + + @property + def name(self): + """Return the name of the entity.""" + return self._heater.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + res = { + "open_window": self._heater.open_window, + "heating": self._heater.is_heating, + "controlled_by_tibber": self._heater.tibber_control, + "heater_generation": 1 if self._heater.is_gen1 else 2, + } + if self._heater.room: + res['room'] = self._heater.room.name + res['avg_room_temp'] = self._heater.room.avg_temp + else: + res['room'] = "Independent device" + return res + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._heater.set_temp + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._heater.current_temp + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return STATE_ON if self._heater.fan_status == 1 else STATE_OFF + + @property + def fan_list(self): + """List of available fan modes.""" + return [STATE_ON, STATE_OFF] + + @property + def is_on(self): + """Return true if heater is on.""" + if self._heater.is_gen1: + return True + return self._heater.power_status == 1 + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMP + + @property + def current_operation(self): + """Return current operation.""" + return STATE_HEAT if self.is_on else STATE_OFF + + @property + def operation_list(self): + """List of available operation modes.""" + if self._heater.is_gen1: + return None + return [STATE_HEAT, STATE_OFF] + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._conn.set_heater_temp(self._heater.device_id, + int(temperature)) + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + fan_status = 1 if fan_mode == STATE_ON else 0 + await self._conn.heater_control(self._heater.device_id, + fan_status=fan_status) + + async def async_turn_on(self): + """Turn Mill unit on.""" + await self._conn.heater_control(self._heater.device_id, + power_status=1) + + async def async_turn_off(self): + """Turn Mill unit off.""" + await self._conn.heater_control(self._heater.device_id, + power_status=0) + + async def async_update(self): + """Retrieve latest state.""" + self._heater = await self._conn.update_device(self._heater.device_id) + + async def async_set_operation_mode(self, operation_mode): + """Set operation mode.""" + if operation_mode == STATE_HEAT: + await self.async_turn_on() + elif operation_mode == STATE_OFF and not self._heater.is_gen1: + await self.async_turn_off() + else: + _LOGGER.error("Unrecognized operation mode: %s", operation_mode) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/nuheat.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/nuheat.py new file mode 100644 index 00000000000000..d0bfe5add58151 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/nuheat.py @@ -0,0 +1,227 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.nuheat/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, + DOMAIN, + SUPPORT_HOLD_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + STATE_AUTO, + STATE_HEAT, + STATE_IDLE) +from homeassistant.components.nuheat import DOMAIN as NUHEAT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +DEPENDENCIES = ["nuheat"] + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:thermometer" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +# Hold modes +MODE_AUTO = STATE_AUTO # Run device schedule +MODE_HOLD_TEMPERATURE = "temperature" +MODE_TEMPORARY_HOLD = "temporary_temperature" + +OPERATION_LIST = [STATE_HEAT, STATE_IDLE] + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + +SERVICE_RESUME_PROGRAM = "nuheat_resume_program" + +RESUME_PROGRAM_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | + SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the NuHeat thermostat(s).""" + if discovery_info is None: + return + + temperature_unit = hass.config.units.temperature_unit + api, serial_numbers = hass.data[NUHEAT_DOMAIN] + thermostats = [ + NuHeatThermostat(api, serial_number, temperature_unit) + for serial_number in serial_numbers + ] + add_entities(thermostats, True) + + def resume_program_set_service(service): + """Resume the program on the target thermostats.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + if entity_id: + target_thermostats = [device for device in thermostats + if device.entity_id in entity_id] + else: + target_thermostats = thermostats + + for thermostat in target_thermostats: + thermostat.resume_program() + + thermostat.schedule_update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, + schema=RESUME_PROGRAM_SCHEMA) + + +class NuHeatThermostat(ClimateDevice): + """Representation of a NuHeat Thermostat.""" + + def __init__(self, api, serial_number, temperature_unit): + """Initialize the thermostat.""" + self._thermostat = api.get_thermostat(serial_number) + self._temperature_unit = temperature_unit + self._force_update = False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._thermostat.room + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self._temperature_unit == "C": + return TEMP_CELSIUS + + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._temperature_unit == "C": + return self._thermostat.celsius + + return self._thermostat.fahrenheit + + @property + def current_operation(self): + """Return current operation. ie. heat, idle.""" + if self._thermostat.heating: + return STATE_HEAT + + return STATE_IDLE + + @property + def min_temp(self): + """Return the minimum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.min_celsius + + return self._thermostat.min_fahrenheit + + @property + def max_temp(self): + """Return the maximum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.max_celsius + + return self._thermostat.max_fahrenheit + + @property + def target_temperature(self): + """Return the currently programmed temperature.""" + if self._temperature_unit == "C": + return self._thermostat.target_celsius + + return self._thermostat.target_fahrenheit + + @property + def current_hold_mode(self): + """Return current hold mode.""" + schedule_mode = self._thermostat.schedule_mode + if schedule_mode == SCHEDULE_RUN: + return MODE_AUTO + + if schedule_mode == SCHEDULE_HOLD: + return MODE_HOLD_TEMPERATURE + + if schedule_mode == SCHEDULE_TEMPORARY_HOLD: + return MODE_TEMPORARY_HOLD + + return MODE_AUTO + + @property + def operation_list(self): + """Return list of possible operation modes.""" + return OPERATION_LIST + + def resume_program(self): + """Resume the thermostat's programmed schedule.""" + self._thermostat.resume_schedule() + self._force_update = True + + def set_hold_mode(self, hold_mode): + """Update the hold mode of the thermostat.""" + if hold_mode == MODE_AUTO: + schedule_mode = SCHEDULE_RUN + + if hold_mode == MODE_HOLD_TEMPERATURE: + schedule_mode = SCHEDULE_HOLD + + if hold_mode == MODE_TEMPORARY_HOLD: + schedule_mode = SCHEDULE_TEMPORARY_HOLD + + self._thermostat.schedule_mode = schedule_mode + self._force_update = True + + def set_temperature(self, **kwargs): + """Set a new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if self._temperature_unit == "C": + self._thermostat.target_celsius = temperature + else: + self._thermostat.target_fahrenheit = temperature + + _LOGGER.debug( + "Setting NuHeat thermostat temperature to %s %s", + temperature, self.temperature_unit) + + self._force_update = True + + def update(self): + """Get the latest state from the thermostat.""" + if self._force_update: + self._throttled_update(no_throttle=True) + self._force_update = False + else: + self._throttled_update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _throttled_update(self, **kwargs): + """Get the latest state from the thermostat with a throttle.""" + self._thermostat.get_data() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/oem.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/oem.py new file mode 100644 index 00000000000000..e006242331c1bc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/oem.py @@ -0,0 +1,147 @@ +""" +OpenEnergyMonitor Thermostat Support. + +This provides a climate component for the ESP8266 based thermostat sold by +OpenEnergyMonitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.oem/ +""" +import logging + +import requests +import voluptuous as vol + +# Import the device class from the component that you want to support +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_IDLE, ATTR_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_AWAY_MODE) +from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, + CONF_PORT, TEMP_CELSIUS, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['oemthermostat==1.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_AWAY_TEMP = 'away_temp' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default="Thermostat"): cv.string, + vol.Optional(CONF_PORT, default=80): cv.port, + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_AWAY_TEMP, default=14): vol.Coerce(float) +}) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the oemthermostat platform.""" + from oemthermostat import Thermostat + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + away_temp = config.get(CONF_AWAY_TEMP) + + try: + therm = Thermostat( + host, port=port, username=username, password=password) + except (ValueError, AssertionError, requests.RequestException): + return False + + add_entities((ThermostatDevice(hass, therm, name, away_temp), ), True) + + +class ThermostatDevice(ClimateDevice): + """Interface class for the oemthermostat module.""" + + def __init__(self, hass, thermostat, name, away_temp): + """Initialize the device.""" + self._name = name + self.hass = hass + + # Away mode stuff + self._away = False + self._away_temp = away_temp + self._prev_temp = thermostat.setpoint + + self.thermostat = thermostat + # Set the thermostat mode to manual + self.thermostat.mode = 2 + + # set up internal state varS + self._state = None + self._temperature = None + self._setpoint = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of this Thermostat.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation i.e. heat, cool, idle.""" + if self._state: + return STATE_HEAT + return STATE_IDLE + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._setpoint + + def set_temperature(self, **kwargs): + """Set the temperature.""" + # If we are setting the temp, then we don't want away mode anymore. + self.turn_away_mode_off() + + temp = kwargs.get(ATTR_TEMPERATURE) + self.thermostat.setpoint = temp + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away + + def turn_away_mode_on(self): + """Turn away mode on.""" + if not self._away: + self._prev_temp = self._setpoint + + self.thermostat.setpoint = self._away_temp + self._away = True + + def turn_away_mode_off(self): + """Turn away mode off.""" + if self._away: + self.thermostat.setpoint = self._prev_temp + + self._away = False + + def update(self): + """Update local state.""" + self._setpoint = self.thermostat.setpoint + self._temperature = self.thermostat.temperature + self._state = self.thermostat.state diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/proliphix.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/proliphix.py new file mode 100644 index 00000000000000..76160a28c6e517 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/proliphix.py @@ -0,0 +1,115 @@ +""" +Support for Proliphix NT10e Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.proliphix/ +""" +import voluptuous as vol + +from homeassistant.components.climate import ( + PRECISION_TENTHS, STATE_COOL, STATE_HEAT, STATE_IDLE, + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['proliphix==0.4.1'] + +ATTR_FAN = 'fan' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Proliphix thermostats.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + + import proliphix + + pdp = proliphix.PDP(host, username, password) + + add_entities([ProliphixThermostat(pdp)]) + + +class ProliphixThermostat(ClimateDevice): + """Representation a Proliphix thermostat.""" + + def __init__(self, pdp): + """Initialize the thermostat.""" + self._pdp = pdp + self._pdp.update() + self._name = self._pdp.name + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def should_poll(self): + """Set up polling needed for thermostat.""" + return True + + def update(self): + """Update the data from the thermostat.""" + self._pdp.update() + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def precision(self): + """Return the precision of the system. + + Proliphix temperature values are passed back and forth in the + API as tenths of degrees F (i.e. 690 for 69 degrees). + """ + return PRECISION_TENTHS + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_FAN: self._pdp.fan_state + } + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._pdp.cur_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._pdp.setback + + @property + def current_operation(self): + """Return the current state of the thermostat.""" + state = self._pdp.hvac_state + if state in (1, 2): + return STATE_IDLE + if state == 3: + return STATE_HEAT + if state == 6: + return STATE_COOL + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._pdp.setback = temperature diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/radiotherm.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/radiotherm.py new file mode 100644 index 00000000000000..a72bf711242b38 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/radiotherm.py @@ -0,0 +1,344 @@ +""" +Support for Radio Thermostat wifi-enabled home thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.radiotherm/ +""" +import datetime +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_ON, STATE_OFF, + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE) +from homeassistant.const import ( + CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, PRECISION_HALVES) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['radiotherm==2.0.0'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_FAN = 'fan' +ATTR_MODE = 'mode' + +CONF_HOLD_TEMP = 'hold_temp' +CONF_AWAY_TEMPERATURE_HEAT = 'away_temperature_heat' +CONF_AWAY_TEMPERATURE_COOL = 'away_temperature_cool' + +DEFAULT_AWAY_TEMPERATURE_HEAT = 60 +DEFAULT_AWAY_TEMPERATURE_COOL = 85 + +STATE_CIRCULATE = "circulate" + +OPERATION_LIST = [STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_OFF] +CT30_FAN_OPERATION_LIST = [STATE_ON, STATE_AUTO] +CT80_FAN_OPERATION_LIST = [STATE_ON, STATE_CIRCULATE, STATE_AUTO] + +# Mappings from radiotherm json data codes to and from HASS state +# flags. CODE is the thermostat integer code and these map to and +# from HASS state flags. + +# Programmed temperature mode of the thermostat. +CODE_TO_TEMP_MODE = {0: STATE_OFF, 1: STATE_HEAT, 2: STATE_COOL, 3: STATE_AUTO} +TEMP_MODE_TO_CODE = {v: k for k, v in CODE_TO_TEMP_MODE.items()} + +# Programmed fan mode (circulate is supported by CT80 models) +CODE_TO_FAN_MODE = {0: STATE_AUTO, 1: STATE_CIRCULATE, 2: STATE_ON} +FAN_MODE_TO_CODE = {v: k for k, v in CODE_TO_FAN_MODE.items()} + +# Active thermostat state (is it heating or cooling?). In the future +# this should probably made into heat and cool binary sensors. +CODE_TO_TEMP_STATE = {0: STATE_IDLE, 1: STATE_HEAT, 2: STATE_COOL} + +# Active fan state. This is if the fan is actually on or not. In the +# future this should probably made into a binary sensor for the fan. +CODE_TO_FAN_STATE = {0: STATE_OFF, 1: STATE_ON} + + +def round_temp(temperature): + """Round a temperature to the resolution of the thermostat. + + RadioThermostats can handle 0.5 degree temps so the input + temperature is rounded to that value and returned. + """ + return round(temperature * 2.0) / 2.0 + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, + vol.Optional(CONF_AWAY_TEMPERATURE_HEAT, + default=DEFAULT_AWAY_TEMPERATURE_HEAT): + vol.All(vol.Coerce(float), round_temp), + vol.Optional(CONF_AWAY_TEMPERATURE_COOL, + default=DEFAULT_AWAY_TEMPERATURE_COOL): + vol.All(vol.Coerce(float), round_temp), +}) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Radio Thermostat.""" + import radiotherm + + hosts = [] + if CONF_HOST in config: + hosts = config[CONF_HOST] + else: + hosts.append(radiotherm.discover.discover_address()) + + if hosts is None: + _LOGGER.error("No Radiotherm Thermostats detected") + return False + + hold_temp = config.get(CONF_HOLD_TEMP) + away_temps = [ + config.get(CONF_AWAY_TEMPERATURE_HEAT), + config.get(CONF_AWAY_TEMPERATURE_COOL) + ] + tstats = [] + + for host in hosts: + try: + tstat = radiotherm.get_thermostat(host) + tstats.append(RadioThermostat(tstat, hold_temp, away_temps)) + except OSError: + _LOGGER.exception("Unable to connect to Radio Thermostat: %s", + host) + + add_entities(tstats, True) + + +class RadioThermostat(ClimateDevice): + """Representation of a Radio Thermostat.""" + + def __init__(self, device, hold_temp, away_temps): + """Initialize the thermostat.""" + self.device = device + self._target_temperature = None + self._current_temperature = None + self._current_operation = STATE_IDLE + self._name = None + self._fmode = None + self._fstate = None + self._tmode = None + self._tstate = None + self._hold_temp = hold_temp + self._hold_set = False + self._away = False + self._away_temps = away_temps + self._prev_temp = None + + # Fan circulate mode is only supported by the CT80 models. + import radiotherm + self._is_model_ct80 = isinstance( + self.device, radiotherm.thermostat.CT80) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + async def async_added_to_hass(self): + """Register callbacks.""" + # Set the time on the device. This shouldn't be in the + # constructor because it's a network call. We can't put it in + # update() because calling it will clear any temporary mode or + # temperature in the thermostat. So add it as a future job + # for the event loop to run. + self.hass.async_add_job(self.set_time) + + @property + def name(self): + """Return the name of the Radio Thermostat.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_HALVES + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_FAN: self._fstate, + ATTR_MODE: self._tstate, + } + + @property + def fan_list(self): + """List of available fan modes.""" + if self._is_model_ct80: + return CT80_FAN_OPERATION_LIST + return CT30_FAN_OPERATION_LIST + + @property + def current_fan_mode(self): + """Return whether the fan is on.""" + return self._fmode + + def set_fan_mode(self, fan_mode): + """Turn fan on/off.""" + code = FAN_MODE_TO_CODE.get(fan_mode, None) + if code is not None: + self.device.fmode = code + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def current_operation(self): + """Return the current operation. head, cool idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the operation modes list.""" + return OPERATION_LIST + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away + + @property + def is_on(self): + """Return true if on.""" + return self._tstate != STATE_IDLE + + def update(self): + """Update and validate the data from the thermostat.""" + # Radio thermostats are very slow, and sometimes don't respond + # very quickly. So we need to keep the number of calls to them + # to a bare minimum or we'll hit the HASS 10 sec warning. We + # have to make one call to /tstat to get temps but we'll try and + # keep the other calls to a minimum. Even with this, these + # thermostats tend to time out sometimes when they're actively + # heating or cooling. + + # First time - get the name from the thermostat. This is + # normally set in the radio thermostat web app. + if self._name is None: + self._name = self.device.name['raw'] + + # Request the current state from the thermostat. + import radiotherm + try: + data = self.device.tstat['raw'] + except radiotherm.validate.RadiothermTstatError: + _LOGGER.error('%s (%s) was busy (invalid value returned)', + self._name, self.device.host) + return + + current_temp = data['temp'] + + # Map thermostat values into various STATE_ flags. + self._current_temperature = current_temp + self._fmode = CODE_TO_FAN_MODE[data['fmode']] + self._fstate = CODE_TO_FAN_STATE[data['fstate']] + self._tmode = CODE_TO_TEMP_MODE[data['tmode']] + self._tstate = CODE_TO_TEMP_STATE[data['tstate']] + + self._current_operation = self._tmode + if self._tmode == STATE_COOL: + self._target_temperature = data['t_cool'] + elif self._tmode == STATE_HEAT: + self._target_temperature = data['t_heat'] + elif self._tmode == STATE_AUTO: + # This doesn't really work - tstate is only set if the HVAC is + # active. If it's idle, we don't know what to do with the target + # temperature. + if self._tstate == STATE_COOL: + self._target_temperature = data['t_cool'] + elif self._tstate == STATE_HEAT: + self._target_temperature = data['t_heat'] + else: + self._current_operation = STATE_IDLE + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + temperature = round_temp(temperature) + + if self._current_operation == STATE_COOL: + self.device.t_cool = temperature + elif self._current_operation == STATE_HEAT: + self.device.t_heat = temperature + elif self._current_operation == STATE_AUTO: + if self._tstate == STATE_COOL: + self.device.t_cool = temperature + elif self._tstate == STATE_HEAT: + self.device.t_heat = temperature + + # Only change the hold if requested or if hold mode was turned + # on and we haven't set it yet. + if kwargs.get('hold_changed', False) or not self._hold_set: + if self._hold_temp or self._away: + self.device.hold = 1 + self._hold_set = True + else: + self.device.hold = 0 + + def set_time(self): + """Set device time.""" + # Calling this clears any local temperature override and + # reverts to the scheduled temperature. + now = datetime.datetime.now() + self.device.time = { + 'day': now.weekday(), + 'hour': now.hour, + 'minute': now.minute + } + + def set_operation_mode(self, operation_mode): + """Set operation mode (auto, cool, heat, off).""" + if operation_mode in (STATE_OFF, STATE_AUTO): + self.device.tmode = TEMP_MODE_TO_CODE[operation_mode] + + # Setting t_cool or t_heat automatically changes tmode. + elif operation_mode == STATE_COOL: + self.device.t_cool = self._target_temperature + elif operation_mode == STATE_HEAT: + self.device.t_heat = self._target_temperature + + def turn_away_mode_on(self): + """Turn away on. + + The RTCOA app simulates away mode by using a hold. + """ + away_temp = None + if not self._away: + self._prev_temp = self._target_temperature + if self._current_operation == STATE_HEAT: + away_temp = self._away_temps[0] + elif self._current_operation == STATE_COOL: + away_temp = self._away_temps[1] + + self._away = True + self.set_temperature(temperature=away_temp, hold_changed=True) + + def turn_away_mode_off(self): + """Turn away off.""" + self._away = False + self.set_temperature(temperature=self._prev_temp, hold_changed=True) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/sensibo.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/sensibo.py new file mode 100644 index 00000000000000..bf1cf5bf345e09 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/sensibo.py @@ -0,0 +1,356 @@ +""" +Support for Sensibo wifi-enabled home thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.sensibo/ +""" + +import asyncio +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_STATE, ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, + STATE_ON, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, ClimateDevice, DOMAIN, PLATFORM_SCHEMA, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, + SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, + SUPPORT_ON_OFF, STATE_HEAT, STATE_COOL, STATE_FAN_ONLY, STATE_DRY, + STATE_AUTO) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.temperature import convert as convert_temperature + +REQUIREMENTS = ['pysensibo==1.0.3'] + +_LOGGER = logging.getLogger(__name__) + +ALL = ['all'] +TIMEOUT = 10 + +SERVICE_ASSUME_STATE = 'sensibo_assume_state' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_ID, default=ALL): vol.All(cv.ensure_list, [cv.string]), +}) + +ASSUME_STATE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_STATE): cv.string, +}) + +_FETCH_FIELDS = ','.join([ + 'room{name}', 'measurements', 'remoteCapabilities', + 'acState', 'connectionStatus{isAlive}', 'temperatureUnit']) +_INITIAL_FETCH_FIELDS = 'id,' + _FETCH_FIELDS + +FIELD_TO_FLAG = { + 'fanLevel': SUPPORT_FAN_MODE, + 'mode': SUPPORT_OPERATION_MODE, + 'swing': SUPPORT_SWING_MODE, + 'targetTemperature': SUPPORT_TARGET_TEMPERATURE, + 'on': SUPPORT_ON_OFF, +} + +SENSIBO_TO_HA = { + "cool": STATE_COOL, + "heat": STATE_HEAT, + "fan": STATE_FAN_ONLY, + "auto": STATE_AUTO, + "dry": STATE_DRY +} + +HA_TO_SENSIBO = {value: key for key, value in SENSIBO_TO_HA.items()} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up Sensibo devices.""" + import pysensibo + + client = pysensibo.SensiboClient( + config[CONF_API_KEY], session=async_get_clientsession(hass), + timeout=TIMEOUT) + devices = [] + try: + for dev in ( + await client.async_get_devices(_INITIAL_FETCH_FIELDS)): + if config[CONF_ID] == ALL or dev['id'] in config[CONF_ID]: + devices.append(SensiboClimate( + client, dev, hass.config.units.temperature_unit)) + except (aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError): + _LOGGER.exception('Failed to connect to Sensibo servers.') + raise PlatformNotReady + + if devices: + async_add_entities(devices) + + async def async_assume_state(service): + """Set state according to external service call..""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_climate = [device for device in devices + if device.entity_id in entity_ids] + else: + target_climate = devices + + update_tasks = [] + for climate in target_climate: + await climate.async_assume_state( + service.data.get(ATTR_STATE)) + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) + hass.services.async_register( + DOMAIN, SERVICE_ASSUME_STATE, async_assume_state, + schema=ASSUME_STATE_SCHEMA) + + +class SensiboClimate(ClimateDevice): + """Representation of a Sensibo device.""" + + def __init__(self, client, data, units): + """Build SensiboClimate. + + client: aiohttp session. + data: initially-fetched data. + """ + self._client = client + self._id = data['id'] + self._external_state = None + self._units = units + self._do_update(data) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + + def _do_update(self, data): + self._name = data['room']['name'] + self._measurements = data['measurements'] + self._ac_states = data['acState'] + self._status = data['connectionStatus']['isAlive'] + capabilities = data['remoteCapabilities'] + self._operations = [SENSIBO_TO_HA[mode] for mode + in capabilities['modes']] + self._current_capabilities = \ + capabilities['modes'][self._ac_states['mode']] + temperature_unit_key = data.get('temperatureUnit') or \ + self._ac_states.get('temperatureUnit') + if temperature_unit_key: + self._temperature_unit = TEMP_CELSIUS if \ + temperature_unit_key == 'C' else TEMP_FAHRENHEIT + self._temperatures_list = self._current_capabilities[ + 'temperatures'].get(temperature_unit_key, {}).get('values', []) + else: + self._temperature_unit = self._units + self._temperatures_list = [] + self._supported_features = 0 + for key in self._ac_states: + if key in FIELD_TO_FLAG: + self._supported_features |= FIELD_TO_FLAG[key] + + @property + def state(self): + """Return the current state.""" + return self._external_state or super().state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_CURRENT_HUMIDITY: self.current_humidity, + 'battery': self.current_battery} + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return self._temperature_unit + + @property + def available(self): + """Return True if entity is available.""" + return self._status + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._ac_states.get('targetTemperature') + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + if self.temperature_unit == self.hass.config.units.temperature_unit: + # We are working in same units as the a/c unit. Use whole degrees + # like the API supports. + return 1 + # Unit conversion is going on. No point to stick to specific steps. + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return SENSIBO_TO_HA.get(self._ac_states['mode']) + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._measurements['humidity'] + + @property + def current_battery(self): + """Return the current battery voltage.""" + return self._measurements.get('batteryVoltage') + + @property + def current_temperature(self): + """Return the current temperature.""" + # This field is not affected by temperatureUnit. + # It is always in C + return convert_temperature( + self._measurements['temperature'], + TEMP_CELSIUS, + self.temperature_unit) + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operations + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._ac_states.get('fanLevel') + + @property + def fan_list(self): + """List of available fan modes.""" + return self._current_capabilities.get('fanLevels') + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return self._ac_states.get('swing') + + @property + def swing_list(self): + """List of available swing modes.""" + return self._current_capabilities.get('swing') + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def is_on(self): + """Return true if AC is on.""" + return self._ac_states['on'] + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._temperatures_list[0] \ + if self._temperatures_list else super().min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._temperatures_list[-1] \ + if self._temperatures_list else super().max_temp + + @property + def unique_id(self): + """Return unique ID based on Sensibo ID.""" + return self._id + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + temperature = int(temperature) + if temperature not in self._temperatures_list: + # Requested temperature is not supported. + if temperature == self.target_temperature: + return + index = self._temperatures_list.index(self.target_temperature) + if temperature > self.target_temperature and index < len( + self._temperatures_list) - 1: + temperature = self._temperatures_list[index + 1] + elif temperature < self.target_temperature and index > 0: + temperature = self._temperatures_list[index - 1] + else: + return + + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, 'targetTemperature', temperature, self._ac_states) + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, 'fanLevel', fan_mode, self._ac_states) + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, 'mode', HA_TO_SENSIBO[operation_mode], + self._ac_states) + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, 'swing', swing_mode, self._ac_states) + + async def async_turn_on(self): + """Turn Sensibo unit on.""" + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, 'on', True, self._ac_states) + + async def async_turn_off(self): + """Turn Sensibo unit on.""" + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, 'on', False, self._ac_states) + + async def async_assume_state(self, state): + """Set external state.""" + change_needed = (state != STATE_OFF and not self.is_on) \ + or (state == STATE_OFF and self.is_on) + if change_needed: + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, + 'on', + state != STATE_OFF, # value + self._ac_states, + True # assumed_state + ) + + if state in [STATE_ON, STATE_OFF]: + self._external_state = None + else: + self._external_state = state + + async def async_update(self): + """Retrieve latest state.""" + try: + with async_timeout.timeout(TIMEOUT): + data = await self._client.async_get_device( + self._id, _FETCH_FIELDS) + self._do_update(data) + except aiohttp.client_exceptions.ClientError: + _LOGGER.warning('Failed to connect to Sensibo servers.') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/climate/services.yaml new file mode 100644 index 00000000000000..1460181ddc22f0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/services.yaml @@ -0,0 +1,150 @@ +# Describes the format for available climate services + +set_aux_heat: + description: Turn auxiliary heater on/off for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + aux_heat: + description: New value of axillary heater. + example: true +set_away_mode: + description: Turn away mode on/off for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + 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. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + temperature: + description: New target temperature for HVAC. + example: 25 + target_temp_high: + description: New target high tempereature for HVAC. + example: 26 + target_temp_low: + description: New target low temperature for HVAC. + example: 20 + operation_mode: + description: Operation mode to set temperature to. This defaults to current_operation mode if not set, or set incorrectly. + example: 'Heat' +set_humidity: + description: Set target humidity of climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + humidity: + description: New target humidity for climate device. + example: 60 +set_fan_mode: + description: Set fan operation for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.nest' + fan_mode: + description: New value of fan mode. + example: On Low +set_operation_mode: + description: Set operation mode for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.nest' + operation_mode: + description: New value of operation mode. + example: Heat +set_swing_mode: + description: Set swing operation for climate device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.nest' + swing_mode: + description: New value of swing mode. + example: + +turn_on: + description: Turn climate device on. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + +turn_off: + description: Turn climate device off. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + +ecobee_set_fan_min_on_time: + description: Set the minimum fan on time. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + fan_min_on_time: + description: New value of fan min on time. + example: 5 + +ecobee_resume_program: + description: Resume the programmed schedule. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + resume_all: + description: Resume all events and return to the scheduled program. This default to false which removes only the top event. + example: true + +mill_set_room_temperature: + description: Set Mill room temperatures. + fields: + room_name: + description: Name of room to change. + example: 'kitchen' + away_temp: + description: Away temp. + example: 12 + comfort_temp: + description: Comfort temp. + example: 22 + sleep_temp: + description: Sleep temp. + example: 17 + +nuheat_resume_program: + description: Resume the programmed schedule. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + +sensibo_assume_state: + description: Set Sensibo device to external state. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + state: + description: State to set. + example: 'idle' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/touchline.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/touchline.py new file mode 100644 index 00000000000000..641f6e9a1d8b7a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/touchline.py @@ -0,0 +1,90 @@ +""" +Platform for Roth Touchline heat pump controller. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.touchline/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import CONF_HOST, TEMP_CELSIUS, ATTR_TEMPERATURE +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pytouchline==0.7'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Touchline devices.""" + from pytouchline import PyTouchline + host = config[CONF_HOST] + py_touchline = PyTouchline() + number_of_devices = int(py_touchline.get_number_of_devices(host)) + devices = [] + for device_id in range(0, number_of_devices): + devices.append(Touchline(PyTouchline(device_id))) + add_entities(devices, True) + + +class Touchline(ClimateDevice): + """Representation of a Touchline device.""" + + def __init__(self, touchline_thermostat): + """Initialize the climate device.""" + self.unit = touchline_thermostat + self._name = None + self._current_temperature = None + self._target_temperature = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def update(self): + """Update unit attributes.""" + self.unit.update() + self._name = self.unit.get_name() + self._current_temperature = self.unit.get_current_temperature() + self._target_temperature = self.unit.get_target_temperature() + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.unit.set_target_temperature(self._target_temperature) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/venstar.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/venstar.py new file mode 100644 index 00000000000000..16c0b2061546e0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/venstar.py @@ -0,0 +1,314 @@ +""" +Support for Venstar WiFi Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.venstar/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + PLATFORM_SCHEMA, STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_AWAY_MODE, + SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW, + SUPPORT_HOLD_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + ClimateDevice) +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_TIMEOUT, + CONF_USERNAME, PRECISION_WHOLE, STATE_OFF, STATE_ON, TEMP_CELSIUS, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['venstarcolortouch==0.6'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_FAN_STATE = 'fan_state' +ATTR_HVAC_STATE = 'hvac_state' + +CONF_HUMIDIFIER = 'humidifier' + +DEFAULT_SSL = False + +VALID_FAN_STATES = [STATE_ON, STATE_AUTO] +VALID_THERMOSTAT_MODES = [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_AUTO] + +HOLD_MODE_OFF = 'off' +HOLD_MODE_TEMPERATURE = 'temperature' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HUMIDIFIER, default=True): cv.boolean, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=5): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_USERNAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Venstar thermostat.""" + import venstarcolortouch + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + timeout = config.get(CONF_TIMEOUT) + humidifier = config.get(CONF_HUMIDIFIER) + + if config.get(CONF_SSL): + proto = 'https' + else: + proto = 'http' + + client = venstarcolortouch.VenstarColorTouch( + addr=host, timeout=timeout, user=username, password=password, + proto=proto) + + add_entities([VenstarThermostat(client, humidifier)], True) + + +class VenstarThermostat(ClimateDevice): + """Representation of a Venstar thermostat.""" + + def __init__(self, client, humidifier): + """Initialize the thermostat.""" + self._client = client + self._humidifier = humidifier + + def update(self): + """Update the data from the thermostat.""" + info_success = self._client.update_info() + sensor_success = self._client.update_sensors() + if not info_success or not sensor_success: + _LOGGER.error("Failed to update data") + + @property + def supported_features(self): + """Return the list of supported features.""" + features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | + SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE | + SUPPORT_HOLD_MODE) + + if self._client.mode == self._client.MODE_AUTO: + features |= (SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW) + + if (self._humidifier and + hasattr(self._client, 'hum_active')): + features |= (SUPPORT_TARGET_HUMIDITY | + SUPPORT_TARGET_HUMIDITY_HIGH | + SUPPORT_TARGET_HUMIDITY_LOW) + + return features + + @property + def name(self): + """Return the name of the thermostat.""" + return self._client.name + + @property + def precision(self): + """Return the precision of the system. + + Venstar temperature values are passed back and forth in the + API as whole degrees C or F. + """ + return PRECISION_WHOLE + + @property + def temperature_unit(self): + """Return the unit of measurement, as defined by the API.""" + if self._client.tempunits == self._client.TEMPUNITS_F: + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return VALID_FAN_STATES + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return VALID_THERMOSTAT_MODES + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._client.get_indoor_temp() + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._client.get_indoor_humidity() + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self._client.mode == self._client.MODE_HEAT: + return STATE_HEAT + if self._client.mode == self._client.MODE_COOL: + return STATE_COOL + if self._client.mode == self._client.MODE_AUTO: + return STATE_AUTO + return STATE_OFF + + @property + def current_fan_mode(self): + """Return the fan setting.""" + if self._client.fan == self._client.FAN_AUTO: + return STATE_AUTO + return STATE_ON + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + return { + ATTR_FAN_STATE: self._client.fanstate, + ATTR_HVAC_STATE: self._client.state, + } + + @property + def target_temperature(self): + """Return the target temperature we try to reach.""" + if self._client.mode == self._client.MODE_HEAT: + return self._client.heattemp + if self._client.mode == self._client.MODE_COOL: + return self._client.cooltemp + return None + + @property + def target_temperature_low(self): + """Return the lower bound temp if auto mode is on.""" + if self._client.mode == self._client.MODE_AUTO: + return self._client.heattemp + return None + + @property + def target_temperature_high(self): + """Return the upper bound temp if auto mode is on.""" + if self._client.mode == self._client.MODE_AUTO: + return self._client.cooltemp + return None + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._client.hum_setpoint + + @property + def min_humidity(self): + """Return the minimum humidity. Hardcoded to 0 in API.""" + return 0 + + @property + def max_humidity(self): + """Return the maximum humidity. Hardcoded to 60 in API.""" + return 60 + + @property + def is_away_mode_on(self): + """Return the status of away mode.""" + return self._client.away == self._client.AWAY_AWAY + + @property + def current_hold_mode(self): + """Return the status of hold mode.""" + if self._client.schedule == 0: + return HOLD_MODE_TEMPERATURE + return HOLD_MODE_OFF + + def _set_operation_mode(self, operation_mode): + """Change the operation mode (internal).""" + if operation_mode == STATE_HEAT: + success = self._client.set_mode(self._client.MODE_HEAT) + elif operation_mode == STATE_COOL: + success = self._client.set_mode(self._client.MODE_COOL) + elif operation_mode == STATE_AUTO: + success = self._client.set_mode(self._client.MODE_AUTO) + else: + success = self._client.set_mode(self._client.MODE_OFF) + + if not success: + _LOGGER.error("Failed to change the operation mode") + return success + + def set_temperature(self, **kwargs): + """Set a new target temperature.""" + set_temp = True + operation_mode = kwargs.get(ATTR_OPERATION_MODE, self._client.mode) + temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temperature = kwargs.get(ATTR_TEMPERATURE) + + if operation_mode != self._client.mode: + set_temp = self._set_operation_mode(operation_mode) + + if set_temp: + if operation_mode == self._client.MODE_HEAT: + success = self._client.set_setpoints( + temperature, self._client.cooltemp) + elif operation_mode == self._client.MODE_COOL: + success = self._client.set_setpoints( + self._client.heattemp, temperature) + elif operation_mode == self._client.MODE_AUTO: + success = self._client.set_setpoints(temp_low, temp_high) + else: + _LOGGER.error("The thermostat is currently not in a mode " + "that supports target temperature") + + if not success: + _LOGGER.error("Failed to change the temperature") + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if fan_mode == STATE_ON: + success = self._client.set_fan(self._client.FAN_ON) + else: + success = self._client.set_fan(self._client.FAN_AUTO) + + if not success: + _LOGGER.error("Failed to change the fan mode") + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + self._set_operation_mode(operation_mode) + + def set_humidity(self, humidity): + """Set new target humidity.""" + success = self._client.set_hum_setpoint(humidity) + + if not success: + _LOGGER.error("Failed to change the target humidity level") + + def set_hold_mode(self, hold_mode): + """Set the hold mode.""" + if hold_mode == HOLD_MODE_TEMPERATURE: + success = self._client.set_schedule(0) + elif hold_mode == HOLD_MODE_OFF: + success = self._client.set_schedule(1) + else: + _LOGGER.error("Unknown hold mode: %s", hold_mode) + success = False + + if not success: + _LOGGER.error("Failed to change the schedule/hold state") + + def turn_away_mode_on(self): + """Activate away mode.""" + success = self._client.set_away(self._client.AWAY_AWAY) + + if not success: + _LOGGER.error("Failed to activate away mode") + + def turn_away_mode_off(self): + """Deactivate away mode.""" + success = self._client.set_away(self._client.AWAY_HOME) + + if not success: + _LOGGER.error("Failed to deactivate away mode") diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/zhong_hong.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/zhong_hong.py new file mode 100644 index 00000000000000..b564e9d1fa4a37 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/zhong_hong.py @@ -0,0 +1,219 @@ +""" +Support for ZhongHong HVAC Controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.zhong_hong/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, PLATFORM_SCHEMA, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.const import (ATTR_TEMPERATURE, CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import (async_dispatcher_connect, + async_dispatcher_send) + +REQUIREMENTS = ['zhong_hong_hvac==1.0.9'] + +_LOGGER = logging.getLogger(__name__) + +CONF_GATEWAY_ADDRRESS = 'gateway_address' + +DEFAULT_PORT = 9999 +DEFAULT_GATEWAY_ADDRRESS = 1 + +SIGNAL_DEVICE_ADDED = 'zhong_hong_device_added' +SIGNAL_ZHONG_HONG_HUB_START = 'zhong_hong_hub_start' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_GATEWAY_ADDRRESS, default=DEFAULT_GATEWAY_ADDRRESS): + cv.positive_int, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ZhongHong HVAC platform.""" + from zhong_hong_hvac.hub import ZhongHongGateway + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + gw_addr = config.get(CONF_GATEWAY_ADDRRESS) + hub = ZhongHongGateway(host, port, gw_addr) + devices = [ + ZhongHongClimate(hub, addr_out, addr_in) + for (addr_out, addr_in) in hub.discovery_ac() + ] + + _LOGGER.debug("We got %s zhong_hong climate devices", len(devices)) + + hub_is_initialized = False + + async def startup(): + """Start hub socket after all climate entity is setted up.""" + nonlocal hub_is_initialized + if not all([device.is_initialized for device in devices]): + return + + if hub_is_initialized: + return + + _LOGGER.debug("zhong_hong hub start listen event") + await hass.async_add_job(hub.start_listen) + await hass.async_add_job(hub.query_all_status) + hub_is_initialized = True + + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADDED, startup) + + # add devices after SIGNAL_DEVICE_SETTED_UP event is listend + add_entities(devices) + + def stop_listen(event): + """Stop ZhongHongHub socket.""" + hub.stop_listen() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_listen) + + +class ZhongHongClimate(ClimateDevice): + """Representation of a ZhongHong controller support HVAC.""" + + def __init__(self, hub, addr_out, addr_in): + """Set up the ZhongHong climate devices.""" + from zhong_hong_hvac.hvac import HVAC + self._device = HVAC(hub, addr_out, addr_in) + self._hub = hub + self._current_operation = None + self._current_temperature = None + self._target_temperature = None + self._current_fan_mode = None + self._is_on = None + self.is_initialized = False + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.register_update_callback(self._after_update) + self.is_initialized = True + async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADDED) + + def _after_update(self, climate): + """Handle state update.""" + _LOGGER.debug("async update ha state") + if self._device.current_operation: + self._current_operation = self._device.current_operation.lower() + if self._device.current_temperature: + self._current_temperature = self._device.current_temperature + if self._device.current_fan_mode: + self._current_fan_mode = self._device.current_fan_mode + if self._device.target_temperature: + self._target_temperature = self._device.target_temperature + self._is_on = self._device.is_on + self.schedule_update_ha_state() + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self.unique_id + + @property + def unique_id(self): + """Return the unique ID of the HVAC.""" + return "zhong_hong_hvac_{}_{}".format(self._device.addr_out, + self._device.addr_in) + + @property + def supported_features(self): + """Return the list of supported features.""" + return (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF) + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY] + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def is_on(self): + """Return true if on.""" + return self._device.is_on + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._device.fan_list + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._device.min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._device.max_temp + + def turn_on(self): + """Turn on ac.""" + return self._device.turn_on() + + def turn_off(self): + """Turn off ac.""" + return self._device.turn_off() + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is not None: + self._device.set_temperature(temperature) + + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + if operation_mode is not None: + self.set_operation_mode(operation_mode) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + self._device.set_operation_mode(operation_mode.upper()) + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + self._device.set_fan_mode(fan_mode) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/climate/zwave.py b/homeassistant-0.88.0.dev0/homeassistant/components/climate/zwave.py new file mode 100644 index 00000000000000..561af9c9f57b62 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/climate/zwave.py @@ -0,0 +1,264 @@ +""" +Support for Z-Wave climate devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.zwave/ +""" +# Because we do not compile openzwave on CI +import logging +from homeassistant.core import callback +from homeassistant.components.climate import ( + DOMAIN, ClimateDevice, STATE_AUTO, STATE_COOL, STATE_HEAT, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) +from homeassistant.components.zwave import ZWaveDeviceEntity +from homeassistant.const import ( + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +CONF_NAME = 'name' +DEFAULT_NAME = 'Z-Wave Climate' + +REMOTEC = 0x5254 +REMOTEC_ZXT_120 = 0x8377 +REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) +ATTR_OPERATING_STATE = 'operating_state' +ATTR_FAN_STATE = 'fan_state' + +WORKAROUND_ZXT_120 = 'zxt_120' + +DEVICE_MAPPINGS = { + REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 +} + +STATE_MAPPINGS = { + 'Off': STATE_OFF, + 'Heat': STATE_HEAT, + 'Heat Mode': STATE_HEAT, + 'Heat (Default)': STATE_HEAT, + 'Cool': STATE_COOL, + 'Auto': STATE_AUTO, +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave climate devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Climate device from Config Entry.""" + @callback + def async_add_climate(climate): + """Add Z-Wave Climate Device.""" + async_add_entities([climate]) + + async_dispatcher_connect(hass, 'zwave_new_climate', async_add_climate) + + +def get_device(hass, values, **kwargs): + """Create Z-Wave entity device.""" + temp_unit = hass.config.units.temperature_unit + return ZWaveClimate(values, temp_unit) + + +class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): + """Representation of a Z-Wave Climate device.""" + + def __init__(self, values, temp_unit): + """Initialize the Z-Wave climate device.""" + ZWaveDeviceEntity.__init__(self, values, DOMAIN) + self._target_temperature = None + self._current_temperature = None + self._current_operation = None + self._operation_list = None + self._operation_mapping = None + self._operating_state = None + self._current_fan_mode = None + self._fan_list = None + self._fan_state = None + self._current_swing_mode = None + self._swing_list = None + self._unit = temp_unit + _LOGGER.debug("temp_unit is %s", self._unit) + self._zxt_120 = None + # Make sure that we have values for the key before converting to int + if (self.node.manufacturer_id.strip() and + self.node.product_id.strip()): + specific_sensor_key = ( + int(self.node.manufacturer_id, 16), + int(self.node.product_id, 16)) + if specific_sensor_key in DEVICE_MAPPINGS: + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZXT_120: + _LOGGER.debug( + "Remotec ZXT-120 Zwave Thermostat workaround") + self._zxt_120 = 1 + self.update_properties() + + @property + def supported_features(self): + """Return the list of supported features.""" + support = SUPPORT_TARGET_TEMPERATURE + if self.values.fan_mode: + support |= SUPPORT_FAN_MODE + if self.values.mode: + support |= SUPPORT_OPERATION_MODE + if self._zxt_120 == 1 and self.values.zxt_120_swing_mode: + support |= SUPPORT_SWING_MODE + return support + + def update_properties(self): + """Handle the data changes for node values.""" + # Operation Mode + if self.values.mode: + self._operation_list = [] + self._operation_mapping = {} + operation_list = self.values.mode.data_items + if operation_list: + for mode in operation_list: + ha_mode = STATE_MAPPINGS.get(mode) + if ha_mode and ha_mode not in self._operation_mapping: + self._operation_mapping[ha_mode] = mode + self._operation_list.append(ha_mode) + continue + self._operation_list.append(mode) + current_mode = self.values.mode.data + self._current_operation = next( + (key for key, value in self._operation_mapping.items() + if value == current_mode), current_mode) + _LOGGER.debug("self._operation_list=%s", self._operation_list) + _LOGGER.debug("self._current_operation=%s", self._current_operation) + + # Current Temp + if self.values.temperature: + self._current_temperature = self.values.temperature.data + device_unit = self.values.temperature.units + if device_unit is not None: + self._unit = device_unit + + # Fan Mode + if self.values.fan_mode: + self._current_fan_mode = self.values.fan_mode.data + fan_list = self.values.fan_mode.data_items + if fan_list: + self._fan_list = list(fan_list) + _LOGGER.debug("self._fan_list=%s", self._fan_list) + _LOGGER.debug("self._current_fan_mode=%s", + self._current_fan_mode) + # Swing mode + if self._zxt_120 == 1: + if self.values.zxt_120_swing_mode: + self._current_swing_mode = self.values.zxt_120_swing_mode.data + swing_list = self.values.zxt_120_swing_mode.data_items + if swing_list: + self._swing_list = list(swing_list) + _LOGGER.debug("self._swing_list=%s", self._swing_list) + _LOGGER.debug("self._current_swing_mode=%s", + self._current_swing_mode) + # Set point + if self.values.primary.data == 0: + _LOGGER.debug("Setpoint is 0, setting default to " + "current_temperature=%s", + self._current_temperature) + if self._current_temperature is not None: + self._target_temperature = ( + round((float(self._current_temperature)), 1)) + else: + self._target_temperature = round( + (float(self.values.primary.data)), 1) + + # Operating state + if self.values.operating_state: + self._operating_state = self.values.operating_state.data + + # Fan operating state + if self.values.fan_state: + self._fan_state = self.values.fan_state.data + + @property + def current_fan_mode(self): + """Return the fan speed set.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return a list of available fan modes.""" + return self._fan_list + + @property + def current_swing_mode(self): + """Return the swing mode set.""" + return self._current_swing_mode + + @property + def swing_list(self): + """Return a list of available swing modes.""" + return self._swing_list + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self._unit == 'C': + return TEMP_CELSIUS + if self._unit == 'F': + return TEMP_FAHRENHEIT + return self._unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def current_operation(self): + """Return the current operation mode.""" + return self._current_operation + + @property + def operation_list(self): + """Return a list of available operation modes.""" + return self._operation_list + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + temperature = kwargs.get(ATTR_TEMPERATURE) + else: + return + + self.values.primary.data = temperature + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if self.values.fan_mode: + self.values.fan_mode.data = fan_mode + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + if self.values.mode: + self.values.mode.data = self._operation_mapping.get( + operation_mode, operation_mode) + + def set_swing_mode(self, swing_mode): + """Set new target swing mode.""" + if self._zxt_120 == 1: + if self.values.zxt_120_swing_mode: + self.values.zxt_120_swing_mode.data = swing_mode + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + data = super().device_state_attributes + if self._operating_state: + data[ATTR_OPERATING_STATE] = self._operating_state + if self._fan_state: + data[ATTR_FAN_STATE] = self._fan_state + return data diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000000..d938dd20e67eaf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/__init__.py @@ -0,0 +1,271 @@ +""" +Component to integrate the Home Assistant cloud. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cloud/ +""" +from datetime import datetime, timedelta +import json +import logging +import os + +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION, + CONF_MODE, CONF_NAME) +from homeassistant.helpers import entityfilter, config_validation as cv +from homeassistant.util import dt as dt_util +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import helpers as ga_h +from homeassistant.components.google_assistant import const as ga_c + +from . import http_api, iot, auth_api, prefs, cloudhooks +from .const import CONFIG_DIR, DOMAIN, SERVERS + +REQUIREMENTS = ['warrant==0.6.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ALEXA = 'alexa' +CONF_ALIASES = 'aliases' +CONF_COGNITO_CLIENT_ID = 'cognito_client_id' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_FILTER = 'filter' +CONF_GOOGLE_ACTIONS = 'google_actions' +CONF_RELAYER = 'relayer' +CONF_USER_POOL_ID = 'user_pool_id' +CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' +CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' + +DEFAULT_MODE = 'production' +DEPENDENCIES = ['http'] + +MODE_DEV = 'development' + +ALEXA_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(alexa_sh.CONF_NAME): cv.string, +}) + +GOOGLE_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ga_c.CONF_ROOM_HINT): cv.string, +}) + +ASSISTANT_SCHEMA = vol.Schema({ + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, +}) + +ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} +}) + +GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV] + list(SERVERS)), + # Change to optional when we include real servers + vol.Optional(CONF_COGNITO_CLIENT_ID): str, + vol.Optional(CONF_USER_POOL_ID): str, + vol.Optional(CONF_REGION): str, + vol.Optional(CONF_RELAYER): str, + vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, + vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, + vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, + vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + if DOMAIN in config: + kwargs = dict(config[DOMAIN]) + else: + kwargs = {CONF_MODE: DEFAULT_MODE} + + alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) + + if CONF_GOOGLE_ACTIONS not in kwargs: + kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) + + kwargs[CONF_ALEXA] = alexa_sh.Config( + endpoint=None, + async_get_access_token=None, + should_expose=alexa_conf[CONF_FILTER], + entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + ) + + cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) + await http_api.async_setup(hass) + return True + + +class Cloud: + """Store the configuration of the cloud connection.""" + + def __init__(self, hass, mode, alexa, google_actions, + cognito_client_id=None, user_pool_id=None, region=None, + relayer=None, google_actions_sync_url=None, + subscription_info_url=None, cloudhook_create_url=None): + """Create an instance of Cloud.""" + self.hass = hass + self.mode = mode + self.alexa_config = alexa + self.google_actions_user_conf = google_actions + self._gactions_config = None + self.prefs = prefs.CloudPreferences(hass) + self.id_token = None + self.access_token = None + self.refresh_token = None + self.iot = iot.CloudIoT(self) + self.cloudhooks = cloudhooks.Cloudhooks(self) + + if mode == MODE_DEV: + self.cognito_client_id = cognito_client_id + self.user_pool_id = user_pool_id + self.region = region + self.relayer = relayer + self.google_actions_sync_url = google_actions_sync_url + self.subscription_info_url = subscription_info_url + self.cloudhook_create_url = cloudhook_create_url + + else: + info = SERVERS[mode] + + self.cognito_client_id = info['cognito_client_id'] + self.user_pool_id = info['user_pool_id'] + self.region = info['region'] + self.relayer = info['relayer'] + self.google_actions_sync_url = info['google_actions_sync_url'] + self.subscription_info_url = info['subscription_info_url'] + self.cloudhook_create_url = info['cloudhook_create_url'] + + @property + def is_logged_in(self): + """Get if cloud is logged in.""" + return self.id_token is not None + + @property + def subscription_expired(self): + """Return a boolean if the subscription has expired.""" + return dt_util.utcnow() > self.expiration_date + timedelta(days=7) + + @property + def expiration_date(self): + """Return the subscription expiration as a UTC datetime object.""" + return datetime.combine( + dt_util.parse_date(self.claims['custom:sub-exp']), + datetime.min.time()).replace(tzinfo=dt_util.UTC) + + @property + def claims(self): + """Return the claims from the id token.""" + return self._decode_claims(self.id_token) + + @property + def user_info_path(self): + """Get path to the stored auth.""" + return self.path('{}_auth.json'.format(self.mode)) + + @property + def gactions_config(self): + """Return the Google Assistant config.""" + if self._gactions_config is None: + conf = self.google_actions_user_conf + + def should_expose(entity): + """If an entity should be exposed.""" + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + return conf['filter'](entity.entity_id) + + self._gactions_config = ga_h.Config( + should_expose=should_expose, + allow_unlock=self.prefs.google_allow_unlock, + agent_user_id=self.claims['cognito:username'], + entity_config=conf.get(CONF_ENTITY_CONFIG), + ) + + return self._gactions_config + + def path(self, *parts): + """Get config path inside cloud dir. + + Async friendly. + """ + return self.hass.config.path(CONFIG_DIR, *parts) + + async def fetch_subscription_info(self): + """Fetch subscription info.""" + await self.hass.async_add_executor_job(auth_api.check_token, self) + websession = self.hass.helpers.aiohttp_client.async_get_clientsession() + return await websession.get( + self.subscription_info_url, headers={ + 'authorization': self.id_token + }) + + async def logout(self): + """Close connection and remove all credentials.""" + await self.iot.disconnect() + + self.id_token = None + self.access_token = None + self.refresh_token = None + self._gactions_config = None + + await self.hass.async_add_job( + lambda: os.remove(self.user_info_path)) + + def write_user_info(self): + """Write user info to a file.""" + with open(self.user_info_path, 'wt') as file: + file.write(json.dumps({ + 'id_token': self.id_token, + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + }, indent=4)) + + async def async_start(self, _): + """Start the cloud component.""" + def load_config(): + """Load config.""" + # Ensure config dir exists + path = self.hass.config.path(CONFIG_DIR) + if not os.path.isdir(path): + os.mkdir(path) + + user_info = self.user_info_path + if not os.path.isfile(user_info): + return None + + with open(user_info, 'rt') as file: + return json.loads(file.read()) + + info = await self.hass.async_add_job(load_config) + await self.prefs.async_initialize() + + if info is None: + return + + self.id_token = info['id_token'] + self.access_token = info['access_token'] + self.refresh_token = info['refresh_token'] + + self.hass.add_job(self.iot.connect()) + + def _decode_claims(self, token): # pylint: disable=no-self-use + """Decode the claims in a token.""" + from jose import jwt + return jwt.get_unverified_claims(token) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/auth_api.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/auth_api.py new file mode 100644 index 00000000000000..954d28b803faba --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/auth_api.py @@ -0,0 +1,173 @@ +"""Package to communicate with the authentication API.""" + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UserNotFound(CloudError): + """Raised when a user is not found.""" + + +class UserNotConfirmed(CloudError): + """Raised when a user has not confirmed email yet.""" + + +class PasswordChangeRequired(CloudError): + """Raised when a password change is required.""" + + # https://github.com/PyCQA/pylint/issues/1085 + # pylint: disable=useless-super-delegation + def __init__(self, message='Password change required.'): + """Initialize a password change required error.""" + super().__init__(message) + + +class UnknownError(CloudError): + """Raised when an unknown error occurs.""" + + +AWS_EXCEPTIONS = { + 'UserNotFoundException': UserNotFound, + 'NotAuthorizedException': Unauthenticated, + 'UserNotConfirmedException': UserNotConfirmed, + 'PasswordResetRequiredException': PasswordChangeRequired, +} + + +def _map_aws_exception(err): + """Map AWS exception to our exceptions.""" + ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) + return ex(err.response['Error']['Message']) + + +def register(cloud, email, password): + """Register a new account.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud) + # Workaround for bug in Warrant. PR with fix: + # https://github.com/capless/warrant/pull/82 + cognito.add_base_attributes() + try: + cognito.register(email, password) + except ClientError as err: + raise _map_aws_exception(err) + + +def resend_email_confirm(cloud, email): + """Resend email confirmation.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud, username=email) + + try: + cognito.client.resend_confirmation_code( + Username=email, + ClientId=cognito.client_id + ) + except ClientError as err: + raise _map_aws_exception(err) + + +def forgot_password(cloud, email): + """Initialize forgotten password flow.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud, username=email) + + try: + cognito.initiate_forgot_password() + except ClientError as err: + raise _map_aws_exception(err) + + +def login(cloud, email, password): + """Log user in and fetch certificate.""" + cognito = _authenticate(cloud, email, password) + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.refresh_token = cognito.refresh_token + cloud.write_user_info() + + +def check_token(cloud): + """Check that the token is valid and verify if needed.""" + from botocore.exceptions import ClientError + + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) + + try: + if cognito.check_token(): + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) + + +def renew_access_token(cloud): + """Renew access token.""" + from botocore.exceptions import ClientError + + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) + + try: + cognito.renew_access_token() + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) + + +def _authenticate(cloud, email, password): + """Log in and return an authenticated Cognito instance.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + + assert not cloud.is_logged_in, 'Cannot login if already logged in.' + + cognito = _cognito(cloud, username=email) + + try: + cognito.authenticate(password=password) + return cognito + + except ForceChangePasswordException: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) + + +def _cognito(cloud, **kwargs): + """Get the client credentials.""" + import botocore + import boto3 + from warrant import Cognito + + cognito = Cognito( + user_pool_id=cloud.user_pool_id, + client_id=cloud.cognito_client_id, + user_pool_region=cloud.region, + **kwargs + ) + cognito.client = boto3.client( + 'cognito-idp', + region_name=cloud.region, + config=botocore.config.Config( + signature_version=botocore.UNSIGNED + ) + ) + return cognito diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/cloud_api.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000000..c62768cc5145a7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,42 @@ +"""Cloud APIs.""" +from functools import wraps +import logging + +from . import auth_api + +_LOGGER = logging.getLogger(__name__) + + +def _check_token(func): + """Decorate a function to verify valid token.""" + @wraps(func) + async def check_token(cloud, *args): + """Validate token, then call func.""" + await cloud.hass.async_add_executor_job(auth_api.check_token, cloud) + return await func(cloud, *args) + + return check_token + + +def _log_response(func): + """Decorate a function to log bad responses.""" + @wraps(func) + async def log_response(*args): + """Log response if it's bad.""" + resp = await func(*args) + meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning + meth('Fetched %s (%s)', resp.url, resp.status) + return resp + + return log_response + + +@_check_token +@_log_response +async def async_create_cloudhook(cloud): + """Create a cloudhook.""" + websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() + return await websession.post( + cloud.cloudhook_create_url, headers={ + 'authorization': cloud.id_token + }) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/cloudhooks.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/cloudhooks.py new file mode 100644 index 00000000000000..3c638d2916637e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/cloudhooks.py @@ -0,0 +1,66 @@ +"""Manage cloud cloudhooks.""" +import async_timeout + +from . import cloud_api + + +class Cloudhooks: + """Class to help manage cloudhooks.""" + + def __init__(self, cloud): + """Initialize cloudhooks.""" + self.cloud = cloud + self.cloud.iot.register_on_connect(self.async_publish_cloudhooks) + + async def async_publish_cloudhooks(self): + """Inform the Relayer of the cloudhooks that we support.""" + cloudhooks = self.cloud.prefs.cloudhooks + await self.cloud.iot.async_send_message('webhook-register', { + 'cloudhook_ids': [info['cloudhook_id'] for info + in cloudhooks.values()] + }, expect_answer=False) + + async def async_create(self, webhook_id): + """Create a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id in cloudhooks: + raise ValueError('Hook is already enabled for the cloud.') + + if not self.cloud.iot.connected: + raise ValueError("Cloud is not connected") + + # Create cloud hook + with async_timeout.timeout(10): + resp = await cloud_api.async_create_cloudhook(self.cloud) + + data = await resp.json() + cloudhook_id = data['cloudhook_id'] + cloudhook_url = data['url'] + + # Store hook + cloudhooks = dict(cloudhooks) + hook = cloudhooks[webhook_id] = { + 'webhook_id': webhook_id, + 'cloudhook_id': cloudhook_id, + 'cloudhook_url': cloudhook_url + } + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() + + return hook + + async def async_delete(self, webhook_id): + """Delete a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id not in cloudhooks: + raise ValueError('Hook is not enabled for the cloud.') + + # Remove hook + cloudhooks = dict(cloudhooks) + cloudhooks.pop(webhook_id) + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/const.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000000..a5019efaa8eecc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/const.py @@ -0,0 +1,34 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +CONFIG_DIR = '.cloud' +REQUEST_TIMEOUT = 10 + +PREF_ENABLE_ALEXA = 'alexa_enabled' +PREF_ENABLE_GOOGLE = 'google_enabled' +PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' +PREF_CLOUDHOOKS = 'cloudhooks' + +SERVERS = { + 'production': { + 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', + 'user_pool_id': 'us-east-1_87ll5WOP8', + 'region': 'us-east-1', + 'relayer': 'wss://cloud.hass.io:8000/websocket', + 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' + 'amazonaws.com/prod/smart_home_sync'), + 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' + 'subscription_info'), + 'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate' + } +} + +MESSAGE_EXPIRATION = """ +It looks like your Home Assistant Cloud subscription has expired. Please check +your [account page](/config/cloud/account) to continue using the service. +""" + +MESSAGE_AUTH_FAIL = """ +You have been logged out of Home Assistant Cloud because we have been unable +to verify your credentials. Please [log in](/config/cloud) again to continue +using the service. +""" diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/http_api.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/http_api.py new file mode 100644 index 00000000000000..a2825eb6d7baae --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/http_api.py @@ -0,0 +1,390 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +from functools import wraps +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import ( + RequestDataValidator) +from homeassistant.components import websocket_api +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import smart_home as google_sh + +from . import auth_api +from .const import ( + DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) +from .iot import STATE_DISCONNECTED, STATE_CONNECTED + +_LOGGER = logging.getLogger(__name__) + + +WS_TYPE_STATUS = 'cloud/status' +SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_STATUS, +}) + + +WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' +SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE_PREFS, + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool, +}) + + +WS_TYPE_SUBSCRIPTION = 'cloud/subscription' +SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SUBSCRIPTION, +}) + + +WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create' +SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_CREATE, + vol.Required('webhook_id'): str +}) + + +WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete' +SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_DELETE, + vol.Required('webhook_id'): str +}) + + +async def async_setup(hass): + """Initialize the HTTP API.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_STATUS, websocket_cloud_status, + SCHEMA_WS_STATUS + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_SUBSCRIPTION, websocket_subscription, + SCHEMA_WS_SUBSCRIPTION + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE_PREFS, websocket_update_prefs, + SCHEMA_WS_UPDATE_PREFS + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_CREATE, websocket_hook_create, + SCHEMA_WS_HOOK_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_DELETE, websocket_hook_delete, + SCHEMA_WS_HOOK_DELETE + ) + hass.http.register_view(GoogleActionsSyncView) + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudRegisterView) + hass.http.register_view(CloudResendConfirmView) + hass.http.register_view(CloudForgotPasswordView) + + +_CLOUD_ERRORS = { + auth_api.UserNotFound: (400, "User does not exist."), + auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), + auth_api.Unauthenticated: (401, 'Authentication failed.'), + auth_api.PasswordChangeRequired: (400, 'Password change required.'), + asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') +} + + +def _handle_cloud_errors(handler): + """Webview decorator to handle auth errors.""" + @wraps(handler) + async def error_handler(view, request, *args, **kwargs): + """Handle exceptions that raise from the wrapped request handler.""" + try: + result = await handler(view, request, *args, **kwargs) + return result + + except Exception as err: # pylint: disable=broad-except + err_info = _CLOUD_ERRORS.get(err.__class__) + if err_info is None: + _LOGGER.exception( + "Unexpected error processing request for %s", request.path) + err_info = (502, 'Unexpected error: {}'.format(err)) + status, msg = err_info + return view.json_message( + msg, status_code=status, + message_code=err.__class__.__name__.lower()) + + return error_handler + + +class GoogleActionsSyncView(HomeAssistantView): + """Trigger a Google Actions Smart Home Sync.""" + + url = '/api/cloud/google_actions/sync' + name = 'api:cloud:google_actions/sync' + + @_handle_cloud_errors + async def post(self, request): + """Trigger a Google Actions sync.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + websession = hass.helpers.aiohttp_client.async_get_clientsession() + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job(auth_api.check_token, cloud) + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = await websession.post( + cloud.google_actions_sync_url, headers={ + 'authorization': cloud.id_token + }) + + return self.json({}, status_code=req.status) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): str, + })) + async def post(self, request, data): + """Handle login request.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job(auth_api.login, cloud, data['email'], + data['password']) + + hass.async_add_job(cloud.iot.connect) + return self.json({'success': True}) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @_handle_cloud_errors + async def post(self, request): + """Handle logout request.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await cloud.logout() + + return self.json_message('ok') + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = '/api/cloud/register' + name = 'api:cloud:register' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): vol.All(str, vol.Length(min=6)), + })) + async def post(self, request, data): + """Handle registration request.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job( + auth_api.register, cloud, data['email'], data['password']) + + return self.json_message('ok') + + +class CloudResendConfirmView(HomeAssistantView): + """Resend email confirmation code.""" + + url = '/api/cloud/resend_confirm' + name = 'api:cloud:resend_confirm' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + async def post(self, request, data): + """Handle resending confirm email code request.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job( + auth_api.resend_email_confirm, cloud, data['email']) + + return self.json_message('ok') + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = '/api/cloud/forgot_password' + name = 'api:cloud:forgot_password' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + async def post(self, request, data): + """Handle forgot password request.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job( + auth_api.forgot_password, cloud, data['email']) + + return self.json_message('ok') + + +@callback +def websocket_cloud_status(hass, connection, msg): + """Handle request for account info. + + Async friendly. + """ + cloud = hass.data[DOMAIN] + connection.send_message( + websocket_api.result_message(msg['id'], _account_data(cloud))) + + +def _require_cloud_login(handler): + """Websocket decorator that requires cloud to be logged in.""" + @wraps(handler) + def with_cloud_auth(hass, connection, msg): + """Require to be logged into the cloud.""" + cloud = hass.data[DOMAIN] + if not cloud.is_logged_in: + connection.send_message(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + handler(hass, connection, msg) + + return with_cloud_auth + + +def _handle_aiohttp_errors(handler): + """Websocket decorator that handlers aiohttp errors. + + Can only wrap async handlers. + """ + @wraps(handler) + async def with_error_handling(hass, connection, msg): + """Handle aiohttp errors.""" + try: + await handler(hass, connection, msg) + except asyncio.TimeoutError: + connection.send_message(websocket_api.error_message( + msg['id'], 'timeout', 'Command timed out.')) + except aiohttp.ClientError: + connection.send_message(websocket_api.error_message( + msg['id'], 'unknown', 'Error making request.')) + + return with_error_handling + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_subscription(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + response = await cloud.fetch_subscription_info() + + if response.status != 200: + connection.send_message(websocket_api.error_message( + msg['id'], 'request_failed', 'Failed to request subscription')) + + data = await response.json() + + # Check if a user is subscribed but local info is outdated + # In that case, let's refresh and reconnect + if data.get('provider') and cloud.iot.state != STATE_CONNECTED: + _LOGGER.debug( + "Found disconnected account with valid subscriotion, connecting") + await hass.async_add_executor_job( + auth_api.renew_access_token, cloud) + + # Cancel reconnect in progress + if cloud.iot.state != STATE_DISCONNECTED: + await cloud.iot.disconnect() + + hass.async_create_task(cloud.iot.connect()) + + connection.send_message(websocket_api.result_message(msg['id'], data)) + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + + changes = dict(msg) + changes.pop('id') + changes.pop('type') + await cloud.prefs.async_update(**changes) + + connection.send_message(websocket_api.result_message(msg['id'])) + + +@_require_cloud_login +@websocket_api.async_response +@_handle_aiohttp_errors +async def websocket_hook_create(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'], hook)) + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_hook_delete(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + await cloud.cloudhooks.async_delete(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'])) + + +def _account_data(cloud): + """Generate the auth data JSON response.""" + if not cloud.is_logged_in: + return { + 'logged_in': False, + 'cloud': STATE_DISCONNECTED, + } + + claims = cloud.claims + + return { + 'logged_in': True, + 'email': claims['email'], + 'cloud': cloud.iot.state, + 'prefs': cloud.prefs.as_dict(), + 'google_entities': cloud.google_actions_user_conf['filter'].config, + 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), + 'alexa_entities': cloud.alexa_config.should_expose.config, + 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/iot.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/iot.py new file mode 100644 index 00000000000000..d725cb309bcc63 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/iot.py @@ -0,0 +1,373 @@ +"""Module to handle messages from Home Assistant cloud.""" +import asyncio +import logging +import pprint +import random +import uuid + +from aiohttp import hdrs, client_exceptions, WSMsgType + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.alexa import smart_home as alexa +from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.core import callback +from homeassistant.util.decorator import Registry +from homeassistant.util.aiohttp import MockRequest +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import auth_api +from . import utils +from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + +STATE_CONNECTING = 'connecting' +STATE_CONNECTED = 'connected' +STATE_DISCONNECTED = 'disconnected' + + +class UnknownHandler(Exception): + """Exception raised when trying to handle unknown handler.""" + + +class NotConnected(Exception): + """Exception raised when trying to handle unknown handler.""" + + +class ErrorMessage(Exception): + """Exception raised when there was error handling message in the cloud.""" + + def __init__(self, error): + """Initialize Error Message.""" + super().__init__(self, "Error in Cloud") + self.error = error + + +class CloudIoT: + """Class to manage the IoT connection.""" + + def __init__(self, cloud): + """Initialize the CloudIoT class.""" + self.cloud = cloud + # The WebSocket client + self.client = None + # Scheduled sleep task till next connection retry + self.retry_task = None + # Boolean to indicate if we wanted the connection to close + self.close_requested = False + # The current number of attempts to connect, impacts wait time + self.tries = 0 + # Current state of the connection + self.state = STATE_DISCONNECTED + # Local code waiting for a response + self._response_handler = {} + self._on_connect = [] + + @callback + def register_on_connect(self, on_connect_cb): + """Register an async on_connect callback.""" + self._on_connect.append(on_connect_cb) + + @property + def connected(self): + """Return if we're currently connected.""" + return self.state == STATE_CONNECTED + + @asyncio.coroutine + def connect(self): + """Connect to the IoT broker.""" + if self.state != STATE_DISCONNECTED: + raise RuntimeError('Connect called while not disconnected') + + hass = self.cloud.hass + self.close_requested = False + self.state = STATE_CONNECTING + self.tries = 0 + + @asyncio.coroutine + def _handle_hass_stop(event): + """Handle Home Assistant shutting down.""" + nonlocal remove_hass_stop_listener + remove_hass_stop_listener = None + yield from self.disconnect() + + remove_hass_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) + + while True: + try: + yield from self._handle_connection() + except Exception: # pylint: disable=broad-except + # Safety net. This should never hit. + # Still adding it here to make sure we can always reconnect + _LOGGER.exception("Unexpected error") + + if self.close_requested: + break + + self.state = STATE_CONNECTING + self.tries += 1 + + try: + # Sleep 2^tries + 0…tries*3 seconds between retries + self.retry_task = hass.async_create_task( + asyncio.sleep(2**min(9, self.tries) + + random.randint(0, self.tries * 3), + loop=hass.loop)) + yield from self.retry_task + self.retry_task = None + except asyncio.CancelledError: + # Happens if disconnect called + break + + self.state = STATE_DISCONNECTED + if remove_hass_stop_listener is not None: + remove_hass_stop_listener() + + async def async_send_message(self, handler, payload, + expect_answer=True): + """Send a message.""" + if self.state != STATE_CONNECTED: + raise NotConnected + + msgid = uuid.uuid4().hex + + if expect_answer: + fut = self._response_handler[msgid] = asyncio.Future() + + message = { + 'msgid': msgid, + 'handler': handler, + 'payload': payload, + } + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(message)) + await self.client.send_json(message) + + if expect_answer: + return await fut + + @asyncio.coroutine + def _handle_connection(self): + """Connect to the IoT broker.""" + hass = self.cloud.hass + + try: + yield from hass.async_add_job(auth_api.check_token, self.cloud) + except auth_api.Unauthenticated as err: + _LOGGER.error('Unable to refresh token: %s', err) + + hass.components.persistent_notification.async_create( + MESSAGE_AUTH_FAIL, 'Home Assistant Cloud', + 'cloud_subscription_expired') + + # Don't await it because it will cancel this task + hass.async_create_task(self.cloud.logout()) + return + except auth_api.CloudError as err: + _LOGGER.warning("Unable to refresh token: %s", err) + return + + if self.cloud.subscription_expired: + hass.components.persistent_notification.async_create( + MESSAGE_EXPIRATION, 'Home Assistant Cloud', + 'cloud_subscription_expired') + self.close_requested = True + return + + session = async_get_clientsession(self.cloud.hass) + client = None + disconnect_warn = None + + try: + self.client = client = yield from session.ws_connect( + self.cloud.relayer, heartbeat=55, headers={ + hdrs.AUTHORIZATION: + 'Bearer {}'.format(self.cloud.id_token) + }) + self.tries = 0 + + _LOGGER.info("Connected") + self.state = STATE_CONNECTED + + if self._on_connect: + yield from asyncio.wait([cb() for cb in self._on_connect]) + + while not client.closed: + msg = yield from client.receive() + + if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): + break + + elif msg.type == WSMsgType.ERROR: + disconnect_warn = 'Connection error' + break + + elif msg.type != WSMsgType.TEXT: + disconnect_warn = 'Received non-Text message: {}'.format( + msg.type) + break + + try: + msg = msg.json() + except ValueError: + disconnect_warn = 'Received invalid JSON.' + break + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received message:\n%s\n", + pprint.pformat(msg)) + + response_handler = self._response_handler.pop(msg['msgid'], + None) + + if response_handler is not None: + if 'payload' in msg: + response_handler.set_result(msg["payload"]) + else: + response_handler.set_exception( + ErrorMessage(msg['error'])) + continue + + response = { + 'msgid': msg['msgid'], + } + try: + result = yield from async_handle_message( + hass, self.cloud, msg['handler'], msg['payload']) + + # No response from handler + if result is None: + continue + + response['payload'] = result + + except UnknownHandler: + response['error'] = 'unknown-handler' + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error handling message") + response['error'] = 'exception' + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(response)) + yield from client.send_json(response) + + except client_exceptions.WSServerHandshakeError as err: + if err.status == 401: + disconnect_warn = 'Invalid auth.' + self.close_requested = True + # Should we notify user? + else: + _LOGGER.warning("Unable to connect: %s", err) + + except client_exceptions.ClientError as err: + _LOGGER.warning("Unable to connect: %s", err) + + finally: + if disconnect_warn is None: + _LOGGER.info("Connection closed") + else: + _LOGGER.warning("Connection closed: %s", disconnect_warn) + + @asyncio.coroutine + def disconnect(self): + """Disconnect the client.""" + self.close_requested = True + + if self.client is not None: + yield from self.client.close() + elif self.retry_task is not None: + self.retry_task.cancel() + + +@asyncio.coroutine +def async_handle_message(hass, cloud, handler_name, payload): + """Handle incoming IoT message.""" + handler = HANDLERS.get(handler_name) + + if handler is None: + raise UnknownHandler() + + return (yield from handler(hass, cloud, payload)) + + +@HANDLERS.register('alexa') +@asyncio.coroutine +def async_handle_alexa(hass, cloud, payload): + """Handle an incoming IoT message for Alexa.""" + result = yield from alexa.async_handle_message( + hass, cloud.alexa_config, payload, + enabled=cloud.prefs.alexa_enabled) + return result + + +@HANDLERS.register('google_actions') +@asyncio.coroutine +def async_handle_google_actions(hass, cloud, payload): + """Handle an incoming IoT message for Google Actions.""" + if not cloud.prefs.google_enabled: + return ga.turned_off_response(payload) + + result = yield from ga.async_handle_message( + hass, cloud.gactions_config, payload) + return result + + +@HANDLERS.register('cloud') +async def async_handle_cloud(hass, cloud, payload): + """Handle an incoming IoT message for cloud component.""" + action = payload['action'] + + if action == 'logout': + # Log out of Home Assistant Cloud + await cloud.logout() + _LOGGER.error("You have been logged out from Home Assistant cloud: %s", + payload['reason']) + elif action == 'refresh_auth': + # Refresh the auth token between now and payload['seconds'] + hass.helpers.event.async_call_later( + random.randint(0, payload['seconds']), + lambda now: auth_api.check_token(cloud)) + else: + _LOGGER.warning("Received unknown cloud action: %s", action) + + +@HANDLERS.register('webhook') +async def async_handle_webhook(hass, cloud, payload): + """Handle an incoming IoT message for cloud webhooks.""" + cloudhook_id = payload['cloudhook_id'] + + found = None + for cloudhook in cloud.prefs.cloudhooks.values(): + if cloudhook['cloudhook_id'] == cloudhook_id: + found = cloudhook + break + + if found is None: + return { + 'status': 200 + } + + request = MockRequest( + content=payload['body'].encode('utf-8'), + headers=payload['headers'], + method=payload['method'], + query_string=payload['query'], + ) + + response = await hass.components.webhook.async_handle_webhook( + found['webhook_id'], request) + + response_dict = utils.aiohttp_serialize_response(response) + body = response_dict.get('body') + + return { + 'body': body, + 'status': response_dict['status'], + 'headers': { + 'Content-Type': response.content_type + } + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/prefs.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/prefs.py new file mode 100644 index 00000000000000..32362df2fa98f8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/prefs.py @@ -0,0 +1,70 @@ +"""Preference management for cloud.""" +from .const import ( + DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CloudPreferences: + """Handle cloud preferences.""" + + def __init__(self, hass): + """Initialize cloud prefs.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + prefs = { + PREF_ENABLE_ALEXA: True, + PREF_ENABLE_GOOGLE: True, + PREF_GOOGLE_ALLOW_UNLOCK: False, + PREF_CLOUDHOOKS: {} + } + + self._prefs = prefs + + async def async_update(self, *, google_enabled=_UNDEF, + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF, + cloudhooks=_UNDEF): + """Update user preferences.""" + for key, value in ( + (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + (PREF_CLOUDHOOKS, cloudhooks), + ): + if value is not _UNDEF: + self._prefs[key] = value + + await self._store.async_save(self._prefs) + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[PREF_ENABLE_ALEXA] + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[PREF_ENABLE_GOOGLE] + + @property + def google_allow_unlock(self): + """Return if Google is allowed to unlock locks.""" + return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) + + @property + def cloudhooks(self): + """Return the published cloud webhooks.""" + return self._prefs.get(PREF_CLOUDHOOKS, {}) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloud/utils.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/utils.py new file mode 100644 index 00000000000000..da1d3809989e75 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloud/utils.py @@ -0,0 +1,13 @@ +"""Helper functions for cloud components.""" +from typing import Any, Dict + +from aiohttp import web + + +def aiohttp_serialize_response(response: web.Response) -> Dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + return { + 'status': response.status, + 'body': response.text, + 'headers': dict(response.headers), + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cloudflare.py b/homeassistant-0.88.0.dev0/homeassistant/components/cloudflare.py new file mode 100644 index 00000000000000..ae400ca638569d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cloudflare.py @@ -0,0 +1,77 @@ +""" +Update the IP addresses of your Cloudflare DNS records. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cloudflare/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['pycfdns==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RECORDS = 'records' + +DOMAIN = 'cloudflare' + +INTERVAL = timedelta(minutes=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Cloudflare component.""" + from pycfdns import CloudflareUpdater + + cfupdate = CloudflareUpdater() + email = config[DOMAIN][CONF_EMAIL] + key = config[DOMAIN][CONF_API_KEY] + zone = config[DOMAIN][CONF_ZONE] + records = config[DOMAIN][CONF_RECORDS] + + def update_records_interval(now): + """Set up recurring update.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + track_time_interval(hass, update_records_interval, INTERVAL) + hass.services.register( + DOMAIN, 'update_records', update_records_service) + return True + + +def _update_cloudflare(cfupdate, email, key, zone, records): + """Update DNS records for a given zone.""" + _LOGGER.debug("Starting update for zone %s", zone) + + headers = cfupdate.set_header(email, key) + _LOGGER.debug("Header data defined as: %s", headers) + + zoneid = cfupdate.get_zoneID(headers, zone) + _LOGGER.debug("Zone ID is set to: %s", zoneid) + + update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records) + _LOGGER.debug("Records: %s", update_records) + + result = cfupdate.update_records(headers, zoneid, update_records) + _LOGGER.debug("Update for zone %s is complete", zone) + + if result is not True: + _LOGGER.warning(result) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/coinbase.py b/homeassistant-0.88.0.dev0/homeassistant/components/coinbase.py new file mode 100644 index 00000000000000..98c321b9f5a436 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/coinbase.py @@ -0,0 +1,100 @@ +""" +Support for Coinbase. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/coinbase/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import Throttle + +REQUIREMENTS = ['coinbase==2.1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'coinbase' + +CONF_API_SECRET = 'api_secret' +CONF_ACCOUNT_CURRENCIES = 'account_balance_currencies' +CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +DATA_COINBASE = 'coinbase_cache' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_SECRET): cv.string, + vol.Optional(CONF_ACCOUNT_CURRENCIES): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Coinbase component. + + Will automatically setup sensors to support + wallets discovered on the network. + """ + api_key = config[DOMAIN].get(CONF_API_KEY) + api_secret = config[DOMAIN].get(CONF_API_SECRET) + account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) + exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES) + + hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData( + api_key, api_secret) + + if not hasattr(coinbase_data, 'accounts'): + return False + for account in coinbase_data.accounts.data: + if (account_currencies is None or + account.currency in account_currencies): + load_platform(hass, + 'sensor', + DOMAIN, + {'account': account}, + config) + for currency in exchange_currencies: + if currency not in coinbase_data.exchange_rates.rates: + _LOGGER.warning("Currency %s not found", currency) + continue + native = coinbase_data.exchange_rates.currency + load_platform(hass, + 'sensor', + DOMAIN, + {'native_currency': native, + 'exchange_currency': currency}, + config) + + return True + + +class CoinbaseData: + """Get the latest data and update the states.""" + + def __init__(self, api_key, api_secret): + """Init the coinbase data object.""" + from coinbase.wallet.client import Client + self.client = Client(api_key, api_secret) + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from coinbase.""" + from coinbase.wallet.error import AuthenticationError + try: + self.accounts = self.client.get_accounts() + self.exchange_rates = self.client.get_exchange_rates() + except AuthenticationError as coinbase_error: + _LOGGER.error("Authentication error connecting" + " to coinbase: %s", coinbase_error) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/__init__.py new file mode 100644 index 00000000000000..69d88274f2965b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/__init__.py @@ -0,0 +1,134 @@ +""" +Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/comfoconnect/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PIN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send + +REQUIREMENTS = ['pycomfoconnect==0.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'comfoconnect' + +SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = 'comfoconnect_update_received' + +ATTR_CURRENT_TEMPERATURE = 'current_temperature' +ATTR_CURRENT_HUMIDITY = 'current_humidity' +ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' +ATTR_OUTSIDE_HUMIDITY = 'outside_humidity' +ATTR_AIR_FLOW_SUPPLY = 'air_flow_supply' +ATTR_AIR_FLOW_EXHAUST = 'air_flow_exhaust' + +CONF_USER_AGENT = 'user_agent' + +DEFAULT_NAME = 'ComfoAirQ' +DEFAULT_PIN = 0 +DEFAULT_TOKEN = '00000000000000000000000000000001' +DEFAULT_USER_AGENT = 'Home Assistant' + +DEVICE = None + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): + vol.Length(min=32, max=32, msg='invalid token'), + vol.Optional(CONF_USER_AGENT, default=DEFAULT_USER_AGENT): cv.string, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the ComfoConnect bridge.""" + from pycomfoconnect import (Bridge) + + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + name = conf.get(CONF_NAME) + token = conf.get(CONF_TOKEN) + user_agent = conf.get(CONF_USER_AGENT) + pin = conf.get(CONF_PIN) + + # Run discovery on the configured ip + bridges = Bridge.discover(host) + if not bridges: + _LOGGER.error("Could not connect to ComfoConnect bridge on %s", host) + return False + bridge = bridges[0] + _LOGGER.info("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host) + + # Setup ComfoConnect Bridge + ccb = ComfoConnectBridge(hass, bridge, name, token, user_agent, pin) + hass.data[DOMAIN] = ccb + + # Start connection with bridge + ccb.connect() + + # Schedule disconnect on shutdown + def _shutdown(_event): + ccb.disconnect() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + # Load platforms + discovery.load_platform(hass, 'fan', DOMAIN, {}, config) + + return True + + +class ComfoConnectBridge: + """Representation of a ComfoConnect bridge.""" + + def __init__(self, hass, bridge, name, token, friendly_name, pin): + """Initialize the ComfoConnect bridge.""" + from pycomfoconnect import (ComfoConnect) + + self.data = {} + self.name = name + self.hass = hass + + self.comfoconnect = ComfoConnect( + bridge=bridge, local_uuid=bytes.fromhex(token), + local_devicename=friendly_name, pin=pin) + self.comfoconnect.callback_sensor = self.sensor_callback + + def connect(self): + """Connect with the bridge.""" + _LOGGER.debug("Connecting with bridge") + self.comfoconnect.connect(True) + + def disconnect(self): + """Disconnect from the bridge.""" + _LOGGER.debug("Disconnecting from bridge") + self.comfoconnect.disconnect() + + def sensor_callback(self, var, value): + """Call function for sensor updates.""" + _LOGGER.debug("Got value from bridge: %d = %d", var, value) + + from pycomfoconnect import ( + SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR) + + if var in [SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR]: + self.data[var] = value / 10 + else: + self.data[var] = value + + # Notify listeners that we have received an update + dispatcher_send(self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, var) + + def subscribe_sensor(self, sensor_id): + """Subscribe for the specified sensor.""" + self.comfoconnect.register_sensor(sensor_id) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/fan.py b/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/fan.py new file mode 100644 index 00000000000000..0e0ac8c80b64bf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/fan.py @@ -0,0 +1,116 @@ +""" +Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.comfoconnect/ +""" +import logging + +from homeassistant.components.comfoconnect import ( + DOMAIN, ComfoConnectBridge, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED) +from homeassistant.components.fan import ( + FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + SUPPORT_SET_SPEED) +from homeassistant.helpers.dispatcher import (dispatcher_connect) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['comfoconnect'] + +SPEED_MAPPING = { + 0: SPEED_OFF, + 1: SPEED_LOW, + 2: SPEED_MEDIUM, + 3: SPEED_HIGH +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ComfoConnect fan platform.""" + ccb = hass.data[DOMAIN] + + add_entities([ComfoConnectFan(hass, name=ccb.name, ccb=ccb)], True) + + +class ComfoConnectFan(FanEntity): + """Representation of the ComfoConnect fan platform.""" + + def __init__(self, hass, name, ccb: ComfoConnectBridge) -> None: + """Initialize the ComfoConnect fan.""" + from pycomfoconnect import SENSOR_FAN_SPEED_MODE + + self._ccb = ccb + self._name = name + + # Ask the bridge to keep us updated + self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE) + + def _handle_update(var): + if var == SENSOR_FAN_SPEED_MODE: + _LOGGER.debug("Dispatcher update for %s", var) + self.schedule_update_ha_state() + + # Register for dispatcher updates + dispatcher_connect( + hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update) + + @property + def name(self): + """Return the name of the fan.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return 'mdi:air-conditioner' + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def speed(self): + """Return the current fan mode.""" + from pycomfoconnect import (SENSOR_FAN_SPEED_MODE) + + try: + speed = self._ccb.data[SENSOR_FAN_SPEED_MODE] + return SPEED_MAPPING[speed] + except KeyError: + return None + + @property + def speed_list(self): + """List of available fan modes.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + def turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the fan.""" + if speed is None: + speed = SPEED_LOW + self.set_speed(speed) + + def turn_off(self, **kwargs) -> None: + """Turn off the fan (to away).""" + self.set_speed(SPEED_OFF) + + def set_speed(self, speed: str): + """Set fan speed.""" + _LOGGER.debug('Changing fan speed to %s.', speed) + + from pycomfoconnect import ( + CMD_FAN_MODE_AWAY, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM, + CMD_FAN_MODE_HIGH) + + if speed == SPEED_OFF: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) + elif speed == SPEED_LOW: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) + elif speed == SPEED_MEDIUM: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) + elif speed == SPEED_HIGH: + self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) + + # Update current mode + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/sensor.py new file mode 100644 index 00000000000000..9ae6a4de091457 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/comfoconnect/sensor.py @@ -0,0 +1,139 @@ +""" +Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.comfoconnect/ +""" +import logging + +from homeassistant.components.comfoconnect import ( + DOMAIN, ComfoConnectBridge, ATTR_CURRENT_TEMPERATURE, + ATTR_CURRENT_HUMIDITY, ATTR_OUTSIDE_TEMPERATURE, + ATTR_OUTSIDE_HUMIDITY, ATTR_AIR_FLOW_SUPPLY, + ATTR_AIR_FLOW_EXHAUST, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED) +from homeassistant.const import CONF_RESOURCES, TEMP_CELSIUS +from homeassistant.helpers.dispatcher import dispatcher_connect +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['comfoconnect'] + +SENSOR_TYPES = {} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the ComfoConnect fan platform.""" + from pycomfoconnect import ( + SENSOR_TEMPERATURE_EXTRACT, SENSOR_HUMIDITY_EXTRACT, + SENSOR_TEMPERATURE_OUTDOOR, SENSOR_HUMIDITY_OUTDOOR, + SENSOR_FAN_SUPPLY_FLOW, SENSOR_FAN_EXHAUST_FLOW) + + global SENSOR_TYPES + SENSOR_TYPES = { + ATTR_CURRENT_TEMPERATURE: [ + 'Inside Temperature', + TEMP_CELSIUS, + 'mdi:thermometer', + SENSOR_TEMPERATURE_EXTRACT + ], + ATTR_CURRENT_HUMIDITY: [ + 'Inside Humidity', + '%', + 'mdi:water-percent', + SENSOR_HUMIDITY_EXTRACT + ], + ATTR_OUTSIDE_TEMPERATURE: [ + 'Outside Temperature', + TEMP_CELSIUS, + 'mdi:thermometer', + SENSOR_TEMPERATURE_OUTDOOR + ], + ATTR_OUTSIDE_HUMIDITY: [ + 'Outside Humidity', + '%', + 'mdi:water-percent', + SENSOR_HUMIDITY_OUTDOOR + ], + ATTR_AIR_FLOW_SUPPLY: [ + 'Supply airflow', + 'm³/h', + 'mdi:air-conditioner', + SENSOR_FAN_SUPPLY_FLOW + ], + ATTR_AIR_FLOW_EXHAUST: [ + 'Exhaust airflow', + 'm³/h', + 'mdi:air-conditioner', + SENSOR_FAN_EXHAUST_FLOW + ], + } + + ccb = hass.data[DOMAIN] + + sensors = [] + for resource in config[CONF_RESOURCES]: + sensor_type = resource.lower() + + if sensor_type not in SENSOR_TYPES: + _LOGGER.warning("Sensor type: %s is not a valid sensor.", + sensor_type) + continue + + sensors.append( + ComfoConnectSensor( + hass, + name="%s %s" % (ccb.name, SENSOR_TYPES[sensor_type][0]), + ccb=ccb, + sensor_type=sensor_type + ) + ) + + add_entities(sensors, True) + + +class ComfoConnectSensor(Entity): + """Representation of a ComfoConnect sensor.""" + + def __init__(self, hass, name, ccb: ComfoConnectBridge, + sensor_type) -> None: + """Initialize the ComfoConnect sensor.""" + self._ccb = ccb + self._sensor_type = sensor_type + self._sensor_id = SENSOR_TYPES[self._sensor_type][3] + self._name = name + + # Register the requested sensor + self._ccb.comfoconnect.register_sensor(self._sensor_id) + + def _handle_update(var): + if var == self._sensor_id: + _LOGGER.debug('Dispatcher update for %s.', var) + self.schedule_update_ha_state() + + # Register for dispatcher updates + dispatcher_connect( + hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update) + + @property + def state(self): + """Return the state of the entity.""" + try: + return self._ccb.data[self._sensor_id] + except KeyError: + return None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return SENSOR_TYPES[self._sensor_type][2] + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self._sensor_type][1] diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/__init__.py new file mode 100644 index 00000000000000..65a4d50be84c23 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/__init__.py @@ -0,0 +1,206 @@ +"""Component to configure Home Assistant via an API.""" +import asyncio +import os + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID +from homeassistant.setup import ( + async_prepare_setup_platform, ATTR_COMPONENT) +from homeassistant.components.http import HomeAssistantView +from homeassistant.util.yaml import load_yaml, dump + +DOMAIN = 'config' +DEPENDENCIES = ['http'] +SECTIONS = ( + 'area_registry', + 'auth', + 'auth_provider_homeassistant', + 'automation', + 'config_entries', + 'core', + 'customize', + 'device_registry', + 'entity_registry', + 'group', + 'hassbian', + 'script', +) +ON_DEMAND = ('zwave',) + + +async def async_setup(hass, config): + """Set up the config component.""" + await hass.components.frontend.async_register_built_in_panel( + 'config', 'config', 'hass:settings') + + async def setup_panel(panel_name): + """Set up a panel.""" + panel = await async_prepare_setup_platform( + hass, config, DOMAIN, panel_name) + + if not panel: + return + + success = await panel.async_setup(hass) + + if success: + key = '{}.{}'.format(DOMAIN, panel_name) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) + hass.config.components.add(key) + + @callback + def component_loaded(event): + """Respond to components being loaded.""" + panel_name = event.data.get(ATTR_COMPONENT) + if panel_name in ON_DEMAND: + hass.async_create_task(setup_panel(panel_name)) + + hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + + tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + + for panel_name in ON_DEMAND: + if panel_name in hass.config.components: + tasks.append(setup_panel(panel_name)) + + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + return True + + +class BaseEditConfigView(HomeAssistantView): + """Configure a Group endpoint.""" + + def __init__(self, component, config_type, path, key_schema, data_schema, + *, post_write_hook=None): + """Initialize a config view.""" + self.url = '/api/config/%s/%s/{config_key}' % (component, config_type) + self.name = 'api:config:%s:%s' % (component, config_type) + self.path = path + self.key_schema = key_schema + self.data_schema = data_schema + self.post_write_hook = post_write_hook + + def _empty_config(self): + """Empty config if file not found.""" + raise NotImplementedError + + def _get_value(self, hass, data, config_key): + """Get value.""" + raise NotImplementedError + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + raise NotImplementedError + + async def get(self, request, config_key): + """Fetch device specific config.""" + hass = request.app['hass'] + current = await self.read_config(hass) + value = self._get_value(hass, current, config_key) + + if value is None: + return self.json_message('Resource not found', 404) + + return self.json(value) + + async def post(self, request, config_key): + """Validate config and return results.""" + try: + data = await request.json() + except ValueError: + return self.json_message('Invalid JSON specified', 400) + + try: + self.key_schema(config_key) + except vol.Invalid as err: + return self.json_message('Key malformed: {}'.format(err), 400) + + try: + # We just validate, we don't store that data because + # we don't want to store the defaults. + self.data_schema(data) + except vol.Invalid as err: + return self.json_message('Message malformed: {}'.format(err), 400) + + hass = request.app['hass'] + path = hass.config.path(self.path) + + current = await self.read_config(hass) + self._write_value(hass, current, config_key, data) + + await hass.async_add_job(_write, path, current) + + if self.post_write_hook is not None: + hass.async_create_task(self.post_write_hook(hass)) + + return self.json({ + 'result': 'ok', + }) + + async def read_config(self, hass): + """Read the config.""" + current = await hass.async_add_job( + _read, hass.config.path(self.path)) + if not current: + current = self._empty_config() + return current + + +class EditKeyBasedConfigView(BaseEditConfigView): + """Configure a list of entries.""" + + def _empty_config(self): + """Return an empty config.""" + return {} + + def _get_value(self, hass, data, config_key): + """Get value.""" + return data.get(config_key) + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data.setdefault(config_key, {}).update(new_value) + + +class EditIdBasedConfigView(BaseEditConfigView): + """Configure key based config entries.""" + + def _empty_config(self): + """Return an empty config.""" + return [] + + def _get_value(self, hass, data, config_key): + """Get value.""" + return next( + (val for val in data if val.get(CONF_ID) == config_key), None) + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + value = self._get_value(hass, data, config_key) + + if value is None: + value = {CONF_ID: config_key} + data.append(value) + + value.update(new_value) + + +def _read(path): + """Read YAML helper.""" + if not os.path.isfile(path): + return None + + return load_yaml(path) + + +def _write(path, data): + """Write YAML helper.""" + # Do it before opening file. If dump causes error it will now not + # truncate the file. + data = dump(data) + with open(path, 'w', encoding='utf-8') as outfile: + outfile.write(data) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/area_registry.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/area_registry.py new file mode 100644 index 00000000000000..7f1bb93822877d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/area_registry.py @@ -0,0 +1,126 @@ +"""HTTP views to interact with the area registry.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.decorators import ( + async_response, require_admin) +from homeassistant.core import callback +from homeassistant.helpers.area_registry import async_get_registry + + +DEPENDENCIES = ['websocket_api'] + +WS_TYPE_LIST = 'config/area_registry/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_CREATE = 'config/area_registry/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('name'): str, +}) + +WS_TYPE_DELETE = 'config/area_registry/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('area_id'): str, +}) + +WS_TYPE_UPDATE = 'config/area_registry/update' +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE, + vol.Required('area_id'): str, + vol.Required('name'): str, +}) + + +async def async_setup(hass): + """Enable the Area Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_areas, SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create_area, SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete_area, SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_area, SCHEMA_WS_UPDATE + ) + return True + + +@async_response +async def websocket_list_areas(hass, connection, msg): + """Handle list areas command.""" + registry = await async_get_registry(hass) + connection.send_message(websocket_api.result_message( + msg['id'], [{ + 'name': entry.name, + 'area_id': entry.id, + } for entry in registry.async_list_areas()] + )) + + +@require_admin +@async_response +async def websocket_create_area(hass, connection, msg): + """Create area command.""" + registry = await async_get_registry(hass) + try: + entry = registry.async_create(msg['name']) + except ValueError as err: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', str(err) + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@require_admin +@async_response +async def websocket_delete_area(hass, connection, msg): + """Delete area command.""" + registry = await async_get_registry(hass) + + try: + await registry.async_delete(msg['area_id']) + except KeyError: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', "Area ID doesn't exist" + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], 'success' + )) + + +@require_admin +@async_response +async def websocket_update_area(hass, connection, msg): + """Handle update area websocket command.""" + registry = await async_get_registry(hass) + + try: + entry = registry.async_update(msg['area_id'], msg['name']) + except ValueError as err: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', str(err) + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@callback +def _entry_dict(entry): + """Convert entry to API format.""" + return { + 'area_id': entry.id, + 'name': entry.name + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/auth.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/auth.py new file mode 100644 index 00000000000000..625dbefbbb3e2f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/auth.py @@ -0,0 +1,101 @@ +"""Offer API to configure Home Assistant auth.""" +import voluptuous as vol + +from homeassistant.components import websocket_api + + +WS_TYPE_LIST = 'config/auth/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_DELETE = 'config/auth/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('user_id'): str, +}) + +WS_TYPE_CREATE = 'config/auth/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('name'): str, +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list, + SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + return True + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_list(hass, connection, msg): + """Return a list of users.""" + result = [_user_info(u) for u in await hass.auth.async_get_users()] + + connection.send_message( + websocket_api.result_message(msg['id'], result)) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete(hass, connection, msg): + """Delete a user.""" + if msg['user_id'] == connection.user.id: + connection.send_message(websocket_api.error_message( + msg['id'], 'no_delete_self', + 'Unable to delete your own account')) + return + + user = await hass.auth.async_get_user(msg['user_id']) + + if not user: + connection.send_message(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + await hass.auth.async_remove_user(user) + + connection.send_message( + websocket_api.result_message(msg['id'])) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create(hass, connection, msg): + """Create a user.""" + user = await hass.auth.async_create_user(msg['name']) + + connection.send_message( + websocket_api.result_message(msg['id'], { + 'user': _user_info(user) + })) + + +def _user_info(user): + """Format a user.""" + return { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'system_generated': user.system_generated, + 'group_ids': [group.id for group in user.groups], + 'credentials': [ + { + 'type': c.auth_provider_type, + } for c in user.credentials + ] + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/auth_provider_homeassistant.py new file mode 100644 index 00000000000000..5455277aa7886b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/auth_provider_homeassistant.py @@ -0,0 +1,162 @@ +"""Offer API to configure the Home Assistant auth provider.""" +import voluptuous as vol + +from homeassistant.auth.providers import homeassistant as auth_ha +from homeassistant.components import websocket_api + + +WS_TYPE_CREATE = 'config/auth_provider/homeassistant/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('user_id'): str, + vol.Required('username'): str, + vol.Required('password'): str, +}) + +WS_TYPE_DELETE = 'config/auth_provider/homeassistant/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('username'): str, +}) + +WS_TYPE_CHANGE_PASSWORD = 'config/auth_provider/homeassistant/change_password' +SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CHANGE_PASSWORD, + vol.Required('current_password'): str, + vol.Required('new_password'): str +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CHANGE_PASSWORD, websocket_change_password, + SCHEMA_WS_CHANGE_PASSWORD + ) + return True + + +def _get_provider(hass): + """Get homeassistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('Provider not found') + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create(hass, connection, msg): + """Create credentials and attach to a user.""" + provider = _get_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_get_user(msg['user_id']) + + if user is None: + connection.send_message(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + if user.system_generated: + connection.send_message(websocket_api.error_message( + msg['id'], 'system_generated', + 'Cannot add credentials to a system generated user.')) + return + + try: + await hass.async_add_executor_job( + provider.data.add_auth, msg['username'], msg['password']) + except auth_ha.InvalidUser: + connection.send_message(websocket_api.error_message( + msg['id'], 'username_exists', 'Username already exists')) + return + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + await hass.auth.async_link_user(user, credentials) + + await provider.data.async_save() + connection.send_message(websocket_api.result_message(msg['id'])) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete(hass, connection, msg): + """Delete username and related credential.""" + provider = _get_provider(hass) + await provider.async_initialize() + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + + # if not new, an existing credential exists. + # Removing the credential will also remove the auth. + if not credentials.is_new: + await hass.auth.async_remove_credentials(credentials) + + connection.send_message( + websocket_api.result_message(msg['id'])) + return + + try: + provider.data.async_remove_auth(msg['username']) + await provider.data.async_save() + except auth_ha.InvalidUser: + connection.send_message(websocket_api.error_message( + msg['id'], 'auth_not_found', 'Given username was not found.')) + return + + connection.send_message( + websocket_api.result_message(msg['id'])) + + +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_change_password(hass, connection, msg): + """Change user password.""" + user = connection.user + if user is None: + connection.send_message(websocket_api.error_message( + msg['id'], 'user_not_found', 'User not found')) + return + + provider = _get_provider(hass) + await provider.async_initialize() + + username = None + for credential in user.credentials: + if credential.auth_provider_type == provider.type: + username = credential.data['username'] + break + + if username is None: + connection.send_message(websocket_api.error_message( + msg['id'], 'credentials_not_found', 'Credentials not found')) + return + + try: + await provider.async_validate_login( + username, msg['current_password']) + except auth_ha.InvalidAuth: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_password', 'Invalid password')) + return + + await hass.async_add_executor_job( + provider.data.change_password, username, msg['new_password']) + await provider.data.async_save() + + connection.send_message( + websocket_api.result_message(msg['id'])) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/automation.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/automation.py new file mode 100644 index 00000000000000..7836cba6cf9dac --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/automation.py @@ -0,0 +1,59 @@ +"""Provide configuration end points for Automations.""" +from collections import OrderedDict +import uuid + +from homeassistant.components.config import EditIdBasedConfigView +from homeassistant.const import CONF_ID, SERVICE_RELOAD +from homeassistant.components.automation import DOMAIN, PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv + + +CONFIG_PATH = 'automations.yaml' + + +async def async_setup(hass): + """Set up the Automation config API.""" + async def hook(hass): + """post_write_hook for Config View that reloads automations.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view(EditAutomationConfigView( + DOMAIN, 'config', CONFIG_PATH, cv.string, + PLATFORM_SCHEMA, post_write_hook=hook + )) + return True + + +class EditAutomationConfigView(EditIdBasedConfigView): + """Edit automation config.""" + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + index = None + for index, cur_value in enumerate(data): + # When people copy paste their automations to the config file, + # they sometimes forget to add IDs. Fix it here. + if CONF_ID not in cur_value: + cur_value[CONF_ID] = uuid.uuid4().hex + + elif cur_value[CONF_ID] == config_key: + break + else: + cur_value = OrderedDict() + cur_value[CONF_ID] = config_key + index = len(data) + data.append(cur_value) + + # Iterate through some keys that we want to have ordered in the output + updated_value = OrderedDict() + for key in ('id', 'alias', 'trigger', 'condition', 'action'): + if key in cur_value: + updated_value[key] = cur_value[key] + if key in new_value: + updated_value[key] = new_value[key] + + # We cover all current fields above, but just in case we start + # supporting more fields in the future. + updated_value.update(cur_value) + updated_value.update(new_value) + data[index] = updated_value diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/config_entries.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/config_entries.py new file mode 100644 index 00000000000000..68890a79ca653f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/config_entries.py @@ -0,0 +1,147 @@ +"""Http views to control the config manager.""" + +from homeassistant import config_entries, data_entry_flow +from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES +from homeassistant.components.http import HomeAssistantView +from homeassistant.exceptions import Unauthorized +from homeassistant.helpers.data_entry_flow import ( + FlowManagerIndexView, FlowManagerResourceView) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.http.register_view(ConfigManagerEntryIndexView) + hass.http.register_view(ConfigManagerEntryResourceView) + hass.http.register_view( + ConfigManagerFlowIndexView(hass.config_entries.flow)) + hass.http.register_view( + ConfigManagerFlowResourceView(hass.config_entries.flow)) + hass.http.register_view(ConfigManagerAvailableFlowView) + return True + + +def _prepare_json(result): + """Convert result for JSON.""" + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class ConfigManagerEntryIndexView(HomeAssistantView): + """View to get available config entries.""" + + url = '/api/config/config_entries/entry' + name = 'api:config:config_entries:entry' + + async def get(self, request): + """List flows in progress.""" + hass = request.app['hass'] + return self.json([{ + 'entry_id': entry.entry_id, + 'domain': entry.domain, + 'title': entry.title, + 'source': entry.source, + 'state': entry.state, + 'connection_class': entry.connection_class, + } for entry in hass.config_entries.async_entries()]) + + +class ConfigManagerEntryResourceView(HomeAssistantView): + """View to interact with a config entry.""" + + url = '/api/config/config_entries/entry/{entry_id}' + name = 'api:config:config_entries:entry:resource' + + async def delete(self, request, entry_id): + """Delete a config entry.""" + if not request['hass_user'].is_admin: + raise Unauthorized(config_entry_id=entry_id, permission='remove') + + hass = request.app['hass'] + + try: + result = await hass.config_entries.async_remove(entry_id) + except config_entries.UnknownEntry: + return self.json_message('Invalid entry specified', 404) + + return self.json(result) + + +class ConfigManagerFlowIndexView(FlowManagerIndexView): + """View to create config flows.""" + + url = '/api/config/config_entries/flow' + name = 'api:config:config_entries:flow' + + async def get(self, request): + """List flows that are in progress but not started by a user. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + hass = request.app['hass'] + + return self.json([ + flw for flw in hass.config_entries.flow.async_progress() + if flw['context']['source'] != config_entries.SOURCE_USER]) + + # pylint: disable=arguments-differ + async def post(self, request): + """Handle a POST request.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + # pylint: disable=no-value-for-parameter + return await super().post(request) + + +class ConfigManagerFlowResourceView(FlowManagerResourceView): + """View to interact with the flow manager.""" + + url = '/api/config/config_entries/flow/{flow_id}' + name = 'api:config:config_entries:flow:resource' + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + return await super().get(request, flow_id) + + # pylint: disable=arguments-differ + async def post(self, request, flow_id): + """Handle a POST request.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + # pylint: disable=no-value-for-parameter + return await super().post(request, flow_id) + + +class ConfigManagerAvailableFlowView(HomeAssistantView): + """View to query available flows.""" + + url = '/api/config/config_entries/flow_handlers' + name = 'api:config:config_entries:flow_handlers' + + async def get(self, request): + """List available flow handlers.""" + return self.json(config_entries.FLOWS) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/core.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/core.py new file mode 100644 index 00000000000000..ce7675c41f47bf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/core.py @@ -0,0 +1,28 @@ +"""Component to interact with Hassbian tools.""" + +from homeassistant.components.http import HomeAssistantView +from homeassistant.config import async_check_ha_config_file + + +async def async_setup(hass): + """Set up the Hassbian config.""" + hass.http.register_view(CheckConfigView) + return True + + +class CheckConfigView(HomeAssistantView): + """Hassbian packages endpoint.""" + + url = '/api/config/core/check_config' + name = 'api:config:core:check_config' + + async def post(self, request): + """Validate configuration and return results.""" + errors = await async_check_ha_config_file(request.app['hass']) + + state = 'invalid' if errors else 'valid' + + return self.json({ + "result": state, + "errors": errors, + }) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/customize.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/customize.py new file mode 100644 index 00000000000000..b7a8c9c070ab6f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/customize.py @@ -0,0 +1,42 @@ +"""Provide configuration end points for Customize.""" + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components import SERVICE_RELOAD_CORE_CONFIG +from homeassistant.config import DATA_CUSTOMIZE +from homeassistant.core import DOMAIN + +import homeassistant.helpers.config_validation as cv + +CONFIG_PATH = 'customize.yaml' + + +async def async_setup(hass): + """Set up the Customize config API.""" + async def hook(hass): + """post_write_hook for Config View that reloads groups.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD_CORE_CONFIG) + + hass.http.register_view(CustomizeConfigView( + 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, + post_write_hook=hook + )) + + return True + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + def _get_value(self, hass, data, config_key): + """Get value.""" + customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} + return {'global': customize, 'local': data.get(config_key, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/device_registry.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/device_registry.py new file mode 100644 index 00000000000000..0677531242a987 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/device_registry.py @@ -0,0 +1,73 @@ +"""HTTP views to interact with the device registry.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.decorators import ( + async_response, require_admin) +from homeassistant.core import callback +from homeassistant.helpers.device_registry import async_get_registry + +DEPENDENCIES = ['websocket_api'] + +WS_TYPE_LIST = 'config/device_registry/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_UPDATE = 'config/device_registry/update' +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE, + vol.Required('device_id'): str, + vol.Optional('area_id'): vol.Any(str, None), +}) + + +async def async_setup(hass): + """Enable the Device Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_devices, + SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_device, SCHEMA_WS_UPDATE + ) + return True + + +@async_response +async def websocket_list_devices(hass, connection, msg): + """Handle list devices command.""" + registry = await async_get_registry(hass) + connection.send_message(websocket_api.result_message( + msg['id'], [_entry_dict(entry) for entry in registry.devices.values()] + )) + + +@require_admin +@async_response +async def websocket_update_device(hass, connection, msg): + """Handle update area websocket command.""" + registry = await async_get_registry(hass) + + entry = registry.async_update_device( + msg['device_id'], area_id=msg['area_id']) + + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@callback +def _entry_dict(entry): + """Convert entry to API format.""" + return { + 'config_entries': list(entry.config_entries), + 'connections': list(entry.connections), + 'manufacturer': entry.manufacturer, + 'model': entry.model, + 'name': entry.name, + 'sw_version': entry.sw_version, + 'id': entry.id, + 'hub_device_id': entry.hub_device_id, + 'area_id': entry.area_id, + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/entity_registry.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/entity_registry.py new file mode 100644 index 00000000000000..39dd622540dafe --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/entity_registry.py @@ -0,0 +1,161 @@ +"""HTTP views to interact with the entity registry.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.const import ERR_NOT_FOUND +from homeassistant.components.websocket_api.decorators import ( + async_response, require_admin) +from homeassistant.helpers import config_validation as cv + +DEPENDENCIES = ['websocket_api'] + +WS_TYPE_LIST = 'config/entity_registry/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_GET = 'config/entity_registry/get' +SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET, + vol.Required('entity_id'): cv.entity_id +}) + +WS_TYPE_UPDATE = 'config/entity_registry/update' +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE, + vol.Required('entity_id'): cv.entity_id, + # If passed in, we update value. Passing None will remove old value. + vol.Optional('name'): vol.Any(str, None), + vol.Optional('new_entity_id'): str, +}) + +WS_TYPE_REMOVE = 'config/entity_registry/remove' +SCHEMA_WS_REMOVE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_REMOVE, + vol.Required('entity_id'): cv.entity_id +}) + + +async def async_setup(hass): + """Enable the Entity Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_entities, + SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET, websocket_get_entity, + SCHEMA_WS_GET + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_entity, + SCHEMA_WS_UPDATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_REMOVE, websocket_remove_entity, + SCHEMA_WS_REMOVE + ) + return True + + +@async_response +async def websocket_list_entities(hass, connection, msg): + """Handle list registry entries command. + + Async friendly. + """ + registry = await async_get_registry(hass) + connection.send_message(websocket_api.result_message( + msg['id'], [_entry_dict(entry) for entry in registry.entities.values()] + )) + + +@async_response +async def websocket_get_entity(hass, connection, msg): + """Handle get entity registry entry command. + + Async friendly. + """ + registry = await async_get_registry(hass) + entry = registry.entities.get(msg['entity_id']) + + if entry is None: + connection.send_message(websocket_api.error_message( + msg['id'], ERR_NOT_FOUND, 'Entity not found')) + return + + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@require_admin +@async_response +async def websocket_update_entity(hass, connection, msg): + """Handle update entity websocket command. + + Async friendly. + """ + registry = await async_get_registry(hass) + + if msg['entity_id'] not in registry.entities: + connection.send_message(websocket_api.error_message( + msg['id'], ERR_NOT_FOUND, 'Entity not found')) + return + + changes = {} + + if 'name' in msg: + changes['name'] = msg['name'] + + if 'new_entity_id' in msg and msg['new_entity_id'] != msg['entity_id']: + changes['new_entity_id'] = msg['new_entity_id'] + if hass.states.get(msg['new_entity_id']) is not None: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', 'Entity is already registered')) + return + + try: + if changes: + entry = registry.async_update_entity( + msg['entity_id'], **changes) + except ValueError as err: + connection.send_message(websocket_api.error_message( + msg['id'], 'invalid_info', str(err) + )) + else: + connection.send_message(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + +@require_admin +@async_response +async def websocket_remove_entity(hass, connection, msg): + """Handle remove entity websocket command. + + Async friendly. + """ + registry = await async_get_registry(hass) + + if msg['entity_id'] not in registry.entities: + connection.send_message(websocket_api.error_message( + msg['id'], ERR_NOT_FOUND, 'Entity not found')) + return + + registry.async_remove(msg['entity_id']) + connection.send_message(websocket_api.result_message(msg['id'])) + + +@callback +def _entry_dict(entry): + """Convert entry to API format.""" + return { + 'config_entry_id': entry.config_entry_id, + 'device_id': entry.device_id, + 'disabled_by': entry.disabled_by, + 'entity_id': entry.entity_id, + 'name': entry.name, + 'platform': entry.platform, + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/group.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/group.py new file mode 100644 index 00000000000000..f9b9a2c491867e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/group.py @@ -0,0 +1,21 @@ +"""Provide configuration end points for Groups.""" +from homeassistant.const import SERVICE_RELOAD +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components.group import DOMAIN, GROUP_SCHEMA +import homeassistant.helpers.config_validation as cv + + +CONFIG_PATH = 'groups.yaml' + + +async def async_setup(hass): + """Set up the Group config API.""" + async def hook(hass): + """post_write_hook for Config View that reloads groups.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view(EditKeyBasedConfigView( + 'group', 'config', CONFIG_PATH, cv.slug, GROUP_SCHEMA, + post_write_hook=hook + )) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/hassbian.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/hassbian.py new file mode 100644 index 00000000000000..c475dc317f7215 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/hassbian.py @@ -0,0 +1,86 @@ +"""Component to interact with Hassbian tools.""" +import json +import os + +from homeassistant.components.http import HomeAssistantView + + +_TEST_OUTPUT = """ +{ + "suites":{ + "libcec":{ + "state":"Uninstalled", + "description":"Installs the libcec package for controlling CEC devices from this Pi" + }, + "mosquitto":{ + "state":"failed", + "description":"Installs the Mosquitto package for setting up a local MQTT server" + }, + "openzwave":{ + "state":"Uninstalled", + "description":"Installs the Open Z-wave package for setting up your zwave network" + }, + "samba":{ + "state":"installing", + "description":"Installs the samba package for sharing the hassbian configuration files over the Pi's network." + } + } +} +""" # noqa + + +async def async_setup(hass): + """Set up the Hassbian config.""" + # Test if is Hassbian + test_mode = 'FORCE_HASSBIAN' in os.environ + is_hassbian = test_mode + + if not is_hassbian: + return False + + hass.http.register_view(HassbianSuitesView(test_mode)) + hass.http.register_view(HassbianSuiteInstallView(test_mode)) + + return True + + +async def hassbian_status(hass, test_mode=False): + """Query for the Hassbian status.""" + # Fetch real output when not in test mode + if test_mode: + return json.loads(_TEST_OUTPUT) + + raise Exception('Real mode not implemented yet.') + + +class HassbianSuitesView(HomeAssistantView): + """Hassbian packages endpoint.""" + + url = '/api/config/hassbian/suites' + name = 'api:config:hassbian:suites' + + def __init__(self, test_mode): + """Initialize suites view.""" + self._test_mode = test_mode + + async def get(self, request): + """Request suite status.""" + inp = await hassbian_status(request.app['hass'], self._test_mode) + + return self.json(inp['suites']) + + +class HassbianSuiteInstallView(HomeAssistantView): + """Hassbian packages endpoint.""" + + url = '/api/config/hassbian/suites/{suite}/install' + name = 'api:config:hassbian:suite' + + def __init__(self, test_mode): + """Initialize suite view.""" + self._test_mode = test_mode + + async def post(self, request, suite): + """Request suite status.""" + # do real install if not in test mode + return self.json({"status": "ok"}) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/script.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/script.py new file mode 100644 index 00000000000000..3adc6f14233888 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/script.py @@ -0,0 +1,22 @@ +"""Provide configuration end points for scripts.""" + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components.script import DOMAIN, SCRIPT_ENTRY_SCHEMA +from homeassistant.const import SERVICE_RELOAD +import homeassistant.helpers.config_validation as cv + + +CONFIG_PATH = 'scripts.yaml' + + +async def async_setup(hass): + """Set up the script config API.""" + async def hook(hass): + """post_write_hook for Config View that reloads scripts.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view(EditKeyBasedConfigView( + 'script', 'config', CONFIG_PATH, cv.slug, SCRIPT_ENTRY_SCHEMA, + post_write_hook=hook + )) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/config/zwave.py b/homeassistant-0.88.0.dev0/homeassistant/components/config/zwave.py new file mode 100644 index 00000000000000..57123ee12de385 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/config/zwave.py @@ -0,0 +1,252 @@ +"""Provide configuration end points for Z-Wave.""" +import logging + +from collections import deque +from aiohttp.web import Response +import homeassistant.core as ha +from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components.zwave import const, DEVICE_CONFIG_SCHEMA_ENTRY +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +CONFIG_PATH = 'zwave_device_config.yaml' +OZW_LOG_FILENAME = 'OZW_Log.txt' + + +async def async_setup(hass): + """Set up the Z-Wave config API.""" + hass.http.register_view(EditKeyBasedConfigView( + 'zwave', 'device_config', CONFIG_PATH, cv.entity_id, + DEVICE_CONFIG_SCHEMA_ENTRY + )) + hass.http.register_view(ZWaveNodeValueView) + hass.http.register_view(ZWaveNodeGroupView) + hass.http.register_view(ZWaveNodeConfigView) + hass.http.register_view(ZWaveUserCodeView) + hass.http.register_view(ZWaveLogView) + hass.http.register_view(ZWaveConfigWriteView) + hass.http.register_view(ZWaveProtectionView) + + return True + + +class ZWaveLogView(HomeAssistantView): + """View to read the ZWave log file.""" + + url = "/api/zwave/ozwlog" + name = "api:zwave:ozwlog" + +# pylint: disable=no-self-use + async def get(self, request): + """Retrieve the lines from ZWave log.""" + try: + lines = int(request.query.get('lines', 0)) + except ValueError: + return Response(text='Invalid datetime', status=400) + + hass = request.app['hass'] + response = await hass.async_add_job(self._get_log, hass, lines) + + return Response(text='\n'.join(response)) + + def _get_log(self, hass, lines): + """Retrieve the logfile content.""" + logfilepath = hass.config.path(OZW_LOG_FILENAME) + with open(logfilepath, 'r') as logfile: + data = (line.rstrip() for line in logfile) + if lines == 0: + loglines = list(data) + else: + loglines = deque(data, lines) + return loglines + + +class ZWaveConfigWriteView(HomeAssistantView): + """View to save the ZWave configuration to zwcfg_xxxxx.xml.""" + + url = "/api/zwave/saveconfig" + name = "api:zwave:saveconfig" + + @ha.callback + def post(self, request): + """Save cache configuration to zwcfg_xxxxx.xml.""" + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + if network is None: + return self.json_message('No Z-Wave network data found', + HTTP_NOT_FOUND) + _LOGGER.info("Z-Wave configuration written to file.") + network.write_config() + return self.json_message('Z-Wave configuration saved to file.', + HTTP_OK) + + +class ZWaveNodeValueView(HomeAssistantView): + """View to return the node values.""" + + url = r"/api/zwave/values/{node_id:\d+}" + name = "api:zwave:values" + + @ha.callback + def get(self, request, node_id): + """Retrieve groups of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + values_list = hass.data[const.DATA_ENTITY_VALUES] + + values_data = {} + # Return a list of values for this node that are used as a + # primary value for an entity + for entity_values in values_list: + if entity_values.primary.node.node_id != nodeid: + continue + + values_data[entity_values.primary.value_id] = { + 'label': entity_values.primary.label, + 'index': entity_values.primary.index, + 'instance': entity_values.primary.instance, + 'poll_intensity': entity_values.primary.poll_intensity, + } + return self.json(values_data) + + +class ZWaveNodeGroupView(HomeAssistantView): + """View to return the nodes group configuration.""" + + url = r"/api/zwave/groups/{node_id:\d+}" + name = "api:zwave:groups" + + @ha.callback + def get(self, request, node_id): + """Retrieve groups of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + groupdata = node.groups + groups = {} + for key, value in groupdata.items(): + groups[key] = {'associations': value.associations, + 'association_instances': + value.associations_instances, + 'label': value.label, + 'max_associations': value.max_associations} + return self.json(groups) + + +class ZWaveNodeConfigView(HomeAssistantView): + """View to return the nodes configuration options.""" + + url = r"/api/zwave/config/{node_id:\d+}" + name = "api:zwave:config" + + @ha.callback + def get(self, request, node_id): + """Retrieve configurations of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + config = {} + for value in ( + node.get_values(class_id=const.COMMAND_CLASS_CONFIGURATION) + .values()): + config[value.index] = {'label': value.label, + 'type': value.type, + 'help': value.help, + 'data_items': value.data_items, + 'data': value.data, + 'max': value.max, + 'min': value.min} + return self.json(config) + + +class ZWaveUserCodeView(HomeAssistantView): + """View to return the nodes usercode configuration.""" + + url = r"/api/zwave/usercodes/{node_id:\d+}" + name = "api:zwave:usercodes" + + @ha.callback + def get(self, request, node_id): + """Retrieve usercodes of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + usercodes = {} + if not node.has_command_class(const.COMMAND_CLASS_USER_CODE): + return self.json(usercodes) + for value in ( + node.get_values(class_id=const.COMMAND_CLASS_USER_CODE) + .values()): + if value.genre != const.GENRE_USER: + continue + usercodes[value.index] = {'code': value.data, + 'label': value.label, + 'length': len(value.data)} + return self.json(usercodes) + + +class ZWaveProtectionView(HomeAssistantView): + """View for the protection commandclass of a node.""" + + url = r"/api/zwave/protection/{node_id:\d+}" + name = "api:zwave:protection" + + async def get(self, request, node_id): + """Retrieve the protection commandclass options of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + + def _fetch_protection(): + """Get protection data.""" + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + protection_options = {} + if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): + return self.json(protection_options) + protections = node.get_protections() + protection_options = { + 'value_id': '{0:d}'.format(list(protections)[0]), + 'selected': node.get_protection_item(list(protections)[0]), + 'options': node.get_protection_items(list(protections)[0])} + return self.json(protection_options) + + return await hass.async_add_executor_job(_fetch_protection) + + async def post(self, request, node_id): + """Change the selected option in protection commandclass.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + protection_data = await request.json() + + def _set_protection(): + """Set protection data.""" + node = network.nodes.get(nodeid) + selection = protection_data["selection"] + value_id = int(protection_data[const.ATTR_VALUE_ID]) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): + return self.json_message( + 'No protection commandclass on this node', HTTP_NOT_FOUND) + state = node.set_protection(value_id, selection) + if not state: + return self.json_message( + 'Protection setting did not complete', 202) + return self.json_message( + 'Protection setting succsessfully set', HTTP_OK) + + return await hass.async_add_executor_job(_set_protection) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/configurator.py b/homeassistant-0.88.0.dev0/homeassistant/components/configurator.py new file mode 100644 index 00000000000000..74d8339b1fa931 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/configurator.py @@ -0,0 +1,230 @@ +""" +Support to allow pieces of code to request configuration from the user. + +Initiate a request by calling the `request_config` method with a callback. +This will return a request id that has to be used for future calls. +A callback has to be provided to `request_config` which will be called when +the user has submitted configuration information. +""" +import functools as ft +import logging + +from homeassistant.core import callback as async_callback +from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \ + ATTR_ENTITY_PICTURE +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.util.async_ import run_callback_threadsafe + +_LOGGER = logging.getLogger(__name__) +_KEY_INSTANCE = 'configurator' + +DATA_REQUESTS = 'configurator_requests' + +ATTR_CONFIGURE_ID = 'configure_id' +ATTR_DESCRIPTION = 'description' +ATTR_DESCRIPTION_IMAGE = 'description_image' +ATTR_ERRORS = 'errors' +ATTR_FIELDS = 'fields' +ATTR_LINK_NAME = 'link_name' +ATTR_LINK_URL = 'link_url' +ATTR_SUBMIT_CAPTION = 'submit_caption' + +DOMAIN = 'configurator' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_CONFIGURE = 'configure' +STATE_CONFIGURE = 'configure' +STATE_CONFIGURED = 'configured' + + +@bind_hass +@async_callback +def async_request_config( + hass, name, callback=None, description=None, description_image=None, + submit_caption=None, fields=None, link_name=None, link_url=None, + entity_picture=None): + """Create a new request for configuration. + + Will return an ID to be used for sequent calls. + """ + if link_name is not None and link_url is not None: + description += '\n\n[{}]({})'.format(link_name, link_url) + + if description_image is not None: + description += '\n\n![Description image]({})'.format(description_image) + + instance = hass.data.get(_KEY_INSTANCE) + + if instance is None: + instance = hass.data[_KEY_INSTANCE] = Configurator(hass) + + request_id = instance.async_request_config( + name, callback, description, submit_caption, fields, entity_picture) + + if DATA_REQUESTS not in hass.data: + hass.data[DATA_REQUESTS] = {} + + hass.data[DATA_REQUESTS][request_id] = instance + + return request_id + + +@bind_hass +def request_config(hass, *args, **kwargs): + """Create a new request for configuration. + + Will return an ID to be used for sequent calls. + """ + return run_callback_threadsafe( + hass.loop, ft.partial(async_request_config, hass, *args, **kwargs) + ).result() + + +@bind_hass +@async_callback +def async_notify_errors(hass, request_id, error): + """Add errors to a config request.""" + try: + hass.data[DATA_REQUESTS][request_id].async_notify_errors( + request_id, error) + except KeyError: + # If request_id does not exist + pass + + +@bind_hass +def notify_errors(hass, request_id, error): + """Add errors to a config request.""" + return run_callback_threadsafe( + hass.loop, async_notify_errors, hass, request_id, error + ).result() + + +@bind_hass +@async_callback +def async_request_done(hass, request_id): + """Mark a configuration request as done.""" + try: + hass.data[DATA_REQUESTS].pop(request_id).async_request_done(request_id) + except KeyError: + # If request_id does not exist + pass + + +@bind_hass +def request_done(hass, request_id): + """Mark a configuration request as done.""" + return run_callback_threadsafe( + hass.loop, async_request_done, hass, request_id + ).result() + + +async def async_setup(hass, config): + """Set up the configurator component.""" + return True + + +class Configurator: + """The class to keep track of current configuration requests.""" + + def __init__(self, hass): + """Initialize the configurator.""" + self.hass = hass + self._cur_id = 0 + self._requests = {} + hass.services.async_register( + DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call) + + @async_callback + def async_request_config( + self, name, callback, description, submit_caption, fields, + entity_picture): + """Set up a request for configuration.""" + entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, name, hass=self.hass) + + if fields is None: + fields = [] + + request_id = self._generate_unique_id() + + self._requests[request_id] = (entity_id, fields, callback) + + data = { + ATTR_CONFIGURE_ID: request_id, + ATTR_FIELDS: fields, + ATTR_FRIENDLY_NAME: name, + ATTR_ENTITY_PICTURE: entity_picture, + } + + data.update({ + key: value for key, value in [ + (ATTR_DESCRIPTION, description), + (ATTR_SUBMIT_CAPTION, submit_caption), + ] if value is not None + }) + + self.hass.states.async_set(entity_id, STATE_CONFIGURE, data) + + return request_id + + @async_callback + def async_notify_errors(self, request_id, error): + """Update the state with errors.""" + if not self._validate_request_id(request_id): + return + + entity_id = self._requests[request_id][0] + + state = self.hass.states.get(entity_id) + + new_data = dict(state.attributes) + new_data[ATTR_ERRORS] = error + + self.hass.states.async_set(entity_id, STATE_CONFIGURE, new_data) + + @async_callback + def async_request_done(self, request_id): + """Remove the configuration request.""" + if not self._validate_request_id(request_id): + return + + entity_id = self._requests.pop(request_id)[0] + + # If we remove the state right away, it will not be included with + # the result fo the service call (current design limitation). + # Instead, we will set it to configured to give as feedback but delete + # it shortly after so that it is deleted when the client updates. + self.hass.states.async_set(entity_id, STATE_CONFIGURED) + + def deferred_remove(event): + """Remove the request state.""" + self.hass.states.async_remove(entity_id) + + self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) + + async def async_handle_service_call(self, call): + """Handle a configure service call.""" + request_id = call.data.get(ATTR_CONFIGURE_ID) + + if not self._validate_request_id(request_id): + return + + # pylint: disable=unused-variable + entity_id, fields, callback = self._requests[request_id] + + # field validation goes here? + if callback: + await self.hass.async_add_job(callback, + call.data.get(ATTR_FIELDS, {})) + + def _generate_unique_id(self): + """Generate a unique configurator ID.""" + self._cur_id += 1 + return "{}-{}".format(id(self), self._cur_id) + + def _validate_request_id(self, request_id): + """Validate that the request belongs to this instance.""" + return request_id in self._requests diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/conversation/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/conversation/__init__.py new file mode 100644 index 00000000000000..d8d386f5ca048a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/conversation/__init__.py @@ -0,0 +1,191 @@ +""" +Support for functionality to have conversations with Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/conversation/ +""" +import logging +import re + +import voluptuous as vol + +from homeassistant import core +from homeassistant.components import http +from homeassistant.components.conversation.util import create_matcher +from homeassistant.components.http.data_validator import ( + RequestDataValidator) +from homeassistant.components.cover import (INTENT_OPEN_COVER, + INTENT_CLOSE_COVER) +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import intent +from homeassistant.loader import bind_hass +from homeassistant.setup import (ATTR_COMPONENT) + +_LOGGER = logging.getLogger(__name__) + +ATTR_TEXT = 'text' + +DEPENDENCIES = ['http'] +DOMAIN = 'conversation' + +REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') +REGEX_TYPE = type(re.compile('')) + +UTTERANCES = { + 'cover': { + INTENT_OPEN_COVER: ['Open [the] [a] [an] {name}[s]'], + INTENT_CLOSE_COVER: ['Close [the] [a] [an] {name}[s]'] + } +} + +SERVICE_PROCESS = 'process' + +SERVICE_PROCESS_SCHEMA = vol.Schema({ + vol.Required(ATTR_TEXT): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ + vol.Optional('intents'): vol.Schema({ + cv.string: vol.All(cv.ensure_list, [cv.string]) + }) +})}, extra=vol.ALLOW_EXTRA) + + +@core.callback +@bind_hass +def async_register(hass, intent_type, utterances): + """Register utterances and any custom intents. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.get(DOMAIN) + + if intents is None: + intents = hass.data[DOMAIN] = {} + + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + for utterance in utterances: + if isinstance(utterance, REGEX_TYPE): + conf.append(utterance) + else: + conf.append(create_matcher(utterance)) + + +async def async_setup(hass, config): + """Register the process service.""" + config = config.get(DOMAIN, {}) + intents = hass.data.get(DOMAIN) + + if intents is None: + intents = hass.data[DOMAIN] = {} + + for intent_type, utterances in config.get('intents', {}).items(): + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(create_matcher(utterance) for utterance in utterances) + + async def process(service): + """Parse text into commands.""" + text = service.data[ATTR_TEXT] + _LOGGER.debug('Processing: <%s>', text) + try: + await _process(hass, text) + except intent.IntentHandleError as err: + _LOGGER.error('Error processing %s: %s', text, err) + + hass.services.async_register( + DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) + + hass.http.register_view(ConversationProcessView) + + # We strip trailing 's' from name because our state matcher will fail + # if a letter is not there. By removing 's' we can match singular and + # plural names. + + async_register(hass, intent.INTENT_TURN_ON, [ + 'Turn [the] [a] {name}[s] on', + 'Turn on [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TURN_OFF, [ + 'Turn [the] [a] [an] {name}[s] off', + 'Turn off [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TOGGLE, [ + 'Toggle [the] [a] [an] {name}[s]', + '[the] [a] [an] {name}[s] toggle', + ]) + + @callback + def register_utterances(component): + """Register utterances for a component.""" + if component not in UTTERANCES: + return + for intent_type, sentences in UTTERANCES[component].items(): + async_register(hass, intent_type, sentences) + + @callback + def component_loaded(event): + """Handle a new component loaded.""" + register_utterances(event.data[ATTR_COMPONENT]) + + hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + + # Check already loaded components. + for component in hass.config.components: + register_utterances(component) + + return True + + +async def _process(hass, text): + """Process a line of text.""" + intents = hass.data.get(DOMAIN, {}) + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + response = await hass.helpers.intent.async_handle( + DOMAIN, intent_type, + {key: {'value': value} for key, value + in match.groupdict().items()}, text) + return response + + +class ConversationProcessView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/conversation/process' + name = "api:conversation:process" + + @RequestDataValidator(vol.Schema({ + vol.Required('text'): str, + })) + async def post(self, request, data): + """Send a request for processing.""" + hass = request.app['hass'] + + try: + intent_result = await _process(hass, data['text']) + except intent.IntentHandleError as err: + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) + + if intent_result is None: + intent_result = intent.IntentResponse() + intent_result.async_set_speech("Sorry, I didn't understand that") + + return self.json(intent_result) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/conversation/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/conversation/services.yaml new file mode 100644 index 00000000000000..a1b980d8e05a3d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/conversation/services.yaml @@ -0,0 +1,10 @@ +# Describes the format for available component services + +process: + description: Launch a conversation from a transcribed text. + fields: + text: + description: Transcribed text + example: Turn all lights on + + diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/conversation/util.py b/homeassistant-0.88.0.dev0/homeassistant/components/conversation/util.py new file mode 100644 index 00000000000000..60d861afdbe4a7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/conversation/util.py @@ -0,0 +1,35 @@ +"""Util for Conversation.""" +import re + + +def create_matcher(utterance): + """Create a regex that matches the utterance.""" + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} + group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') + + pattern = ['^'] + for part in parts: + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) + + # Normal part + if group_match is None and optional_match is None: + pattern.append(part) + continue + + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) + + pattern.append('$') + return re.compile(''.join(pattern), re.I) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/counter/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/counter/__init__.py new file mode 100644 index 00000000000000..aeef2818f6368a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/counter/__init__.py @@ -0,0 +1,151 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_RESTORE = 'restore' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_RESTORE, default=True): cv.boolean, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + ) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the counters.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + restore = cfg.get(CONF_RESTORE) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, restore, step, icon)) + + if not entities: + return False + + component.async_register_entity_service( + SERVICE_INCREMENT, SERVICE_SCHEMA, + 'async_increment') + component.async_register_entity_service( + SERVICE_DECREMENT, SERVICE_SCHEMA, + 'async_decrement') + component.async_register_entity_service( + SERVICE_RESET, SERVICE_SCHEMA, + 'async_reset') + + await component.async_add_entities(entities) + return True + + +class Counter(RestoreEntity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, restore, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._restore = restore + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() + # __init__ will set self._state to self._initial, only override + # if needed. + if self._restore: + state = await self.async_get_last_state() + if state is not None: + self._state = int(state.state) + + async def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + await self.async_update_ha_state() + + async def async_increment(self): + """Increment a counter.""" + self._state += self._step + await self.async_update_ha_state() + + async def async_reset(self): + """Reset a counter.""" + self._state = self._initial + await self.async_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/counter/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/counter/services.yaml new file mode 100644 index 00000000000000..ef76f9b9eacbf1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/counter/services.yaml @@ -0,0 +1,20 @@ +# Describes the format for available counter services + +decrement: + description: Decrement a counter. + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' +increment: + description: Increment a counter. + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' +reset: + description: Reset a counter. + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/__init__.py new file mode 100644 index 00000000000000..b5b2a91b097891 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/__init__.py @@ -0,0 +1,321 @@ +""" +Support for Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover/ +""" +from datetime import timedelta +import functools as ft +import logging + +import voluptuous as vol + +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv +from homeassistant.components import group +from homeassistant.helpers import intent +from homeassistant.const import ( + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, + SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, + STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'cover' +DEPENDENCIES = ['group'] +SCAN_INTERVAL = timedelta(seconds=15) + +GROUP_NAME_ALL_COVERS = 'all covers' +ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format('all_covers') + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +DEVICE_CLASSES = [ + 'damper', + 'garage', # Garage door control + 'window', # Window control +] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + +SUPPORT_OPEN = 1 +SUPPORT_CLOSE = 2 +SUPPORT_SET_POSITION = 4 +SUPPORT_STOP = 8 +SUPPORT_OPEN_TILT = 16 +SUPPORT_CLOSE_TILT = 32 +SUPPORT_STOP_TILT = 64 +SUPPORT_SET_TILT_POSITION = 128 + +ATTR_CURRENT_POSITION = 'current_position' +ATTR_CURRENT_TILT_POSITION = 'current_tilt_position' +ATTR_POSITION = 'position' +ATTR_TILT_POSITION = 'tilt_position' + +INTENT_OPEN_COVER = 'HassOpenCover' +INTENT_CLOSE_COVER = 'HassCloseCover' + +COVER_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, +}) + +COVER_SET_COVER_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_POSITION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), +}) + +COVER_SET_COVER_TILT_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_TILT_POSITION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), +}) + + +@bind_hass +def is_closed(hass, entity_id=None): + """Return if the cover is closed based on the statemachine.""" + entity_id = entity_id or ENTITY_ID_ALL_COVERS + return hass.states.is_state(entity_id, STATE_CLOSED) + + +async def async_setup(hass, config): + """Track states and offer events for covers.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) + + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_OPEN_COVER, COVER_SERVICE_SCHEMA, + 'async_open_cover' + ) + + component.async_register_entity_service( + SERVICE_CLOSE_COVER, COVER_SERVICE_SCHEMA, + 'async_close_cover' + ) + + component.async_register_entity_service( + SERVICE_SET_COVER_POSITION, COVER_SET_COVER_POSITION_SCHEMA, + 'async_set_cover_position' + ) + + component.async_register_entity_service( + SERVICE_STOP_COVER, COVER_SERVICE_SCHEMA, + 'async_stop_cover' + ) + + component.async_register_entity_service( + SERVICE_OPEN_COVER_TILT, COVER_SERVICE_SCHEMA, + 'async_open_cover_tilt' + ) + + component.async_register_entity_service( + SERVICE_CLOSE_COVER_TILT, COVER_SERVICE_SCHEMA, + 'async_close_cover_tilt' + ) + + component.async_register_entity_service( + SERVICE_STOP_COVER_TILT, COVER_SERVICE_SCHEMA, + 'async_stop_cover_tilt' + ) + + component.async_register_entity_service( + SERVICE_SET_COVER_TILT_POSITION, COVER_SET_COVER_TILT_POSITION_SCHEMA, + 'async_set_cover_tilt_position' + ) + + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER, + "Opened {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, + "Closed {}")) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class CoverDevice(Entity): + """Representation a cover.""" + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pass + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + pass + + @property + def state(self): + """Return the state of the cover.""" + if self.is_opening: + return STATE_OPENING + if self.is_closing: + return STATE_CLOSING + + closed = self.is_closed + + if closed is None: + return None + + return STATE_CLOSED if closed else STATE_OPEN + + @property + def state_attributes(self): + """Return the state attributes.""" + data = {} + + current = self.current_cover_position + if current is not None: + data[ATTR_CURRENT_POSITION] = self.current_cover_position + + current_tilt = self.current_cover_tilt_position + if current_tilt is not None: + data[ATTR_CURRENT_TILT_POSITION] = self.current_cover_tilt_position + + return data + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + if self.current_cover_position is not None: + supported_features |= SUPPORT_SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | + SUPPORT_SET_TILT_POSITION) + + return supported_features + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + pass + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + pass + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + raise NotImplementedError() + + def open_cover(self, **kwargs): + """Open the cover.""" + raise NotImplementedError() + + def async_open_cover(self, **kwargs): + """Open the cover. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) + + def close_cover(self, **kwargs): + """Close cover.""" + raise NotImplementedError() + + def async_close_cover(self, **kwargs): + """Close cover. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + pass + + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + ft.partial(self.set_cover_position, **kwargs)) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + pass + + def async_stop_cover(self, **kwargs): + """Stop the cover. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + pass + + def async_open_cover_tilt(self, **kwargs): + """Open the cover tilt. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + ft.partial(self.open_cover_tilt, **kwargs)) + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + pass + + def async_close_cover_tilt(self, **kwargs): + """Close the cover tilt. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + ft.partial(self.close_cover_tilt, **kwargs)) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + pass + + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + ft.partial(self.set_cover_tilt_position, **kwargs)) + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + pass + + def async_stop_cover_tilt(self, **kwargs): + """Stop the cover. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + ft.partial(self.stop_cover_tilt, **kwargs)) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/aladdin_connect.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/aladdin_connect.py new file mode 100644 index 00000000000000..4627ba7778148e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/aladdin_connect.py @@ -0,0 +1,120 @@ +""" +Platform for the Aladdin Connect cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.aladdin_connect/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA, + SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, + STATE_OPENING, STATE_CLOSING, STATE_OPEN) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['aladdin_connect==0.3'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'aladdin_notification' +NOTIFICATION_TITLE = 'Aladdin Connect Cover Setup' + +STATES_MAP = { + 'open': STATE_OPEN, + 'opening': STATE_OPENING, + 'closed': STATE_CLOSED, + 'closing': STATE_CLOSING +} + +SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Aladdin Connect platform.""" + from aladdin_connect import AladdinConnectClient + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + acc = AladdinConnectClient(username, password) + + try: + if not acc.login(): + raise ValueError("Username or Password is incorrect") + add_entities(AladdinDevice(acc, door) for door in acc.get_doors()) + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + +class AladdinDevice(CoverDevice): + """Representation of Aladdin Connect cover.""" + + def __init__(self, acc, device): + """Initialize the cover.""" + self._acc = acc + self._device_id = device['device_id'] + self._number = device['door_number'] + self._name = device['name'] + self._status = STATES_MAP.get(device['status']) + + @property + def device_class(self): + """Define this cover as a garage door.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + @property + def unique_id(self): + """Return a unique ID.""" + return '{}-{}'.format(self._device_id, self._number) + + @property + def name(self): + """Return the name of the garage door.""" + return self._name + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._status == STATE_OPENING + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._status == STATE_CLOSING + + @property + def is_closed(self): + """Return None if status is unknown, True if closed, else False.""" + if self._status is None: + return None + return self._status == STATE_CLOSED + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._acc.close_door(self._device_id, self._number) + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._acc.open_door(self._device_id, self._number) + + def update(self): + """Update status of cover.""" + acc_status = self._acc.get_door_status(self._device_id, self._number) + self._status = STATES_MAP.get(acc_status) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/brunt.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/brunt.py new file mode 100644 index 00000000000000..746f3840a01ff8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/brunt.py @@ -0,0 +1,182 @@ +""" +Support for Brunt Blind Engine covers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cover.brunt +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.components.cover import ( + ATTR_POSITION, CoverDevice, + PLATFORM_SCHEMA, SUPPORT_CLOSE, + SUPPORT_OPEN, SUPPORT_SET_POSITION +) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['brunt==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION +DEVICE_CLASS = 'window' + +ATTR_REQUEST_POSITION = 'request_position' +NOTIFICATION_ID = 'brunt_notification' +NOTIFICATION_TITLE = 'Brunt Cover Setup' +ATTRIBUTION = 'Based on an unofficial Brunt SDK.' + +CLOSED_POSITION = 0 +OPEN_POSITION = 100 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the brunt platform.""" + # pylint: disable=no-name-in-module + from brunt import BruntAPI + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + bapi = BruntAPI(username=username, password=password) + try: + things = bapi.getThings()['things'] + if not things: + _LOGGER.error("No things present in account.") + else: + add_entities([BruntDevice( + bapi, thing['NAME'], + thing['thingUri']) for thing in things], True) + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + +class BruntDevice(CoverDevice): + """ + Representation of a Brunt cover device. + + Contains the common logic for all Brunt devices. + """ + + def __init__(self, bapi, name, thing_uri): + """Init the Brunt device.""" + self._bapi = bapi + self._name = name + self._thing_uri = thing_uri + + self._state = {} + self._available = None + + @property + def name(self): + """Return the name of the device as reported by tellcore.""" + return self._name + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self._state.get('currentPosition') + return int(pos) if pos else None + + @property + def request_cover_position(self): + """ + Return request position of cover. + + The request position is the position of the last request + to Brunt, at times there is a diff of 1 to current + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self._state.get('requestPosition') + return int(pos) if pos else None + + @property + def move_state(self): + """ + Return current moving state of cover. + + None is unknown, 0 when stopped, 1 when opening, 2 when closing + """ + mov = self._state.get('moveState') + return int(mov) if mov else None + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self.move_state == 1 + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self.move_state == 2 + + @property + def device_state_attributes(self): + """Return the detailed device state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_REQUEST_POSITION: self.request_cover_position + } + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS + + @property + def supported_features(self): + """Flag supported features.""" + return COVER_FEATURES + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self.current_cover_position == CLOSED_POSITION + + def update(self): + """Poll the current state of the device.""" + try: + self._state = self._bapi.getState( + thingUri=self._thing_uri).get('thing') + self._available = True + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + self._available = False + + def open_cover(self, **kwargs): + """Set the cover to the open position.""" + self._bapi.changeRequestPosition( + OPEN_POSITION, thingUri=self._thing_uri) + + def close_cover(self, **kwargs): + """Set the cover to the closed position.""" + self._bapi.changeRequestPosition( + CLOSED_POSITION, thingUri=self._thing_uri) + + def set_cover_position(self, **kwargs): + """Set the cover to a specific position.""" + self._bapi.changeRequestPosition( + kwargs[ATTR_POSITION], thingUri=self._thing_uri) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/command_line.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/command_line.py new file mode 100644 index 00000000000000..4f4fca1b27a965 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/command_line.py @@ -0,0 +1,151 @@ +""" +Support for command line covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.command_line/ +""" +import logging +import subprocess + +import voluptuous as vol + +from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, + CONF_COMMAND_STOP, CONF_COVERS, CONF_VALUE_TEMPLATE, CONF_FRIENDLY_NAME) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +COVER_SCHEMA = vol.Schema({ + vol.Optional(CONF_COMMAND_CLOSE, default='true'): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default='true'): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default='true'): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up cover controlled by shell commands.""" + devices = config.get(CONF_COVERS, {}) + covers = [] + + for device_name, device_config in devices.items(): + value_template = device_config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + + covers.append( + CommandCover( + hass, + device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_COMMAND_OPEN), + device_config.get(CONF_COMMAND_CLOSE), + device_config.get(CONF_COMMAND_STOP), + device_config.get(CONF_COMMAND_STATE), + value_template, + ) + ) + + if not covers: + _LOGGER.error("No covers added") + return False + + add_entities(covers) + + +class CommandCover(CoverDevice): + """Representation a command line cover.""" + + def __init__(self, hass, name, command_open, command_close, command_stop, + command_state, value_template): + """Initialize the cover.""" + self._hass = hass + self._name = name + self._state = None + self._command_open = command_open + self._command_close = command_close + self._command_stop = command_stop + self._command_state = command_state + self._value_template = value_template + + @staticmethod + def _move_cover(command): + """Execute the actual commands.""" + _LOGGER.info("Running command: %s", command) + + success = (subprocess.call(command, shell=True) == 0) + + if not success: + _LOGGER.error("Command failed: %s", command) + + return success + + @staticmethod + def _query_state_value(command): + """Execute state command for return value.""" + _LOGGER.info("Running state command: %s", command) + + try: + return_value = subprocess.check_output(command, shell=True) + return return_value.strip().decode('utf-8') + except subprocess.CalledProcessError: + _LOGGER.error("Command failed: %s", command) + + @property + def should_poll(self): + """Only poll if we have state command.""" + return self._command_state is not None + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + return self.current_cover_position == 0 + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._state + + def _query_state(self): + """Query for the state.""" + if not self._command_state: + _LOGGER.error("No state command specified") + return + return self._query_state_value(self._command_state) + + def update(self): + """Update device state.""" + if self._command_state: + payload = str(self._query_state()) + if self._value_template: + payload = self._value_template.render_with_possible_json_value( + payload) + self._state = int(payload) + + def open_cover(self, **kwargs): + """Open the cover.""" + self._move_cover(self._command_open) + + def close_cover(self, **kwargs): + """Close the cover.""" + self._move_cover(self._command_close) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._move_cover(self._command_stop) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/demo.py new file mode 100644 index 00000000000000..21add0a6c62ae5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/demo.py @@ -0,0 +1,217 @@ +""" +Demo platform for the cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION, + ATTR_TILT_POSITION) +from homeassistant.helpers.event import track_utc_time_change + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo covers.""" + add_entities([ + DemoCover(hass, 'Kitchen Window'), + DemoCover(hass, 'Hall Window', 10), + DemoCover(hass, 'Living Room Window', 70, 50), + DemoCover(hass, 'Garage Door', device_class='garage', + supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE)), + ]) + + +class DemoCover(CoverDevice): + """Representation of a demo cover.""" + + def __init__(self, hass, name, position=None, tilt_position=None, + device_class=None, supported_features=None): + """Initialize the cover.""" + self.hass = hass + self._name = name + self._position = position + self._device_class = device_class + self._supported_features = supported_features + self._set_position = None + self._set_tilt_position = None + self._tilt_position = tilt_position + self._requested_closing = True + self._requested_closing_tilt = True + self._unsub_listener_cover = None + self._unsub_listener_cover_tilt = None + self._is_opening = False + self._is_closing = False + if position is None: + self._closed = True + else: + self._closed = self.current_cover_position <= 0 + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo cover.""" + return False + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self._position + + @property + def current_cover_tilt_position(self): + """Return the current tilt position of the cover.""" + return self._tilt_position + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._closed + + @property + def is_closing(self): + """Return if the cover is closing.""" + return self._is_closing + + @property + def is_opening(self): + """Return if the cover is opening.""" + return self._is_opening + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class + + @property + def supported_features(self): + """Flag supported features.""" + if self._supported_features is not None: + return self._supported_features + return super().supported_features + + def close_cover(self, **kwargs): + """Close the cover.""" + if self._position == 0: + return + if self._position is None: + self._closed = True + self.schedule_update_ha_state() + return + + self._is_closing = True + self._listen_cover() + self._requested_closing = True + self.schedule_update_ha_state() + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + if self._tilt_position in (0, None): + return + + self._listen_cover_tilt() + self._requested_closing_tilt = True + + def open_cover(self, **kwargs): + """Open the cover.""" + if self._position == 100: + return + if self._position is None: + self._closed = False + self.schedule_update_ha_state() + return + + self._is_opening = True + self._listen_cover() + self._requested_closing = False + self.schedule_update_ha_state() + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + if self._tilt_position in (100, None): + return + + self._listen_cover_tilt() + self._requested_closing_tilt = False + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) + self._set_position = round(position, -1) + if self._position == position: + return + + self._listen_cover() + self._requested_closing = position < self._position + + def set_cover_tilt_position(self, **kwargs): + """Move the cover til to a specific position.""" + tilt_position = kwargs.get(ATTR_TILT_POSITION) + self._set_tilt_position = round(tilt_position, -1) + if self._tilt_position == tilt_position: + return + + self._listen_cover_tilt() + self._requested_closing_tilt = tilt_position < self._tilt_position + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._is_closing = False + self._is_opening = False + if self._position is None: + return + if self._unsub_listener_cover is not None: + self._unsub_listener_cover() + self._unsub_listener_cover = None + self._set_position = None + + def stop_cover_tilt(self, **kwargs): + """Stop the cover tilt.""" + if self._tilt_position is None: + return + + if self._unsub_listener_cover_tilt is not None: + self._unsub_listener_cover_tilt() + self._unsub_listener_cover_tilt = None + self._set_tilt_position = None + + def _listen_cover(self): + """Listen for changes in cover.""" + if self._unsub_listener_cover is None: + self._unsub_listener_cover = track_utc_time_change( + self.hass, self._time_changed_cover) + + def _time_changed_cover(self, now): + """Track time changes.""" + if self._requested_closing: + self._position -= 10 + else: + self._position += 10 + + if self._position in (100, 0, self._set_position): + self.stop_cover() + + self._closed = self.current_cover_position <= 0 + + self.schedule_update_ha_state() + + def _listen_cover_tilt(self): + """Listen for changes in cover tilt.""" + if self._unsub_listener_cover_tilt is None: + self._unsub_listener_cover_tilt = track_utc_time_change( + self.hass, self._time_changed_cover_tilt) + + def _time_changed_cover_tilt(self, now): + """Track time changes.""" + if self._requested_closing_tilt: + self._tilt_position -= 10 + else: + self._tilt_position += 10 + + if self._tilt_position in (100, 0, self._set_tilt_position): + self.stop_cover_tilt() + + self.schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/garadget.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/garadget.py new file mode 100644 index 00000000000000..426afc6d3147e4 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/garadget.py @@ -0,0 +1,267 @@ +""" +Platform for the Garadget cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/garadget/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.const import ( + CONF_DEVICE, CONF_USERNAME, CONF_PASSWORD, CONF_ACCESS_TOKEN, CONF_NAME, + STATE_CLOSED, STATE_OPEN, CONF_COVERS) + +_LOGGER = logging.getLogger(__name__) + +ATTR_AVAILABLE = 'available' +ATTR_SENSOR_STRENGTH = 'sensor_reflection_rate' +ATTR_SIGNAL_STRENGTH = 'wifi_signal_strength' +ATTR_TIME_IN_STATE = 'time_in_state' + +DEFAULT_NAME = 'Garadget' + +STATE_CLOSING = 'closing' +STATE_OFFLINE = 'offline' +STATE_OPENING = 'opening' +STATE_STOPPED = 'stopped' + +STATES_MAP = { + 'open': STATE_OPEN, + 'opening': STATE_OPENING, + 'closed': STATE_CLOSED, + 'closing': STATE_CLOSING, + 'stopped': STATE_STOPPED +} + +COVER_SCHEMA = vol.Schema({ + vol.Optional(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_DEVICE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Garadget covers.""" + covers = [] + devices = config.get(CONF_COVERS) + + for device_id, device_config in devices.items(): + args = { + 'name': device_config.get(CONF_NAME), + 'device_id': device_config.get(CONF_DEVICE, device_id), + 'username': device_config.get(CONF_USERNAME), + 'password': device_config.get(CONF_PASSWORD), + 'access_token': device_config.get(CONF_ACCESS_TOKEN) + } + + covers.append(GaradgetCover(hass, args)) + + add_entities(covers) + + +class GaradgetCover(CoverDevice): + """Representation of a Garadget cover.""" + + def __init__(self, hass, args): + """Initialize the cover.""" + self.particle_url = 'https://api.particle.io' + self.hass = hass + self._name = args['name'] + self.device_id = args['device_id'] + self.access_token = args['access_token'] + self.obtained_token = False + self._username = args['username'] + self._password = args['password'] + self._state = None + self.time_in_state = None + self.signal = None + self.sensor = None + self._unsub_listener_cover = None + self._available = True + + if self.access_token is None: + self.access_token = self.get_token() + self._obtained_token = True + + try: + if self._name is None: + doorconfig = self._get_variable('doorConfig') + if doorconfig['nme'] is not None: + self._name = doorconfig['nme'] + self.update() + except requests.exceptions.ConnectionError as ex: + _LOGGER.error( + "Unable to connect to server: %(reason)s", dict(reason=ex)) + self._state = STATE_OFFLINE + self._available = False + self._name = DEFAULT_NAME + except KeyError: + _LOGGER.warning("Garadget device %(device)s seems to be offline", + dict(device=self.device_id)) + self._name = DEFAULT_NAME + self._state = STATE_OFFLINE + self._available = False + + def __del__(self): + """Try to remove token.""" + if self._obtained_token is True: + if self.access_token is not None: + self.remove_token() + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo cover.""" + return True + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {} + + if self.signal is not None: + data[ATTR_SIGNAL_STRENGTH] = self.signal + + if self.time_in_state is not None: + data[ATTR_TIME_IN_STATE] = self.time_in_state + + if self.sensor is not None: + data[ATTR_SENSOR_STRENGTH] = self.sensor + + if self.access_token is not None: + data[CONF_ACCESS_TOKEN] = self.access_token + + return data + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._state is None: + return None + return self._state == STATE_CLOSED + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + def get_token(self): + """Get new token for usage during this session.""" + args = { + 'grant_type': 'password', + 'username': self._username, + 'password': self._password + } + url = '{}/oauth/token'.format(self.particle_url) + ret = requests.post( + url, auth=('particle', 'particle'), data=args, timeout=10) + + try: + return ret.json()['access_token'] + except KeyError: + _LOGGER.error("Unable to retrieve access token") + + def remove_token(self): + """Remove authorization token from API.""" + url = '{}/v1/access_tokens/{}'.format( + self.particle_url, self.access_token) + ret = requests.delete( + url, auth=(self._username, self._password), timeout=10) + return ret.text + + def _start_watcher(self, command): + """Start watcher.""" + _LOGGER.debug("Starting Watcher for command: %s ", command) + if self._unsub_listener_cover is None: + self._unsub_listener_cover = track_utc_time_change( + self.hass, self._check_state) + + def _check_state(self, now): + """Check the state of the service during an operation.""" + self.schedule_update_ha_state(True) + + def close_cover(self, **kwargs): + """Close the cover.""" + if self._state not in ['close', 'closing']: + ret = self._put_command('setState', 'close') + self._start_watcher('close') + return ret.get('return_value') == 1 + + def open_cover(self, **kwargs): + """Open the cover.""" + if self._state not in ['open', 'opening']: + ret = self._put_command('setState', 'open') + self._start_watcher('open') + return ret.get('return_value') == 1 + + def stop_cover(self, **kwargs): + """Stop the door where it is.""" + if self._state not in ['stopped']: + ret = self._put_command('setState', 'stop') + self._start_watcher('stop') + return ret['return_value'] == 1 + + def update(self): + """Get updated status from API.""" + try: + status = self._get_variable('doorStatus') + _LOGGER.debug("Current Status: %s", status['status']) + self._state = STATES_MAP.get(status['status'], None) + self.time_in_state = status['time'] + self.signal = status['signal'] + self.sensor = status['sensor'] + self._available = True + except requests.exceptions.ConnectionError as ex: + _LOGGER.error( + "Unable to connect to server: %(reason)s", dict(reason=ex)) + self._state = STATE_OFFLINE + except KeyError: + _LOGGER.warning("Garadget device %(device)s seems to be offline", + dict(device=self.device_id)) + self._state = STATE_OFFLINE + + if self._state not in [STATE_CLOSING, STATE_OPENING]: + if self._unsub_listener_cover is not None: + self._unsub_listener_cover() + self._unsub_listener_cover = None + + def _get_variable(self, var): + """Get latest status.""" + url = '{}/v1/devices/{}/{}?access_token={}'.format( + self.particle_url, self.device_id, var, self.access_token) + ret = requests.get(url, timeout=10) + result = {} + for pairs in ret.json()['result'].split('|'): + key = pairs.split('=') + result[key[0]] = key[1] + return result + + def _put_command(self, func, arg=None): + """Send commands to API.""" + params = {'access_token': self.access_token} + if arg: + params['command'] = arg + url = '{}/v1/devices/{}/{}'.format( + self.particle_url, self.device_id, func) + ret = requests.post(url, data=params, timeout=10) + return ret.json() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/gogogate2.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/gogogate2.py new file mode 100644 index 00000000000000..accc4f9ec98222 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/gogogate2.py @@ -0,0 +1,117 @@ +""" +Support for Gogogate2 garage Doors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.gogogate2/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, + CONF_IP_ADDRESS, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pygogogate2==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'gogogate2' + +NOTIFICATION_ID = 'gogogate2_notification' +NOTIFICATION_TITLE = 'Gogogate2 Cover Setup' + +COVER_SCHEMA = vol.Schema({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Gogogate2 component.""" + from pygogogate2 import Gogogate2API as pygogogate2 + + ip_address = config.get(CONF_IP_ADDRESS) + name = config.get(CONF_NAME) + password = config.get(CONF_PASSWORD) + username = config.get(CONF_USERNAME) + + mygogogate2 = pygogogate2(username, password, ip_address) + + try: + devices = mygogogate2.get_devices() + if devices is False: + raise ValueError( + "Username or Password is incorrect or no devices found") + + add_entities(MyGogogate2Device( + mygogogate2, door, name) for door in devices) + + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + +class MyGogogate2Device(CoverDevice): + """Representation of a Gogogate2 cover.""" + + def __init__(self, mygogogate2, device, name): + """Initialize with API object, device id.""" + self.mygogogate2 = mygogogate2 + self.device_id = device['door'] + self._name = name or device['name'] + self._status = device['status'] + self._available = None + + @property + def name(self): + """Return the name of the garage door if any.""" + return self._name if self._name else DEFAULT_NAME + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._status == STATE_CLOSED + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self.mygogogate2.close_device(self.device_id) + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self.mygogogate2.open_device(self.device_id) + + def update(self): + """Update status of cover.""" + try: + self._status = self.mygogogate2.get_status(self.device_id) + self._available = True + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + self._status = None + self._available = False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/group.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/group.py new file mode 100644 index 00000000000000..0424c900747bc2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/group.py @@ -0,0 +1,271 @@ +""" +This platform allows several cover to be grouped into one cover. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.group/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.cover import ( + DOMAIN, PLATFORM_SCHEMA, CoverDevice, ATTR_POSITION, + ATTR_CURRENT_POSITION, ATTR_TILT_POSITION, ATTR_CURRENT_TILT_POSITION, + SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, SUPPORT_SET_POSITION, + SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, + SUPPORT_STOP_TILT, SUPPORT_SET_TILT_POSITION, + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, + SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, CONF_NAME, STATE_CLOSED) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +KEY_OPEN_CLOSE = 'open_close' +KEY_STOP = 'stop' +KEY_POSITION = 'position' + +DEFAULT_NAME = 'Cover Group' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Group Cover platform.""" + async_add_entities( + [CoverGroup(config[CONF_NAME], config[CONF_ENTITIES])]) + + +class CoverGroup(CoverDevice): + """Representation of a CoverGroup.""" + + def __init__(self, name, entities): + """Initialize a CoverGroup entity.""" + self._name = name + self._is_closed = False + self._cover_position = 100 + self._tilt_position = None + self._supported_features = 0 + self._assumed_state = True + + self._entities = entities + self._covers = {KEY_OPEN_CLOSE: set(), KEY_STOP: set(), + KEY_POSITION: set()} + self._tilts = {KEY_OPEN_CLOSE: set(), KEY_STOP: set(), + KEY_POSITION: set()} + + @callback + def update_supported_features(self, entity_id, old_state, new_state, + update_state=True): + """Update dictionaries with supported features.""" + if not new_state: + for values in self._covers.values(): + values.discard(entity_id) + for values in self._tilts.values(): + values.discard(entity_id) + if update_state: + self.async_schedule_update_ha_state(True) + return + + features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if features & (SUPPORT_OPEN | SUPPORT_CLOSE): + self._covers[KEY_OPEN_CLOSE].add(entity_id) + else: + self._covers[KEY_OPEN_CLOSE].discard(entity_id) + if features & (SUPPORT_STOP): + self._covers[KEY_STOP].add(entity_id) + else: + self._covers[KEY_STOP].discard(entity_id) + if features & (SUPPORT_SET_POSITION): + self._covers[KEY_POSITION].add(entity_id) + else: + self._covers[KEY_POSITION].discard(entity_id) + + if features & (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT): + self._tilts[KEY_OPEN_CLOSE].add(entity_id) + else: + self._tilts[KEY_OPEN_CLOSE].discard(entity_id) + if features & (SUPPORT_STOP_TILT): + self._tilts[KEY_STOP].add(entity_id) + else: + self._tilts[KEY_STOP].discard(entity_id) + if features & (SUPPORT_SET_TILT_POSITION): + self._tilts[KEY_POSITION].add(entity_id) + else: + self._tilts[KEY_POSITION].discard(entity_id) + + if update_state: + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register listeners.""" + for entity_id in self._entities: + new_state = self.hass.states.get(entity_id) + self.update_supported_features(entity_id, None, new_state, + update_state=False) + async_track_state_change(self.hass, self._entities, + self.update_supported_features) + await self.async_update() + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def assumed_state(self): + """Enable buttons even if at end position.""" + return self._assumed_state + + @property + def should_poll(self): + """Disable polling for cover group.""" + return False + + @property + def supported_features(self): + """Flag supported features for the cover.""" + return self._supported_features + + @property + def is_closed(self): + """Return if all covers in group are closed.""" + return self._is_closed + + @property + def current_cover_position(self): + """Return current position for all covers.""" + return self._cover_position + + @property + def current_cover_tilt_position(self): + """Return current tilt position for all covers.""" + return self._tilt_position + + async def async_open_cover(self, **kwargs): + """Move the covers up.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, data, blocking=True) + + async def async_close_cover(self, **kwargs): + """Move the covers down.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, data, blocking=True) + + async def async_stop_cover(self, **kwargs): + """Fire the stop action.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_STOP]} + await self.hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER, data, blocking=True) + + async def async_set_cover_position(self, **kwargs): + """Set covers position.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_POSITION], + ATTR_POSITION: kwargs[ATTR_POSITION]} + await self.hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_POSITION, data, blocking=True) + + async def async_open_cover_tilt(self, **kwargs): + """Tilt covers open.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, data, blocking=True) + + async def async_close_cover_tilt(self, **kwargs): + """Tilt covers closed.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, data, blocking=True) + + async def async_stop_cover_tilt(self, **kwargs): + """Stop cover tilt.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_STOP]} + await self.hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER_TILT, data, blocking=True) + + async def async_set_cover_tilt_position(self, **kwargs): + """Set tilt position.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_POSITION], + ATTR_TILT_POSITION: kwargs[ATTR_TILT_POSITION]} + await self.hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data, blocking=True) + + async def async_update(self): + """Update state and attributes.""" + self._assumed_state = False + + self._is_closed = True + for entity_id in self._entities: + state = self.hass.states.get(entity_id) + if not state: + continue + if state.state != STATE_CLOSED: + self._is_closed = False + break + + self._cover_position = None + if self._covers[KEY_POSITION]: + position = -1 + self._cover_position = 0 if self.is_closed else 100 + for entity_id in self._covers[KEY_POSITION]: + state = self.hass.states.get(entity_id) + pos = state.attributes.get(ATTR_CURRENT_POSITION) + if position == -1: + position = pos + elif position != pos: + self._assumed_state = True + break + else: + if position != -1: + self._cover_position = position + + self._tilt_position = None + if self._tilts[KEY_POSITION]: + position = -1 + self._tilt_position = 100 + for entity_id in self._tilts[KEY_POSITION]: + state = self.hass.states.get(entity_id) + pos = state.attributes.get(ATTR_CURRENT_TILT_POSITION) + if position == -1: + position = pos + elif position != pos: + self._assumed_state = True + break + else: + if position != -1: + self._tilt_position = position + + supported_features = 0 + supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE \ + if self._covers[KEY_OPEN_CLOSE] else 0 + supported_features |= SUPPORT_STOP \ + if self._covers[KEY_STOP] else 0 + supported_features |= SUPPORT_SET_POSITION \ + if self._covers[KEY_POSITION] else 0 + supported_features |= SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT \ + if self._tilts[KEY_OPEN_CLOSE] else 0 + supported_features |= SUPPORT_STOP_TILT \ + if self._tilts[KEY_STOP] else 0 + supported_features |= SUPPORT_SET_TILT_POSITION \ + if self._tilts[KEY_POSITION] else 0 + self._supported_features = supported_features + + if not self._assumed_state: + for entity_id in self._entities: + state = self.hass.states.get(entity_id) + if state and state.attributes.get(ATTR_ASSUMED_STATE): + self._assumed_state = True + break diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/myq.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/myq.py new file mode 100644 index 00000000000000..bdff232fec9156 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/myq.py @@ -0,0 +1,112 @@ +""" +Support for MyQ-Enabled Garage Doors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.myq/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, SUPPORT_CLOSE, SUPPORT_OPEN, CoverDevice) +from homeassistant.const import ( + CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_CLOSING, + STATE_OPEN, STATE_OPENING) +from homeassistant.helpers import aiohttp_client, config_validation as cv + +REQUIREMENTS = ['pymyq==1.0.0'] +_LOGGER = logging.getLogger(__name__) + +MYQ_TO_HASS = { + 'closed': STATE_CLOSED, + 'closing': STATE_CLOSING, + 'open': STATE_OPEN, + 'opening': STATE_OPENING +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TYPE): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the platform.""" + from pymyq import login + from pymyq.errors import MyQError, UnsupportedBrandError + + websession = aiohttp_client.async_get_clientsession(hass) + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + brand = config[CONF_TYPE] + + try: + myq = await login(username, password, brand, websession) + except UnsupportedBrandError: + _LOGGER.error('Unsupported brand: %s', brand) + return + except MyQError as err: + _LOGGER.error('There was an error while logging in: %s', err) + return + + devices = await myq.get_devices() + async_add_entities([MyQDevice(device) for device in devices], True) + + +class MyQDevice(CoverDevice): + """Representation of a MyQ cover.""" + + def __init__(self, device): + """Initialize with API object, device id.""" + self._device = device + + @property + def device_class(self): + """Define this cover as a garage door.""" + return 'garage' + + @property + def name(self): + """Return the name of the garage door if any.""" + return self._device.name + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return self._device.device_id + + async def async_close_cover(self, **kwargs): + """Issue close command to cover.""" + await self._device.close() + + async def async_open_cover(self, **kwargs): + """Issue open command to cover.""" + await self._device.open() + + async def async_update(self): + """Update status of cover.""" + await self._device.update() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/opengarage.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/opengarage.py new file mode 100644 index 00000000000000..664d2e291ac6d3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/opengarage.py @@ -0,0 +1,191 @@ +""" +Platform for the opengarage.io cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.opengarage/ +""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import ( + CONF_DEVICE, CONF_NAME, STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN, + CONF_COVERS, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_DISTANCE_SENSOR = 'distance_sensor' +ATTR_DOOR_STATE = 'door_state' +ATTR_SIGNAL_STRENGTH = 'wifi_signal' + +CONF_DEVICE_ID = 'device_id' +CONF_DEVICE_KEY = 'device_key' + +DEFAULT_NAME = 'OpenGarage' +DEFAULT_PORT = 80 + +STATE_CLOSING = 'closing' +STATE_OFFLINE = 'offline' +STATE_OPENING = 'opening' +STATE_STOPPED = 'stopped' + +STATES_MAP = { + 0: STATE_CLOSED, + 1: STATE_OPEN, +} + +COVER_SCHEMA = vol.Schema({ + vol.Required(CONF_DEVICE_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the OpenGarage covers.""" + covers = [] + devices = config.get(CONF_COVERS) + + for device_id, device_config in devices.items(): + args = { + CONF_NAME: device_config.get(CONF_NAME), + CONF_HOST: device_config.get(CONF_HOST), + CONF_PORT: device_config.get(CONF_PORT), + CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id), + CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY) + } + + covers.append(OpenGarageCover(hass, args)) + + add_entities(covers, True) + + +class OpenGarageCover(CoverDevice): + """Representation of a OpenGarage cover.""" + + def __init__(self, hass, args): + """Initialize the cover.""" + self.opengarage_url = 'http://{}:{}'.format( + args[CONF_HOST], args[CONF_PORT]) + self.hass = hass + self._name = args[CONF_NAME] + self.device_id = args['device_id'] + self._device_key = args[CONF_DEVICE_KEY] + self._state = None + self._state_before_move = None + self.dist = None + self.signal = None + self._available = True + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {} + + if self.signal is not None: + data[ATTR_SIGNAL_STRENGTH] = self.signal + + if self.dist is not None: + data[ATTR_DISTANCE_SENSOR] = self.dist + + if self._state is not None: + data[ATTR_DOOR_STATE] = self._state + + return data + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: + return None + return self._state in [STATE_CLOSED, STATE_OPENING] + + def close_cover(self, **kwargs): + """Close the cover.""" + if self._state not in [STATE_CLOSED, STATE_CLOSING]: + self._state_before_move = self._state + self._state = STATE_CLOSING + self._push_button() + + def open_cover(self, **kwargs): + """Open the cover.""" + if self._state not in [STATE_OPEN, STATE_OPENING]: + self._state_before_move = self._state + self._state = STATE_OPENING + self._push_button() + + def update(self): + """Get updated status from API.""" + try: + status = self._get_status() + if self._name is None: + if status['name'] is not None: + self._name = status['name'] + state = STATES_MAP.get(status.get('door'), STATE_UNKNOWN) + if self._state_before_move is not None: + if self._state_before_move != state: + self._state = state + self._state_before_move = None + else: + self._state = state + + _LOGGER.debug("%s status: %s", self._name, self._state) + self.signal = status.get('rssi') + self.dist = status.get('dist') + self._available = True + except requests.exceptions.RequestException as ex: + _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", + dict(reason=ex)) + self._state = STATE_OFFLINE + + def _get_status(self): + """Get latest status.""" + url = '{}/jc'.format(self.opengarage_url) + ret = requests.get(url, timeout=10) + return ret.json() + + def _push_button(self): + """Send commands to API.""" + url = '{}/cc?dkey={}&click=1'.format( + self.opengarage_url, self._device_key) + try: + response = requests.get(url, timeout=10).json() + if response['result'] == 2: + _LOGGER.error("Unable to control %s: Device key is incorrect", + self._name) + self._state = self._state_before_move + self._state_before_move = None + except requests.exceptions.RequestException as ex: + _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", + dict(reason=ex)) + self._state = self._state_before_move + self._state_before_move = None + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/rflink.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/rflink.py new file mode 100644 index 00000000000000..cdc7cac3adb052 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/rflink.py @@ -0,0 +1,109 @@ +""" +Support for Rflink Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.rflink/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.rflink import ( + CONF_ALIASES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, + CONF_GROUP, CONF_GROUP_ALIASES, CONF_NOGROUP_ALIASES, + CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, RflinkCommand) +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.const import CONF_NAME, STATE_OPEN + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): vol.Schema({ + cv.string: { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + }, + }), +}) + + +def devices_from_config(domain_config): + """Parse configuration and add Rflink cover devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) + device = RflinkCover(device_id, **device_config) + devices.append(device) + + return devices + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Rflink cover platform.""" + async_add_entities(devices_from_config(config)) + + +class RflinkCover(RflinkCommand, CoverDevice, RestoreEntity): + """Rflink entity which can switch on/stop/off (eg: cover).""" + + async def async_added_to_hass(self): + """Restore RFLink cover state (OPEN/CLOSE).""" + await super().async_added_to_hass() + + old_state = await self.async_get_last_state() + if old_state is not None: + self._state = old_state.state == STATE_OPEN + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event['command'] + if command in ['on', 'allon', 'up']: + self._state = True + elif command in ['off', 'alloff', 'down']: + self._state = False + + @property + def should_poll(self): + """No polling available in RFlink cover.""" + return False + + @property + def is_closed(self): + """Return if the cover is closed.""" + return not self._state + + @property + def assumed_state(self): + """Return True because covers can be stopped midway.""" + return True + + def async_close_cover(self, **kwargs): + """Turn the device close.""" + return self._async_handle_command("close_cover") + + def async_open_cover(self, **kwargs): + """Turn the device open.""" + return self._async_handle_command("open_cover") + + def async_stop_cover(self, **kwargs): + """Turn the device stop.""" + return self._async_handle_command("stop_cover") diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/cover/services.yaml new file mode 100644 index 00000000000000..79f00180a8946d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/services.yaml @@ -0,0 +1,63 @@ +# Describes the format for available cover services + +open_cover: + description: Open all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to open. + example: 'cover.living_room' + +close_cover: + description: Close all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to close. + example: 'cover.living_room' + +set_cover_position: + description: Move to specific position all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to set cover position. + example: 'cover.living_room' + position: + description: Position of the cover (0 to 100). + example: 30 + +stop_cover: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' + +open_cover_tilt: + description: Open all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) tilt to open. + example: 'cover.living_room' + +close_cover_tilt: + description: Close all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to close tilt. + example: 'cover.living_room' + +set_cover_tilt_position: + description: Move to specific position all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to set cover tilt position. + example: 'cover.living_room' + tilt_position: + description: Tilt position of the cover (0 to 100). + example: 30 + +stop_cover_tilt: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/template.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/template.py new file mode 100644 index 00000000000000..1d3642a6036164 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/template.py @@ -0,0 +1,410 @@ +""" +Support for covers which integrate with other components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.template/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.cover import ( + ENTITY_ID_FORMAT, CoverDevice, PLATFORM_SCHEMA, + SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, + SUPPORT_SET_TILT_POSITION, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, + SUPPORT_SET_POSITION, ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.const import ( + CONF_FRIENDLY_NAME, CONF_ENTITY_ID, + EVENT_HOMEASSISTANT_START, MATCH_ALL, + CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, + CONF_ENTITY_PICTURE_TEMPLATE, CONF_OPTIMISTIC, + STATE_OPEN, STATE_CLOSED) +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) +_VALID_STATES = [STATE_OPEN, STATE_CLOSED, 'true', 'false'] + +CONF_COVERS = 'covers' + +CONF_POSITION_TEMPLATE = 'position_template' +CONF_TILT_TEMPLATE = 'tilt_template' +OPEN_ACTION = 'open_cover' +CLOSE_ACTION = 'close_cover' +STOP_ACTION = 'stop_cover' +POSITION_ACTION = 'set_cover_position' +TILT_ACTION = 'set_cover_tilt_position' +CONF_TILT_OPTIMISTIC = 'tilt_optimistic' + +CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position' +CONF_OPEN_OR_CLOSE = 'open_or_close' + +TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | + SUPPORT_SET_TILT_POSITION) + +COVER_SCHEMA = vol.Schema({ + vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Exclusive(CONF_POSITION_TEMPLATE, + CONF_VALUE_OR_POSITION_TEMPLATE): cv.template, + vol.Exclusive(CONF_VALUE_TEMPLATE, + CONF_VALUE_OR_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_TILT_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA), +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Template cover.""" + covers = [] + + for device, device_config in config[CONF_COVERS].items(): + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) + state_template = device_config.get(CONF_VALUE_TEMPLATE) + position_template = device_config.get(CONF_POSITION_TEMPLATE) + tilt_template = device_config.get(CONF_TILT_TEMPLATE) + icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) + open_action = device_config.get(OPEN_ACTION) + close_action = device_config.get(CLOSE_ACTION) + stop_action = device_config.get(STOP_ACTION) + position_action = device_config.get(POSITION_ACTION) + tilt_action = device_config.get(TILT_ACTION) + optimistic = device_config.get(CONF_OPTIMISTIC) + tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) + + if position_action is None and open_action is None: + _LOGGER.error('Must specify at least one of %s' or '%s', + OPEN_ACTION, POSITION_ACTION) + continue + template_entity_ids = set() + if state_template is not None: + temp_ids = state_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if position_template is not None: + temp_ids = position_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if tilt_template is not None: + temp_ids = tilt_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if icon_template is not None: + temp_ids = icon_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if entity_picture_template is not None: + temp_ids = entity_picture_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if not template_entity_ids: + template_entity_ids = MATCH_ALL + + entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids) + + covers.append( + CoverTemplate( + hass, + device, friendly_name, state_template, + position_template, tilt_template, icon_template, + entity_picture_template, open_action, close_action, + stop_action, position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids + ) + ) + if not covers: + _LOGGER.error("No covers added") + return False + + async_add_entities(covers) + return True + + +class CoverTemplate(CoverDevice): + """Representation of a Template cover.""" + + def __init__(self, hass, device_id, friendly_name, state_template, + position_template, tilt_template, icon_template, + entity_picture_template, open_action, close_action, + stop_action, position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids): + """Initialize the Template cover.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._name = friendly_name + self._template = state_template + self._position_template = position_template + self._tilt_template = tilt_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._open_script = None + if open_action is not None: + self._open_script = Script(hass, open_action) + self._close_script = None + if close_action is not None: + self._close_script = Script(hass, close_action) + self._stop_script = None + if stop_action is not None: + self._stop_script = Script(hass, stop_action) + self._position_script = None + if position_action is not None: + self._position_script = Script(hass, position_action) + self._tilt_script = None + if tilt_action is not None: + self._tilt_script = Script(hass, tilt_action) + self._optimistic = (optimistic or + (not state_template and not position_template)) + self._tilt_optimistic = tilt_optimistic or not tilt_template + self._icon = None + self._entity_picture = None + self._position = None + self._tilt_value = None + self._entities = entity_ids + + if self._template is not None: + self._template.hass = self.hass + if self._position_template is not None: + self._position_template.hass = self.hass + if self._tilt_template is not None: + self._tilt_template.hass = self.hass + if self._icon_template is not None: + self._icon_template.hass = self.hass + if self._entity_picture_template is not None: + self._entity_picture_template.hass = self.hass + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def template_cover_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.async_schedule_update_ha_state(True) + + @callback + def template_cover_startup(event): + """Update template on startup.""" + async_track_state_change( + self.hass, self._entities, template_cover_state_listener) + + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_cover_startup) + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._position == 0 + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + if self._position_template or self._position_script: + return self._position + return None + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._tilt_value + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + + if self._stop_script is not None: + supported_features |= SUPPORT_STOP + + if self._position_script is not None: + supported_features |= SUPPORT_SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= TILT_FEATURES + + return supported_features + + @property + def should_poll(self): + """Return the polling state.""" + return False + + async def async_open_cover(self, **kwargs): + """Move the cover up.""" + if self._open_script: + await self._open_script.async_run(context=self._context) + elif self._position_script: + await self._position_script.async_run( + {"position": 100}, context=self._context) + if self._optimistic: + self._position = 100 + self.async_schedule_update_ha_state() + + async def async_close_cover(self, **kwargs): + """Move the cover down.""" + if self._close_script: + await self._close_script.async_run(context=self._context) + elif self._position_script: + await self._position_script.async_run( + {"position": 0}, context=self._context) + if self._optimistic: + self._position = 0 + self.async_schedule_update_ha_state() + + async def async_stop_cover(self, **kwargs): + """Fire the stop action.""" + if self._stop_script: + await self._stop_script.async_run(context=self._context) + + async def async_set_cover_position(self, **kwargs): + """Set cover position.""" + self._position = kwargs[ATTR_POSITION] + await self._position_script.async_run( + {"position": self._position}, context=self._context) + if self._optimistic: + self.async_schedule_update_ha_state() + + async def async_open_cover_tilt(self, **kwargs): + """Tilt the cover open.""" + self._tilt_value = 100 + await self._tilt_script.async_run( + {"tilt": self._tilt_value}, context=self._context) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() + + async def async_close_cover_tilt(self, **kwargs): + """Tilt the cover closed.""" + self._tilt_value = 0 + await self._tilt_script.async_run( + {"tilt": self._tilt_value}, context=self._context) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() + + async def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + self._tilt_value = kwargs[ATTR_TILT_POSITION] + await self._tilt_script.async_run( + {"tilt": self._tilt_value}, context=self._context) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() + + async def async_update(self): + """Update the state from the template.""" + if self._template is not None: + try: + state = self._template.async_render().lower() + if state in _VALID_STATES: + if state in ('true', STATE_OPEN): + self._position = 100 + else: + self._position = 0 + else: + _LOGGER.error( + 'Received invalid cover is_on state: %s. Expected: %s', + state, ', '.join(_VALID_STATES)) + self._position = None + except TemplateError as ex: + _LOGGER.error(ex) + self._position = None + if self._position_template is not None: + try: + state = float(self._position_template.async_render()) + if state < 0 or state > 100: + self._position = None + _LOGGER.error("Cover position value must be" + " between 0 and 100." + " Value was: %.2f", state) + else: + self._position = state + except TemplateError as ex: + _LOGGER.error(ex) + self._position = None + except ValueError as ex: + _LOGGER.error(ex) + self._position = None + if self._tilt_template is not None: + try: + state = float(self._tilt_template.async_render()) + if state < 0 or state > 100: + self._tilt_value = None + _LOGGER.error("Tilt value must be between 0 and 100." + " Value was: %.2f", state) + else: + self._tilt_value = state + except TemplateError as ex: + _LOGGER.error(ex) + self._tilt_value = None + except ValueError as ex: + _LOGGER.error(ex) + self._tilt_value = None + + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + + try: + setattr(self, property_name, template.async_render()) + except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) + return + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/cover/zwave.py b/homeassistant-0.88.0.dev0/homeassistant/components/cover/zwave.py new file mode 100644 index 00000000000000..835305449e8251 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/cover/zwave.py @@ -0,0 +1,189 @@ +""" +Support for Z-Wave cover components. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.zwave/ +""" +import logging +from homeassistant.core import callback +from homeassistant.components.cover import ( + DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) +from homeassistant.components import zwave +from homeassistant.components.zwave import ( + ZWaveDeviceEntity, workaround) +from homeassistant.components.cover import CoverDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave covers.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Cover from Config Entry.""" + @callback + def async_add_cover(cover): + """Add Z-Wave Cover.""" + async_add_entities([cover]) + + async_dispatcher_connect(hass, 'zwave_new_cover', async_add_cover) + + +def get_device(hass, values, node_config, **kwargs): + """Create Z-Wave entity device.""" + invert_buttons = node_config.get(zwave.CONF_INVERT_OPENCLOSE_BUTTONS) + if (values.primary.command_class == + zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL + and values.primary.index == 0): + return ZwaveRollershutter(hass, values, invert_buttons) + if values.primary.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY: + return ZwaveGarageDoorSwitch(values) + if values.primary.command_class == \ + zwave.const.COMMAND_CLASS_BARRIER_OPERATOR: + return ZwaveGarageDoorBarrier(values) + return None + + +class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): + """Representation of an Z-Wave cover.""" + + def __init__(self, hass, values, invert_buttons): + """Initialize the Z-Wave rollershutter.""" + ZWaveDeviceEntity.__init__(self, values, DOMAIN) + self._network = hass.data[zwave.const.DATA_NETWORK] + self._open_id = None + self._close_id = None + self._current_position = None + self._invert_buttons = invert_buttons + + self._workaround = workaround.get_device_mapping(values.primary) + if self._workaround: + _LOGGER.debug("Using workaround %s", self._workaround) + self.update_properties() + + def update_properties(self): + """Handle data changes for node values.""" + # Position value + self._current_position = self.values.primary.data + + if self.values.open and self.values.close and \ + self._open_id is None and self._close_id is None: + if self._invert_buttons: + self._open_id = self.values.close.value_id + self._close_id = self.values.open.value_id + else: + self._open_id = self.values.open.value_id + self._close_id = self.values.close.value_id + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is None: + return None + if self.current_cover_position > 0: + return False + return True + + @property + def current_cover_position(self): + """Return the current position of Zwave roller shutter.""" + if self._workaround == workaround.WORKAROUND_NO_POSITION: + return None + if self._current_position is not None: + if self._current_position <= 5: + return 0 + if self._current_position >= 95: + return 100 + return self._current_position + + def open_cover(self, **kwargs): + """Move the roller shutter up.""" + self._network.manager.pressButton(self._open_id) + + def close_cover(self, **kwargs): + """Move the roller shutter down.""" + self._network.manager.pressButton(self._close_id) + + def set_cover_position(self, **kwargs): + """Move the roller shutter to a specific position.""" + self.node.set_dimmer(self.values.primary.value_id, + kwargs.get(ATTR_POSITION)) + + def stop_cover(self, **kwargs): + """Stop the roller shutter.""" + self._network.manager.releaseButton(self._open_id) + + +class ZwaveGarageDoorBase(zwave.ZWaveDeviceEntity, CoverDevice): + """Base class for a Zwave garage door device.""" + + def __init__(self, values): + """Initialize the zwave garage door.""" + ZWaveDeviceEntity.__init__(self, values, DOMAIN) + self._state = None + self.update_properties() + + def update_properties(self): + """Handle data changes for node values.""" + self._state = self.values.primary.data + _LOGGER.debug("self._state=%s", self._state) + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_GARAGE + + +class ZwaveGarageDoorSwitch(ZwaveGarageDoorBase): + """Representation of a switch based Zwave garage door device.""" + + @property + def is_closed(self): + """Return the current position of Zwave garage door.""" + return not self._state + + def close_cover(self, **kwargs): + """Close the garage door.""" + self.values.primary.data = False + + def open_cover(self, **kwargs): + """Open the garage door.""" + self.values.primary.data = True + + +class ZwaveGarageDoorBarrier(ZwaveGarageDoorBase): + """Representation of a barrier operator Zwave garage door device.""" + + @property + def is_opening(self): + """Return true if cover is in an opening state.""" + return self._state == "Opening" + + @property + def is_closing(self): + """Return true if cover is in a closing state.""" + return self._state == "Closing" + + @property + def is_closed(self): + """Return the current position of Zwave garage door.""" + return self._state == "Closed" + + def close_cover(self, **kwargs): + """Close the garage door.""" + self.values.primary.data = "Closed" + + def open_cover(self, **kwargs): + """Open the garage door.""" + self.values.primary.data = "Opened" diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ca.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ca.json new file mode 100644 index 00000000000000..2fa60015ca33cf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "device_fail": "S'ha produ\u00eft un error inesperat al crear el dispositiu.", + "device_timeout": "S'ha acabat el temps d'espera en la connexi\u00f3 amb el dispositiu." + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "description": "Introdueix l'adre\u00e7a IP del teu Daikin AC.", + "title": "Configuraci\u00f3 de Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/de.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/de.json new file mode 100644 index 00000000000000..0a09c7b5cfabd3 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "device_fail": "Unerwarteter Fehler beim Erstellen des Ger\u00e4ts.", + "device_timeout": "Zeit\u00fcberschreitung beim Verbinden mit dem Ger\u00e4t." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Geben Sie die IP-Adresse Ihrer Daikin AC ein.", + "title": "Daikin AC konfigurieren" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/en.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/en.json new file mode 100644 index 00000000000000..1605e1dc8f6952 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "device_fail": "Unexpected error creating device.", + "device_timeout": "Timeout connecting to the device." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Enter IP address of your Daikin AC.", + "title": "Configure Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/hu.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/hu.json new file mode 100644 index 00000000000000..623fab6828a02f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ko.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ko.json new file mode 100644 index 00000000000000..a203affdbb8bb2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\uc7a5\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "device_fail": "\uc7a5\uce58\ub97c \uad6c\uc131\ud558\ub294\uc911 \uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "device_timeout": "\uc7a5\uce58 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + }, + "description": "Daikin AC \uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "Daikin AC \uad6c\uc131" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/lb.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/lb.json new file mode 100644 index 00000000000000..cdf98f5e597ee5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "device_fail": "Onerwaarte Feeler beim erstelle vum Apparat.", + "device_timeout": "Z\u00e4it Iwwerschreidung beim verbannen mam Apparat." + }, + "step": { + "user": { + "data": { + "host": "Apparat" + }, + "description": "Gitt d'IP Adresse vum Daikin AC an:", + "title": "Daikin AC konfigur\u00e9ieren" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/nl.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/nl.json new file mode 100644 index 00000000000000..683bb61dd44ac7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "device_fail": "Onverwachte fout bij het aanmaken van een apparaat.", + "device_timeout": "Time-out voor verbinding met het apparaat." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Voer het IP-adres van uw Daikin AC in.", + "title": "Daikin AC instellen" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/no.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/no.json new file mode 100644 index 00000000000000..806106c5e52e56 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "device_fail": "Uventet feil under oppretting av enheten.", + "device_timeout": "Tidsavbrudd for tilkobling til enheten." + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "description": "Angi IP-adressen til din Daikin AC.", + "title": "Konfigurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pl.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pl.json new file mode 100644 index 00000000000000..49c5a4976673b6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "device_fail": "Nieoczekiwany b\u0142\u0105d tworzenia urz\u0105dzenia.", + "device_timeout": "Limit czasu pod\u0142\u0105czenia do urz\u0105dzenia." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Wprowad\u017a adres IP Daikin AC.", + "title": "Konfiguracja Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pt-BR.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pt-BR.json new file mode 100644 index 00000000000000..58c5a9c77b27ac --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pt-BR.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "device_fail": "Erro inesperado ao criar dispositivo.", + "device_timeout": "Excedido tempo limite conectando ao dispositivo" + }, + "step": { + "user": { + "description": "Digite o endere\u00e7o IP do seu AC Daikin.", + "title": "Configurar o AC Daikin" + } + }, + "title": "AC Daikin" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pt.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pt.json new file mode 100644 index 00000000000000..34b4c86e77d33d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "device_fail": "Erro inesperado ao criar dispositivo.", + "device_timeout": "Tempo excedido a tentar ligar ao dispositivo." + }, + "step": { + "user": { + "data": { + "host": "Servidor" + }, + "description": "Introduza o endere\u00e7o IP do seu Daikin AC.", + "title": "Configurar o Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ru.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ru.json new file mode 100644 index 00000000000000..83c42f4280c837 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "device_fail": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", + "device_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 \u0432\u0430\u0448\u0435\u0433\u043e Daikin AC.", + "title": "Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/sl.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/sl.json new file mode 100644 index 00000000000000..088b354fbb1bf0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "device_fail": "Nepri\u010dakovana napaka pri ustvarjanju naprave.", + "device_timeout": "\u010casovna omejitev za priklop na napravo je potekla." + }, + "step": { + "user": { + "data": { + "host": "Gostitelj" + }, + "description": "Vnesite naslov IP va\u0161e Daikin klime.", + "title": "Nastavite Daikin klimo" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/zh-Hans.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/zh-Hans.json new file mode 100644 index 00000000000000..1330e3a932d668 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u914d\u7f6e\u5b8c\u6210", + "device_fail": "\u521b\u5efa\u8bbe\u5907\u65f6\u51fa\u73b0\u610f\u5916\u9519\u8bef\u3002", + "device_timeout": "\u8fde\u63a5\u8bbe\u5907\u8d85\u65f6\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a" + }, + "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684IP\u5730\u5740\u3002", + "title": "\u914d\u7f6e Daikin \u7a7a\u8c03" + } + }, + "title": "Daikin \u7a7a\u8c03" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/zh-Hant.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/zh-Hant.json new file mode 100644 index 00000000000000..1699bcad8f08bd --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "device_fail": "\u5275\u5efa\u88dd\u7f6e\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002", + "device_timeout": "\u9023\u7dda\u81f3\u88dd\u7f6e\u903e\u6642\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "description": "\u8f38\u5165\u60a8\u7684\u5927\u91d1\u7a7a\u8abf IP \u4f4d\u5740\u3002", + "title": "\u8a2d\u5b9a\u5927\u91d1\u7a7a\u8abf" + } + }, + "title": "\u5927\u91d1\u7a7a\u8abf\uff08Daikin AC\uff09" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/__init__.py new file mode 100644 index 00000000000000..f16d6a87d55c6c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/__init__.py @@ -0,0 +1,146 @@ +""" +Platform for the Daikin AC. + +For more details about this component, please refer to the documentation +https://home-assistant.io/components/daikin/ +""" +import asyncio +from datetime import timedelta +import logging +from socket import timeout + +import async_timeout +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOSTS +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +from . import config_flow # noqa pylint_disable=unused-import +from .const import KEY_HOST + +REQUIREMENTS = ['pydaikin==0.9'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'daikin' + + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +COMPONENT_TYPES = ['climate', 'sensor'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional( + CONF_HOSTS, default=[] + ): vol.All(cv.ensure_list, [cv.string]), + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Establish connection with Daikin.""" + if DOMAIN not in config: + return True + + hosts = config[DOMAIN].get(CONF_HOSTS) + if not hosts: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': SOURCE_IMPORT})) + for host in hosts: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + KEY_HOST: host, + })) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Establish connection with Daikin.""" + conf = entry.data + daikin_api = await daikin_api_setup(hass, conf[KEY_HOST]) + if not daikin_api: + return False + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) + await asyncio.wait([ + hass.config_entries.async_forward_entry_setup(entry, component) + for component in COMPONENT_TYPES + ]) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await asyncio.wait([ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in COMPONENT_TYPES + ]) + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return True + + +async def daikin_api_setup(hass, host): + """Create a Daikin instance only once.""" + from pydaikin.appliance import Appliance + try: + with async_timeout.timeout(10): + device = await hass.async_add_executor_job(Appliance, host) + except asyncio.TimeoutError: + _LOGGER.error("Connection to Daikin could not be established") + return None + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unexpected error creating device") + return None + + name = device.values['name'] + api = DaikinApi(device, name) + + return api + + +class DaikinApi: + """Keep the Daikin instance in one place and centralize the update.""" + + def __init__(self, device, name): + """Initialize the Daikin Handle.""" + self.device = device + self.name = name + self.ip_address = device.ip + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Pull the latest data from Daikin.""" + try: + self.device.update_status() + except timeout: + _LOGGER.warning( + "Connection failed for %s", self.ip_address + ) + + @property + def mac(self): + """Return mac-address of device.""" + return self.device.values.get(CONNECTION_NETWORK_MAC) + + @property + def device_info(self): + """Return a device description for device registry.""" + info = self.device.values + return { + 'connections': {(CONNECTION_NETWORK_MAC, self.mac)}, + 'identifieres': self.mac, + 'manufacturer': 'Daikin', + 'model': info.get('model'), + 'name': info.get('name'), + 'sw_version': info.get('ver').replace('_', '.'), + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/climate.py b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/climate.py new file mode 100644 index 00000000000000..45589c128644e6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/climate.py @@ -0,0 +1,272 @@ +""" +Support for the Daikin HVAC. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.daikin/ +""" +import logging +import re + +import voluptuous as vol + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_OPERATION_MODE, + ATTR_SWING_MODE, PLATFORM_SCHEMA, STATE_AUTO, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, STATE_HEAT, STATE_OFF, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, + ClimateDevice) +from homeassistant.components.daikin import DOMAIN as DAIKIN_DOMAIN +from homeassistant.components.daikin.const import ( + ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE) +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + +HA_STATE_TO_DAIKIN = { + STATE_FAN_ONLY: 'fan', + STATE_DRY: 'dry', + STATE_COOL: 'cool', + STATE_HEAT: 'hot', + STATE_AUTO: 'auto', + STATE_OFF: 'off', +} + +DAIKIN_TO_HA_STATE = { + 'fan': STATE_FAN_ONLY, + 'dry': STATE_DRY, + 'cool': STATE_COOL, + 'hot': STATE_HEAT, + 'auto': STATE_AUTO, + 'off': STATE_OFF, +} + +HA_ATTR_TO_DAIKIN = { + ATTR_OPERATION_MODE: 'mode', + ATTR_FAN_MODE: 'f_rate', + ATTR_SWING_MODE: 'f_dir', + ATTR_INSIDE_TEMPERATURE: 'htemp', + ATTR_OUTSIDE_TEMPERATURE: 'otemp', + ATTR_TARGET_TEMPERATURE: 'stemp' +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Old way of setting up the Daikin HVAC platform. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + async_add_entities([DaikinClimate(daikin_api)]) + + +class DaikinClimate(ClimateDevice): + """Representation of a Daikin HVAC.""" + + def __init__(self, api): + """Initialize the climate device.""" + from pydaikin import appliance + + self._api = api + self._list = { + ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN), + ATTR_FAN_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE]) + ) + ), + ATTR_SWING_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]) + ) + ), + } + + self._supported_features = SUPPORT_TARGET_TEMPERATURE \ + | SUPPORT_OPERATION_MODE + + if self._api.device.support_fan_mode: + self._supported_features |= SUPPORT_FAN_MODE + + if self._api.device.support_swing_mode: + self._supported_features |= SUPPORT_SWING_MODE + + def get(self, key): + """Retrieve device settings from API library cache.""" + value = None + cast_to_float = False + + if key in [ATTR_TEMPERATURE, ATTR_INSIDE_TEMPERATURE, + ATTR_CURRENT_TEMPERATURE]: + key = ATTR_INSIDE_TEMPERATURE + + daikin_attr = HA_ATTR_TO_DAIKIN.get(key) + + if key == ATTR_INSIDE_TEMPERATURE: + value = self._api.device.values.get(daikin_attr) + cast_to_float = True + elif key == ATTR_TARGET_TEMPERATURE: + value = self._api.device.values.get(daikin_attr) + cast_to_float = True + elif key == ATTR_OUTSIDE_TEMPERATURE: + value = self._api.device.values.get(daikin_attr) + cast_to_float = True + elif key == ATTR_FAN_MODE: + value = self._api.device.represent(daikin_attr)[1].title() + elif key == ATTR_SWING_MODE: + value = self._api.device.represent(daikin_attr)[1].title() + elif key == ATTR_OPERATION_MODE: + # Daikin can return also internal states auto-1 or auto-7 + # and we need to translate them as AUTO + daikin_mode = re.sub( + '[^a-z]', '', + self._api.device.represent(daikin_attr)[1]) + ha_mode = DAIKIN_TO_HA_STATE.get(daikin_mode) + value = ha_mode + + if value is None: + _LOGGER.error("Invalid value requested for key %s", key) + else: + if value in ("-", "--"): + value = None + elif cast_to_float: + try: + value = float(value) + except ValueError: + value = None + + return value + + def set(self, settings): + """Set device settings using API.""" + values = {} + + for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, + ATTR_OPERATION_MODE]: + value = settings.get(attr) + if value is None: + continue + + daikin_attr = HA_ATTR_TO_DAIKIN.get(attr) + if daikin_attr is not None: + if attr == ATTR_OPERATION_MODE: + values[daikin_attr] = HA_STATE_TO_DAIKIN[value] + elif value in self._list[attr]: + values[daikin_attr] = value.lower() + else: + _LOGGER.error("Invalid value %s for %s", attr, value) + + # temperature + elif attr == ATTR_TEMPERATURE: + try: + values['stemp'] = str(int(value)) + except ValueError: + _LOGGER.error("Invalid temperature %s", value) + + if values: + self._api.device.set(values) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._api.name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._api.mac + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.get(ATTR_CURRENT_TEMPERATURE) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.get(ATTR_TARGET_TEMPERATURE) + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + self.set(kwargs) + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self.get(ATTR_OPERATION_MODE) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._list.get(ATTR_OPERATION_MODE) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode.""" + self.set({ATTR_OPERATION_MODE: operation_mode}) + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self.get(ATTR_FAN_MODE) + + def set_fan_mode(self, fan_mode): + """Set fan mode.""" + self.set({ATTR_FAN_MODE: fan_mode}) + + @property + def fan_list(self): + """List of available fan modes.""" + return self._list.get(ATTR_FAN_MODE) + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return self.get(ATTR_SWING_MODE) + + def set_swing_mode(self, swing_mode): + """Set new target temperature.""" + self.set({ATTR_SWING_MODE: swing_mode}) + + @property + def swing_list(self): + """List of available swing modes.""" + return self._list.get(ATTR_SWING_MODE) + + def update(self): + """Retrieve latest state.""" + self._api.update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/config_flow.py b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/config_flow.py new file mode 100644 index 00000000000000..b46e800c3d8765 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the Daikin platform.""" +import asyncio +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries + +from .const import KEY_HOST, KEY_IP, KEY_MAC + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register('daikin') +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def _create_entry(self, host, mac): + """Register new entry.""" + # Check if mac already is registered + for entry in self._async_current_entries(): + if entry.data[KEY_MAC] == mac: + return self.async_abort(reason='already_configured') + + return self.async_create_entry( + title=host, + data={ + KEY_HOST: host, + KEY_MAC: mac + }) + + async def _create_device(self, host): + """Create device.""" + from pydaikin.appliance import Appliance + try: + with async_timeout.timeout(10): + device = await self.hass.async_add_executor_job( + Appliance, host) + except asyncio.TimeoutError: + return self.async_abort(reason='device_timeout') + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error creating device") + return self.async_abort(reason='device_fail') + + mac = device.values.get('mac') + return await self._create_entry(host, mac) + + async def async_step_user(self, user_input=None): + """User initiated config flow.""" + if user_input is None: + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(KEY_HOST): str + }) + ) + return await self._create_device(user_input[KEY_HOST]) + + async def async_step_import(self, user_input): + """Import a config entry.""" + host = user_input.get(KEY_HOST) + if not host: + return await self.async_step_user() + return await self._create_device(host) + + async def async_step_discovery(self, user_input): + """Initialize step from discovery.""" + _LOGGER.info("Discovered device: %s", user_input) + return await self._create_entry(user_input[KEY_IP], + user_input[KEY_MAC]) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/const.py b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/const.py new file mode 100644 index 00000000000000..cd2742b5b5e060 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/const.py @@ -0,0 +1,25 @@ +"""Constants for Daikin.""" +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE + +ATTR_TARGET_TEMPERATURE = 'target_temperature' +ATTR_INSIDE_TEMPERATURE = 'inside_temperature' +ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' + +SENSOR_TYPE_TEMPERATURE = 'temperature' + +SENSOR_TYPES = { + ATTR_INSIDE_TEMPERATURE: { + CONF_NAME: 'Inside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + }, + ATTR_OUTSIDE_TEMPERATURE: { + CONF_NAME: 'Outside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + } +} + +KEY_HOST = 'host' +KEY_MAC = 'mac' +KEY_IP = 'ip' diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/sensor.py new file mode 100644 index 00000000000000..f887ccf64a0f2b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/sensor.py @@ -0,0 +1,111 @@ +""" +Support for Daikin AC Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.daikin/ +""" +import logging + +from homeassistant.components.daikin import DOMAIN as DAIKIN_DOMAIN +from homeassistant.components.daikin.const import ( + ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, SENSOR_TYPE_TEMPERATURE, + SENSOR_TYPES) +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE +from homeassistant.helpers.entity import Entity +from homeassistant.util.unit_system import UnitSystem + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Old way of setting up the Daikin sensors. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + async_add_entities([ + DaikinClimateSensor(daikin_api, sensor, hass.config.units) + for sensor in SENSOR_TYPES + ]) + + +class DaikinClimateSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, api, monitored_state, units: UnitSystem, + name=None) -> None: + """Initialize the sensor.""" + self._api = api + self._sensor = SENSOR_TYPES.get(monitored_state) + if name is None: + name = "{} {}".format(self._sensor[CONF_NAME], api.name) + + self._name = "{} {}".format(name, monitored_state.replace("_", " ")) + self._device_attribute = monitored_state + + if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: + self._unit_of_measurement = units.temperature_unit + + @property + def unique_id(self): + """Return a unique ID.""" + return "{}-{}".format(self._api.mac, self._device_attribute) + + def get(self, key): + """Retrieve device settings from API library cache.""" + value = None + cast_to_float = False + + if key == ATTR_INSIDE_TEMPERATURE: + value = self._api.device.values.get('htemp') + cast_to_float = True + elif key == ATTR_OUTSIDE_TEMPERATURE: + value = self._api.device.values.get('otemp') + + if value is None: + _LOGGER.warning("Invalid value requested for key %s", key) + else: + if value in ("-", "--"): + value = None + elif cast_to_float: + try: + value = float(value) + except ValueError: + value = None + + return value + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._sensor[CONF_ICON] + + @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.get(self._device_attribute) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Retrieve latest state.""" + self._api.update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/daikin/strings.json b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/strings.json new file mode 100644 index 00000000000000..4badc8b72d7890 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/daikin/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Daikin AC", + "step": { + "user": { + "title": "Configure Daikin AC", + "description": "Enter IP address of your Daikin AC.", + "data": { + "host": "Host" + } + } + }, + "abort": { + "device_timeout": "Timeout connecting to the device.", + "device_fail": "Unexpected error creating device.", + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/__init__.py new file mode 100644 index 00000000000000..80c36b6f0c63f5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/__init__.py @@ -0,0 +1,80 @@ +""" +Support for Danfoss Air HRV. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/danfoss_air/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['pydanfossair==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +DANFOSS_AIR_PLATFORMS = ['sensor', 'binary_sensor'] +DOMAIN = 'danfoss_air' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Danfoss Air component.""" + conf = config[DOMAIN] + + hass.data[DOMAIN] = DanfossAir(conf[CONF_HOST]) + + for platform in DANFOSS_AIR_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True + + +class DanfossAir: + """Handle all communication with Danfoss Air CCM unit.""" + + def __init__(self, host): + """Initialize the Danfoss Air CCM connection.""" + self._data = {} + + from pydanfossair.danfossclient import DanfossClient + + self._client = DanfossClient(host) + + def get_value(self, item): + """Get value for sensor.""" + return self._data.get(item) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Danfoss Air API.""" + _LOGGER.debug("Fetching data from Danfoss Air CCM module") + from pydanfossair.commands import ReadCommand + self._data[ReadCommand.exhaustTemperature] \ + = self._client.command(ReadCommand.exhaustTemperature) + self._data[ReadCommand.outdoorTemperature] \ + = self._client.command(ReadCommand.outdoorTemperature) + self._data[ReadCommand.supplyTemperature] \ + = self._client.command(ReadCommand.supplyTemperature) + self._data[ReadCommand.extractTemperature] \ + = self._client.command(ReadCommand.extractTemperature) + self._data[ReadCommand.humidity] \ + = round(self._client.command(ReadCommand.humidity), 2) + self._data[ReadCommand.filterPercent] \ + = round(self._client.command(ReadCommand.filterPercent), 2) + self._data[ReadCommand.bypass] \ + = self._client.command(ReadCommand.bypass) + + _LOGGER.debug("Done fetching data from Danfoss Air CCM module") diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/binary_sensor.py new file mode 100644 index 00000000000000..bf8fe952993d48 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/binary_sensor.py @@ -0,0 +1,56 @@ +""" +Support for the for Danfoss Air HRV binary sensor platform. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.danfoss_air/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.danfoss_air import DOMAIN \ + as DANFOSS_AIR_DOMAIN + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Danfoss Air sensors etc.""" + from pydanfossair.commands import ReadCommand + data = hass.data[DANFOSS_AIR_DOMAIN] + + sensors = [["Danfoss Air Bypass Active", ReadCommand.bypass]] + + dev = [] + + for sensor in sensors: + dev.append(DanfossAirBinarySensor(data, sensor[0], sensor[1])) + + add_entities(dev, True) + + +class DanfossAirBinarySensor(BinarySensorDevice): + """Representation of a Danfoss Air binary sensor.""" + + def __init__(self, data, name, sensor_type): + """Initialize the Danfoss Air binary sensor.""" + self._data = data + self._name = name + self._state = None + self._type = sensor_type + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Type of device class.""" + return "opening" + + def update(self): + """Fetch new state data for the sensor.""" + self._data.update() + + self._state = self._data.get_value(self._type) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/sensor.py new file mode 100644 index 00000000000000..2f3807c4999943 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/danfoss_air/sensor.py @@ -0,0 +1,76 @@ +""" +Support for the for Danfoss Air HRV sensor platform. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.danfoss_air/ +""" +from homeassistant.components.danfoss_air import DOMAIN \ + as DANFOSS_AIR_DOMAIN +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the available Danfoss Air sensors etc.""" + from pydanfossair.commands import ReadCommand + + data = hass.data[DANFOSS_AIR_DOMAIN] + + sensors = [ + ["Danfoss Air Exhaust Temperature", TEMP_CELSIUS, + ReadCommand.exhaustTemperature], + ["Danfoss Air Outdoor Temperature", TEMP_CELSIUS, + ReadCommand.outdoorTemperature], + ["Danfoss Air Supply Temperature", TEMP_CELSIUS, + ReadCommand.supplyTemperature], + ["Danfoss Air Extract Temperature", TEMP_CELSIUS, + ReadCommand.extractTemperature], + ["Danfoss Air Remaining Filter", '%', + ReadCommand.filterPercent], + ["Danfoss Air Humidity", '%', + ReadCommand.humidity] + ] + + dev = [] + + for sensor in sensors: + dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2])) + + add_entities(dev, True) + + +class DanfossAir(Entity): + """Representation of a Sensor.""" + + def __init__(self, data, name, sensor_unit, sensor_type): + """Initialize the sensor.""" + self._data = data + self._name = name + self._state = None + self._type = sensor_type + self._unit = sensor_unit + + @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 unit of measurement.""" + return self._unit + + def update(self): + """Update the new state of the sensor. + + This is done through the DanfossAir object that does the actual + communication with the Air CCM. + """ + self._data.update() + + self._state = self._data.get_value(self._type) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/datadog.py b/homeassistant-0.88.0.dev0/homeassistant/components/datadog.py new file mode 100644 index 00000000000000..58503d7187b373 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/datadog.py @@ -0,0 +1,104 @@ +""" +A component which allows you to send data to Datadog. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/datadog/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_LOGBOOK_ENTRY, + EVENT_STATE_CHANGED, STATE_UNKNOWN) +from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['datadog==0.15.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RATE = 'rate' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = 'hass' +DEFAULT_RATE = 1 +DOMAIN = 'datadog' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, + vol.Optional(CONF_RATE, default=DEFAULT_RATE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Datadog component.""" + from datadog import initialize, statsd + + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + sample_rate = conf.get(CONF_RATE) + prefix = conf.get(CONF_PREFIX) + + initialize(statsd_host=host, statsd_port=port) + + def logbook_entry_listener(event): + """Listen for logbook entries and send them as events.""" + name = event.data.get('name') + message = event.data.get('message') + + statsd.event( + title="Home Assistant", + text="%%% \n **{}** {} \n %%%".format(name, message), + tags=[ + "entity:{}".format(event.data.get('entity_id')), + "domain:{}".format(event.data.get('domain')) + ] + ) + + _LOGGER.debug('Sent event %s', event.data.get('entity_id')) + + def state_changed_listener(event): + """Listen for new messages on the bus and sends them to Datadog.""" + state = event.data.get('new_state') + + if state is None or state.state == STATE_UNKNOWN: + return + + if state.attributes.get('hidden') is True: + return + + states = dict(state.attributes) + metric = "{}.{}".format(prefix, state.domain) + tags = ["entity:{}".format(state.entity_id)] + + for key, value in states.items(): + if isinstance(value, (float, int)): + attribute = "{}.{}".format(metric, key.replace(' ', '_')) + statsd.gauge( + attribute, value, sample_rate=sample_rate, tags=tags) + + _LOGGER.debug( + "Sent metric %s: %s (tags: %s)", attribute, value, tags) + + try: + value = state_helper.state_as_number(state) + except ValueError: + _LOGGER.debug( + "Error sending %s: %s (tags: %s)", metric, state.state, tags) + return + + statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) + + _LOGGER.debug('Sent metric %s: %s (tags: %s)', metric, value, tags) + + hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/bg.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/bg.json new file mode 100644 index 00000000000000..2ea6576206375d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/bg.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u043e\u0441\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u043c\u043e\u0441\u0442\u043e\u0432\u0435 deCONZ", + "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u043e \u043a\u043e\u043f\u0438\u0435 \u043d\u0430 deCONZ" + }, + "error": { + "no_key": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u043f\u043e\u043b\u0443\u0447\u0438 API \u043a\u043b\u044e\u0447" + }, + "step": { + "init": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442 (\u0441\u0442\u043e\u0439\u043d\u043e\u0441\u0442 \u043f\u043e \u043f\u043e\u0434\u0440\u0430\u0437\u0431\u0438\u0440\u0430\u043d\u0435: '80')" + }, + "title": "\u0414\u0435\u0444\u0438\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0448\u043b\u044e\u0437" + }, + "link": { + "description": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438 deCONZ \u0448\u043b\u044e\u0437\u0430 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430 \u0441 Home Assistant.\n\n1. \u041e\u0442\u0432\u043e\u0440\u0435\u0442\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 deCONZ\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Unlock Gateway\"", + "title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ca.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ca.json new file mode 100644 index 00000000000000..87189a938064a8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", + "no_bridges": "No s'han descobert enlla\u00e7os amb deCONZ", + "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia deCONZ" + }, + "error": { + "no_key": "No s'ha pogut obtenir una clau API" + }, + "step": { + "init": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + }, + "title": "Definici\u00f3 de la passarel\u00b7la deCONZ" + }, + "link": { + "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ\n2. Prem el bot\u00f3 \"Desbloquejar passarel\u00b7la\"", + "title": "Vincular amb deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals", + "allow_deconz_groups": "Permetre la importaci\u00f3 de grups deCONZ" + }, + "title": "Opcions de configuraci\u00f3 addicionals per deCONZ" + } + }, + "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/cs.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/cs.json new file mode 100644 index 00000000000000..1588766e406c78 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/cs.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "P\u0159emost\u011bn\u00ed je ji\u017e nakonfigurov\u00e1no", + "no_bridges": "\u017d\u00e1dn\u00e9 deCONZ p\u0159emost\u011bn\u00ed nebyly nalezeny", + "one_instance_only": "Komponent podporuje pouze jednu instanci deCONZ" + }, + "error": { + "no_key": "Nelze z\u00edskat kl\u00ed\u010d API" + }, + "step": { + "init": { + "data": { + "host": "Hostitel", + "port": "Port (v\u00fdchoz\u00ed hodnota: '80')" + }, + "title": "Definujte br\u00e1nu deCONZ" + }, + "link": { + "description": "Odemkn\u011bte br\u00e1nu deCONZ, pro registraci v Home Assistant. \n\n 1. P\u0159ejd\u011bte do nastaven\u00ed syst\u00e9mu deCONZ \n 2. Stiskn\u011bte tla\u010d\u00edtko \"Unlock Gateway\"", + "title": "Propojit s deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel", + "allow_deconz_groups": "Povolit import skupin deCONZ " + }, + "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ" + } + }, + "title": "Br\u00e1na deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/cy.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/cy.json new file mode 100644 index 00000000000000..fff54bb3f6cfd1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/cy.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Pont eisoes wedi'i ffurfweddu", + "no_bridges": "Dim pontydd deCONZ wedi eu darganfod", + "one_instance_only": "Elfen dim ond yn cefnogi enghraifft deCONZ" + }, + "error": { + "no_key": "Methu cael allwedd API" + }, + "step": { + "init": { + "data": { + "host": "Gwesteiwr", + "port": "Port (gwerth diofyn: '80')" + }, + "title": "Diffiniwch porth dad-adeiladu" + }, + "link": { + "description": "Datgloi eich porth deCONZ i gofrestru gyda Cynorthwydd Cartref.\n\n1. Ewch i osodiadau system deCONZ \n2. Bwyso botwm \"Datgloi porth\"", + "title": "Cysylltu \u00e2 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/da.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/da.json new file mode 100644 index 00000000000000..7f9aad83160d92 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/da.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "no_bridges": "Ingen deConz bridge fundet", + "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n deCONZ forekomst" + }, + "error": { + "no_key": "Kunne ikke f\u00e5 en API-n\u00f8gle" + }, + "step": { + "init": { + "data": { + "host": "V\u00e6rt", + "port": "Port (standardv\u00e6rdi: '80')" + } + }, + "options": { + "data": { + "allow_clip_sensor": "Tillad import af virtuelle sensorer", + "allow_deconz_groups": "Tillad importering af deCONZ grupper" + }, + "title": "Ekstra konfiguration valgmuligheder for deCONZ" + } + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/de.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/de.json new file mode 100644 index 00000000000000..dce2c7a6704138 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/de.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ist bereits konfiguriert", + "no_bridges": "Keine deCON-Bridges entdeckt", + "one_instance_only": "Komponente unterst\u00fctzt nur eine deCONZ-Instanz" + }, + "error": { + "no_key": "Es konnte kein API-Schl\u00fcssel abgerufen werden" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Definiere das deCONZ-Gateway" + }, + "link": { + "description": "Entsperre dein deCONZ-Gateway, um dich bei Home Assistant zu registrieren. \n\n 1. Gehe zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", + "title": "Mit deCONZ verbinden" + }, + "options": { + "data": { + "allow_clip_sensor": "Import virtueller Sensoren zulassen", + "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" + }, + "title": "Weitere Konfigurationsoptionen f\u00fcr deCONZ" + } + }, + "title": "deCONZ Zigbee Gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/en.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/en.json new file mode 100644 index 00000000000000..d8bcc95a115a34 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is already configured", + "no_bridges": "No deCONZ bridges discovered", + "one_instance_only": "Component only supports one deCONZ instance" + }, + "error": { + "no_key": "Couldn't get an API key" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Define deCONZ gateway" + }, + "link": { + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button", + "title": "Link with deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + }, + "title": "Extra configuration options for deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/es-419.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/es-419.json new file mode 100644 index 00000000000000..ab47a5b43c824c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/es-419.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El Bridge ya est\u00e1 configurado", + "no_bridges": "No se descubrieron puentes deCONZ", + "one_instance_only": "El componente solo admite una instancia deCONZ" + }, + "error": { + "no_key": "No se pudo obtener una clave de API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Puerto (valor predeterminado: '80')" + }, + "title": "Definir el gateway deCONZ" + }, + "link": { + "title": "Enlazar con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + } + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/es.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/es.json new file mode 100644 index 00000000000000..34661f447d8181 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado", + "no_bridges": "No se han descubierto puentes deCONZ", + "one_instance_only": "El componente s\u00f3lo soporta una instancia deCONZ" + }, + "error": { + "no_key": "No se pudo obtener una clave API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "title": "Definir pasarela deCONZ" + }, + "link": { + "description": "Desbloquee su pasarela deCONZ para registrarse con Home Assistant. \n\n 1. Ir a la configuraci\u00f3n del sistema deCONZ \n 2. Presione el bot\u00f3n \"Desbloquear Gateway\"", + "title": "Enlazar con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir importar sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + }, + "title": "Opciones de configuraci\u00f3n adicionales para deCONZ" + } + }, + "title": "Pasarela Zigbee deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/et.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/et.json new file mode 100644 index 00000000000000..93c54b3915cb76 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "", + "port": "" + } + } + }, + "title": "deCONZ Zigbee l\u00fc\u00fcs" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/fr.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/fr.json new file mode 100644 index 00000000000000..56399a3c6fd957 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/fr.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", + "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", + "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ" + }, + "error": { + "no_key": "Impossible d'obtenir une cl\u00e9 d'API" + }, + "step": { + "init": { + "data": { + "host": "H\u00f4te", + "port": "Port (valeur par d\u00e9faut : 80)" + }, + "title": "Initialiser la passerelle deCONZ" + }, + "link": { + "description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer aupr\u00e8s de Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"", + "title": "Lien vers deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels", + "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ" + }, + "title": "Options de configuration suppl\u00e9mentaires pour deCONZ" + } + }, + "title": "Passerelle deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/he.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/he.json new file mode 100644 index 00000000000000..89a2d69950e418 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/he.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ", + "one_instance_only": "\u05d4\u05e8\u05db\u05d9\u05d1 \u05ea\u05d5\u05de\u05da \u05e8\u05e7 \u05d0\u05d7\u05d3 deCONZ \u05dc\u05de\u05e9\u05dc" + }, + "error": { + "no_key": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05d1\u05dc \u05de\u05e4\u05ea\u05d7 API" + }, + "step": { + "init": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05d5\u05e8\u05d8 (\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc: '80')" + }, + "title": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d2\u05e9\u05e8 deCONZ Zigbee" + }, + "link": { + "description": "\u05d1\u05d8\u05dc \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea \u05d4\u05de\u05e9\u05e8 deCONZ \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd Home Assistant.\n\n 1. \u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea deCONZ \n .2 \u05dc\u05d7\u05e5 \u05e2\u05dc \"Unlock Gateway\"", + "title": "\u05e7\u05e9\u05e8 \u05e2\u05dd deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d5\u05d9\u05e8\u05d8\u05d5\u05d0\u05dc\u05d9\u05d9\u05dd", + "allow_deconz_groups": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05e7\u05d1\u05d5\u05e6\u05d5\u05ea deCONZ" + }, + "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e0\u05d5\u05e1\u05e4\u05d5\u05ea \u05e2\u05d1\u05d5\u05e8 deCONZ" + } + }, + "title": "\u05de\u05d2\u05e9\u05e8 deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/hu.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/hu.json new file mode 100644 index 00000000000000..fbb5c26ba04aa1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/hu.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", + "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", + "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat" + }, + "error": { + "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" + }, + "step": { + "init": { + "data": { + "host": "H\u00e1zigazda (Host)", + "port": "Port (alap\u00e9rtelmezett \u00e9rt\u00e9k: '80')" + }, + "title": "deCONZ \u00e1tj\u00e1r\u00f3 megad\u00e1sa" + }, + "link": { + "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot", + "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" + }, + "options": { + "data": { + "allow_clip_sensor": "Virtu\u00e1lis szenzorok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se", + "allow_deconz_groups": "deCONZ csoportok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se" + }, + "title": "Extra be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gek a deCONZhoz" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/id.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/id.json new file mode 100644 index 00000000000000..7d0b3163a40b85 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/id.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge sudah dikonfigurasi", + "no_bridges": "deCONZ bridges tidak ditemukan", + "one_instance_only": "Komponen hanya mendukung satu instance deCONZ" + }, + "error": { + "no_key": "Tidak bisa mendapatkan kunci API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (nilai default: '80')" + }, + "title": "Tentukan deCONZ gateway" + }, + "link": { + "description": "Buka gerbang deCONZ Anda untuk mendaftar dengan Home Assistant. \n\n 1. Pergi ke pengaturan sistem deCONZ \n 2. Tekan tombol \"Buka Kunci Gateway\"", + "title": "Tautan dengan deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Izinkan mengimpor sensor virtual", + "allow_deconz_groups": "Izinkan mengimpor grup deCONZ" + }, + "title": "Opsi konfigurasi tambahan untuk deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/it.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/it.json new file mode 100644 index 00000000000000..87dcd0610f2ccf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "no_bridges": "Nessun bridge deCONZ rilevato", + "one_instance_only": "Il componente supporto solo un'istanza di deCONZ" + }, + "error": { + "no_key": "Impossibile ottenere una API key" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Porta (valore di default: '80')" + }, + "title": "Definisci il gateway deCONZ" + }, + "link": { + "description": "Sblocca il tuo gateway deCONZ per registrarlo in Home Assistant.\n\n1. Vai nelle impostazioni di sistema di deCONZ\n2. Premi il bottone \"Unlock Gateway\"", + "title": "Collega con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Consenti l'importazione di sensori virtuali", + "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ" + }, + "title": "Opzioni di configurazione extra per deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ja.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ja.json new file mode 100644 index 00000000000000..5148ebeaa86b2c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "no_key": "API\u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "step": { + "init": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8\uff08\u30c7\u30d5\u30a9\u30eb\u30c8\u5024\uff1a'80'\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ko.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ko.json new file mode 100644 index 00000000000000..a501951540b3e7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ko.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4" + }, + "error": { + "no_key": "API \ud0a4\ub97c \uac00\uc838\uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "step": { + "init": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8 (\uae30\ubcf8\uac12: '80')" + }, + "title": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uc815\uc758" + }, + "link": { + "description": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \uc5b8\ub77d\ud558\uc5ec Home Assistant \uc5d0 \uc5f0\uacb0\ud558\uae30\n\n1. deCONZ \uc2dc\uc2a4\ud15c \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc138\uc694\n2. \"Unlock Gateway\" \ubc84\ud2bc\uc744 \ub204\ub974\uc138\uc694 ", + "title": "deCONZ\uc640 \uc5f0\uacb0" + }, + "options": { + "data": { + "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9" + }, + "title": "deCONZ\ub97c \uc704\ud55c \ucd94\uac00 \uad6c\uc131 \uc635\uc158" + } + }, + "title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/lb.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/lb.json new file mode 100644 index 00000000000000..fc05bb8b94e06b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ass schon konfigur\u00e9iert", + "no_bridges": "Keng dECONZ bridges fonnt", + "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng deCONZ Instanz" + }, + "error": { + "no_key": "Konnt keen API Schl\u00ebssel kr\u00e9ien" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (Standard Wert: '80')" + }, + "title": "deCONZ gateway d\u00e9fin\u00e9ieren" + }, + "link": { + "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", + "title": "Link mat deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren", + "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen" + }, + "title": "Extra Konfiguratiouns Optiounen fir deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/nl.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/nl.json new file mode 100644 index 00000000000000..9084d22f4a303e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/nl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is al geconfigureerd", + "no_bridges": "Geen deCONZ bruggen ontdekt", + "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance" + }, + "error": { + "no_key": "Kon geen API-sleutel ophalen" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Poort (standaard: '80')" + }, + "title": "Definieer deCONZ gateway" + }, + "link": { + "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"", + "title": "Koppel met deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "title": "Extra configuratieopties voor deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/nn.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/nn.json new file mode 100644 index 00000000000000..4bdc4b4c1bee38 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/nn.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Brua er allereie konfigurert", + "no_bridges": "Oppdaga ingen deCONZ-bruer", + "one_instance_only": "Komponenten st\u00f8ttar berre \u00e9in deCONZ-instans" + }, + "error": { + "no_key": "Kunne ikkje f\u00e5 ein API-n\u00f8kkel" + }, + "step": { + "init": { + "data": { + "host": "Vert", + "port": "Port (standardverdi: '80')" + }, + "title": "Definer deCONZ-gateway" + }, + "link": { + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere den med Home Assistant.\n\n1. G\u00e5 til systeminnstillingane til deCONZ\n2. Trykk p\u00e5 \"L\u00e5s opp gateway\"-knappen", + "title": "Link med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillat importering av virtuelle sensorar", + "allow_deconz_groups": "Tillat importering av deCONZ-grupper" + }, + "title": "Ekstra konfigurasjonsalternativ for deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/no.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/no.json new file mode 100644 index 00000000000000..1b0407e633d902 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Broen er allerede konfigurert", + "no_bridges": "Ingen deCONZ broer oppdaget", + "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst" + }, + "error": { + "no_key": "Kunne ikke f\u00e5 en API-n\u00f8kkel" + }, + "step": { + "init": { + "data": { + "host": "Vert", + "port": "Port" + }, + "title": "Definer deCONZ-gatewayen" + }, + "link": { + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", + "title": "Koble til deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillat import av virtuelle sensorer", + "allow_deconz_groups": "Tillat import av deCONZ grupper" + }, + "title": "Ekstra konfigurasjonsalternativer for deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pl.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pl.json new file mode 100644 index 00000000000000..5a8b710c006d96 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Mostek jest ju\u017c skonfigurowany", + "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", + "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ" + }, + "error": { + "no_key": "Nie mo\u017cna uzyska\u0107 klucza API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Zdefiniuj bramk\u0119 deCONZ" + }, + "link": { + "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawie\u0144 systemu deCONZ \n 2. Naci\u015bnij przycisk \"Odblokuj bramk\u0119\"", + "title": "Po\u0142\u0105cz z deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w", + "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ" + }, + "title": "Dodatkowe opcje konfiguracji dla deCONZ" + } + }, + "title": "Brama deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pt-BR.json new file mode 100644 index 00000000000000..be79e7e461ae0b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pt-BR.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "A ponte j\u00e1 est\u00e1 configurada", + "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ" + }, + "error": { + "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" + }, + "step": { + "init": { + "data": { + "host": "Hospedeiro", + "port": "Porta (valor padr\u00e3o: '80')" + }, + "title": "Defina o gateway deCONZ" + }, + "link": { + "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"", + "title": "Linkar com deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais", + "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ" + }, + "title": "Op\u00e7\u00f5es extras de configura\u00e7\u00e3o para deCONZ" + } + }, + "title": "Gateway deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pt.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pt.json new file mode 100644 index 00000000000000..a0419b8baa42d1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/pt.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge j\u00e1 est\u00e1 configurada", + "no_bridges": "Nenhum deCONZ descoberto", + "one_instance_only": "Componente suporta apenas uma conex\u00e3o deCONZ" + }, + "error": { + "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" + }, + "step": { + "init": { + "data": { + "host": "Servidor", + "port": "Porta (por omiss\u00e3o: '80')" + }, + "title": "Defina o gateway deCONZ" + }, + "link": { + "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"", + "title": "Link com deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais", + "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ" + }, + "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o extra para deCONZ" + } + }, + "title": "Gateway Zigbee deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ru.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ru.json new file mode 100644 index 00000000000000..3ff60254a6a50c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 deCONZ" + }, + "error": { + "no_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API" + }, + "step": { + "init": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ" + }, + "link": { + "description": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0439\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432 Home Assistant:\n\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u044b deCONZ\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00ab\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0448\u043b\u044e\u0437\u00bb", + "title": "\u0421\u0432\u044f\u0437\u044c \u0441 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" + }, + "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/sl.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/sl.json new file mode 100644 index 00000000000000..cea6f8ef4dd65e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/sl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Most je \u017ee nastavljen", + "no_bridges": "Ni odkritih mostov deCONZ", + "one_instance_only": "Komponenta podpira le en primerek deCONZ" + }, + "error": { + "no_key": "Klju\u010da API ni mogo\u010de dobiti" + }, + "step": { + "init": { + "data": { + "host": "Gostitelj", + "port": "Vrata" + }, + "title": "Dolo\u010dite deCONZ prehod" + }, + "link": { + "description": "Odklenite va\u0161 deCONZ gateway za registracijo z Home Assistant-om. \n1. Pojdite v deCONT sistemske nastavitve\n2. Pritisnite tipko \"odkleni prehod\"", + "title": "Povezava z deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev", + "allow_deconz_groups": "Dovoli uvoz deCONZ skupin" + }, + "title": "Dodatne mo\u017enosti konfiguracije za deCONZ" + } + }, + "title": "deCONZ Zigbee prehod" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/sv.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/sv.json new file mode 100644 index 00000000000000..88cf8742acde8c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/sv.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bryggan \u00e4r redan konfigurerad", + "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes", + "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans" + }, + "error": { + "no_key": "Det gick inte att ta emot en API-nyckel" + }, + "step": { + "init": { + "data": { + "host": "V\u00e4rd", + "port": "Port (standardv\u00e4rde: '80')" + }, + "title": "Definiera deCONZ-gatewaye" + }, + "link": { + "description": "L\u00e5s upp din deCONZ-gateway f\u00f6r att registrera dig med Home Assistant. \n\n 1. G\u00e5 till deCONZ-systeminst\u00e4llningarna \n 2. Tryck p\u00e5 \"L\u00e5s upp gateway\"-knappen", + "title": "L\u00e4nka med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer", + "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper" + }, + "title": "Extra konfigurationsalternativ f\u00f6r deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/vi.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/vi.json new file mode 100644 index 00000000000000..00f1d9be57f07e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/vi.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "C\u1ea7u \u0111\u00e3 \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh", + "no_bridges": "Kh\u00f4ng t\u00ecm th\u1ea5y c\u1ea7u deCONZ n\u00e0o", + "one_instance_only": "Th\u00e0nh ph\u1ea7n ch\u1ec9 h\u1ed7 tr\u1ee3 m\u1ed9t c\u00e1 th\u1ec3 deCONZ" + }, + "error": { + "no_key": "Kh\u00f4ng th\u1ec3 l\u1ea5y kh\u00f3a API" + }, + "step": { + "init": { + "data": { + "port": "C\u1ed5ng (gi\u00e1 tr\u1ecb m\u1eb7c \u0111\u1ecbnh: '80')" + } + }, + "options": { + "data": { + "allow_clip_sensor": "Cho ph\u00e9p nh\u1eadp c\u1ea3m bi\u1ebfn \u1ea3o", + "allow_deconz_groups": "Cho ph\u00e9p nh\u1eadp c\u00e1c nh\u00f3m deCONZ" + }, + "title": "T\u00f9y ch\u1ecdn c\u1ea5u h\u00ecnh b\u1ed5 sung cho deCONZ" + } + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/zh-Hans.json new file mode 100644 index 00000000000000..2e5a216c77ddec --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/zh-Hans.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u6865\u63a5\u5668\u5df2\u914d\u7f6e\u5b8c\u6210", + "no_bridges": "\u6ca1\u6709\u53d1\u73b0 deCONZ \u7684\u6865\u63a5\u8bbe\u5907", + "one_instance_only": "\u7ec4\u4ef6\u53ea\u652f\u6301\u4e00\u4e2a deCONZ \u5b9e\u4f8b" + }, + "error": { + "no_key": "\u65e0\u6cd5\u83b7\u53d6 API \u5bc6\u94a5" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3\uff08\u9ed8\u8ba4\u503c\uff1a'80'\uff09" + }, + "title": "\u5b9a\u4e49 deCONZ \u7f51\u5173" + }, + "link": { + "description": "\u89e3\u9501\u60a8\u7684 deCONZ \u7f51\u5173\u4ee5\u6ce8\u518c\u5230 Home Assistant\u3002 \n\n 1. \u524d\u5f80 deCONZ \u7cfb\u7edf\u8bbe\u7f6e\n 2. \u70b9\u51fb\u201c\u89e3\u9501\u7f51\u5173\u201d\u6309\u94ae", + "title": "\u8fde\u63a5 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u5141\u8bb8\u5bfc\u5165\u865a\u62df\u4f20\u611f\u5668", + "allow_deconz_groups": "\u5141\u8bb8\u5bfc\u5165 deCONZ \u7fa4\u7ec4" + }, + "title": "deCONZ \u7684\u9644\u52a0\u914d\u7f6e\u9879" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/zh-Hant.json new file mode 100644 index 00000000000000..524f68d41bc057 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe", + "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u7269\u4ef6" + }, + "error": { + "no_key": "\u7121\u6cd5\u53d6\u5f97 API key" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0\uff08\u9810\u8a2d\u503c\uff1a'80'\uff09" + }, + "title": "\u5b9a\u7fa9 deCONZ \u7db2\u95dc" + }, + "link": { + "description": "\u89e3\u9664 deCONZ \u7db2\u95dc\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u89e3\u9664\u7db2\u95dc\u9396\u5b9a\uff08Unlock Gateway\uff09\u300d\u6309\u9215", + "title": "\u9023\u7d50\u81f3 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44" + }, + "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/__init__.py new file mode 100644 index 00000000000000..4d3e2cbc6a926d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/__init__.py @@ -0,0 +1,174 @@ +""" +Support for deCONZ devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/deconz/ +""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +# Loading the config flow file will register the flow +from .config_flow import configured_hosts +from .const import DEFAULT_PORT, DOMAIN, _LOGGER +from .gateway import DeconzGateway + +REQUIREMENTS = ['pydeconz==47'] + +SUPPORTED_PLATFORMS = ['binary_sensor', 'cover', + 'light', 'scene', 'sensor', 'switch'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_DECONZ = 'configure' + +SERVICE_FIELD = 'field' +SERVICE_ENTITY = 'entity' +SERVICE_DATA = 'data' + +SERVICE_SCHEMA = vol.All(vol.Schema({ + vol.Optional(SERVICE_ENTITY): cv.entity_id, + vol.Optional(SERVICE_FIELD): cv.matches_regex('/.*'), + vol.Required(SERVICE_DATA): dict, +}), cv.has_at_least_one_key(SERVICE_ENTITY, SERVICE_FIELD)) + +SERVICE_DEVICE_REFRESH = 'device_refresh' + + +async def async_setup(hass, config): + """Load configuration for deCONZ component. + + Discovery has loaded the component if DOMAIN is not present in config. + """ + if DOMAIN in config: + deconz_config = None + if CONF_HOST in config[DOMAIN]: + deconz_config = config[DOMAIN] + if deconz_config and not configured_hosts(hass): + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data=deconz_config + )) + return True + + +async def async_setup_entry(hass, config_entry): + """Set up a deCONZ bridge for a config entry. + + Load config, group, light and sensor data for server information. + Start websocket for push notification of state changes from deCONZ. + """ + if DOMAIN in hass.data: + _LOGGER.error( + "Config entry failed since one deCONZ instance already exists") + return False + + gateway = DeconzGateway(hass, config_entry) + + hass.data[DOMAIN] = gateway + + if not await gateway.async_setup(): + return False + + device_registry = await \ + hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, gateway.api.config.mac)}, + identifiers={(DOMAIN, gateway.api.config.bridgeid)}, + manufacturer='Dresden Elektronik', model=gateway.api.config.modelid, + name=gateway.api.config.name, sw_version=gateway.api.config.swversion) + + async def async_configure(call): + """Set attribute of device in deCONZ. + + Entity is used to resolve to a device path (e.g. '/lights/1'). + Field is a string representing either a full path + (e.g. '/lights/1/state') when entity is not specified, or a + subpath (e.g. '/state') when used together with entity. + Data is a json object with what data you want to alter + e.g. data={'on': true}. + { + "field": "/lights/1/state", + "data": {"on": true} + } + See Dresden Elektroniks REST API documentation for details: + http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + """ + field = call.data.get(SERVICE_FIELD, '') + entity_id = call.data.get(SERVICE_ENTITY) + data = call.data.get(SERVICE_DATA) + gateway = hass.data[DOMAIN] + + if entity_id: + try: + field = gateway.deconz_ids[entity_id] + field + except KeyError: + _LOGGER.error('Could not find the entity %s', entity_id) + return + + await gateway.api.async_put_state(field, data) + + hass.services.async_register( + DOMAIN, SERVICE_DECONZ, async_configure, schema=SERVICE_SCHEMA) + + async def async_refresh_devices(call): + """Refresh available devices from deCONZ.""" + gateway = hass.data[DOMAIN] + + groups = set(gateway.api.groups.keys()) + lights = set(gateway.api.lights.keys()) + scenes = set(gateway.api.scenes.keys()) + sensors = set(gateway.api.sensors.keys()) + + if not await gateway.api.async_load_parameters(): + return + + gateway.async_add_device_callback( + 'group', [group + for group_id, group in gateway.api.groups.items() + if group_id not in groups] + ) + + gateway.async_add_device_callback( + 'light', [light + for light_id, light in gateway.api.lights.items() + if light_id not in lights] + ) + + gateway.async_add_device_callback( + 'scene', [scene + for scene_id, scene in gateway.api.scenes.items() + if scene_id not in scenes] + ) + + gateway.async_add_device_callback( + 'sensor', [sensor + for sensor_id, sensor in gateway.api.sensors.items() + if sensor_id not in sensors] + ) + + hass.services.async_register( + DOMAIN, SERVICE_DEVICE_REFRESH, async_refresh_devices) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload deCONZ config entry.""" + gateway = hass.data.pop(DOMAIN) + hass.services.async_remove(DOMAIN, SERVICE_DECONZ) + hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + return await gateway.async_reset() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/binary_sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/binary_sensor.py new file mode 100644 index 00000000000000..286b310c1a99af --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/binary_sensor.py @@ -0,0 +1,89 @@ +""" +Support for deCONZ binary sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.deconz/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DECONZ_DOMAIN) +from .deconz_device import DeconzDevice + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ binary sensor.""" + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_sensor(sensors): + """Add binary sensor from deCONZ.""" + from pydeconz.sensor import DECONZ_BINARY_SENSOR + entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_BINARY_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + entities.append(DeconzBinarySensor(sensor, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) + + async_add_sensor(gateway.api.sensors.values()) + + +class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): + """Representation of a deCONZ binary sensor.""" + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._device.is_tripped + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._device.sensor_icon + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + from pydeconz.sensor import PRESENCE + attr = {} + if self._device.battery: + attr[ATTR_BATTERY_LEVEL] = self._device.battery + if self._device.on is not None: + attr[ATTR_ON] = self._device.on + if self._device.type in PRESENCE and self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + return attr diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/config_flow.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/config_flow.py new file mode 100644 index 00000000000000..f7bc71a23987ed --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow to configure deCONZ component.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, DEFAULT_PORT, DOMAIN) + +CONF_BRIDGEID = 'bridgeid' + + +@callback +def configured_hosts(hass): + """Return a set of the configured hosts.""" + return set(entry.data[CONF_HOST] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class DeconzFlowHandler(config_entries.ConfigFlow): + """Handle a deCONZ config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the deCONZ config flow.""" + self.bridges = [] + self.deconz_config = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): + """Handle a deCONZ config flow start. + + Only allows one instance to be set up. + If only one bridge is found go to link step. + If more than one bridge is found let user choose bridge to link. + """ + from pydeconz.utils import async_discovery + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + + if user_input is not None: + for bridge in self.bridges: + if bridge[CONF_HOST] == user_input[CONF_HOST]: + self.deconz_config = bridge + return await self.async_step_link() + self.deconz_config = user_input + return await self.async_step_link() + + session = aiohttp_client.async_get_clientsession(self.hass) + self.bridges = await async_discovery(session) + + if len(self.bridges) == 1: + self.deconz_config = self.bridges[0] + return await self.async_step_link() + + if len(self.bridges) > 1: + hosts = [] + for bridge in self.bridges: + hosts.append(bridge[CONF_HOST]) + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): vol.In(hosts) + }) + ) + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + }), + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the deCONZ bridge.""" + from pydeconz.utils import async_get_api_key + errors = {} + + if user_input is not None: + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + session = aiohttp_client.async_get_clientsession(self.hass) + api_key = await async_get_api_key(session, **self.deconz_config) + if api_key: + self.deconz_config[CONF_API_KEY] = api_key + return await self.async_step_options() + errors['base'] = 'no_key' + + return self.async_show_form( + step_id='link', + errors=errors, + ) + + async def async_step_options(self, user_input=None): + """Extra options for deCONZ. + + CONF_CLIP_SENSOR -- Allow user to choose if they want clip sensors. + CONF_DECONZ_GROUPS -- Allow user to choose if they want deCONZ groups. + """ + from pydeconz.utils import async_get_bridgeid + + if user_input is not None: + self.deconz_config[CONF_ALLOW_CLIP_SENSOR] = \ + user_input[CONF_ALLOW_CLIP_SENSOR] + self.deconz_config[CONF_ALLOW_DECONZ_GROUPS] = \ + user_input[CONF_ALLOW_DECONZ_GROUPS] + + if CONF_BRIDGEID not in self.deconz_config: + session = aiohttp_client.async_get_clientsession(self.hass) + self.deconz_config[CONF_BRIDGEID] = await async_get_bridgeid( + session, **self.deconz_config) + + return self.async_create_entry( + title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], + data=self.deconz_config + ) + + return self.async_show_form( + step_id='options', + data_schema=vol.Schema({ + vol.Optional(CONF_ALLOW_CLIP_SENSOR): bool, + vol.Optional(CONF_ALLOW_DECONZ_GROUPS): bool, + }), + ) + + async def async_step_discovery(self, discovery_info): + """Prepare configuration for a discovered deCONZ bridge. + + This flow is triggered by the discovery component. + """ + deconz_config = {} + deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) + deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) + deconz_config[CONF_BRIDGEID] = discovery_info.get('serial') + + return await self.async_step_import(deconz_config) + + async def async_step_import(self, import_config): + """Import a deCONZ bridge as a config entry. + + This flow is triggered by `async_setup` for configured bridges. + This flow is also triggered by `async_step_discovery`. + + This will execute for any bridge that does not have a + config entry yet (based on host). + + If an API key is provided, we will create an entry. + Otherwise we will delegate to `link` step which + will ask user to link the bridge. + """ + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + + self.deconz_config = import_config + if CONF_API_KEY not in import_config: + return await self.async_step_link() + + user_input = {CONF_ALLOW_CLIP_SENSOR: True, + CONF_ALLOW_DECONZ_GROUPS: True} + return await self.async_step_options(user_input=user_input) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/const.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/const.py new file mode 100644 index 00000000000000..b08f3d7182428d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/const.py @@ -0,0 +1,27 @@ +"""Constants for the deCONZ component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.deconz') + +DOMAIN = 'deconz' + +DEFAULT_PORT = 80 + +CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' +CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' + +SUPPORTED_PLATFORMS = ['binary_sensor', 'cover', + 'light', 'scene', 'sensor', 'switch'] + +DECONZ_REACHABLE = 'deconz_reachable' + +ATTR_DARK = 'dark' +ATTR_ON = 'on' + +DAMPERS = ["Level controllable output"] +WINDOW_COVERS = ["Window covering device"] +COVER_TYPES = DAMPERS + WINDOW_COVERS + +POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] +SIRENS = ["Warning device"] +SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/cover.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/cover.py new file mode 100644 index 00000000000000..99bdd20a2954b7 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/cover.py @@ -0,0 +1,134 @@ +""" +Support for deCONZ covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.deconz/ +""" +from homeassistant.components.cover import ( + ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, + SUPPORT_SET_POSITION) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import COVER_TYPES, DAMPERS, DOMAIN as DECONZ_DOMAIN, WINDOW_COVERS +from .deconz_device import DeconzDevice + +DEPENDENCIES = ['deconz'] + +ZIGBEE_SPEC = ['lumi.curtain'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Unsupported way of setting up deCONZ covers.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up covers for deCONZ component. + + Covers are based on same device class as lights in deCONZ. + """ + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_cover(lights): + """Add cover from deCONZ.""" + entities = [] + for light in lights: + if light.type in COVER_TYPES: + if light.modelid in ZIGBEE_SPEC: + entities.append(DeconzCoverZigbeeSpec(light, gateway)) + else: + entities.append(DeconzCover(light, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_cover)) + + async_add_cover(gateway.api.lights.values()) + + +class DeconzCover(DeconzDevice, CoverDevice): + """Representation of a deCONZ cover.""" + + def __init__(self, device, gateway): + """Set up cover and add update callback to get data from websocket.""" + super().__init__(device, gateway) + + self._features = SUPPORT_OPEN + self._features |= SUPPORT_CLOSE + self._features |= SUPPORT_STOP + self._features |= SUPPORT_SET_POSITION + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + if self.is_closed: + return 0 + return int(self._device.brightness / 255 * 100) + + @property + def is_closed(self): + """Return if the cover is closed.""" + return not self._device.state + + @property + def device_class(self): + """Return the class of the cover.""" + if self._device.type in DAMPERS: + return 'damper' + if self._device.type in WINDOW_COVERS: + return 'window' + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + data = {'on': False} + if position > 0: + data['on'] = True + data['bri'] = int(position / 100 * 255) + await self._device.async_set_state(data) + + async def async_open_cover(self, **kwargs): + """Open cover.""" + data = {ATTR_POSITION: 100} + await self.async_set_cover_position(**data) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + data = {ATTR_POSITION: 0} + await self.async_set_cover_position(**data) + + async def async_stop_cover(self, **kwargs): + """Stop cover.""" + data = {'bri_inc': 0} + await self._device.async_set_state(data) + + +class DeconzCoverZigbeeSpec(DeconzCover): + """Zigbee spec is the inverse of how deCONZ normally reports attributes.""" + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return 100 - int(self._device.brightness / 255 * 100) + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._device.state + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + data = {'on': False} + if position < 100: + data['on'] = True + data['bri'] = 255 - int(position / 100 * 255) + await self._device.async_set_state(data) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/deconz_device.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/deconz_device.py new file mode 100644 index 00000000000000..bfcbd158b9f457 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/deconz_device.py @@ -0,0 +1,74 @@ +"""Base class for deCONZ devices.""" +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN + + +class DeconzDevice(Entity): + """Representation of a deCONZ device.""" + + def __init__(self, device, gateway): + """Set up device and add update callback to get data from websocket.""" + self._device = device + self.gateway = gateway + self.unsub_dispatcher = None + + async def async_added_to_hass(self): + """Subscribe to device events.""" + self._device.register_async_callback(self.async_update_callback) + self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() + self._device.remove_callback(self.async_update_callback) + self._device = None + + @callback + def async_update_callback(self, reason): + """Update the device's state.""" + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._device.uniqueid + + @property + def available(self): + """Return True if device is available.""" + return self.gateway.available and self._device.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_info(self): + """Return a device description for device registry.""" + if (self._device.uniqueid is None or + self._device.uniqueid.count(':') != 7): + return None + serial = self._device.uniqueid.split('-', 1)[0] + bridgeid = self.gateway.api.config.bridgeid + return { + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, + 'manufacturer': self._device.manufacturer, + 'model': self._device.modelid, + 'name': self._device.name, + 'sw_version': self._device.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), + } diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/gateway.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/gateway.py new file mode 100644 index 00000000000000..8d33e011b9438e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/gateway.py @@ -0,0 +1,176 @@ +"""Representation of a deCONZ gateway.""" +from homeassistant import config_entries +from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.core import EventOrigin, callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.util import slugify + +from .const import ( + _LOGGER, DECONZ_REACHABLE, CONF_ALLOW_CLIP_SENSOR, SUPPORTED_PLATFORMS) + + +class DeconzGateway: + """Manages a single deCONZ gateway.""" + + def __init__(self, hass, config_entry): + """Initialize the system.""" + self.hass = hass + self.config_entry = config_entry + self.available = True + self.api = None + self._cancel_retry_setup = None + + self.deconz_ids = {} + self.events = [] + self.listeners = [] + + async def async_setup(self, tries=0): + """Set up a deCONZ gateway.""" + hass = self.hass + + self.api = await get_gateway( + hass, self.config_entry.data, self.async_add_device_callback, + self.async_connection_status_callback + ) + + if self.api is False: + retry_delay = 2 ** (tries + 1) + _LOGGER.error( + "Error connecting to deCONZ gateway. Retrying in %d seconds", + retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + # This feels hacky, we should find a better way to do this + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + self._cancel_retry_setup = hass.helpers.event.async_call_later( + retry_delay, retry_setup) + + return False + + for component in SUPPORTED_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + self.config_entry, component)) + + self.listeners.append( + async_dispatcher_connect( + hass, 'deconz_new_sensor', self.async_add_remote)) + + self.async_add_remote(self.api.sensors.values()) + + self.api.start() + + return True + + @callback + def async_connection_status_callback(self, available): + """Handle signals of gateway connection status.""" + self.available = available + async_dispatcher_send( + self.hass, DECONZ_REACHABLE, {'state': True, 'attr': 'reachable'}) + + @callback + def async_add_device_callback(self, device_type, device): + """Handle event of new device creation in deCONZ.""" + if not isinstance(device, list): + device = [device] + async_dispatcher_send( + self.hass, 'deconz_new_{}'.format(device_type), device) + + @callback + def async_add_remote(self, sensors): + """Set up remote from deCONZ.""" + from pydeconz.sensor import SWITCH as DECONZ_REMOTE + allow_clip_sensor = self.config_entry.data.get( + CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_REMOTE and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + self.events.append(DeconzEvent(self.hass, sensor)) + + @callback + def shutdown(self, event): + """Wrap the call to deconz.close. + + Used as an argument to EventBus.async_listen_once. + """ + self.api.close() + + async def async_reset(self): + """Reset this gateway to default state. + + Will cancel any scheduled setup retry and will unload + the config entry. + """ + # If we have a retry scheduled, we were never setup. + if self._cancel_retry_setup is not None: + self._cancel_retry_setup() + self._cancel_retry_setup = None + return True + + self.api.close() + + for component in SUPPORTED_PLATFORMS: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, component) + + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + + for event in self.events: + event.async_will_remove_from_hass() + self.events.remove(event) + + self.deconz_ids = {} + return True + + +async def get_gateway(hass, config, async_add_device_callback, + async_connection_status_callback): + """Create a gateway object and verify configuration.""" + from pydeconz import DeconzSession + + session = aiohttp_client.async_get_clientsession(hass) + deconz = DeconzSession(hass.loop, session, **config, + async_add_device=async_add_device_callback, + connection_status=async_connection_status_callback) + result = await deconz.async_load_parameters() + + if result: + return deconz + return result + + +class DeconzEvent: + """When you want signals instead of entities. + + Stateless sensors such as remotes are expected to generate an event + instead of a sensor entity in hass. + """ + + def __init__(self, hass, device): + """Register callback that will be used for signals.""" + self._hass = hass + self._device = device + self._device.register_async_callback(self.async_update_callback) + self._event = 'deconz_{}'.format(CONF_EVENT) + self._id = slugify(self._device.name) + + @callback + def async_will_remove_from_hass(self) -> None: + """Disconnect event object when removed.""" + self._device.remove_callback(self.async_update_callback) + self._device = None + + @callback + def async_update_callback(self, reason): + """Fire the event if reason is that state is updated.""" + if reason['state']: + data = {CONF_ID: self._id, CONF_EVENT: self._device.state} + self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/light.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/light.py new file mode 100644 index 00000000000000..f7c777b810077d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/light.py @@ -0,0 +1,175 @@ +""" +Support for deCONZ light. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/light.deconz/ +""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, + ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, + SUPPORT_FLASH, SUPPORT_TRANSITION, Light) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.color as color_util + +from .const import ( + CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DECONZ_DOMAIN, COVER_TYPES, + SWITCH_TYPES) +from .deconz_device import DeconzDevice + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ lights and group.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ lights and groups from a config entry.""" + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_light(lights): + """Add light from deCONZ.""" + entities = [] + for light in lights: + if light.type not in COVER_TYPES + SWITCH_TYPES: + entities.append(DeconzLight(light, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_light)) + + @callback + def async_add_group(groups): + """Add group from deCONZ.""" + entities = [] + allow_group = config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True) + for group in groups: + if group.lights and allow_group: + entities.append(DeconzLight(group, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_group', async_add_group)) + + async_add_light(gateway.api.lights.values()) + async_add_group(gateway.api.groups.values()) + + +class DeconzLight(DeconzDevice, Light): + """Representation of a deCONZ light.""" + + def __init__(self, device, gateway): + """Set up light and add update callback to get data from websocket.""" + super().__init__(device, gateway) + + self._features = SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION + + if self._device.ct is not None: + self._features |= SUPPORT_COLOR_TEMP + + if self._device.xy is not None: + self._features |= SUPPORT_COLOR + + if self._device.effect is not None: + self._features |= SUPPORT_EFFECT + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._device.brightness + + @property + def effect_list(self): + """Return the list of supported effects.""" + return [EFFECT_COLORLOOP] + + @property + def color_temp(self): + """Return the CT color value.""" + if self._device.colormode != 'ct': + return None + + return self._device.ct + + @property + def hs_color(self): + """Return the hs color value.""" + if self._device.colormode in ('xy', 'hs') and self._device.xy: + return color_util.color_xy_to_hs(*self._device.xy) + return None + + @property + def is_on(self): + """Return true if light is on.""" + return self._device.state + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + async def async_turn_on(self, **kwargs): + """Turn on light.""" + data = {'on': True} + + if ATTR_COLOR_TEMP in kwargs: + data['ct'] = kwargs[ATTR_COLOR_TEMP] + + if ATTR_HS_COLOR in kwargs: + data['xy'] = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + + if ATTR_BRIGHTNESS in kwargs: + data['bri'] = kwargs[ATTR_BRIGHTNESS] + + if ATTR_TRANSITION in kwargs: + data['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) + + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_SHORT: + data['alert'] = 'select' + del data['on'] + elif kwargs[ATTR_FLASH] == FLASH_LONG: + data['alert'] = 'lselect' + del data['on'] + + if ATTR_EFFECT in kwargs: + if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + data['effect'] = 'colorloop' + else: + data['effect'] = 'none' + + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off light.""" + data = {'on': False} + + if ATTR_TRANSITION in kwargs: + data['bri'] = 0 + data['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) + + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_SHORT: + data['alert'] = 'select' + del data['on'] + elif kwargs[ATTR_FLASH] == FLASH_LONG: + data['alert'] = 'lselect' + del data['on'] + + await self._device.async_set_state(data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + attributes['is_deconz_group'] = self._device.type == 'LightGroup' + if self._device.type == 'LightGroup': + attributes['all_on'] = self._device.all_on + return attributes diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/scene.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/scene.py new file mode 100644 index 00000000000000..05845a022886ad --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/scene.py @@ -0,0 +1,61 @@ +""" +Support for deCONZ scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.deconz/ +""" +from homeassistant.components.deconz import DOMAIN as DECONZ_DOMAIN +from homeassistant.components.scene import Scene +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ scenes.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up scenes for deCONZ component.""" + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_scene(scenes): + """Add scene from deCONZ.""" + entities = [] + for scene in scenes: + entities.append(DeconzScene(scene, gateway)) + async_add_entities(entities) + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_scene', async_add_scene)) + + async_add_scene(gateway.api.scenes.values()) + + +class DeconzScene(Scene): + """Representation of a deCONZ scene.""" + + def __init__(self, scene, gateway): + """Set up a scene.""" + self._scene = scene + self.gateway = gateway + + async def async_added_to_hass(self): + """Subscribe to sensors events.""" + self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id + + async def async_will_remove_from_hass(self) -> None: + """Disconnect scene object when removed.""" + self._scene = None + + async def async_activate(self): + """Activate the scene.""" + await self._scene.async_set_state({}) + + @property + def name(self): + """Return the name of the scene.""" + return self._scene.full_name diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/sensor.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/sensor.py new file mode 100644 index 00000000000000..1913e3d50879ae --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/sensor.py @@ -0,0 +1,153 @@ +""" +Support for deCONZ sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.deconz/ +""" +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import slugify + +from .const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DECONZ_DOMAIN) +from .deconz_device import DeconzDevice + +DEPENDENCIES = ['deconz'] + +ATTR_CURRENT = 'current' +ATTR_DAYLIGHT = 'daylight' +ATTR_EVENT_ID = 'event_id' + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the deCONZ sensors.""" + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_sensor(sensors): + """Add sensors from deCONZ.""" + from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE + entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + if sensor.type in DECONZ_REMOTE: + if sensor.battery: + entities.append(DeconzBattery(sensor, gateway)) + else: + entities.append(DeconzSensor(sensor, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) + + async_add_sensor(gateway.api.sensors.values()) + + +class DeconzSensor(DeconzDevice): + """Representation of a deCONZ sensor.""" + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.state + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._device.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._device.sensor_icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this sensor.""" + return self._device.sensor_unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + from pydeconz.sensor import LIGHTLEVEL + attr = {} + if self._device.battery: + attr[ATTR_BATTERY_LEVEL] = self._device.battery + if self._device.on is not None: + attr[ATTR_ON] = self._device.on + if self._device.type in LIGHTLEVEL and self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + if self.unit_of_measurement == 'Watts': + attr[ATTR_CURRENT] = self._device.current + attr[ATTR_VOLTAGE] = self._device.voltage + if self._device.sensor_class == 'daylight': + attr[ATTR_DAYLIGHT] = self._device.daylight + return attr + + +class DeconzBattery(DeconzDevice): + """Battery class for when a device is only represented as an event.""" + + def __init__(self, device, gateway): + """Register dispatcher callback for update of battery state.""" + super().__init__(device, gateway) + + self._name = '{} {}'.format(self._device.name, 'Battery Level') + self._unit_of_measurement = "%" + + @callback + def async_update_callback(self, reason): + """Update the battery's state, if needed.""" + if 'reachable' in reason['attr'] or 'battery' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the battery.""" + return self._device.battery + + @property + def name(self): + """Return the name of the battery.""" + return self._name + + @property + def device_class(self): + """Return the class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the battery.""" + attr = { + ATTR_EVENT_ID: slugify(self._device.name), + } + return attr diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/services.yaml b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/services.yaml new file mode 100644 index 00000000000000..cde7ac79f4c741 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/services.yaml @@ -0,0 +1,18 @@ +configure: + description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details. + fields: + entity: + description: Entity id representing a specific device in deCONZ. + example: 'light.rgb_light' + field: + description: >- + Field is a string representing a full path to deCONZ endpoint (when + entity is not specified) or a subpath of the device path for the + entity (when entity is specified). + example: '"/lights/1/state" or "/state"' + data: + description: Data is a json object with what data you want to alter. + example: '{"on": true}' + +device_refresh: + description: Refresh device lists from deCONZ. \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/strings.json b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/strings.json new file mode 100644 index 00000000000000..1bf7235713afbb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "deCONZ Zigbee gateway", + "step": { + "init": { + "title": "Define deCONZ gateway", + "data": { + "host": "Host", + "port": "Port" + } + }, + "link": { + "title": "Link with deCONZ", + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + }, + "options": { + "title": "Extra configuration options for deCONZ", + "data":{ + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + } + } + }, + "error": { + "no_key": "Couldn't get an API key" + }, + "abort": { + "already_configured": "Bridge is already configured", + "no_bridges": "No deCONZ bridges discovered", + "one_instance_only": "Component only supports one deCONZ instance" + } + } +} \ No newline at end of file diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/deconz/switch.py b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/switch.py new file mode 100644 index 00000000000000..64d933896705d4 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/deconz/switch.py @@ -0,0 +1,83 @@ +""" +Support for deCONZ switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.deconz/ +""" +from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS, SIRENS +from .deconz_device import DeconzDevice + + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up deCONZ switches.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches for deCONZ component. + + Switches are based same device class as lights in deCONZ. + """ + gateway = hass.data[DECONZ_DOMAIN] + + @callback + def async_add_switch(lights): + """Add switch from deCONZ.""" + entities = [] + for light in lights: + if light.type in POWER_PLUGS: + entities.append(DeconzPowerPlug(light, gateway)) + elif light.type in SIRENS: + entities.append(DeconzSiren(light, gateway)) + async_add_entities(entities, True) + + gateway.listeners.append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) + + async_add_switch(gateway.api.lights.values()) + + +class DeconzPowerPlug(DeconzDevice, SwitchDevice): + """Representation of a deCONZ power plug.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.state + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'on': True} + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'on': False} + await self._device.async_set_state(data) + + +class DeconzSiren(DeconzDevice, SwitchDevice): + """Representation of a deCONZ siren.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.alert == 'lselect' + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'alert': 'lselect'} + await self._device.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'alert': 'none'} + await self._device.async_set_state(data) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/demo.py b/homeassistant-0.88.0.dev0/homeassistant/components/demo.py new file mode 100644 index 00000000000000..2b9854fbcc74c0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/demo.py @@ -0,0 +1,231 @@ +""" +Set up the demo environment that mimics interaction with devices. + +For more details about this component, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import asyncio +import time + +from homeassistant import bootstrap +import homeassistant.core as ha +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM + +DEPENDENCIES = ['conversation', 'introduction', 'zone'] +DOMAIN = 'demo' + +COMPONENTS_WITH_DEMO_PLATFORM = [ + 'air_quality', + 'alarm_control_panel', + 'binary_sensor', + 'calendar', + 'camera', + 'climate', + 'cover', + 'device_tracker', + 'fan', + 'image_processing', + 'light', + 'lock', + 'media_player', + 'notify', + 'sensor', + 'switch', + 'tts', + 'mailbox', +] + + +async def async_setup(hass, config): + """Set up the demo environment.""" + group = hass.components.group + configurator = hass.components.configurator + persistent_notification = hass.components.persistent_notification + + config.setdefault(ha.DOMAIN, {}) + config.setdefault(DOMAIN, {}) + + if config[DOMAIN].get('hide_demo_state') != 1: + hass.states.async_set('a.Demo_Mode', 'Enabled') + + # Setup sun + if not hass.config.latitude: + hass.config.latitude = 32.87336 + + if not hass.config.longitude: + hass.config.longitude = 117.22743 + + tasks = [ + bootstrap.async_setup_component(hass, 'sun') + ] + + # Set up demo platforms + demo_config = config.copy() + for component in COMPONENTS_WITH_DEMO_PLATFORM: + demo_config[component] = {CONF_PLATFORM: 'demo'} + tasks.append( + bootstrap.async_setup_component(hass, component, demo_config)) + + # Set up input select + tasks.append(bootstrap.async_setup_component( + hass, 'input_select', + {'input_select': + {'living_room_preset': {'options': ['Visitors', + 'Visitors with kids', + 'Home Alone']}, + 'who_cooks': {'icon': 'mdi:panda', + 'initial': 'Anne Therese', + 'name': 'Cook today', + 'options': ['Paulus', 'Anne Therese']}}})) + # Set up input boolean + tasks.append(bootstrap.async_setup_component( + hass, 'input_boolean', + {'input_boolean': {'notify': { + 'icon': 'mdi:car', + 'initial': False, + 'name': 'Notify Anne Therese is home'}}})) + + # Set up input boolean + tasks.append(bootstrap.async_setup_component( + hass, 'input_number', + {'input_number': { + 'noise_allowance': {'icon': 'mdi:bell-ring', + 'min': 0, + 'max': 10, + 'name': 'Allowed Noise', + 'unit_of_measurement': 'dB'}}})) + + # Set up weblink + tasks.append(bootstrap.async_setup_component( + hass, 'weblink', + {'weblink': {'entities': [{'name': 'Router', + 'url': 'http://192.168.1.1'}]}})) + + results = await asyncio.gather(*tasks, loop=hass.loop) + + if any(not result for result in results): + return False + + # Set up example persistent notification + persistent_notification.async_create( + 'This is an example of a persistent notification.', + title='Example Notification') + + # Set up room groups + lights = sorted(hass.states.async_entity_ids('light')) + switches = sorted(hass.states.async_entity_ids('switch')) + media_players = sorted(hass.states.async_entity_ids('media_player')) + + tasks2 = [] + + # Set up history graph + tasks2.append(bootstrap.async_setup_component( + hass, 'history_graph', + {'history_graph': {'switches': { + 'name': 'Recent Switches', + 'entities': switches, + 'hours_to_show': 1, + 'refresh': 60 + }}} + )) + + # Set up scripts + tasks2.append(bootstrap.async_setup_component( + hass, 'script', + {'script': { + 'demo': { + 'alias': 'Toggle {}'.format(lights[0].split('.')[1]), + 'sequence': [{ + 'service': 'light.turn_off', + 'data': {ATTR_ENTITY_ID: lights[0]} + }, { + 'delay': {'seconds': 5} + }, { + 'service': 'light.turn_on', + 'data': {ATTR_ENTITY_ID: lights[0]} + }, { + 'delay': {'seconds': 5} + }, { + 'service': 'light.turn_off', + 'data': {ATTR_ENTITY_ID: lights[0]} + }] + }}})) + + # Set up scenes + tasks2.append(bootstrap.async_setup_component( + hass, 'scene', + {'scene': [ + {'name': 'Romantic lights', + 'entities': { + lights[0]: True, + lights[1]: {'state': 'on', 'xy_color': [0.33, 0.66], + 'brightness': 200}, + }}, + {'name': 'Switch on and off', + 'entities': { + switches[0]: True, + switches[1]: False, + }}, + ]})) + + tasks2.append(group.Group.async_create_group(hass, 'Living Room', [ + lights[1], switches[0], 'input_select.living_room_preset', + 'cover.living_room_window', media_players[1], + 'scene.romantic_lights'])) + tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ + lights[0], switches[1], media_players[0], + 'input_number.noise_allowance'])) + tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [ + lights[2], 'cover.kitchen_window', 'lock.kitchen_door'])) + tasks2.append(group.Group.async_create_group(hass, 'Doors', [ + 'lock.front_door', 'lock.kitchen_door', + 'garage_door.right_garage_door', 'garage_door.left_garage_door'])) + tasks2.append(group.Group.async_create_group(hass, 'Automations', [ + 'input_select.who_cooks', 'input_boolean.notify', ])) + tasks2.append(group.Group.async_create_group(hass, 'People', [ + 'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy', + 'device_tracker.demo_paulus'])) + tasks2.append(group.Group.async_create_group(hass, 'Downstairs', [ + 'group.living_room', 'group.kitchen', + 'scene.romantic_lights', 'cover.kitchen_window', + 'cover.living_room_window', 'group.doors', + 'climate.ecobee', + ], view=True)) + + results = await asyncio.gather(*tasks2, loop=hass.loop) + + if any(not result for result in results): + return False + + # Set up configurator + configurator_ids = [] + + def hue_configuration_callback(data): + """Fake callback, mark config as done.""" + time.sleep(2) + + # First time it is called, pretend it failed. + if len(configurator_ids) == 1: + configurator.notify_errors( + configurator_ids[0], + "Failed to register, please try again.") + + configurator_ids.append(0) + else: + configurator.request_done(configurator_ids[0]) + + def setup_configurator(): + """Set up a configurator.""" + request_id = configurator.request_config( + "Philips Hue", hue_configuration_callback, + description=("Press the button on the bridge to register Philips " + "Hue with Home Assistant."), + description_image="/static/images/config_philips_hue.jpg", + fields=[{'id': 'username', 'name': 'Username'}], + submit_caption="I have pressed the button" + ) + configurator_ids.append(request_id) + + hass.async_add_job(setup_configurator) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/device_sun_light_trigger.py b/homeassistant-0.88.0.dev0/homeassistant/components/device_sun_light_trigger.py new file mode 100644 index 00000000000000..2b047e92c1e6a6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/device_sun_light_trigger.py @@ -0,0 +1,197 @@ +""" +Provides functionality to turn on lights based on the states. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/device_sun_light_trigger/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.util.dt as dt_util +from homeassistant.components.light import ( + ATTR_PROFILE, ATTR_TRANSITION, DOMAIN as DOMAIN_LIGHT) +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_HOME, + STATE_NOT_HOME, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) +from homeassistant.helpers.event import ( + async_track_point_in_utc_time, async_track_state_change) +from homeassistant.helpers.sun import is_up, get_astral_event_next +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'device_sun_light_trigger' +DEPENDENCIES = ['light', 'device_tracker', 'group'] + +CONF_DEVICE_GROUP = 'device_group' +CONF_DISABLE_TURN_OFF = 'disable_turn_off' +CONF_LIGHT_GROUP = 'light_group' +CONF_LIGHT_PROFILE = 'light_profile' + +DEFAULT_DISABLE_TURN_OFF = False +DEFAULT_LIGHT_PROFILE = 'relax' + +LIGHT_TRANSITION_TIME = timedelta(minutes=15) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICE_GROUP): cv.entity_id, + vol.Optional(CONF_DISABLE_TURN_OFF, default=DEFAULT_DISABLE_TURN_OFF): + cv.boolean, + vol.Optional(CONF_LIGHT_GROUP): cv.string, + vol.Optional(CONF_LIGHT_PROFILE, default=DEFAULT_LIGHT_PROFILE): + cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the triggers to control lights based on device presence.""" + logger = logging.getLogger(__name__) + device_tracker = hass.components.device_tracker + group = hass.components.group + light = hass.components.light + conf = config[DOMAIN] + disable_turn_off = conf.get(CONF_DISABLE_TURN_OFF) + light_group = conf.get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) + light_profile = conf.get(CONF_LIGHT_PROFILE) + device_group = conf.get( + CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) + device_entity_ids = group.get_entity_ids( + device_group, device_tracker.DOMAIN) + + if not device_entity_ids: + logger.error("No devices found to track") + return False + + # Get the light IDs from the specified group + light_ids = group.get_entity_ids(light_group, light.DOMAIN) + + if not light_ids: + logger.error("No lights found to turn on") + return False + + def calc_time_for_light_when_sunset(): + """Calculate the time when to start fading lights in when sun sets. + + Returns None if no next_setting data available. + + Async friendly. + """ + next_setting = get_astral_event_next(hass, SUN_EVENT_SUNSET) + if not next_setting: + return None + return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) + + def async_turn_on_before_sunset(light_id): + """Turn on lights.""" + if not device_tracker.is_on() or light.is_on(light_id): + return + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: light_id, + ATTR_TRANSITION: LIGHT_TRANSITION_TIME.seconds, + ATTR_PROFILE: light_profile})) + + def async_turn_on_factory(light_id): + """Generate turn on callbacks as factory.""" + @callback + def async_turn_on_light(now): + """Turn on specific light.""" + async_turn_on_before_sunset(light_id) + + return async_turn_on_light + + # Track every time sun rises so we can schedule a time-based + # pre-sun set event + @callback + def schedule_light_turn_on(now): + """Turn on all the lights at the moment sun sets. + + We will schedule to have each light start after one another + and slowly transition in. + """ + start_point = calc_time_for_light_when_sunset() + if not start_point: + return + + for index, light_id in enumerate(light_ids): + async_track_point_in_utc_time( + hass, async_turn_on_factory(light_id), + start_point + index * LIGHT_TRANSITION_TIME) + + async_track_point_in_utc_time(hass, schedule_light_turn_on, + get_astral_event_next(hass, + SUN_EVENT_SUNRISE)) + + # If the sun is already above horizon schedule the time-based pre-sun set + # event. + if is_up(hass): + schedule_light_turn_on(None) + + @callback + def check_light_on_dev_state_change(entity, old_state, new_state): + """Handle tracked device state changes.""" + lights_are_on = group.is_on(light_group) + light_needed = not (lights_are_on or is_up(hass)) + + # These variables are needed for the elif check + now = dt_util.utcnow() + start_point = calc_time_for_light_when_sunset() + + # Do we need lights? + if light_needed: + logger.info("Home coming event for %s. Turning lights on", entity) + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light_ids, ATTR_PROFILE: light_profile})) + + # Are we in the time span were we would turn on the lights + # if someone would be home? + # Check this by seeing if current time is later then the point + # in time when we would start putting the lights on. + elif (start_point and + start_point < now < get_astral_event_next(hass, + SUN_EVENT_SUNSET)): + + # Check for every light if it would be on if someone was home + # when the fading in started and turn it on if so + for index, light_id in enumerate(light_ids): + if now > start_point + index * LIGHT_TRANSITION_TIME: + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light_id})) + + else: + # If this light didn't happen to be turned on yet so + # will all the following then, break. + break + + async_track_state_change( + hass, device_entity_ids, check_light_on_dev_state_change, + STATE_NOT_HOME, STATE_HOME) + + if disable_turn_off: + return True + + @callback + def turn_off_lights_when_all_leave(entity, old_state, new_state): + """Handle device group state change.""" + if not group.is_on(light_group): + return + + logger.info( + "Everyone has left but there are lights on. Turning them off") + hass.async_create_task( + hass.services.async_call( + DOMAIN_LIGHT, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: light_ids})) + + async_track_state_change( + hass, device_group, turn_off_lights_when_all_leave, + STATE_HOME, STATE_NOT_HOME) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/device_tracker/__init__.py b/homeassistant-0.88.0.dev0/homeassistant/components/device_tracker/__init__.py new file mode 100644 index 00000000000000..202883713c740f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/device_tracker/__init__.py @@ -0,0 +1,767 @@ +""" +Provide functionality to keep track of devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/device_tracker/ +""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, List, Sequence, Callable + +import voluptuous as vol + +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.components import group, zone +from homeassistant.components.group import ( + ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE, + DOMAIN as DOMAIN_GROUP, SERVICE_SET) +from homeassistant.components.zone.zone import async_active_zone +from homeassistant.config import load_yaml_config_file, async_log_exception +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType +import homeassistant.helpers.config_validation as cv +from homeassistant import util +from homeassistant.util.async_ import run_coroutine_threadsafe +import homeassistant.util.dt as dt_util +from homeassistant.util.yaml import dump + +from homeassistant.helpers.event import async_track_utc_time_change +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_ICON, ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_NAME, CONF_ICON, CONF_MAC, CONF_NAME, + DEVICE_DEFAULT_NAME, STATE_NOT_HOME, STATE_HOME) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'device_tracker' +DEPENDENCIES = ['zone', 'group'] + +GROUP_NAME_ALL_DEVICES = 'all devices' +ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices') + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +YAML_DEVICES = 'known_devices.yaml' + +CONF_TRACK_NEW = 'track_new_devices' +DEFAULT_TRACK_NEW = True +CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults' + +CONF_CONSIDER_HOME = 'consider_home' +DEFAULT_CONSIDER_HOME = timedelta(seconds=180) + +CONF_SCAN_INTERVAL = 'interval_seconds' +DEFAULT_SCAN_INTERVAL = timedelta(seconds=12) + +CONF_AWAY_HIDE = 'hide_if_away' +DEFAULT_AWAY_HIDE = False + +EVENT_NEW_DEVICE = 'device_tracker_new_device' + +SERVICE_SEE = 'see' + +ATTR_ATTRIBUTES = 'attributes' +ATTR_BATTERY = 'battery' +ATTR_DEV_ID = 'dev_id' +ATTR_GPS = 'gps' +ATTR_HOST_NAME = 'host_name' +ATTR_LOCATION_NAME = 'location_name' +ATTR_MAC = 'mac' +ATTR_SOURCE_TYPE = 'source_type' +ATTR_CONSIDER_HOME = 'consider_home' + +SOURCE_TYPE_GPS = 'gps' +SOURCE_TYPE_ROUTER = 'router' +SOURCE_TYPE_BLUETOOTH = 'bluetooth' +SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' +SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) + +NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ + vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, +})) +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_TRACK_NEW): cv.boolean, + vol.Optional(CONF_CONSIDER_HOME, + default=DEFAULT_CONSIDER_HOME): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_NEW_DEVICE_DEFAULTS, + default={}): NEW_DEVICE_DEFAULTS_SCHEMA +}) +SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), { + ATTR_MAC: cv.string, + ATTR_DEV_ID: cv.string, + ATTR_HOST_NAME: cv.string, + ATTR_LOCATION_NAME: cv.string, + ATTR_GPS: cv.gps, + ATTR_GPS_ACCURACY: cv.positive_int, + ATTR_BATTERY: cv.positive_int, + ATTR_ATTRIBUTES: dict, + ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES), + ATTR_CONSIDER_HOME: cv.time_period, + # Temp workaround for iOS app introduced in 0.65 + vol.Optional('battery_status'): str, + vol.Optional('hostname'): str, + })) + + +@bind_hass +def is_on(hass: HomeAssistantType, entity_id: str = None): + """Return the state if any or a specified device is home.""" + entity = entity_id or ENTITY_ID_ALL_DEVICES + + return hass.states.is_state(entity, STATE_HOME) + + +def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, + battery: int = None, attributes: dict = None): + """Call service to notify you see device.""" + data = {key: value for key, value in + ((ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps), + (ATTR_GPS_ACCURACY, gps_accuracy), + (ATTR_BATTERY, battery)) if value is not None} + if attributes: + data[ATTR_ATTRIBUTES] = attributes + hass.services.call(DOMAIN, SERVICE_SEE, data) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the device tracker.""" + yaml_path = hass.config.path(YAML_DEVICES) + + conf = config.get(DOMAIN, []) + conf = conf[0] if conf else {} + consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) + + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) + track_new = conf.get(CONF_TRACK_NEW) + if track_new is None: + track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + + devices = await async_load_config(yaml_path, hass, consider_home) + tracker = DeviceTracker( + hass, consider_home, track_new, defaults, devices) + + async def async_setup_platform(p_type, p_config, disc_info=None): + """Set up a device tracker platform.""" + platform = await async_prepare_setup_platform( + hass, config, DOMAIN, p_type) + if platform is None: + return + + _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) + try: + scanner = None + setup = None + if hasattr(platform, 'async_get_scanner'): + scanner = await platform.async_get_scanner( + hass, {DOMAIN: p_config}) + elif hasattr(platform, 'get_scanner'): + scanner = await hass.async_add_job( + platform.get_scanner, hass, {DOMAIN: p_config}) + elif hasattr(platform, 'async_setup_scanner'): + setup = await platform.async_setup_scanner( + hass, p_config, tracker.async_see, disc_info) + elif hasattr(platform, 'setup_scanner'): + setup = await hass.async_add_job( + platform.setup_scanner, hass, p_config, tracker.see, + disc_info) + elif hasattr(platform, 'async_setup_entry'): + setup = await platform.async_setup_entry( + hass, p_config, tracker.async_see) + else: + raise HomeAssistantError("Invalid device_tracker platform.") + + if scanner: + async_setup_scanner_platform( + hass, p_config, scanner, tracker.async_see, p_type) + return + + if not setup: + _LOGGER.error("Error setting up platform %s", p_type) + return + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error setting up platform %s", p_type) + + hass.data[DOMAIN] = async_setup_platform + + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config + in config_per_platform(config, DOMAIN)] + if setup_tasks: + await asyncio.wait(setup_tasks, loop=hass.loop) + + tracker.async_setup_group() + + async def async_platform_discovered(platform, info): + """Load a platform.""" + await async_setup_platform(platform, {}, disc_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + # Clean up stale devices + async_track_utc_time_change( + hass, tracker.async_update_stale, second=range(0, 60, 5)) + + async def async_see_service(call): + """Service to see a device.""" + # Temp workaround for iOS, introduced in 0.65 + data = dict(call.data) + data.pop('hostname', None) + data.pop('battery_status', None) + await tracker.async_see(**data) + + hass.services.async_register( + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) + + # restore + await tracker.async_setup_tracked_device() + return True + + +async def async_setup_entry(hass, entry): + """Set up an entry.""" + await hass.data[DOMAIN](entry.domain, entry) + return True + + +class DeviceTracker: + """Representation of a device tracker.""" + + def __init__(self, hass: HomeAssistantType, consider_home: timedelta, + track_new: bool, defaults: dict, + devices: Sequence) -> None: + """Initialize a device tracker.""" + self.hass = hass + self.devices = {dev.dev_id: dev for dev in devices} + self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} + self.consider_home = consider_home + self.track_new = track_new if track_new is not None \ + else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + self.defaults = defaults + self.group = None + self._is_updating = asyncio.Lock(loop=hass.loop) + + for dev in devices: + if self.devices[dev.dev_id] is not dev: + _LOGGER.warning('Duplicate device IDs detected %s', dev.dev_id) + if dev.mac and self.mac_to_dev[dev.mac] is not dev: + _LOGGER.warning('Duplicate device MAC addresses detected %s', + dev.mac) + + def see(self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): + """Notify the device tracker that you see a device.""" + self.hass.add_job( + self.async_see(mac, dev_id, host_name, location_name, gps, + gps_accuracy, battery, attributes, source_type, + picture, icon, consider_home) + ) + + async def async_see( + self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): + """Notify the device tracker that you see a device. + + This method is a coroutine. + """ + if mac is None and dev_id is None: + raise HomeAssistantError('Neither mac or device id passed in') + elif mac is not None: + mac = str(mac).upper() + device = self.mac_to_dev.get(mac) + if not device: + dev_id = util.slugify(host_name or '') or util.slugify(mac) + else: + dev_id = cv.slug(str(dev_id).lower()) + device = self.devices.get(dev_id) + + if device: + await device.async_seen( + host_name, location_name, gps, gps_accuracy, battery, + attributes, source_type, consider_home) + if device.track: + await device.async_update_ha_state() + return + + # If no device can be found, create it + dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) + device = Device( + self.hass, consider_home or self.consider_home, self.track_new, + dev_id, mac, (host_name or dev_id).replace('_', ' '), + picture=picture, icon=icon, + hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) + self.devices[dev_id] = device + if mac is not None: + self.mac_to_dev[mac] = device + + await device.async_seen( + host_name, location_name, gps, gps_accuracy, battery, attributes, + source_type) + + if device.track: + await device.async_update_ha_state() + + # During init, we ignore the group + if self.group and self.track_new: + self.hass.async_create_task( + self.hass.async_call( + DOMAIN_GROUP, SERVICE_SET, { + ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES), + ATTR_VISIBLE: False, + ATTR_NAME: GROUP_NAME_ALL_DEVICES, + ATTR_ADD_ENTITIES: [device.entity_id]})) + + self.hass.bus.async_fire(EVENT_NEW_DEVICE, { + ATTR_ENTITY_ID: device.entity_id, + ATTR_HOST_NAME: device.host_name, + ATTR_MAC: device.mac, + }) + + # update known_devices.yaml + self.hass.async_create_task( + self.async_update_config( + self.hass.config.path(YAML_DEVICES), dev_id, device) + ) + + async def async_update_config(self, path, dev_id, device): + """Add device to YAML configuration file. + + This method is a coroutine. + """ + async with self._is_updating: + await self.hass.async_add_executor_job( + update_config, self.hass.config.path(YAML_DEVICES), + dev_id, device) + + @callback + def async_setup_group(self): + """Initialize group for all tracked devices. + + This method must be run in the event loop. + """ + entity_ids = [dev.entity_id for dev in self.devices.values() + if dev.track] + + self.hass.async_create_task( + self.hass.services.async_call( + DOMAIN_GROUP, SERVICE_SET, { + ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES), + ATTR_VISIBLE: False, + ATTR_NAME: GROUP_NAME_ALL_DEVICES, + ATTR_ENTITIES: entity_ids})) + + @callback + def async_update_stale(self, now: dt_util.dt.datetime): + """Update stale devices. + + This method must be run in the event loop. + """ + for device in self.devices.values(): + if (device.track and device.last_update_home) and \ + device.stale(now): + self.hass.async_create_task(device.async_update_ha_state(True)) + + async def async_setup_tracked_device(self): + """Set up all not exists tracked devices. + + This method is a coroutine. + """ + async def async_init_single_device(dev): + """Init a single device_tracker entity.""" + await dev.async_added_to_hass() + await dev.async_update_ha_state() + + tasks = [] + for device in self.devices.values(): + if device.track and not device.last_seen: + tasks.append(self.hass.async_create_task( + async_init_single_device(device))) + + if tasks: + await asyncio.wait(tasks, loop=self.hass.loop) + + +class Device(RestoreEntity): + """Represent a tracked device.""" + + host_name = None # type: str + location_name = None # type: str + gps = None # type: GPSType + gps_accuracy = 0 # type: int + last_seen = None # type: dt_util.dt.datetime + consider_home = None # type: dt_util.dt.timedelta + battery = None # type: int + attributes = None # type: dict + icon = None # type: str + + # Track if the last update of this device was HOME. + last_update_home = False + _state = STATE_NOT_HOME + + def __init__(self, hass: HomeAssistantType, consider_home: timedelta, + track: bool, dev_id: str, mac: str, name: str = None, + picture: str = None, gravatar: str = None, icon: str = None, + hide_if_away: bool = False) -> None: + """Initialize a device.""" + self.hass = hass + self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + + # Timedelta object how long we consider a device home if it is not + # detected anymore. + self.consider_home = consider_home + + # Device ID + self.dev_id = dev_id + self.mac = mac + + # If we should track this device + self.track = track + + # Configured name + self.config_name = name + + # Configured picture + if gravatar is not None: + self.config_picture = get_gravatar_for_email(gravatar) + else: + self.config_picture = picture + + self.icon = icon + + self.away_hide = hide_if_away + + self.source_type = None + + self._attributes = {} + + @property + def name(self): + """Return the name of the entity.""" + return self.config_name or self.host_name or DEVICE_DEFAULT_NAME + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def entity_picture(self): + """Return the picture of the device.""" + return self.config_picture + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = { + ATTR_SOURCE_TYPE: self.source_type + } + + if self.gps: + attr[ATTR_LATITUDE] = self.gps[0] + attr[ATTR_LONGITUDE] = self.gps[1] + attr[ATTR_GPS_ACCURACY] = self.gps_accuracy + + if self.battery: + attr[ATTR_BATTERY] = self.battery + + return attr + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return self._attributes + + @property + def hidden(self): + """If device should be hidden.""" + return self.away_hide and self.state != STATE_HOME + + async def async_seen( + self, host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=0, battery: int = None, + attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS, + consider_home: timedelta = None): + """Mark the device as seen.""" + self.source_type = source_type + self.last_seen = dt_util.utcnow() + self.host_name = host_name + self.location_name = location_name + self.consider_home = consider_home or self.consider_home + + if battery: + self.battery = battery + if attributes: + self._attributes.update(attributes) + + self.gps = None + + if gps is not None: + try: + self.gps = float(gps[0]), float(gps[1]) + self.gps_accuracy = gps_accuracy or 0 + except (ValueError, TypeError, IndexError): + self.gps = None + self.gps_accuracy = 0 + _LOGGER.warning( + "Could not parse gps value for %s: %s", self.dev_id, gps) + + # pylint: disable=not-an-iterable + await self.async_update() + + def stale(self, now: dt_util.dt.datetime = None): + """Return if device state is stale. + + Async friendly. + """ + return self.last_seen is None or \ + (now or dt_util.utcnow()) - self.last_seen > self.consider_home + + def mark_stale(self): + """Mark the device state as stale.""" + self._state = STATE_NOT_HOME + self.gps = None + self.last_update_home = False + + async def async_update(self): + """Update state of entity. + + This method is a coroutine. + """ + if not self.last_seen: + return + if self.location_name: + self._state = self.location_name + elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: + zone_state = async_active_zone( + self.hass, self.gps[0], self.gps[1], self.gps_accuracy) + if zone_state is None: + self._state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + self._state = STATE_HOME + else: + self._state = zone_state.name + elif self.stale(): + self.mark_stale() + else: + self._state = STATE_HOME + self.last_update_home = True + + async def async_added_to_hass(self): + """Add an entity.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if not state: + return + self._state = state.state + self.last_update_home = (state.state == STATE_HOME) + + for attr, var in ( + (ATTR_SOURCE_TYPE, 'source_type'), + (ATTR_GPS_ACCURACY, 'gps_accuracy'), + (ATTR_BATTERY, 'battery'), + ): + if attr in state.attributes: + setattr(self, var, state.attributes[attr]) + + if ATTR_LONGITUDE in state.attributes: + self.gps = (state.attributes[ATTR_LATITUDE], + state.attributes[ATTR_LONGITUDE]) + + +class DeviceScanner: + """Device scanner object.""" + + hass = None # type: HomeAssistantType + + def scan_devices(self) -> List[str]: + """Scan for devices.""" + raise NotImplementedError() + + def async_scan_devices(self) -> Any: + """Scan for devices. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.scan_devices) + + def get_device_name(self, device: str) -> str: + """Get the name of a device.""" + raise NotImplementedError() + + def async_get_device_name(self, device: str) -> Any: + """Get the name of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_device_name, device) + + def get_extra_attributes(self, device: str) -> dict: + """Get the extra attributes of a device.""" + raise NotImplementedError() + + def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_extra_attributes, device) + + +def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): + """Load devices from YAML configuration file.""" + return run_coroutine_threadsafe( + async_load_config(path, hass, consider_home), hass.loop).result() + + +async def async_load_config(path: str, hass: HomeAssistantType, + consider_home: timedelta): + """Load devices from YAML configuration file. + + This method is a coroutine. + """ + dev_schema = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), + vol.Optional('track', default=False): cv.boolean, + vol.Optional(CONF_MAC, default=None): + vol.Any(None, vol.All(cv.string, vol.Upper)), + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, + vol.Optional('gravatar', default=None): vol.Any(None, cv.string), + vol.Optional('picture', default=None): vol.Any(None, cv.string), + vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( + cv.time_period, cv.positive_timedelta), + }) + try: + result = [] + try: + devices = await hass.async_add_job( + load_yaml_config_file, path) + except HomeAssistantError as err: + _LOGGER.error("Unable to load %s: %s", path, str(err)) + return [] + + for dev_id, device in devices.items(): + # Deprecated option. We just ignore it to avoid breaking change + device.pop('vendor', None) + try: + device = dev_schema(device) + device['dev_id'] = cv.slugify(dev_id) + except vol.Invalid as exp: + async_log_exception(exp, dev_id, devices, hass) + else: + result.append(Device(hass, **device)) + return result + except (HomeAssistantError, FileNotFoundError): + # When YAML file could not be loaded/did not contain a dict + return [] + + +@callback +def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType, + scanner: Any, async_see_device: Callable, + platform: str): + """Set up the connect scanner-based platform to device tracker. + + This method must be run in the event loop. + """ + interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + update_lock = asyncio.Lock(loop=hass.loop) + scanner.hass = hass + + # Initial scan of each mac we also tell about host name for config + seen = set() # type: Any + + async def async_device_tracker_scan(now: dt_util.dt.datetime): + """Handle interval matches.""" + if update_lock.locked(): + _LOGGER.warning( + "Updating device list from %s took longer than the scheduled " + "scan interval %s", platform, interval) + return + + async with update_lock: + found_devices = await scanner.async_scan_devices() + + for mac in found_devices: + if mac in seen: + host_name = None + else: + host_name = await scanner.async_get_device_name(mac) + seen.add(mac) + + try: + extra_attributes = \ + await scanner.async_get_extra_attributes(mac) + except NotImplementedError: + extra_attributes = dict() + + kwargs = { + 'mac': mac, + 'host_name': host_name, + 'source_type': SOURCE_TYPE_ROUTER, + 'attributes': { + 'scanner': scanner.__class__.__name__, + **extra_attributes + } + } + + zone_home = hass.states.get(zone.ENTITY_ID_HOME) + if zone_home: + kwargs['gps'] = [zone_home.attributes[ATTR_LATITUDE], + zone_home.attributes[ATTR_LONGITUDE]] + kwargs['gps_accuracy'] = 0 + + hass.async_create_task(async_see_device(**kwargs)) + + async_track_time_interval(hass, async_device_tracker_scan, interval) + hass.async_create_task(async_device_tracker_scan(None)) + + +def update_config(path: str, dev_id: str, device: Device): + """Add device to YAML configuration file.""" + with open(path, 'a') as out: + device = {device.dev_id: { + ATTR_NAME: device.name, + ATTR_MAC: device.mac, + ATTR_ICON: device.icon, + 'picture': device.config_picture, + 'track': device.track, + CONF_AWAY_HIDE: device.away_hide, + }} + out.write('\n') + out.write(dump(device)) + + +def get_gravatar_for_email(email: str): + """Return an 80px Gravatar for the given email address. + + Async friendly. + """ + import hashlib + url = 'https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar' + return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest()) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/device_tracker/actiontec.py b/homeassistant-0.88.0.dev0/homeassistant/components/device_tracker/actiontec.py new file mode 100644 index 00000000000000..72d9992c60f3e2 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/device_tracker/actiontec.py @@ -0,0 +1,120 @@ +""" +Support for Actiontec MI424WR (Verizon FIOS) routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.actiontec/ +""" +import logging +import re +import telnetlib +from collections import namedtuple +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +_LOGGER = logging.getLogger(__name__) + +_LEASES_REGEX = re.compile( + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' + + r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' + + r'\svalid\sfor:\s(?P(-?\d+))' + + r'\ssec') + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + + +def get_scanner(hass, config): + """Validate the configuration and return an Actiontec scanner.""" + scanner = ActiontecDeviceScanner(config[DOMAIN]) + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'ip', 'last_update']) + + +class ActiontecDeviceScanner(DeviceScanner): + """This class queries an actiontec router for connected devices.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.last_results = [] + data = self.get_actiontec_data() + self.success_init = data is not None + _LOGGER.info("canner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + return [client.mac for client in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + if not self.last_results: + return None + for client in self.last_results: + if client.mac == device: + return client.ip + return None + + def _update_info(self): + """Ensure the information from the router is up to date. + + Return boolean if scanning successful. + """ + _LOGGER.info("Scanning") + if not self.success_init: + return False + + now = dt_util.now() + actiontec_data = self.get_actiontec_data() + if not actiontec_data: + return False + self.last_results = [Device(data['mac'], name, now) + for name, data in actiontec_data.items() + if data['timevalid'] > -60] + _LOGGER.info("Scan successful") + return True + + def get_actiontec_data(self): + """Retrieve data from Actiontec MI424WR and return parsed result.""" + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'Username: ') + telnet.write((self.username + '\n').encode('ascii')) + telnet.read_until(b'Password: ') + telnet.write((self.password + '\n').encode('ascii')) + prompt = telnet.read_until( + b'Wireless Broadband Router> ').split(b'\n')[-1] + telnet.write('firewall mac_cache_dump\n'.encode('ascii')) + telnet.write('\n'.encode('ascii')) + telnet.read_until(prompt) + leases_result = telnet.read_until(prompt).split(b'\n')[1:-1] + telnet.write('exit\n'.encode('ascii')) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router. Telnet enabled?") + return None + + devices = {} + for lease in leases_result: + match = _LEASES_REGEX.search(lease.decode('utf-8')) + if match is not None: + devices[match.group('ip')] = { + 'ip': match.group('ip'), + 'mac': match.group('mac').upper(), + 'timevalid': int(match.group('timevalid')) + } + return devices diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/discovery.py b/homeassistant-0.88.0.dev0/homeassistant/components/discovery.py new file mode 100644 index 00000000000000..d8198ba3033624 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/discovery.py @@ -0,0 +1,216 @@ +""" +Starts a service to scan in intervals for new devices. + +Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. + +Knows which components handle certain types, will make sure they are +loaded before the EVENT_PLATFORM_DISCOVERED is fired. +""" +import json +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import EVENT_HOMEASSISTANT_START +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.discovery import async_load_platform, async_discover +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['netdisco==2.3.0'] + +DOMAIN = 'discovery' + +SCAN_INTERVAL = timedelta(seconds=300) +SERVICE_NETGEAR = 'netgear_router' +SERVICE_WEMO = 'belkin_wemo' +SERVICE_HASS_IOS_APP = 'hass_ios' +SERVICE_IKEA_TRADFRI = 'ikea_tradfri' +SERVICE_HASSIO = 'hassio' +SERVICE_AXIS = 'axis' +SERVICE_APPLE_TV = 'apple_tv' +SERVICE_WINK = 'wink' +SERVICE_XIAOMI_GW = 'xiaomi_gw' +SERVICE_TELLDUSLIVE = 'tellstick' +SERVICE_HUE = 'philips_hue' +SERVICE_KONNECTED = 'konnected' +SERVICE_DECONZ = 'deconz' +SERVICE_DAIKIN = 'daikin' +SERVICE_SABNZBD = 'sabnzbd' +SERVICE_SAMSUNG_PRINTER = 'samsung_printer' +SERVICE_HOMEKIT = 'homekit' +SERVICE_OCTOPRINT = 'octoprint' +SERVICE_FREEBOX = 'freebox' +SERVICE_IGD = 'igd' +SERVICE_DLNA_DMR = 'dlna_dmr' +SERVICE_ROKU = 'roku' + +CONFIG_ENTRY_HANDLERS = { + SERVICE_DAIKIN: 'daikin', + SERVICE_DECONZ: 'deconz', + 'esphome': 'esphome', + 'google_cast': 'cast', + SERVICE_HUE: 'hue', + SERVICE_TELLDUSLIVE: 'tellduslive', + SERVICE_IKEA_TRADFRI: 'tradfri', + 'sonos': 'sonos', + SERVICE_IGD: 'upnp', +} + +SERVICE_HANDLERS = { + SERVICE_HASS_IOS_APP: ('ios', None), + SERVICE_NETGEAR: ('device_tracker', None), + SERVICE_WEMO: ('wemo', None), + SERVICE_HASSIO: ('hassio', None), + SERVICE_AXIS: ('axis', None), + SERVICE_APPLE_TV: ('apple_tv', None), + SERVICE_ROKU: ('roku', None), + SERVICE_WINK: ('wink', None), + SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), + SERVICE_SABNZBD: ('sabnzbd', None), + SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), + SERVICE_KONNECTED: ('konnected', None), + SERVICE_OCTOPRINT: ('octoprint', None), + SERVICE_FREEBOX: ('freebox', None), + 'panasonic_viera': ('media_player', 'panasonic_viera'), + 'plex_mediaserver': ('media_player', 'plex'), + 'yamaha': ('media_player', 'yamaha'), + 'logitech_mediaserver': ('media_player', 'squeezebox'), + 'directv': ('media_player', 'directv'), + 'denonavr': ('media_player', 'denonavr'), + 'samsung_tv': ('media_player', 'samsungtv'), + 'yeelight': ('light', 'yeelight'), + 'frontier_silicon': ('media_player', 'frontier_silicon'), + 'openhome': ('media_player', 'openhome'), + 'harmony': ('remote', 'harmony'), + 'bose_soundtouch': ('media_player', 'soundtouch'), + 'bluesound': ('media_player', 'bluesound'), + 'songpal': ('media_player', 'songpal'), + 'kodi': ('media_player', 'kodi'), + 'volumio': ('media_player', 'volumio'), + 'lg_smart_device': ('media_player', 'lg_soundbar'), + 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), +} + +OPTIONAL_SERVICE_HANDLERS = { + SERVICE_HOMEKIT: ('homekit_controller', None), + SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), +} + +CONF_IGNORE = 'ignore' +CONF_ENABLE = 'enable' + +CONFIG_SCHEMA = vol.Schema({ + vol.Required(DOMAIN): vol.Schema({ + vol.Optional(CONF_IGNORE, default=[]): + vol.All(cv.ensure_list, [ + vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]), + vol.Optional(CONF_ENABLE, default=[]): + vol.All(cv.ensure_list, [vol.In(OPTIONAL_SERVICE_HANDLERS)]) + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Start a discovery service.""" + from netdisco.discovery import NetworkDiscovery + + logger = logging.getLogger(__name__) + netdisco = NetworkDiscovery() + already_discovered = set() + + # Disable zeroconf logging, it spams + logging.getLogger('zeroconf').setLevel(logging.CRITICAL) + + # Platforms ignore by config + ignored_platforms = config[DOMAIN][CONF_IGNORE] + + # Optional platforms enabled by config + enabled_platforms = config[DOMAIN][CONF_ENABLE] + + async def new_service_found(service, info): + """Handle a new service if one is found.""" + if service in ignored_platforms: + logger.info("Ignoring service: %s %s", service, info) + return + + discovery_hash = json.dumps([service, info], sort_keys=True) + if discovery_hash in already_discovered: + logger.debug("Already discovered service %s %s.", service, info) + return + + already_discovered.add(discovery_hash) + + if service in CONFIG_ENTRY_HANDLERS: + await hass.config_entries.flow.async_init( + CONFIG_ENTRY_HANDLERS[service], + context={'source': config_entries.SOURCE_DISCOVERY}, + data=info + ) + return + + comp_plat = SERVICE_HANDLERS.get(service) + + if not comp_plat and service in enabled_platforms: + comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + + # We do not know how to handle this service. + if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) + return + + logger.info("Found new service: %s %s", service, info) + + component, platform = comp_plat + + if platform is None: + await async_discover(hass, service, info, component, config) + else: + await async_load_platform( + hass, component, platform, info, config) + + async def scan_devices(now): + """Scan for devices.""" + try: + results = await hass.async_add_job(_discover, netdisco) + + for result in results: + hass.async_create_task(new_service_found(*result)) + except OSError: + logger.error("Network is unreachable") + + async_track_point_in_utc_time( + hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL) + + @callback + def schedule_first(event): + """Schedule the first discovery when Home Assistant starts up.""" + async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) + + # Discovery for local services + if 'HASSIO' in os.environ: + hass.async_create_task(new_service_found(SERVICE_HASSIO, {})) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first) + + return True + + +def _discover(netdisco): + """Discover devices.""" + results = [] + try: + netdisco.scan() + + for disc in netdisco.discover(): + for service in netdisco.get_info(disc): + results.append((disc, service)) + + finally: + netdisco.stop() + + return results diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/dominos.py b/homeassistant-0.88.0.dev0/homeassistant/components/dominos.py new file mode 100644 index 00000000000000..2c9f763aaa843d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/dominos.py @@ -0,0 +1,252 @@ +""" +Support for Dominos Pizza ordering. + +The Dominos Pizza component creates a service which can be invoked to order +from their menu + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/dominos/. +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +# The domain of your component. Should be equal to the name of your component. +DOMAIN = 'dominos' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +ATTR_COUNTRY = 'country_code' +ATTR_FIRST_NAME = 'first_name' +ATTR_LAST_NAME = 'last_name' +ATTR_EMAIL = 'email' +ATTR_PHONE = 'phone' +ATTR_ADDRESS = 'address' +ATTR_ORDERS = 'orders' +ATTR_SHOW_MENU = 'show_menu' +ATTR_ORDER_ENTITY = 'order_entity_id' +ATTR_ORDER_NAME = 'name' +ATTR_ORDER_CODES = 'codes' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +MIN_TIME_BETWEEN_STORE_UPDATES = timedelta(minutes=3330) + +REQUIREMENTS = ['pizzapi==0.0.3'] + +DEPENDENCIES = ['http'] + +_ORDERS_SCHEMA = vol.Schema({ + vol.Required(ATTR_ORDER_NAME): cv.string, + vol.Required(ATTR_ORDER_CODES): vol.All(cv.ensure_list, [cv.string]), +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(ATTR_COUNTRY): cv.string, + vol.Required(ATTR_FIRST_NAME): cv.string, + vol.Required(ATTR_LAST_NAME): cv.string, + vol.Required(ATTR_EMAIL): cv.string, + vol.Required(ATTR_PHONE): cv.string, + vol.Required(ATTR_ADDRESS): cv.string, + vol.Optional(ATTR_SHOW_MENU): cv.boolean, + vol.Optional(ATTR_ORDERS, default=[]): vol.All( + cv.ensure_list, [_ORDERS_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up is called when Home Assistant is loading our component.""" + dominos = Dominos(hass, config) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = {} + entities = [] + conf = config[DOMAIN] + + hass.services.register(DOMAIN, 'order', dominos.handle_order) + + if conf.get(ATTR_SHOW_MENU): + hass.http.register_view(DominosProductListView(dominos)) + + for order_info in conf.get(ATTR_ORDERS): + order = DominosOrder(order_info, dominos) + entities.append(order) + + if entities: + component.add_entities(entities) + + # Return boolean to indicate that initialization was successfully. + return True + + +class Dominos(): + """Main Dominos service.""" + + def __init__(self, hass, config): + """Set up main service.""" + conf = config[DOMAIN] + from pizzapi import Address, Customer + from pizzapi.address import StoreException + self.hass = hass + self.customer = Customer( + conf.get(ATTR_FIRST_NAME), + conf.get(ATTR_LAST_NAME), + conf.get(ATTR_EMAIL), + conf.get(ATTR_PHONE), + conf.get(ATTR_ADDRESS)) + self.address = Address( + *self.customer.address.split(','), + country=conf.get(ATTR_COUNTRY)) + self.country = conf.get(ATTR_COUNTRY) + try: + self.closest_store = self.address.closest_store() + except StoreException: + self.closest_store = None + + def handle_order(self, call): + """Handle ordering pizza.""" + entity_ids = call.data.get(ATTR_ORDER_ENTITY, None) + + target_orders = [order for order in self.hass.data[DOMAIN]['entities'] + if order.entity_id in entity_ids] + + for order in target_orders: + order.place() + + @Throttle(MIN_TIME_BETWEEN_STORE_UPDATES) + def update_closest_store(self): + """Update the shared closest store (if open).""" + from pizzapi.address import StoreException + try: + self.closest_store = self.address.closest_store() + return True + except StoreException: + self.closest_store = None + return False + + def get_menu(self): + """Return the products from the closest stores menu.""" + self.update_closest_store() + if self.closest_store is None: + _LOGGER.warning('Cannot get menu. Store may be closed') + return [] + menu = self.closest_store.get_menu() + product_entries = [] + + for product in menu.products: + item = {} + if isinstance(product.menu_data['Variants'], list): + variants = ', '.join(product.menu_data['Variants']) + else: + variants = product.menu_data['Variants'] + item['name'] = product.name + item['variants'] = variants + product_entries.append(item) + + return product_entries + + +class DominosProductListView(http.HomeAssistantView): + """View to retrieve product list content.""" + + url = '/api/dominos' + name = "api:dominos" + + def __init__(self, dominos): + """Initialize suite view.""" + self.dominos = dominos + + @callback + def get(self, request): + """Retrieve if API is running.""" + return self.json(self.dominos.get_menu()) + + +class DominosOrder(Entity): + """Represents a Dominos order entity.""" + + def __init__(self, order_info, dominos): + """Set up the entity.""" + self._name = order_info['name'] + self._product_codes = order_info['codes'] + self._orderable = False + self.dominos = dominos + + @property + def name(self): + """Return the orders name.""" + return self._name + + @property + def product_codes(self): + """Return the orders product codes.""" + return self._product_codes + + @property + def orderable(self): + """Return the true if orderable.""" + return self._orderable + + @property + def state(self): + """Return the state either closed, orderable or unorderable.""" + if self.dominos.closest_store is None: + return 'closed' + return 'orderable' if self._orderable else 'unorderable' + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the order state and refreshes the store.""" + from pizzapi.address import StoreException + try: + self.dominos.update_closest_store() + except StoreException: + self._orderable = False + return + + try: + order = self.order() + order.pay_with() + self._orderable = True + except StoreException: + self._orderable = False + + def order(self): + """Create the order object.""" + from pizzapi import Order + from pizzapi.address import StoreException + + if self.dominos.closest_store is None: + raise StoreException + + order = Order( + self.dominos.closest_store, + self.dominos.customer, + self.dominos.address, + self.dominos.country) + + for code in self._product_codes: + order.add_item(code) + + return order + + def place(self): + """Place the order.""" + from pizzapi.address import StoreException + try: + order = self.order() + order.place() + except StoreException: + self._orderable = False + _LOGGER.warning( + 'Attempted to order Dominos - Order invalid or store closed') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/downloader.py b/homeassistant-0.88.0.dev0/homeassistant/components/downloader.py new file mode 100644 index 00000000000000..0d57740a83d0ee --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/downloader.py @@ -0,0 +1,161 @@ +""" +Support for functionality to download files. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/downloader/ +""" +import logging +import os +import re +import threading + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.util import sanitize_filename + +_LOGGER = logging.getLogger(__name__) + +ATTR_FILENAME = 'filename' +ATTR_SUBDIR = 'subdir' +ATTR_URL = 'url' +ATTR_OVERWRITE = 'overwrite' + +CONF_DOWNLOAD_DIR = 'download_dir' + +DOMAIN = 'downloader' +DOWNLOAD_FAILED_EVENT = 'download_failed' +DOWNLOAD_COMPLETED_EVENT = 'download_completed' + +SERVICE_DOWNLOAD_FILE = 'download_file' + +SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ + vol.Required(ATTR_URL): cv.url, + vol.Optional(ATTR_SUBDIR): cv.string, + vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOWNLOAD_DIR): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Listen for download events to download files.""" + download_path = config[DOMAIN][CONF_DOWNLOAD_DIR] + + # If path is relative, we assume relative to HASS config dir + if not os.path.isabs(download_path): + download_path = hass.config.path(download_path) + + if not os.path.isdir(download_path): + _LOGGER.error( + "Download path %s does not exist. File Downloader not active", + download_path) + + return False + + def download_file(service): + """Start thread to download file specified in the URL.""" + def do_download(): + """Download the file.""" + try: + url = service.data[ATTR_URL] + + subdir = service.data.get(ATTR_SUBDIR) + + filename = service.data.get(ATTR_FILENAME) + + overwrite = service.data.get(ATTR_OVERWRITE) + + if subdir: + subdir = sanitize_filename(subdir) + + final_path = None + + req = requests.get(url, stream=True, timeout=10) + + if req.status_code != 200: + _LOGGER.warning( + "downloading '%s' failed, status_code=%d", + url, + req.status_code) + + else: + if filename is None and \ + 'content-disposition' in req.headers: + match = re.findall(r"filename=(\S+)", + req.headers['content-disposition']) + + if match: + filename = match[0].strip("'\" ") + + if not filename: + filename = os.path.basename(url).strip() + + if not filename: + filename = 'ha_download' + + # Remove stuff to ruin paths + filename = sanitize_filename(filename) + + # Do we want to download to subdir, create if needed + if subdir: + subdir_path = os.path.join(download_path, subdir) + + # Ensure subdir exist + if not os.path.isdir(subdir_path): + os.makedirs(subdir_path) + + final_path = os.path.join(subdir_path, filename) + + else: + final_path = os.path.join(download_path, filename) + + path, ext = os.path.splitext(final_path) + + # If file exist append a number. + # We test filename, filename_2.. + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 + + final_path = "{}_{}.{}".format(path, tries, ext) + + _LOGGER.debug("%s -> %s", url, final_path) + + with open(final_path, 'wb') as fil: + for chunk in req.iter_content(1024): + fil.write(chunk) + + _LOGGER.debug("Downloading of %s done", url) + hass.bus.fire( + "{}_{}".format(DOMAIN, DOWNLOAD_COMPLETED_EVENT), { + 'url': url, + 'filename': filename + }) + + except requests.exceptions.ConnectionError: + _LOGGER.exception("ConnectionError occurred for %s", url) + hass.bus.fire( + "{}_{}".format(DOMAIN, DOWNLOAD_FAILED_EVENT), { + 'url': url, + 'filename': filename + }) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + + threading.Thread(target=do_download).start() + + hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE, download_file, + schema=SERVICE_DOWNLOAD_FILE_SCHEMA) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/duckdns.py b/homeassistant-0.88.0.dev0/homeassistant/components/duckdns.py new file mode 100644 index 00000000000000..3420bbed1bcc08 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/duckdns.py @@ -0,0 +1,98 @@ +""" +Integrate with DuckDNS. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/duckdns/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +ATTR_TXT = 'txt' + +DOMAIN = 'duckdns' + +INTERVAL = timedelta(minutes=5) + +SERVICE_SET_TXT = 'set_txt' + +UPDATE_URL = 'https://www.duckdns.org/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +async def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = await _update_duckdns(session, domain, token) + + if not result: + return False + + async def update_domain_interval(now): + """Update the DuckDNS entry.""" + await _update_duckdns(session, domain, token) + + async def update_domain_service(call): + """Update the DuckDNS entry.""" + await _update_duckdns( + session, domain, token, txt=call.data[ATTR_TXT]) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, + schema=SERVICE_TXT_SCHEMA) + + return result + + +_SENTINEL = object() + + +async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, + clear=False): + """Update DuckDNS.""" + params = { + 'domains': domain, + 'token': token, + } + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params['txt'] = '' + clear = True + else: + params['txt'] = txt + + if clear: + params['clear'] = 'true' + + resp = await session.get(UPDATE_URL, params=params) + body = await resp.text() + + if body != 'OK': + _LOGGER.warning("Updating DuckDNS domain failed: %s", domain) + return False + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/dyson.py b/homeassistant-0.88.0.dev0/homeassistant/components/dyson.py new file mode 100644 index 00000000000000..791f990d9ad4c8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/dyson.py @@ -0,0 +1,107 @@ +"""Parent component for Dyson Pure Cool Link devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/dyson/ +""" + +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, \ + CONF_DEVICES + +REQUIREMENTS = ['libpurecoollink==0.4.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_LANGUAGE = "language" +CONF_RETRY = "retry" + +DEFAULT_TIMEOUT = 5 +DEFAULT_RETRY = 10 + +DOMAIN = "dyson" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_LANGUAGE): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): cv.positive_int, + vol.Optional(CONF_DEVICES, default=[]): + vol.All(cv.ensure_list, [dict]), + }) +}, extra=vol.ALLOW_EXTRA) + +DYSON_DEVICES = "dyson_devices" + + +def setup(hass, config): + """Set up the Dyson parent component.""" + _LOGGER.info("Creating new Dyson component") + + if DYSON_DEVICES not in hass.data: + hass.data[DYSON_DEVICES] = [] + + from libpurecoollink.dyson import DysonAccount + dyson_account = DysonAccount(config[DOMAIN].get(CONF_USERNAME), + config[DOMAIN].get(CONF_PASSWORD), + config[DOMAIN].get(CONF_LANGUAGE)) + + logged = dyson_account.login() + + timeout = config[DOMAIN].get(CONF_TIMEOUT) + retry = config[DOMAIN].get(CONF_RETRY) + + if not logged: + _LOGGER.error("Not connected to Dyson account. Unable to add devices") + return False + + _LOGGER.info("Connected to Dyson account") + dyson_devices = dyson_account.devices() + if CONF_DEVICES in config[DOMAIN] and config[DOMAIN].get(CONF_DEVICES): + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + dyson_device = next((d for d in dyson_devices if + d.serial == device["device_id"]), None) + if dyson_device: + try: + connected = dyson_device.connect(device["device_ip"]) + if connected: + _LOGGER.info("Connected to device %s", dyson_device) + hass.data[DYSON_DEVICES].append(dyson_device) + else: + _LOGGER.warning("Unable to connect to device %s", + dyson_device) + except OSError as ose: + _LOGGER.error("Unable to connect to device %s: %s", + str(dyson_device.network_device), str(ose)) + else: + _LOGGER.warning( + "Unable to find device %s in Dyson account", + device["device_id"]) + else: + # Not yet reliable + for device in dyson_devices: + _LOGGER.info("Trying to connect to device %s with timeout=%i " + "and retry=%i", device, timeout, retry) + connected = device.auto_connect(timeout, retry) + if connected: + _LOGGER.info("Connected to device %s", device) + hass.data[DYSON_DEVICES].append(device) + else: + _LOGGER.warning("Unable to connect to device %s", device) + + # Start fan/sensors components + if hass.data[DYSON_DEVICES]: + _LOGGER.debug("Starting sensor/fan components") + discovery.load_platform(hass, "sensor", DOMAIN, {}, config) + discovery.load_platform(hass, "fan", DOMAIN, {}, config) + discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/emoncms_history.py b/homeassistant-0.88.0.dev0/homeassistant/components/emoncms_history.py new file mode 100644 index 00000000000000..6a92ab64044432 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/emoncms_history.py @@ -0,0 +1,89 @@ +""" +A component which allows you to send data to Emoncms. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/emoncms_history/ +""" +import logging +from datetime import timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_API_KEY, CONF_WHITELIST, CONF_URL, STATE_UNKNOWN, STATE_UNAVAILABLE, + CONF_SCAN_INTERVAL) +from homeassistant.helpers import state as state_helper +from homeassistant.helpers.event import track_point_in_time +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'emoncms_history' +CONF_INPUTNODE = 'inputnode' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_INPUTNODE): cv.positive_int, + vol.Required(CONF_WHITELIST): cv.entity_ids, + vol.Optional(CONF_SCAN_INTERVAL, default=30): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Emoncms history component.""" + conf = config[DOMAIN] + whitelist = conf.get(CONF_WHITELIST) + + def send_data(url, apikey, node, payload): + """Send payload data to Emoncms.""" + try: + fullurl = '{}/input/post.json'.format(url) + data = {"apikey": apikey, "data": payload} + parameters = {"node": node} + req = requests.post( + fullurl, params=parameters, data=data, allow_redirects=True, + timeout=5) + + except requests.exceptions.RequestException: + _LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl) + + else: + if req.status_code != 200: + _LOGGER.error( + "Error saving data %s to %s (http status code = %d)", + payload, fullurl, req.status_code) + + def update_emoncms(time): + """Send whitelisted entities states regularly to Emoncms.""" + payload_dict = {} + + for entity_id in whitelist: + state = hass.states.get(entity_id) + + if state is None or state.state in ( + STATE_UNKNOWN, '', STATE_UNAVAILABLE): + continue + + try: + payload_dict[entity_id] = state_helper.state_as_number(state) + except ValueError: + continue + + if payload_dict: + payload = "{%s}" % ",".join("{}:{}".format(key, val) + for key, val in + payload_dict.items()) + + send_data(conf.get(CONF_URL), conf.get(CONF_API_KEY), + str(conf.get(CONF_INPUTNODE)), payload) + + track_point_in_time(hass, update_emoncms, time + + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL))) + + update_emoncms(dt_util.utcnow()) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/feedreader.py b/homeassistant-0.88.0.dev0/homeassistant/components/feedreader.py new file mode 100644 index 00000000000000..7882cdc5a15106 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/feedreader.py @@ -0,0 +1,213 @@ +""" +Support for RSS/Atom feeds. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/feedreader/ +""" +from datetime import datetime, timedelta +from logging import getLogger +from os.path import exists +from threading import Lock +import pickle + +import voluptuous as vol + +from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL +from homeassistant.helpers.event import track_time_interval +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['feedparser==5.2.1'] + +_LOGGER = getLogger(__name__) + +CONF_URLS = 'urls' +CONF_MAX_ENTRIES = 'max_entries' + +DEFAULT_MAX_ENTRIES = 20 +DEFAULT_SCAN_INTERVAL = timedelta(hours=1) + +DOMAIN = 'feedreader' + +EVENT_FEEDREADER = 'feedreader' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES): + cv.positive_int + } +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Feedreader component.""" + urls = config.get(DOMAIN)[CONF_URLS] + scan_interval = config.get(DOMAIN).get(CONF_SCAN_INTERVAL) + max_entries = config.get(DOMAIN).get(CONF_MAX_ENTRIES) + data_file = hass.config.path("{}.pickle".format(DOMAIN)) + storage = StoredData(data_file) + feeds = [FeedManager(url, scan_interval, max_entries, hass, storage) for + url in urls] + return len(feeds) > 0 + + +class FeedManager: + """Abstraction over Feedparser module.""" + + def __init__(self, url, scan_interval, max_entries, hass, storage): + """Initialize the FeedManager object, poll as per scan interval.""" + self._url = url + self._scan_interval = scan_interval + self._max_entries = max_entries + self._feed = None + self._hass = hass + self._firstrun = True + self._storage = storage + self._last_entry_timestamp = None + self._last_update_successful = False + self._has_published_parsed = False + self._event_type = EVENT_FEEDREADER + self._feed_id = url + hass.bus.listen_once( + EVENT_HOMEASSISTANT_START, lambda _: self._update()) + self._init_regular_updates(hass) + + def _log_no_entries(self): + """Send no entries log at debug level.""" + _LOGGER.debug("No new entries to be published in feed %s", self._url) + + def _init_regular_updates(self, hass): + """Schedule regular updates at the top of the clock.""" + track_time_interval(hass, lambda now: self._update(), + self._scan_interval) + + @property + def last_update_successful(self): + """Return True if the last feed update was successful.""" + return self._last_update_successful + + def _update(self): + """Update the feed and publish new entries to the event bus.""" + import feedparser + _LOGGER.info("Fetching new data from feed %s", self._url) + self._feed = feedparser.parse(self._url, + etag=None if not self._feed + else self._feed.get('etag'), + modified=None if not self._feed + else self._feed.get('modified')) + if not self._feed: + _LOGGER.error("Error fetching feed data from %s", self._url) + self._last_update_successful = False + else: + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log error message but continue + # processing the feed entries if present. + if self._feed.bozo != 0: + _LOGGER.error("Error parsing feed %s: %s", self._url, + self._feed.bozo_exception) + # Using etag and modified, if there's no new data available, + # the entries list will be empty + if self._feed.entries: + _LOGGER.debug("%s entri(es) available in feed %s", + len(self._feed.entries), self._url) + self._filter_entries() + self._publish_new_entries() + if self._has_published_parsed: + self._storage.put_timestamp( + self._feed_id, self._last_entry_timestamp) + else: + self._log_no_entries() + self._last_update_successful = True + _LOGGER.info("Fetch from feed %s completed", self._url) + + def _filter_entries(self): + """Filter the entries provided and return the ones to keep.""" + if len(self._feed.entries) > self._max_entries: + _LOGGER.debug("Processing only the first %s entries " + "in feed %s", self._max_entries, self._url) + self._feed.entries = self._feed.entries[0:self._max_entries] + + def _update_and_fire_entry(self, entry): + """Update last_entry_timestamp and fire entry.""" + # We are lucky, `published_parsed` data available, let's make use of + # it to publish only new available entries since the last run + if 'published_parsed' in entry.keys(): + self._has_published_parsed = True + self._last_entry_timestamp = max( + entry.published_parsed, self._last_entry_timestamp) + else: + self._has_published_parsed = False + _LOGGER.debug("No published_parsed info available for entry %s", + entry) + entry.update({'feed_url': self._url}) + self._hass.bus.fire(self._event_type, entry) + + def _publish_new_entries(self): + """Publish new entries to the event bus.""" + new_entries = False + self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) + if self._last_entry_timestamp: + self._firstrun = False + else: + # Set last entry timestamp as epoch time if not available + self._last_entry_timestamp = \ + datetime.utcfromtimestamp(0).timetuple() + for entry in self._feed.entries: + if self._firstrun or ( + 'published_parsed' in entry.keys() and + entry.published_parsed > self._last_entry_timestamp): + self._update_and_fire_entry(entry) + new_entries = True + else: + _LOGGER.debug("Entry %s already processed", entry) + if not new_entries: + self._log_no_entries() + self._firstrun = False + + +class StoredData: + """Abstraction over pickle data storage.""" + + def __init__(self, data_file): + """Initialize pickle data storage.""" + self._data_file = data_file + self._lock = Lock() + self._cache_outdated = True + self._data = {} + self._fetch_data() + + def _fetch_data(self): + """Fetch data stored into pickle file.""" + if self._cache_outdated and exists(self._data_file): + try: + _LOGGER.debug("Fetching data from file %s", self._data_file) + with self._lock, open(self._data_file, 'rb') as myfile: + self._data = pickle.load(myfile) or {} + self._cache_outdated = False + except: # noqa: E722 pylint: disable=bare-except + _LOGGER.error("Error loading data from pickled file %s", + self._data_file) + + def get_timestamp(self, feed_id): + """Return stored timestamp for given feed id (usually the url).""" + self._fetch_data() + return self._data.get(feed_id) + + def put_timestamp(self, feed_id, timestamp): + """Update timestamp for given feed id (usually the url).""" + self._fetch_data() + with self._lock, open(self._data_file, 'wb') as myfile: + self._data.update({feed_id: timestamp}) + _LOGGER.debug("Overwriting feed %s timestamp in storage file %s", + feed_id, self._data_file) + try: + pickle.dump(self._data, myfile) + except: # noqa: E722 pylint: disable=bare-except + _LOGGER.error( + "Error saving pickled data to %s", self._data_file) + self._cache_outdated = True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ffmpeg.py b/homeassistant-0.88.0.dev0/homeassistant/components/ffmpeg.py new file mode 100644 index 00000000000000..a2f0ca19231071 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ffmpeg.py @@ -0,0 +1,183 @@ +""" +Component that will help set the FFmpeg component. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ffmpeg/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['ha-ffmpeg==1.9'] + +DOMAIN = 'ffmpeg' + +_LOGGER = logging.getLogger(__name__) + +SERVICE_START = 'start' +SERVICE_STOP = 'stop' +SERVICE_RESTART = 'restart' + +SIGNAL_FFMPEG_START = 'ffmpeg.start' +SIGNAL_FFMPEG_STOP = 'ffmpeg.stop' +SIGNAL_FFMPEG_RESTART = 'ffmpeg.restart' + +DATA_FFMPEG = 'ffmpeg' + +CONF_INITIAL_STATE = 'initial_state' +CONF_INPUT = 'input' +CONF_FFMPEG_BIN = 'ffmpeg_bin' +CONF_EXTRA_ARGUMENTS = 'extra_arguments' +CONF_OUTPUT = 'output' + +DEFAULT_BINARY = 'ffmpeg' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_FFMPEG_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +async def async_setup(hass, config): + """Set up the FFmpeg component.""" + conf = config.get(DOMAIN, {}) + + manager = FFmpegManager( + hass, + conf.get(CONF_FFMPEG_BIN, DEFAULT_BINARY) + ) + + # Register service + async def async_service_handle(service): + """Handle service ffmpeg process.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if service.service == SERVICE_START: + async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids) + elif service.service == SERVICE_STOP: + async_dispatcher_send(hass, SIGNAL_FFMPEG_STOP, entity_ids) + else: + async_dispatcher_send(hass, SIGNAL_FFMPEG_RESTART, entity_ids) + + hass.services.async_register( + DOMAIN, SERVICE_START, async_service_handle, + schema=SERVICE_FFMPEG_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_STOP, async_service_handle, + schema=SERVICE_FFMPEG_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_RESTART, async_service_handle, + schema=SERVICE_FFMPEG_SCHEMA) + + hass.data[DATA_FFMPEG] = manager + return True + + +class FFmpegManager: + """Helper for ha-ffmpeg.""" + + def __init__(self, hass, ffmpeg_bin): + """Initialize helper.""" + self.hass = hass + self._cache = {} + self._bin = ffmpeg_bin + + @property + def binary(self): + """Return ffmpeg binary from config.""" + return self._bin + + +class FFmpegBase(Entity): + """Interface object for FFmpeg.""" + + def __init__(self, initial_state=True): + """Initialize ffmpeg base object.""" + self.ffmpeg = None + self.initial_state = initial_state + + async def async_added_to_hass(self): + """Register dispatcher & events. + + This method is a coroutine. + """ + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg) + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg) + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg) + + # register start/stop + self._async_register_events() + + @property + def available(self): + """Return True if entity is available.""" + return self.ffmpeg.is_running + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + async def _async_start_ffmpeg(self, entity_ids): + """Start a FFmpeg process. + + This method is a coroutine. + """ + raise NotImplementedError() + + async def _async_stop_ffmpeg(self, entity_ids): + """Stop a FFmpeg process. + + This method is a coroutine. + """ + if entity_ids is None or self.entity_id in entity_ids: + await self.ffmpeg.close() + + async def _async_restart_ffmpeg(self, entity_ids): + """Stop a FFmpeg process. + + This method is a coroutine. + """ + if entity_ids is None or self.entity_id in entity_ids: + await self._async_stop_ffmpeg(None) + await self._async_start_ffmpeg(None) + + @callback + def _async_register_events(self): + """Register a FFmpeg process/device.""" + async def async_shutdown_handle(event): + """Stop FFmpeg process.""" + await self._async_stop_ffmpeg(None) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_shutdown_handle) + + # start on startup + if not self.initial_state: + return + + async def async_start_handle(event): + """Start FFmpeg process.""" + await self._async_start_ffmpeg(None) + self.async_schedule_update_ha_state() + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/folder_watcher.py b/homeassistant-0.88.0.dev0/homeassistant/components/folder_watcher.py new file mode 100644 index 00000000000000..098b34ac948cfc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/folder_watcher.py @@ -0,0 +1,110 @@ +""" +Component for monitoring activity on a folder. + +For more details about this platform, refer to the documentation at +https://home-assistant.io/components/folder_watcher/ +""" +import os +import logging +import voluptuous as vol +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['watchdog==0.8.3'] +_LOGGER = logging.getLogger(__name__) + +CONF_FOLDER = 'folder' +CONF_PATTERNS = 'patterns' +DEFAULT_PATTERN = '*' +DOMAIN = "folder_watcher" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_FOLDER): cv.isdir, + vol.Optional(CONF_PATTERNS, default=[DEFAULT_PATTERN]): + vol.All(cv.ensure_list, [cv.string]), + })]) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the folder watcher.""" + conf = config[DOMAIN] + for watcher in conf: + path = watcher[CONF_FOLDER] + patterns = watcher[CONF_PATTERNS] + if not hass.config.is_allowed_path(path): + _LOGGER.error("folder %s is not valid or allowed", path) + return False + Watcher(path, patterns, hass) + + return True + + +def create_event_handler(patterns, hass): + """Return the Watchdog EventHandler object.""" + from watchdog.events import PatternMatchingEventHandler + + class EventHandler(PatternMatchingEventHandler): + """Class for handling Watcher events.""" + + def __init__(self, patterns, hass): + """Initialise the EventHandler.""" + super().__init__(patterns) + self.hass = hass + + def process(self, event): + """On Watcher event, fire HA event.""" + _LOGGER.debug("process(%s)", event) + if not event.is_directory: + folder, file_name = os.path.split(event.src_path) + self.hass.bus.fire( + DOMAIN, { + "event_type": event.event_type, + 'path': event.src_path, + 'file': file_name, + 'folder': folder, + }) + + def on_modified(self, event): + """File modified.""" + self.process(event) + + def on_moved(self, event): + """File moved.""" + self.process(event) + + def on_created(self, event): + """File created.""" + self.process(event) + + def on_deleted(self, event): + """File deleted.""" + self.process(event) + + return EventHandler(patterns, hass) + + +class Watcher(): + """Class for starting Watchdog.""" + + def __init__(self, path, patterns, hass): + """Initialise the watchdog observer.""" + from watchdog.observers import Observer + self._observer = Observer() + self._observer.schedule( + create_event_handler(patterns, hass), + path, + recursive=True) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + + def startup(self, event): + """Start the watcher.""" + self._observer.start() + + def shutdown(self, event): + """Shutdown the watcher.""" + self._observer.stop() + self._observer.join() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/foursquare.py b/homeassistant-0.88.0.dev0/homeassistant/components/foursquare.py new file mode 100644 index 00000000000000..a4a7395adc432b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/foursquare.py @@ -0,0 +1,103 @@ +""" +Support for the Foursquare (Swarm) API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/foursquare/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST +from homeassistant.components.http import HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + +CONF_PUSH_SECRET = 'push_secret' + +DEPENDENCIES = ['http'] +DOMAIN = 'foursquare' + +EVENT_CHECKIN = 'foursquare.checkin' +EVENT_PUSH = 'foursquare.push' + +SERVICE_CHECKIN = 'checkin' + +CHECKIN_SERVICE_SCHEMA = vol.Schema({ + vol.Optional('alt'): cv.string, + vol.Optional('altAcc'): cv.string, + vol.Optional('broadcast'): cv.string, + vol.Optional('eventId'): cv.string, + vol.Optional('ll'): cv.string, + vol.Optional('llAcc'): cv.string, + vol.Optional('mentions'): cv.string, + vol.Optional('shout'): cv.string, + vol.Required('venueId'): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_PUSH_SECRET): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Foursquare component.""" + config = config[DOMAIN] + + def checkin_user(call): + """Check a user in on Swarm.""" + url = ("https://api.foursquare.com/v2/checkins/add" + "?oauth_token={}" + "&v=20160802" + "&m=swarm").format(config[CONF_ACCESS_TOKEN]) + response = requests.post(url, data=call.data, timeout=10) + + if response.status_code not in (200, 201): + _LOGGER.exception( + "Error checking in user. Response %d: %s:", + response.status_code, response.reason) + + hass.bus.fire(EVENT_CHECKIN, response.text) + + # Register our service with Home Assistant. + hass.services.register(DOMAIN, 'checkin', checkin_user, + schema=CHECKIN_SERVICE_SCHEMA) + + hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET])) + + return True + + +class FoursquarePushReceiver(HomeAssistantView): + """Handle pushes from the Foursquare API.""" + + requires_auth = False + url = "/api/foursquare" + name = "foursquare" + + def __init__(self, push_secret): + """Initialize the OAuth callback view.""" + self.push_secret = push_secret + + async def post(self, request): + """Accept the POST from Foursquare.""" + try: + data = await request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + + secret = data.pop('secret', None) + + _LOGGER.debug("Received Foursquare push: %s", data) + + if self.push_secret != secret: + _LOGGER.error("Received Foursquare push with invalid" + "push secret: %s", secret) + return self.json_message('Incorrect secret', HTTP_BAD_REQUEST) + + request.app['hass'].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/freedns.py b/homeassistant-0.88.0.dev0/homeassistant/components/freedns.py new file mode 100644 index 00000000000000..ec38bb59cc7822 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/freedns.py @@ -0,0 +1,99 @@ +""" +Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/freedns/ +""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.const import (CONF_URL, CONF_ACCESS_TOKEN, + CONF_UPDATE_INTERVAL) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'freedns' + +DEFAULT_INTERVAL = timedelta(minutes=10) + +TIMEOUT = 10 +UPDATE_URL = 'https://freedns.afraid.org/dynamic/update.php' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Exclusive(CONF_URL, DOMAIN): cv.string, + vol.Exclusive(CONF_ACCESS_TOKEN, DOMAIN): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta), + + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Initialize the FreeDNS component.""" + url = config[DOMAIN].get(CONF_URL) + auth_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) + update_interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = await _update_freedns( + hass, session, url, auth_token) + + if result is False: + return False + + async def update_domain_callback(now): + """Update the FreeDNS entry.""" + await _update_freedns(hass, session, url, auth_token) + + hass.helpers.event.async_track_time_interval( + update_domain_callback, update_interval) + + return True + + +async def _update_freedns(hass, session, url, auth_token): + """Update FreeDNS.""" + params = None + + if url is None: + url = UPDATE_URL + + if auth_token is not None: + params = {} + params[auth_token] = "" + + try: + with async_timeout.timeout(TIMEOUT, loop=hass.loop): + resp = await session.get(url, params=params) + body = await resp.text() + + if "has not changed" in body: + # IP has not changed. + _LOGGER.debug("FreeDNS update skipped: IP has not changed") + return True + + if "ERROR" not in body: + _LOGGER.debug("Updating FreeDNS was successful: %s", body) + return True + + if "Invalid update URL" in body: + _LOGGER.error("FreeDNS update token is invalid") + else: + _LOGGER.warning("Updating FreeDNS failed: %s", body) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to FreeDNS API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from FreeDNS API at %s", url) + + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/goalfeed.py b/homeassistant-0.88.0.dev0/homeassistant/components/goalfeed.py new file mode 100644 index 00000000000000..c16390302d69a5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/goalfeed.py @@ -0,0 +1,63 @@ +""" +Component for the Goalfeed service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/goalfeed/ +""" +import json + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +# Version downgraded due to regression in library +# For details: https://github.com/nlsdfnbch/Pysher/issues/38 +REQUIREMENTS = ['pysher==1.0.1'] +DOMAIN = 'goalfeed' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +GOALFEED_HOST = 'feed.goalfeed.ca' +GOALFEED_AUTH_ENDPOINT = 'https://goalfeed.ca/feed/auth' +GOALFEED_APP_ID = 'bfd4ed98c1ff22c04074' + + +def setup(hass, config): + """Set up the Goalfeed component.""" + import pysher + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + def goal_handler(data): + """Handle goal events.""" + goal = json.loads(json.loads(data)) + + hass.bus.fire('goal', event_data=goal) + + def connect_handler(data): + """Handle connection.""" + post_data = { + 'username': username, + 'password': password, + 'connection_info': data} + resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, + timeout=30).json() + + channel = pusher.subscribe('private-goals', resp['auth']) + channel.bind('goal', goal_handler) + + pusher = pysher.Pusher(GOALFEED_APP_ID, secure=False, port=8080, + custom_host=GOALFEED_HOST) + + pusher.connection.bind('pusher:connection_established', connect_handler) + pusher.connect() + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/google_domains.py b/homeassistant-0.88.0.dev0/homeassistant/components/google_domains.py new file mode 100644 index 00000000000000..32bdb79557a983 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/google_domains.py @@ -0,0 +1,92 @@ +""" +Integrate with Google Domains. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/google_domains/ +""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'google_domains' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +UPDATE_URL = 'https://{}:{}@domains.google.com/nic/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Initialize the Google Domains component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = await _update_google_domains( + hass, session, domain, user, password, timeout) + + if not result: + return False + + async def update_domain_interval(now): + """Update the Google Domains entry.""" + await _update_google_domains( + hass, session, domain, user, password, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +async def _update_google_domains(hass, session, domain, user, password, + timeout): + """Update Google Domains.""" + url = UPDATE_URL.format(user, password) + + params = { + 'hostname': domain + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = await session.get(url, params=params) + body = await resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning('Updating Google Domains failed: %s => %s', + domain, body) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to Google Domains API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from Google Domains API for domain: %s", + domain) + + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/graphite.py b/homeassistant-0.88.0.dev0/homeassistant/components/graphite.py new file mode 100644 index 00000000000000..26cd80d8da29bd --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/graphite.py @@ -0,0 +1,155 @@ +""" +Component that sends data to a Graphite installation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/graphite/ +""" +import logging +import queue +import socket +import threading +import time + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED) +from homeassistant.helpers import state + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 2003 +DEFAULT_PREFIX = 'ha' +DOMAIN = 'graphite' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Graphite feeder.""" + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + prefix = conf.get(CONF_PREFIX) + port = conf.get(CONF_PORT) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((host, port)) + sock.shutdown(2) + _LOGGER.debug("Connection to Graphite possible") + except socket.error: + _LOGGER.error("Not able to connect to Graphite") + return False + + GraphiteFeeder(hass, host, port, prefix) + return True + + +class GraphiteFeeder(threading.Thread): + """Feed data to Graphite.""" + + def __init__(self, hass, host, port, prefix): + """Initialize the feeder.""" + super(GraphiteFeeder, self).__init__(daemon=True) + self._hass = hass + self._host = host + self._port = port + # rstrip any trailing dots in case they think they need it + self._prefix = prefix.rstrip('.') + self._queue = queue.Queue() + self._quit_object = object() + self._we_started = False + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, + self.start_listen) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + self.shutdown) + hass.bus.listen(EVENT_STATE_CHANGED, self.event_listener) + _LOGGER.debug("Graphite feeding to %s:%i initialized", + self._host, self._port) + + def start_listen(self, event): + """Start event-processing thread.""" + _LOGGER.debug("Event processing thread started") + self._we_started = True + self.start() + + def shutdown(self, event): + """Signal shutdown of processing event.""" + _LOGGER.debug("Event processing signaled exit") + self._queue.put(self._quit_object) + + def event_listener(self, event): + """Queue an event for processing.""" + if self.is_alive() or not self._we_started: + _LOGGER.debug("Received event") + self._queue.put(event) + else: + _LOGGER.error( + "Graphite feeder thread has died, not queuing event!") + + def _send_to_graphite(self, data): + """Send data to Graphite.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((self._host, self._port)) + sock.sendall(data.encode('ascii')) + sock.send('\n'.encode('ascii')) + sock.close() + + def _report_attributes(self, entity_id, new_state): + """Report the attributes.""" + now = time.time() + things = dict(new_state.attributes) + try: + things['state'] = state.state_as_number(new_state) + except ValueError: + pass + lines = ['%s.%s.%s %f %i' % (self._prefix, + entity_id, key.replace(' ', '_'), + value, now) + for key, value in things.items() + if isinstance(value, (float, int))] + if not lines: + return + _LOGGER.debug("Sending to graphite: %s", lines) + try: + self._send_to_graphite('\n'.join(lines)) + except socket.gaierror: + _LOGGER.error("Unable to connect to host %s", self._host) + except socket.error: + _LOGGER.exception("Failed to send data to graphite") + + def run(self): + """Run the process to export the data.""" + while True: + event = self._queue.get() + if event == self._quit_object: + _LOGGER.debug("Event processing thread stopped") + self._queue.task_done() + return + if event.event_type == EVENT_STATE_CHANGED and \ + event.data.get('new_state'): + _LOGGER.debug("Processing STATE_CHANGED event for %s", + event.data['entity_id']) + try: + self._report_attributes( + event.data['entity_id'], event.data['new_state']) + except Exception: # pylint: disable=broad-except + # Catch this so we can avoid the thread dying and + # make it visible. + _LOGGER.exception("Failed to process STATE_CHANGED event") + else: + _LOGGER.warning( + "Processing unexpected event type %s", event.event_type) + + self._queue.task_done() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/greeneye_monitor.py b/homeassistant-0.88.0.dev0/homeassistant/components/greeneye_monitor.py new file mode 100644 index 00000000000000..c1e2f285772aa9 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/greeneye_monitor.py @@ -0,0 +1,179 @@ +""" +Support for monitoring a GreenEye Monitor energy monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/greeneye_monitor/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + CONF_TEMPERATURE_UNIT, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['greeneye_monitor==1.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CHANNELS = 'channels' +CONF_COUNTED_QUANTITY = 'counted_quantity' +CONF_COUNTED_QUANTITY_PER_PULSE = 'counted_quantity_per_pulse' +CONF_MONITOR_SERIAL_NUMBER = 'monitor' +CONF_MONITORS = 'monitors' +CONF_NET_METERING = 'net_metering' +CONF_NUMBER = 'number' +CONF_PULSE_COUNTERS = 'pulse_counters' +CONF_SERIAL_NUMBER = 'serial_number' +CONF_SENSORS = 'sensors' +CONF_SENSOR_TYPE = 'sensor_type' +CONF_TEMPERATURE_SENSORS = 'temperature_sensors' +CONF_TIME_UNIT = 'time_unit' + +DATA_GREENEYE_MONITOR = 'greeneye_monitor' +DOMAIN = 'greeneye_monitor' + +SENSOR_TYPE_CURRENT = 'current_sensor' +SENSOR_TYPE_PULSE_COUNTER = 'pulse_counter' +SENSOR_TYPE_TEMPERATURE = 'temperature_sensor' + +TEMPERATURE_UNIT_CELSIUS = 'C' + +TIME_UNIT_SECOND = 's' +TIME_UNIT_MINUTE = 'min' +TIME_UNIT_HOUR = 'h' + +TEMPERATURE_SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_NUMBER): vol.Range(1, 8), + vol.Required(CONF_NAME): cv.string, +}) + +TEMPERATURE_SENSORS_SCHEMA = vol.Schema({ + vol.Required(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, + [TEMPERATURE_SENSOR_SCHEMA]), +}) + +PULSE_COUNTER_SCHEMA = vol.Schema({ + vol.Required(CONF_NUMBER): vol.Range(1, 4), + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_COUNTED_QUANTITY): cv.string, + vol.Optional( + CONF_COUNTED_QUANTITY_PER_PULSE, default=1.0): vol.Coerce(float), + vol.Optional(CONF_TIME_UNIT, default=TIME_UNIT_SECOND): vol.Any( + TIME_UNIT_SECOND, + TIME_UNIT_MINUTE, + TIME_UNIT_HOUR), +}) + +PULSE_COUNTERS_SCHEMA = vol.All(cv.ensure_list, [PULSE_COUNTER_SCHEMA]) + +CHANNEL_SCHEMA = vol.Schema({ + vol.Required(CONF_NUMBER): vol.Range(1, 48), + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_NET_METERING, default=False): cv.boolean, +}) + +CHANNELS_SCHEMA = vol.All(cv.ensure_list, [CHANNEL_SCHEMA]) + +MONITOR_SCHEMA = vol.Schema({ + vol.Required(CONF_SERIAL_NUMBER): + vol.All( + cv.string, + vol.Length( + min=8, + max=8, + msg="GEM serial number must be specified as an 8-character " + "string (including leading zeroes)."), + vol.Coerce(int)), + vol.Optional(CONF_CHANNELS, default=[]): CHANNELS_SCHEMA, + vol.Optional( + CONF_TEMPERATURE_SENSORS, + default={ + CONF_TEMPERATURE_UNIT: TEMPERATURE_UNIT_CELSIUS, + CONF_SENSORS: [], + }): TEMPERATURE_SENSORS_SCHEMA, + vol.Optional(CONF_PULSE_COUNTERS, default=[]): PULSE_COUNTERS_SCHEMA, +}) + +MONITORS_SCHEMA = vol.All(cv.ensure_list, [MONITOR_SCHEMA]) + +COMPONENT_SCHEMA = vol.Schema({ + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_MONITORS): MONITORS_SCHEMA, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: COMPONENT_SCHEMA, +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the GreenEye Monitor component.""" + from greeneye import Monitors + + monitors = Monitors() + hass.data[DATA_GREENEYE_MONITOR] = monitors + + server_config = config[DOMAIN] + server = await monitors.start_server(server_config[CONF_PORT]) + + async def close_server(*args): + """Close the monitoring server.""" + await server.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_server) + + all_sensors = [] + for monitor_config in server_config[CONF_MONITORS]: + monitor_serial_number = { + CONF_MONITOR_SERIAL_NUMBER: monitor_config[CONF_SERIAL_NUMBER], + } + + channel_configs = monitor_config[CONF_CHANNELS] + for channel_config in channel_configs: + all_sensors.append({ + CONF_SENSOR_TYPE: SENSOR_TYPE_CURRENT, + **monitor_serial_number, + **channel_config, + }) + + sensor_configs = \ + monitor_config[CONF_TEMPERATURE_SENSORS] + if sensor_configs: + temperature_unit = { + CONF_TEMPERATURE_UNIT: sensor_configs[CONF_TEMPERATURE_UNIT], + } + for sensor_config in sensor_configs[CONF_SENSORS]: + all_sensors.append({ + CONF_SENSOR_TYPE: SENSOR_TYPE_TEMPERATURE, + **monitor_serial_number, + **temperature_unit, + **sensor_config, + }) + + counter_configs = monitor_config[CONF_PULSE_COUNTERS] + for counter_config in counter_configs: + all_sensors.append({ + CONF_SENSOR_TYPE: SENSOR_TYPE_PULSE_COUNTER, + **monitor_serial_number, + **counter_config, + }) + + if not all_sensors: + _LOGGER.error("Configuration must specify at least one " + "channel, pulse counter or temperature sensor") + return False + + hass.async_create_task(async_load_platform( + hass, + 'sensor', + DOMAIN, + all_sensors, + config)) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/history.py b/homeassistant-0.88.0.dev0/homeassistant/components/history.py new file mode 100644 index 00000000000000..1773a55b3f1bb1 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/history.py @@ -0,0 +1,409 @@ +""" +Provide pre-made queries on top of the recorder component. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/history/ +""" +from collections import defaultdict +from datetime import timedelta +from itertools import groupby +import logging +import time + +import voluptuous as vol + +from homeassistant.const import ( + HTTP_BAD_REQUEST, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE) +import homeassistant.util.dt as dt_util +from homeassistant.components import recorder, script +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ATTR_HIDDEN +from homeassistant.components.recorder.util import session_scope, execute +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'history' +DEPENDENCIES = ['recorder', 'http'] + +CONF_ORDER = 'use_include_order' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: recorder.FILTER_SCHEMA.extend({ + vol.Optional(CONF_ORDER, default=False): cv.boolean, + }) +}, extra=vol.ALLOW_EXTRA) + +SIGNIFICANT_DOMAINS = ('thermostat', 'climate') +IGNORE_DOMAINS = ('zone', 'scene',) + + +def get_significant_states(hass, start_time, end_time=None, entity_ids=None, + filters=None, include_start_time_state=True): + """ + Return states changes during UTC period start_time - end_time. + + Significant states are all states where there is a state change, + as well as all states from certain domains (for instance + thermostat so that we get current temperature in our graphs). + """ + timer_start = time.perf_counter() + from homeassistant.components.recorder.models import States + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.domain.in_(SIGNIFICANT_DOMAINS) | + (States.last_changed == States.last_updated)) & + (States.last_updated > start_time)) + + if filters: + query = filters.apply(query, entity_ids) + + if end_time is not None: + query = query.filter(States.last_updated < end_time) + + query = query.order_by(States.last_updated) + + states = ( + state for state in execute(query) + if (_is_significant(state) and + not state.attributes.get(ATTR_HIDDEN, False))) + + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug( + 'get_significant_states took %fs', elapsed) + + return states_to_json( + hass, states, start_time, entity_ids, filters, + include_start_time_state) + + +def state_changes_during_period(hass, start_time, end_time=None, + entity_id=None): + """Return states changes during UTC period start_time - end_time.""" + from homeassistant.components.recorder.models import States + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated) & + (States.last_updated > start_time)) + + if end_time is not None: + query = query.filter(States.last_updated < end_time) + + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) + + entity_ids = [entity_id] if entity_id is not None else None + + states = execute( + query.order_by(States.last_updated)) + + return states_to_json(hass, states, start_time, entity_ids) + + +def get_last_state_changes(hass, number_of_states, entity_id): + """Return the last number_of_states.""" + from homeassistant.components.recorder.models import States + + start_time = dt_util.utcnow() + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated)) + + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) + + entity_ids = [entity_id] if entity_id is not None else None + + states = execute( + query.order_by(States.last_updated.desc()).limit(number_of_states)) + + return states_to_json(hass, reversed(states), + start_time, + entity_ids, + include_start_time_state=False) + + +def get_states(hass, utc_point_in_time, entity_ids=None, run=None, + filters=None): + """Return the states at a specific point in time.""" + from homeassistant.components.recorder.models import States + + if run is None: + run = recorder.run_information(hass, utc_point_in_time) + + # History did not run before utc_point_in_time + if run is None: + return [] + + from sqlalchemy import and_, func + + with session_scope(hass=hass) as session: + if entity_ids and len(entity_ids) == 1: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + most_recent_state_ids = session.query( + States.state_id.label('max_state_id') + ).filter( + (States.last_updated < utc_point_in_time) & + (States.entity_id.in_(entity_ids)) + ).order_by( + States.last_updated.desc()) + + most_recent_state_ids = most_recent_state_ids.limit(1) + + else: + # We have more than one entity to look at (most commonly we want + # all entities,) so we need to do a search on all states since the + # last recorder run started. + + most_recent_states_by_date = session.query( + States.entity_id.label('max_entity_id'), + func.max(States.last_updated).label('max_last_updated') + ).filter( + (States.last_updated >= run.start) & + (States.last_updated < utc_point_in_time) + ) + + if entity_ids: + most_recent_states_by_date.filter( + States.entity_id.in_(entity_ids)) + + most_recent_states_by_date = most_recent_states_by_date.group_by( + States.entity_id) + + most_recent_states_by_date = most_recent_states_by_date.subquery() + + most_recent_state_ids = session.query( + func.max(States.state_id).label('max_state_id') + ).join(most_recent_states_by_date, and_( + States.entity_id == most_recent_states_by_date.c.max_entity_id, + States.last_updated == most_recent_states_by_date.c. + max_last_updated)) + + most_recent_state_ids = most_recent_state_ids.group_by( + States.entity_id) + + most_recent_state_ids = most_recent_state_ids.subquery() + + query = session.query(States).join( + most_recent_state_ids, + States.state_id == most_recent_state_ids.c.max_state_id + ).filter((~States.domain.in_(IGNORE_DOMAINS))) + + if filters: + query = filters.apply(query, entity_ids) + + return [state for state in execute(query) + if not state.attributes.get(ATTR_HIDDEN, False)] + + +def states_to_json( + hass, + states, + start_time, + entity_ids, + filters=None, + include_start_time_state=True): + """Convert SQL results into JSON friendly data structure. + + This takes our state list and turns it into a JSON friendly data + structure {'entity_id': [list of states], 'entity_id2': [list of states]} + + We also need to go back and create a synthetic zero data point for + each list of states, otherwise our graphs won't start on the Y + axis correctly. + """ + result = defaultdict(list) + + # Get the states at the start time + timer_start = time.perf_counter() + if include_start_time_state: + for state in get_states(hass, start_time, entity_ids, filters=filters): + state.last_changed = start_time + state.last_updated = start_time + result[state.entity_id].append(state) + + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug( + 'getting %d first datapoints took %fs', len(result), elapsed) + + # Append all changes to it + for ent_id, group in groupby(states, lambda state: state.entity_id): + result[ent_id].extend(group) + return result + + +def get_state(hass, utc_point_in_time, entity_id, run=None): + """Return a state at a specific point in time.""" + states = list(get_states(hass, utc_point_in_time, (entity_id,), run)) + return states[0] if states else None + + +async def async_setup(hass, config): + """Set up the history hooks.""" + filters = Filters() + conf = config.get(DOMAIN, {}) + exclude = conf.get(CONF_EXCLUDE) + if exclude: + filters.excluded_entities = exclude.get(CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(CONF_DOMAINS, []) + include = conf.get(CONF_INCLUDE) + if include: + filters.included_entities = include.get(CONF_ENTITIES, []) + filters.included_domains = include.get(CONF_DOMAINS, []) + use_include_order = conf.get(CONF_ORDER) + + hass.http.register_view(HistoryPeriodView(filters, use_include_order)) + await hass.components.frontend.async_register_built_in_panel( + 'history', 'history', 'hass:poll-box') + + return True + + +class HistoryPeriodView(HomeAssistantView): + """Handle history period requests.""" + + url = '/api/history/period' + name = 'api:history:view-period' + extra_urls = ['/api/history/period/{datetime}'] + + def __init__(self, filters, use_include_order): + """Initialize the history period view.""" + self.filters = filters + self.use_include_order = use_include_order + + async def get(self, request, datetime=None): + """Return history over a period of time.""" + timer_start = time.perf_counter() + if datetime: + datetime = dt_util.parse_datetime(datetime) + + if datetime is None: + return self.json_message('Invalid datetime', HTTP_BAD_REQUEST) + + now = dt_util.utcnow() + + one_day = timedelta(days=1) + if datetime: + start_time = dt_util.as_utc(datetime) + else: + start_time = now - one_day + + if start_time > now: + return self.json([]) + + end_time = request.query.get('end_time') + if end_time: + end_time = dt_util.parse_datetime(end_time) + if end_time: + end_time = dt_util.as_utc(end_time) + else: + return self.json_message('Invalid end_time', HTTP_BAD_REQUEST) + else: + end_time = start_time + one_day + entity_ids = request.query.get('filter_entity_id') + if entity_ids: + entity_ids = entity_ids.lower().split(',') + include_start_time_state = 'skip_initial_state' not in request.query + + hass = request.app['hass'] + + result = await hass.async_add_job( + get_significant_states, hass, start_time, end_time, + entity_ids, self.filters, include_start_time_state) + result = list(result.values()) + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug( + 'Extracted %d states in %fs', sum(map(len, result)), elapsed) + + # Optionally reorder the result to respect the ordering given + # by any entities explicitly included in the configuration. + + if self.use_include_order: + sorted_result = [] + for order_entity in self.filters.included_entities: + for state_list in result: + if state_list[0].entity_id == order_entity: + sorted_result.append(state_list) + result.remove(state_list) + break + sorted_result.extend(result) + result = sorted_result + + return await hass.async_add_job(self.json, result) + + +class Filters: + """Container for the configured include and exclude filters.""" + + def __init__(self): + """Initialise the include and exclude filters.""" + self.excluded_entities = [] + self.excluded_domains = [] + self.included_entities = [] + self.included_domains = [] + + def apply(self, query, entity_ids=None): + """Apply the include/exclude filter on domains and entities on query. + + Following rules apply: + * only the include section is configured - just query the specified + entities or domains. + * only the exclude section is configured - filter the specified + entities and domains from all the entities in the system. + * if include and exclude is defined - select the entities specified in + the include and filter out the ones from the exclude list. + """ + from homeassistant.components.recorder.models import States + + # specific entities requested - do not in/exclude anything + if entity_ids is not None: + return query.filter(States.entity_id.in_(entity_ids)) + query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) + + filter_query = None + # filter if only excluded domain is configured + if self.excluded_domains and not self.included_domains: + filter_query = ~States.domain.in_(self.excluded_domains) + if self.included_entities: + filter_query &= States.entity_id.in_(self.included_entities) + # filter if only included domain is configured + elif not self.excluded_domains and self.included_domains: + filter_query = States.domain.in_(self.included_domains) + if self.included_entities: + filter_query |= States.entity_id.in_(self.included_entities) + # filter if included and excluded domain is configured + elif self.excluded_domains and self.included_domains: + filter_query = ~States.domain.in_(self.excluded_domains) + if self.included_entities: + filter_query &= (States.domain.in_(self.included_domains) | + States.entity_id.in_(self.included_entities)) + else: + filter_query &= (States.domain.in_(self.included_domains) & ~ + States.domain.in_(self.excluded_domains)) + # no domain filter just included entities + elif not self.excluded_domains and not self.included_domains and \ + self.included_entities: + filter_query = States.entity_id.in_(self.included_entities) + if filter_query is not None: + query = query.filter(filter_query) + # finally apply excluded entities filter if configured + if self.excluded_entities: + query = query.filter(~States.entity_id.in_(self.excluded_entities)) + return query + + +def _is_significant(state): + """Test if state is significant for history charts. + + Will only test for things that are not filtered out in SQL. + """ + # scripts that are not cancellable will never change state + return (state.domain != 'script' or + state.attributes.get(script.ATTR_CAN_CANCEL)) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/history_graph.py b/homeassistant-0.88.0.dev0/homeassistant/components/history_graph.py new file mode 100644 index 00000000000000..7d9db379705a31 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/history_graph.py @@ -0,0 +1,85 @@ +""" +Support to graphs card in the UI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/history_graph/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_ENTITIES, CONF_NAME, ATTR_ENTITY_ID +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +DEPENDENCIES = ['history'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'history_graph' + +CONF_HOURS_TO_SHOW = 'hours_to_show' +CONF_REFRESH = 'refresh' +ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW +ATTR_REFRESH = CONF_REFRESH + + +GRAPH_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1), + vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0), +}) + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys(GRAPH_SCHEMA) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Load graph configurations.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass) + graphs = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME, object_id) + graph = HistoryGraphEntity(name, cfg) + graphs.append(graph) + + await component.async_add_entities(graphs) + + return True + + +class HistoryGraphEntity(Entity): + """Representation of a graph entity.""" + + def __init__(self, name, cfg): + """Initialize the graph.""" + self._name = name + self._hours = cfg[CONF_HOURS_TO_SHOW] + self._refresh = cfg[CONF_REFRESH] + self._entities = cfg[CONF_ENTITIES] + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = { + ATTR_HOURS_TO_SHOW: self._hours, + ATTR_REFRESH: self._refresh, + ATTR_ENTITY_ID: self._entities, + } + return attrs diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/idteck_prox.py b/homeassistant-0.88.0.dev0/homeassistant/components/idteck_prox.py new file mode 100644 index 00000000000000..90c07c487b6b95 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/idteck_prox.py @@ -0,0 +1,75 @@ +"""Component for interfacing RFK101 proximity card readers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/idteck_prox/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_NAME, EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['rfk101py==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "idteck_prox" + +EVENT_IDTECK_PROX_KEYCARD = 'idteck_prox_keycard' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_NAME): cv.string, + })]) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the IDTECK proximity card component.""" + conf = config[DOMAIN] + for unit in conf: + host = unit[CONF_HOST] + port = unit[CONF_PORT] + name = unit[CONF_NAME] + + try: + reader = IdteckReader(hass, host, port, name) + reader.connect() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, reader.stop) + except OSError as error: + _LOGGER.error('Error creating "%s". %s', name, error) + return False + + return True + + +class IdteckReader(): + """Representation of an IDTECK proximity card reader.""" + + def __init__(self, hass, host, port, name): + """Initialize the reader.""" + self.hass = hass + self._host = host + self._port = port + self._name = name + self._connection = None + + def connect(self): + """Connect to the reader.""" + from rfk101py.rfk101py import rfk101py + self._connection = rfk101py(self._host, self._port, self._callback) + + def _callback(self, card): + """Send a keycard event message into HASS whenever a card is read.""" + self.hass.bus.fire( + EVENT_IDTECK_PROX_KEYCARD, {'card': card, 'name': self._name}) + + def stop(self): + """Close resources.""" + if self._connection: + self._connection.close() + self._connection = None diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/influxdb.py b/homeassistant-0.88.0.dev0/homeassistant/components/influxdb.py new file mode 100644 index 00000000000000..2b9a5d9e19307d --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/influxdb.py @@ -0,0 +1,344 @@ +""" +A component which allows you to send data to an Influx database. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/influxdb/ +""" +import logging +import re +import queue +import threading +import time +import math + +import requests.exceptions +import voluptuous as vol + +from homeassistant.const import ( + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, + CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, + STATE_UNKNOWN) +from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_values import EntityValues + +REQUIREMENTS = ['influxdb==5.2.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DB_NAME = 'database' +CONF_TAGS = 'tags' +CONF_DEFAULT_MEASUREMENT = 'default_measurement' +CONF_OVERRIDE_MEASUREMENT = 'override_measurement' +CONF_TAGS_ATTRIBUTES = 'tags_attributes' +CONF_COMPONENT_CONFIG = 'component_config' +CONF_COMPONENT_CONFIG_GLOB = 'component_config_glob' +CONF_COMPONENT_CONFIG_DOMAIN = 'component_config_domain' +CONF_RETRY_COUNT = 'max_retries' + +DEFAULT_DATABASE = 'home_assistant' +DEFAULT_VERIFY_SSL = True +DOMAIN = 'influxdb' + +TIMEOUT = 5 +RETRY_DELAY = 20 +QUEUE_BACKLOG_SECONDS = 30 + +BATCH_TIMEOUT = 1 +BATCH_BUFFER_SIZE = 100 + +COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({ + vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(vol.Schema({ + vol.Optional(CONF_HOST): cv.string, + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_SSL): cv.boolean, + vol.Optional(CONF_RETRY_COUNT, default=0): cv.positive_int, + vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string, + vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, + vol.Optional(CONF_TAGS, default={}): + vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_TAGS_ATTRIBUTES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_COMPONENT_CONFIG, default={}): + vol.Schema({cv.entity_id: COMPONENT_CONFIG_SCHEMA_ENTRY}), + vol.Optional(CONF_COMPONENT_CONFIG_GLOB, default={}): + vol.Schema({cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}), + vol.Optional(CONF_COMPONENT_CONFIG_DOMAIN, default={}): + vol.Schema({cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}), + })), +}, extra=vol.ALLOW_EXTRA) + +RE_DIGIT_TAIL = re.compile(r'^[^\.]*\d+\.?\d+[^\.]*$') +RE_DECIMAL = re.compile(r'[^\d.]+') + + +def setup(hass, config): + """Set up the InfluxDB component.""" + from influxdb import InfluxDBClient, exceptions + + conf = config[DOMAIN] + + kwargs = { + 'database': conf[CONF_DB_NAME], + 'verify_ssl': conf[CONF_VERIFY_SSL], + 'timeout': TIMEOUT + } + + if CONF_HOST in conf: + kwargs['host'] = conf[CONF_HOST] + + if CONF_PORT in conf: + kwargs['port'] = conf[CONF_PORT] + + if CONF_USERNAME in conf: + kwargs['username'] = conf[CONF_USERNAME] + + if CONF_PASSWORD in conf: + kwargs['password'] = conf[CONF_PASSWORD] + + if CONF_SSL in conf: + kwargs['ssl'] = conf[CONF_SSL] + + include = conf.get(CONF_INCLUDE, {}) + exclude = conf.get(CONF_EXCLUDE, {}) + whitelist_e = set(include.get(CONF_ENTITIES, [])) + whitelist_d = set(include.get(CONF_DOMAINS, [])) + blacklist_e = set(exclude.get(CONF_ENTITIES, [])) + blacklist_d = set(exclude.get(CONF_DOMAINS, [])) + tags = conf.get(CONF_TAGS) + tags_attributes = conf.get(CONF_TAGS_ATTRIBUTES) + default_measurement = conf.get(CONF_DEFAULT_MEASUREMENT) + override_measurement = conf.get(CONF_OVERRIDE_MEASUREMENT) + component_config = EntityValues( + conf[CONF_COMPONENT_CONFIG], + conf[CONF_COMPONENT_CONFIG_DOMAIN], + conf[CONF_COMPONENT_CONFIG_GLOB]) + max_tries = conf.get(CONF_RETRY_COUNT) + + try: + influx = InfluxDBClient(**kwargs) + influx.write_points([]) + except (exceptions.InfluxDBClientError, + requests.exceptions.ConnectionError) as exc: + _LOGGER.error("Database host is not accessible due to '%s', please " + "check your entries in the configuration file (host, " + "port, etc.) and verify that the database exists and is " + "READ/WRITE", exc) + return False + + def event_to_json(event): + """Add an event to the outgoing Influx list.""" + state = event.data.get('new_state') + if state is None or state.state in ( + STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \ + state.entity_id in blacklist_e or state.domain in blacklist_d: + return + + try: + if ((whitelist_e or whitelist_d) + and state.entity_id not in whitelist_e + and state.domain not in whitelist_d): + return + + _include_state = _include_value = False + + _state_as_value = float(state.state) + _include_value = True + except ValueError: + try: + _state_as_value = float(state_helper.state_as_number(state)) + _include_state = _include_value = True + except ValueError: + _include_state = True + + include_uom = True + measurement = component_config.get(state.entity_id).get( + CONF_OVERRIDE_MEASUREMENT) + if measurement in (None, ''): + if override_measurement: + measurement = override_measurement + else: + measurement = state.attributes.get('unit_of_measurement') + if measurement in (None, ''): + if default_measurement: + measurement = default_measurement + else: + measurement = state.entity_id + else: + include_uom = False + + json = { + 'measurement': measurement, + 'tags': { + 'domain': state.domain, + 'entity_id': state.object_id, + }, + 'time': event.time_fired, + 'fields': {} + } + if _include_state: + json['fields']['state'] = state.state + if _include_value: + json['fields']['value'] = _state_as_value + + for key, value in state.attributes.items(): + if key in tags_attributes: + json['tags'][key] = value + elif key != 'unit_of_measurement' or include_uom: + # If the key is already in fields + if key in json['fields']: + key = key + "_" + # Prevent column data errors in influxDB. + # For each value we try to cast it as float + # But if we can not do it we store the value + # as string add "_str" postfix to the field key + try: + json['fields'][key] = float(value) + except (ValueError, TypeError): + new_key = "{}_str".format(key) + new_value = str(value) + json['fields'][new_key] = new_value + + if RE_DIGIT_TAIL.match(new_value): + json['fields'][key] = float( + RE_DECIMAL.sub('', new_value)) + + # Infinity and NaN are not valid floats in InfluxDB + try: + if not math.isfinite(json['fields'][key]): + del json['fields'][key] + except (KeyError, TypeError): + pass + + json['tags'].update(tags) + + return json + + instance = hass.data[DOMAIN] = InfluxThread( + hass, influx, event_to_json, max_tries) + instance.start() + + def shutdown(event): + """Shut down the thread.""" + instance.queue.put(None) + instance.join() + influx.close() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + return True + + +class InfluxThread(threading.Thread): + """A threaded event handler class.""" + + def __init__(self, hass, influx, event_to_json, max_tries): + """Initialize the listener.""" + threading.Thread.__init__(self, name='InfluxDB') + self.queue = queue.Queue() + self.influx = influx + self.event_to_json = event_to_json + self.max_tries = max_tries + self.write_errors = 0 + self.shutdown = False + hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) + + def _event_listener(self, event): + """Listen for new messages on the bus and queue them for Influx.""" + item = (time.monotonic(), event) + self.queue.put(item) + + @staticmethod + def batch_timeout(): + """Return number of seconds to wait for more events.""" + return BATCH_TIMEOUT + + def get_events_json(self): + """Return a batch of events formatted for writing.""" + queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries*RETRY_DELAY + + count = 0 + json = [] + + dropped = 0 + + try: + while len(json) < BATCH_BUFFER_SIZE and not self.shutdown: + timeout = None if count == 0 else self.batch_timeout() + item = self.queue.get(timeout=timeout) + count += 1 + + if item is None: + self.shutdown = True + else: + timestamp, event = item + age = time.monotonic() - timestamp + + if age < queue_seconds: + event_json = self.event_to_json(event) + if event_json: + json.append(event_json) + else: + dropped += 1 + + except queue.Empty: + pass + + if dropped: + _LOGGER.warning("Catching up, dropped %d old events", dropped) + + return count, json + + def write_to_influxdb(self, json): + """Write preprocessed events to influxdb, with retry.""" + from influxdb import exceptions + + for retry in range(self.max_tries+1): + try: + self.influx.write_points(json) + + if self.write_errors: + _LOGGER.error("Resumed, lost %d events", self.write_errors) + self.write_errors = 0 + + _LOGGER.debug("Wrote %d events", len(json)) + break + except (exceptions.InfluxDBClientError, IOError): + if retry < self.max_tries: + time.sleep(RETRY_DELAY) + else: + if not self.write_errors: + _LOGGER.exception("Write error") + self.write_errors += len(json) + + def run(self): + """Process incoming events.""" + while not self.shutdown: + count, json = self.get_events_json() + if json: + self.write_to_influxdb(json) + for _ in range(count): + self.queue.task_done() + + def block_till_done(self): + """Block till all events processed.""" + self.queue.join() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/input_boolean.py b/homeassistant-0.88.0.dev0/homeassistant/components/input_boolean.py new file mode 100644 index 00000000000000..896de61130cb6b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/input_boolean.py @@ -0,0 +1,135 @@ +""" +Component to keep track of user controlled booleans for within automation. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_boolean/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_TOGGLE, STATE_ON) +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity + +DOMAIN = 'input_boolean' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +_LOGGER = logging.getLogger(__name__) + +CONF_INITIAL = 'initial' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.Any({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INITIAL): cv.boolean, + vol.Optional(CONF_ICON): cv.icon, + }, None) + ) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def is_on(hass, entity_id): + """Test if input_boolean is True.""" + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass, config): + """Set up an input boolean.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + + entities.append(InputBoolean(object_id, name, initial, icon)) + + if not entities: + return False + + component.async_register_entity_service( + SERVICE_TURN_ON, SERVICE_SCHEMA, + 'async_turn_on' + ) + + component.async_register_entity_service( + SERVICE_TURN_OFF, SERVICE_SCHEMA, + 'async_turn_off' + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, SERVICE_SCHEMA, + 'async_toggle' + ) + + await component.async_add_entities(entities) + return True + + +class InputBoolean(ToggleEntity, RestoreEntity): + """Representation of a boolean input.""" + + def __init__(self, object_id, name, initial, icon): + """Initialize a boolean input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._state = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the boolean input.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def is_on(self): + """Return true if entity is on.""" + return self._state + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + if self._state is not None: + return + + state = await self.async_get_last_state() + self._state = state and state.state == STATE_ON + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self._state = True + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self._state = False + await self.async_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/input_datetime.py b/homeassistant-0.88.0.dev0/homeassistant/components/input_datetime.py new file mode 100644 index 00000000000000..63dcc364c9c943 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/input_datetime.py @@ -0,0 +1,200 @@ +""" +Component to offer a way to select a date and / or a time. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_datetime/ +""" +import logging +import datetime + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_datetime' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_HAS_DATE = 'has_date' +CONF_HAS_TIME = 'has_time' +CONF_INITIAL = 'initial' + +ATTR_DATE = 'date' +ATTR_TIME = 'time' + +SERVICE_SET_DATETIME = 'set_datetime' + +SERVICE_SET_DATETIME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_TIME): cv.time, +}) + + +def has_date_or_time(conf): + """Check at least date or time is true.""" + if conf[CONF_HAS_DATE] or conf[CONF_HAS_TIME]: + return conf + + raise vol.Invalid('Entity needs at least a date or a time') + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HAS_DATE, default=False): cv.boolean, + vol.Optional(CONF_HAS_TIME, default=False): cv.boolean, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL): cv.string, + }, has_date_or_time) + ) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up an input datetime.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + has_time = cfg.get(CONF_HAS_TIME) + has_date = cfg.get(CONF_HAS_DATE) + icon = cfg.get(CONF_ICON) + initial = cfg.get(CONF_INITIAL) + entities.append(InputDatetime(object_id, name, + has_date, has_time, icon, initial)) + + if not entities: + return False + + async def async_set_datetime_service(entity, call): + """Handle a call to the input datetime 'set datetime' service.""" + time = call.data.get(ATTR_TIME) + date = call.data.get(ATTR_DATE) + if (entity.has_date and not date) or (entity.has_time and not time): + _LOGGER.error("Invalid service data for %s " + "input_datetime.set_datetime: %s", + entity.entity_id, str(call.data)) + return + + entity.async_set_datetime(date, time) + + component.async_register_entity_service( + SERVICE_SET_DATETIME, SERVICE_SET_DATETIME_SCHEMA, + async_set_datetime_service + ) + + await component.async_add_entities(entities) + return True + + +class InputDatetime(RestoreEntity): + """Representation of a datetime input.""" + + def __init__(self, object_id, name, has_date, has_time, icon, initial): + """Initialize a select input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self.has_date = has_date + self.has_time = has_time + self._icon = icon + self._initial = initial + self._current_datetime = None + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + restore_val = None + + # Priority 1: Initial State + if self._initial is not None: + restore_val = self._initial + + # Priority 2: Old state + if restore_val is None: + old_state = await self.async_get_last_state() + if old_state is not None: + restore_val = old_state.state + + if restore_val is not None: + if not self.has_date: + self._current_datetime = dt_util.parse_time(restore_val) + elif not self.has_time: + self._current_datetime = dt_util.parse_date(restore_val) + else: + self._current_datetime = dt_util.parse_datetime(restore_val) + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_datetime + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = { + 'has_date': self.has_date, + 'has_time': self.has_time, + } + + if self._current_datetime is None: + return attrs + + if self.has_date and self._current_datetime is not None: + attrs['year'] = self._current_datetime.year + attrs['month'] = self._current_datetime.month + attrs['day'] = self._current_datetime.day + + if self.has_time and self._current_datetime is not None: + attrs['hour'] = self._current_datetime.hour + attrs['minute'] = self._current_datetime.minute + attrs['second'] = self._current_datetime.second + + if not self.has_date: + attrs['timestamp'] = self._current_datetime.hour * 3600 + \ + self._current_datetime.minute * 60 + \ + self._current_datetime.second + elif not self.has_time: + extended = datetime.datetime.combine(self._current_datetime, + datetime.time(0, 0)) + attrs['timestamp'] = extended.timestamp() + else: + attrs['timestamp'] = self._current_datetime.timestamp() + + return attrs + + def async_set_datetime(self, date_val, time_val): + """Set a new date / time.""" + if self.has_date and self.has_time and date_val and time_val: + self._current_datetime = datetime.datetime.combine(date_val, + time_val) + elif self.has_date and not self.has_time and date_val: + self._current_datetime = date_val + if self.has_time and not self.has_date and time_val: + self._current_datetime = time_val + + self.async_schedule_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/input_number.py b/homeassistant-0.88.0.dev0/homeassistant/components/input_number.py new file mode 100644 index 00000000000000..8cfa7abaf202cd --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/input_number.py @@ -0,0 +1,221 @@ +""" +Component to offer a way to set a numeric value from a slider or text box. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_number/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_number' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_MIN = 'min' +CONF_MAX = 'max' +CONF_STEP = 'step' + +MODE_SLIDER = 'slider' +MODE_BOX = 'box' + +ATTR_INITIAL = 'initial' +ATTR_VALUE = 'value' +ATTR_MIN = 'min' +ATTR_MAX = 'max' +ATTR_STEP = 'step' +ATTR_MODE = 'mode' + +SERVICE_SET_VALUE = 'set_value' +SERVICE_INCREMENT = 'increment' +SERVICE_DECREMENT = 'decrement' + +SERVICE_DEFAULT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) + +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): vol.Coerce(float), +}) + + +def _cv_input_number(cfg): + """Configure validation helper for input number (voluptuous).""" + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + if minimum >= maximum: + raise vol.Invalid('Maximum ({}) is not greater than minimum ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (state < minimum or state > maximum): + raise vol.Invalid('Initial value {} not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_MIN): vol.Coerce(float), + vol.Required(CONF_MAX): vol.Coerce(float), + vol.Optional(CONF_INITIAL): vol.Coerce(float), + vol.Optional(CONF_STEP, default=1): + vol.All(vol.Coerce(float), vol.Range(min=1e-3)), + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_MODE, default=MODE_SLIDER): + vol.In([MODE_BOX, MODE_SLIDER]), + }, _cv_input_number) + ) +}, required=True, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up an input slider.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + mode = cfg.get(CONF_MODE) + + entities.append(InputNumber( + object_id, name, initial, minimum, maximum, step, icon, unit, + mode)) + + if not entities: + return False + + component.async_register_entity_service( + SERVICE_SET_VALUE, SERVICE_SET_VALUE_SCHEMA, + 'async_set_value' + ) + + component.async_register_entity_service( + SERVICE_INCREMENT, SERVICE_DEFAULT_SCHEMA, + 'async_increment' + ) + + component.async_register_entity_service( + SERVICE_DECREMENT, SERVICE_DEFAULT_SCHEMA, + 'async_decrement' + ) + + await component.async_add_entities(entities) + return True + + +class InputNumber(RestoreEntity): + """Representation of a slider.""" + + def __init__(self, object_id, name, initial, minimum, maximum, step, icon, + unit, mode): + """Initialize an input number.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._initial = initial + self._minimum = minimum + self._maximum = maximum + self._step = step + self._icon = icon + self._unit = unit + self._mode = mode + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the input slider.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_STEP: self._step, + ATTR_MODE: self._mode, + } + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._current_value is not None: + return + + state = await self.async_get_last_state() + value = state and float(state.state) + + # Check against None because value can be 0 + if value is not None and self._minimum <= value <= self._maximum: + self._current_value = value + else: + self._current_value = self._minimum + + async def async_set_value(self, value): + """Set new value.""" + num_value = float(value) + if num_value < self._minimum or num_value > self._maximum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + num_value, self._minimum, self._maximum) + return + self._current_value = num_value + await self.async_update_ha_state() + + async def async_increment(self): + """Increment value.""" + new_value = self._current_value + self._step + if new_value > self._maximum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + new_value, self._minimum, self._maximum) + return + self._current_value = new_value + await self.async_update_ha_state() + + async def async_decrement(self): + """Decrement value.""" + new_value = self._current_value - self._step + if new_value < self._minimum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + new_value, self._minimum, self._maximum) + return + self._current_value = new_value + await self.async_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/input_select.py b/homeassistant-0.88.0.dev0/homeassistant/components/input_select.py new file mode 100644 index 00000000000000..fc858e75397593 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/input_select.py @@ -0,0 +1,189 @@ +""" +Component to offer a way to select an option from a list. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_select/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_select' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_OPTIONS = 'options' + +ATTR_OPTION = 'option' +ATTR_OPTIONS = 'options' + +SERVICE_SELECT_OPTION = 'select_option' + +SERVICE_SELECT_OPTION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OPTION): cv.string, +}) + +SERVICE_SELECT_NEXT = 'select_next' + +SERVICE_SELECT_NEXT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SELECT_PREVIOUS = 'select_previous' + +SERVICE_SELECT_PREVIOUS_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +SERVICE_SET_OPTIONS = 'set_options' + +SERVICE_SET_OPTIONS_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OPTIONS): + vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]), +}) + + +def _cv_input_select(cfg): + """Configure validation helper for input select (voluptuous).""" + options = cfg[CONF_OPTIONS] + initial = cfg.get(CONF_INITIAL) + if initial is not None and initial not in options: + raise vol.Invalid('initial state "{}" is not part of the options: {}' + .format(initial, ','.join(options))) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_OPTIONS): + vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]), + vol.Optional(CONF_INITIAL): cv.string, + vol.Optional(CONF_ICON): cv.icon, + }, _cv_input_select) + ) +}, required=True, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up an input select.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + options = cfg.get(CONF_OPTIONS) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + entities.append(InputSelect(object_id, name, initial, options, icon)) + + if not entities: + return False + + component.async_register_entity_service( + SERVICE_SELECT_OPTION, SERVICE_SELECT_OPTION_SCHEMA, + 'async_select_option' + ) + + component.async_register_entity_service( + SERVICE_SELECT_NEXT, SERVICE_SELECT_NEXT_SCHEMA, + lambda entity, call: entity.async_offset_index(1) + ) + + component.async_register_entity_service( + SERVICE_SELECT_PREVIOUS, SERVICE_SELECT_PREVIOUS_SCHEMA, + lambda entity, call: entity.async_offset_index(-1) + ) + + component.async_register_entity_service( + SERVICE_SET_OPTIONS, SERVICE_SET_OPTIONS_SCHEMA, + 'async_set_options' + ) + + await component.async_add_entities(entities) + return True + + +class InputSelect(RestoreEntity): + """Representation of a select input.""" + + def __init__(self, object_id, name, initial, options, icon): + """Initialize a select input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_option = initial + self._options = options + self._icon = icon + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + if self._current_option is not None: + return + + state = await self.async_get_last_state() + if not state or state.state not in self._options: + self._current_option = self._options[0] + else: + self._current_option = state.state + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_option + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_OPTIONS: self._options, + } + + async def async_select_option(self, option): + """Select new option.""" + if option not in self._options: + _LOGGER.warning('Invalid option: %s (possible options: %s)', + option, ', '.join(self._options)) + return + self._current_option = option + await self.async_update_ha_state() + + async def async_offset_index(self, offset): + """Offset current index.""" + current_index = self._options.index(self._current_option) + new_index = (current_index + offset) % len(self._options) + self._current_option = self._options[new_index] + await self.async_update_ha_state() + + async def async_set_options(self, options): + """Set options.""" + self._current_option = options[0] + self._options = options + await self.async_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/input_text.py b/homeassistant-0.88.0.dev0/homeassistant/components/input_text.py new file mode 100644 index 00000000000000..580337a3af35d6 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/input_text.py @@ -0,0 +1,177 @@ +""" +Component to offer a way to enter a value into a text box. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_text/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_text' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_MIN = 'min' +CONF_MAX = 'max' + +MODE_TEXT = 'text' +MODE_PASSWORD = 'password' + +ATTR_VALUE = 'value' +ATTR_MIN = 'min' +ATTR_MAX = 'max' +ATTR_PATTERN = 'pattern' +ATTR_MODE = 'mode' + +SERVICE_SET_VALUE = 'set_value' + +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): cv.string, +}) + + +def _cv_input_text(cfg): + """Configure validation helper for input box (voluptuous).""" + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + if minimum > maximum: + raise vol.Invalid('Max len ({}) is not greater than min len ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (len(state) < minimum or len(state) > maximum): + raise vol.Invalid('Initial value {} length not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=0): vol.Coerce(int), + vol.Optional(CONF_MAX, default=100): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ''): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_PATTERN): cv.string, + vol.Optional(CONF_MODE, default=MODE_TEXT): + vol.In([MODE_TEXT, MODE_PASSWORD]), + }, _cv_input_text) + ) +}, required=True, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up an input text box.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + pattern = cfg.get(ATTR_PATTERN) + mode = cfg.get(CONF_MODE) + + entities.append(InputText( + object_id, name, initial, minimum, maximum, icon, unit, + pattern, mode)) + + if not entities: + return False + + component.async_register_entity_service( + SERVICE_SET_VALUE, SERVICE_SET_VALUE_SCHEMA, + 'async_set_value' + ) + + await component.async_add_entities(entities) + return True + + +class InputText(RestoreEntity): + """Represent a text box.""" + + def __init__(self, object_id, name, initial, minimum, maximum, icon, + unit, pattern, mode): + """Initialize a text input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._minimum = minimum + self._maximum = maximum + self._icon = icon + self._unit = unit + self._pattern = pattern + self._mode = mode + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the text input entity.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_PATTERN: self._pattern, + ATTR_MODE: self._mode, + } + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._current_value is not None: + return + + state = await self.async_get_last_state() + value = state and state.state + + # Check against None because value can be 0 + if value is not None and self._minimum <= len(value) <= self._maximum: + self._current_value = value + + async def async_set_value(self, value): + """Select new value.""" + if len(value) < self._minimum or len(value) > self._maximum: + _LOGGER.warning("Invalid value: %s (length range %s - %s)", + value, self._minimum, self._maximum) + return + self._current_value = value + await self.async_update_ha_state() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/insteon_local.py b/homeassistant-0.88.0.dev0/homeassistant/components/insteon_local.py new file mode 100644 index 00000000000000..003714d0f944e5 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/insteon_local.py @@ -0,0 +1,28 @@ +""" +Local support for Insteon. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_local/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Set up the insteon_local component. + + This component is deprecated as of release 0.77 and should be removed in + release 0.90. + """ + _LOGGER.warning('The insteon_local component has been replaced by ' + 'the insteon component') + _LOGGER.warning('Please see https://home-assistant.io/components/insteon') + + hass.components.persistent_notification.create( + 'insteon_local has been replaced by the insteon component.
' + 'Please see https://home-assistant.io/components/insteon', + title='insteon_local Component Deactivated', + notification_id='insteon_local') + + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/insteon_plm.py b/homeassistant-0.88.0.dev0/homeassistant/components/insteon_plm.py new file mode 100644 index 00000000000000..b3011e9d7bdd94 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/insteon_plm.py @@ -0,0 +1,28 @@ +""" +Support for INSTEON PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_plm/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the insteon_plm component. + + This component is deprecated as of release 0.77 and should be removed in + release 0.90. + """ + _LOGGER.warning('The insteon_plm component has been replaced by ' + 'the insteon component') + _LOGGER.warning('Please see https://home-assistant.io/components/insteon') + + hass.components.persistent_notification.create( + 'insteon_plm has been replaced by the insteon component.
' + 'Please see https://home-assistant.io/components/insteon', + title='insteon_plm Component Deactivated', + notification_id='insteon_plm') + + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/intent_script.py b/homeassistant-0.88.0.dev0/homeassistant/components/intent_script.py new file mode 100644 index 00000000000000..9c63141e496c92 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/intent_script.py @@ -0,0 +1,97 @@ +"""Handle intents with scripts.""" +import copy +import logging + +import voluptuous as vol + +from homeassistant.helpers import ( + intent, template, script, config_validation as cv) + +DOMAIN = 'intent_script' + +CONF_INTENTS = 'intents' +CONF_SPEECH = 'speech' + +CONF_ACTION = 'action' +CONF_CARD = 'card' +CONF_TYPE = 'type' +CONF_TITLE = 'title' +CONF_CONTENT = 'content' +CONF_TEXT = 'text' +CONF_ASYNC_ACTION = 'async_action' + +DEFAULT_CONF_ASYNC_ACTION = False + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: { + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ASYNC_ACTION, + default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean, + vol.Optional(CONF_CARD): { + vol.Optional(CONF_TYPE, default='simple'): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Required(CONF_CONTENT): cv.template, + }, + vol.Optional(CONF_SPEECH): { + vol.Optional(CONF_TYPE, default='plain'): cv.string, + vol.Required(CONF_TEXT): cv.template, + } + } + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Activate Alexa component.""" + intents = copy.deepcopy(config[DOMAIN]) + template.attach(hass, intents) + + for intent_type, conf in intents.items(): + if CONF_ACTION in conf: + conf[CONF_ACTION] = script.Script( + hass, conf[CONF_ACTION], + "Intent Script {}".format(intent_type)) + intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) + + return True + + +class ScriptIntentHandler(intent.IntentHandler): + """Respond to an intent with a script.""" + + def __init__(self, intent_type, config): + """Initialize the script intent handler.""" + self.intent_type = intent_type + self.config = config + + async def async_handle(self, intent_obj): + """Handle the intent.""" + speech = self.config.get(CONF_SPEECH) + card = self.config.get(CONF_CARD) + action = self.config.get(CONF_ACTION) + is_async_action = self.config.get(CONF_ASYNC_ACTION) + slots = {key: value['value'] for key, value + in intent_obj.slots.items()} + + if action is not None: + if is_async_action: + intent_obj.hass.async_create_task(action.async_run(slots)) + else: + await action.async_run(slots) + + response = intent_obj.create_response() + + if speech is not None: + response.async_set_speech(speech[CONF_TEXT].async_render(slots), + speech[CONF_TYPE]) + + if card is not None: + response.async_set_card( + card[CONF_TITLE].async_render(slots), + card[CONF_CONTENT].async_render(slots), + card[CONF_TYPE]) + + return response diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/introduction.py b/homeassistant-0.88.0.dev0/homeassistant/components/introduction.py new file mode 100644 index 00000000000000..17de7fcd6ca26e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/introduction.py @@ -0,0 +1,61 @@ +""" +Component that will help guide the user taking its first steps. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/introduction/ +""" +import logging + +import voluptuous as vol + +DOMAIN = 'introduction' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({}), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config=None): + """Set up the introduction component.""" + log = logging.getLogger(__name__) + log.info(""" + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Hello, and welcome to Home Assistant! + + We'll hope that we can make all your dreams come true. + + Here are some resources to get started: + + - Configuring Home Assistant: + https://home-assistant.io/getting-started/configuration/ + + - Available components: + https://home-assistant.io/components/ + + - Troubleshooting your configuration: + https://home-assistant.io/getting-started/troubleshooting-configuration/ + + - Getting help: + https://home-assistant.io/help/ + + This message is generated by the introduction component. You can + disable it in configuration.yaml. + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + """) + + hass.components.persistent_notification.async_create(""" +Here are some resources to get started: + + - [Configuring Home Assistant](https://home-assistant.io/getting-started/configuration/) + - [Available components](https://home-assistant.io/components/) + - [Troubleshooting your configuration](https://home-assistant.io/docs/configuration/troubleshooting/) + - [Getting help](https://home-assistant.io/help/) + +To not see this card popup in the future, edit your config in +`configuration.yaml` and disable the `introduction` component. +""", 'Welcome Home!') # noqa + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/keyboard.py b/homeassistant-0.88.0.dev0/homeassistant/components/keyboard.py new file mode 100644 index 00000000000000..16253ba271a5bf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/keyboard.py @@ -0,0 +1,57 @@ +""" +Provides functionality to emulate keyboard presses on host machine. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/keyboard/ +""" +import voluptuous as vol + +from homeassistant.const import ( + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_UP) + +REQUIREMENTS = ['pyuserinput==0.1.11'] + +DOMAIN = 'keyboard' + +TAP_KEY_SCHEMA = vol.Schema({}) + + +def setup(hass, config): + """Listen for keyboard events.""" + import pykeyboard # pylint: disable=import-error + + keyboard = pykeyboard.PyKeyboard() + keyboard.special_key_assignment() + + hass.services.register(DOMAIN, SERVICE_VOLUME_UP, + lambda service: + keyboard.tap_key(keyboard.volume_up_key), + schema=TAP_KEY_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_VOLUME_DOWN, + lambda service: + keyboard.tap_key(keyboard.volume_down_key), + schema=TAP_KEY_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE, + lambda service: + keyboard.tap_key(keyboard.volume_mute_key), + schema=TAP_KEY_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, + lambda service: + keyboard.tap_key(keyboard.media_play_pause_key), + schema=TAP_KEY_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, + lambda service: + keyboard.tap_key(keyboard.media_next_track_key), + schema=TAP_KEY_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, + lambda service: + keyboard.tap_key(keyboard.media_prev_track_key), + schema=TAP_KEY_SCHEMA) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/keyboard_remote.py b/homeassistant-0.88.0.dev0/homeassistant/components/keyboard_remote.py new file mode 100644 index 00000000000000..e02c2ee5475d8b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/keyboard_remote.py @@ -0,0 +1,203 @@ +""" +Receive signals from a keyboard and use it as a remote control. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/keyboard_remote/ +""" +# pylint: disable=import-error +import threading +import logging +import os +import time + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['evdev==0.6.1'] + +_LOGGER = logging.getLogger(__name__) + +DEVICE_DESCRIPTOR = 'device_descriptor' +DEVICE_ID_GROUP = 'Device description' +DEVICE_NAME = 'device_name' +DOMAIN = 'keyboard_remote' + +ICON = 'mdi:remote' + +KEY_CODE = 'key_code' +KEY_VALUE = {'key_up': 0, 'key_down': 1, 'key_hold': 2} +KEYBOARD_REMOTE_COMMAND_RECEIVED = 'keyboard_remote_command_received' +KEYBOARD_REMOTE_CONNECTED = 'keyboard_remote_connected' +KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected' + +TYPE = 'type' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: + vol.All(cv.ensure_list, [vol.Schema({ + 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')) + })]) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the keyboard_remote.""" + config = config.get(DOMAIN) + + keyboard_remote = KeyboardRemote(hass, config) + + def _start_keyboard_remote(_event): + keyboard_remote.run() + + def _stop_keyboard_remote(_event): + keyboard_remote.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_keyboard_remote) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_keyboard_remote) + + return True + + +class KeyboardRemoteThread(threading.Thread): + """This interfaces with the inputdevice using evdev.""" + + def __init__(self, hass, device_name, device_descriptor, key_value): + """Construct a thread listening for events on one device.""" + self.hass = hass + self.device_name = device_name + self.device_descriptor = device_descriptor + self.key_value = key_value + + if self.device_descriptor: + self.device_id = self.device_descriptor + else: + self.device_id = self.device_name + + self.dev = self._get_keyboard_device() + if self.dev is not None: + _LOGGER.debug("Keyboard connected, %s", self.device_id) + else: + _LOGGER.debug( + "Keyboard not connected, %s. " + "Check /dev/input/event* permissions", self.device_id) + + id_folder = '/dev/input/by-id/' + + if os.path.isdir(id_folder): + from evdev import InputDevice, list_devices + device_names = [InputDevice(file_name).name + for file_name in list_devices()] + _LOGGER.debug( + "Possible device names are: %s. " + "Possible device descriptors are %s: %s", + device_names, id_folder, os.listdir(id_folder)) + + threading.Thread.__init__(self) + self.stopped = threading.Event() + self.hass = hass + + def _get_keyboard_device(self): + """Get the keyboard device.""" + 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): + """Run the loop of the KeyboardRemote.""" + from evdev import categorize, ecodes + + if self.dev is not None: + self.dev.grab() + _LOGGER.debug("Interface started for %s", self.dev) + + while not self.stopped.isSet(): + # Sleeps to ease load on processor + time.sleep(.05) + + if self.dev is None: + self.dev = self._get_keyboard_device() + if self.dev is not None: + self.dev.grab() + self.hass.bus.fire( + KEYBOARD_REMOTE_CONNECTED, + { + DEVICE_DESCRIPTOR: self.device_descriptor, + DEVICE_NAME: self.device_name + } + ) + _LOGGER.debug("Keyboard re-connected, %s", self.device_id) + else: + continue + + try: + event = self.dev.read_one() + except IOError: # Keyboard Disconnected + self.dev = None + self.hass.bus.fire( + KEYBOARD_REMOTE_DISCONNECTED, + { + DEVICE_DESCRIPTOR: self.device_descriptor, + DEVICE_NAME: self.device_name + } + ) + _LOGGER.debug("Keyboard disconnected, %s", self.device_id) + continue + + if not event: + continue + + if event.type is ecodes.EV_KEY and event.value is self.key_value: + _LOGGER.debug(categorize(event)) + self.hass.bus.fire( + KEYBOARD_REMOTE_COMMAND_RECEIVED, + { + KEY_CODE: event.code, + DEVICE_DESCRIPTOR: self.device_descriptor, + DEVICE_NAME: self.device_name + } + ) + + +class KeyboardRemote: + """Sets up one thread per device.""" + + def __init__(self, hass, config): + """Construct a KeyboardRemote interface object.""" + self.threads = [] + for dev_block in config: + device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + device_name = dev_block.get(DEVICE_NAME) + key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up')) + + if device_descriptor is not None\ + or device_name is not None: + thread = KeyboardRemoteThread( + hass, device_name, device_descriptor, key_value) + self.threads.append(thread) + + def run(self): + """Run all event listener threads.""" + for thread in self.threads: + thread.start() + + def stop(self): + """Stop all event listener threads.""" + for thread in self.threads: + thread.stopped.set() diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/lirc.py b/homeassistant-0.88.0.dev0/homeassistant/components/lirc.py new file mode 100644 index 00000000000000..d7ec49e00968fb --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/lirc.py @@ -0,0 +1,91 @@ +""" +LIRC interface to receive signals from an infrared remote control. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/lirc/ +""" +# pylint: disable=no-member +import threading +import time +import logging + +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) + +REQUIREMENTS = ['python-lirc==1.2.3'] + +_LOGGER = logging.getLogger(__name__) + +BUTTON_NAME = 'button_name' + +DOMAIN = 'lirc' + +EVENT_IR_COMMAND_RECEIVED = 'ir_command_received' + +ICON = 'mdi:remote' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({}), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the LIRC capability.""" + import lirc + + # blocking=True gives unexpected behavior (multiple responses for 1 press) + # also by not blocking, we allow hass to shut down the thread gracefully + # on exit. + lirc.init('home-assistant', blocking=False) + lirc_interface = LircInterface(hass) + + def _start_lirc(_event): + lirc_interface.start() + + def _stop_lirc(_event): + lirc_interface.stopped.set() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_lirc) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_lirc) + + return True + + +class LircInterface(threading.Thread): + """ + This interfaces with the lirc daemon to read IR commands. + + When using lirc in blocking mode, sometimes repeated commands get produced + in the next read of a command so we use a thread here to just wait + around until a non-empty response is obtained from lirc. + """ + + def __init__(self, hass): + """Construct a LIRC interface object.""" + threading.Thread.__init__(self) + self.daemon = True + self.stopped = threading.Event() + self.hass = hass + + def run(self): + """Run the loop of the LIRC interface thread.""" + import lirc + _LOGGER.debug("LIRC interface thread started") + while not self.stopped.isSet(): + try: + code = lirc.nextcode() # list; empty if no buttons pressed + except lirc.NextCodeError: + _LOGGER.warning("Error reading next code from LIRC") + code = None + # interpret result from python-lirc + if code: + code = code[0] + _LOGGER.info("Got new LIRC code %s", code) + self.hass.bus.fire( + EVENT_IR_COMMAND_RECEIVED, {BUTTON_NAME: code}) + else: + time.sleep(0.2) + lirc.deinit() + _LOGGER.debug('LIRC interface thread stopped') diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/litejet.py b/homeassistant-0.88.0.dev0/homeassistant/components/litejet.py new file mode 100644 index 00000000000000..e7c8452b27b7fc --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/litejet.py @@ -0,0 +1,54 @@ +"""Allows the LiteJet lighting system to be controlled by Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/litejet/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.const import CONF_PORT + +REQUIREMENTS = ['pylitejet==0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_EXCLUDE_NAMES = 'exclude_names' +CONF_INCLUDE_SWITCHES = 'include_switches' + +DOMAIN = 'litejet' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): cv.string, + vol.Optional(CONF_EXCLUDE_NAMES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the LiteJet component.""" + from pylitejet import LiteJet + + url = config[DOMAIN].get(CONF_PORT) + + hass.data['litejet_system'] = LiteJet(url) + hass.data['litejet_config'] = config[DOMAIN] + + discovery.load_platform(hass, 'light', DOMAIN, {}, config) + if config[DOMAIN].get(CONF_INCLUDE_SWITCHES): + discovery.load_platform(hass, 'switch', DOMAIN, {}, config) + discovery.load_platform(hass, 'scene', DOMAIN, {}, config) + + return True + + +def is_ignored(hass, name): + """Determine if a load, switch, or scene should be ignored.""" + for prefix in hass.data['litejet_config'].get(CONF_EXCLUDE_NAMES, []): + if name.startswith(prefix): + return True + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/logbook.py b/homeassistant-0.88.0.dev0/homeassistant/components/logbook.py new file mode 100644 index 00000000000000..0c6608e3572ddf --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/logbook.py @@ -0,0 +1,515 @@ +""" +Event parser and human readable log generator. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/logbook/ +""" +from datetime import timedelta +from itertools import groupby +import logging + +import voluptuous as vol + +from homeassistant.loader import bind_hass +from homeassistant.components import sun +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, ATTR_SERVICE, + CONF_EXCLUDE, CONF_INCLUDE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, + EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, HTTP_BAD_REQUEST, + STATE_NOT_HOME, STATE_OFF, STATE_ON) +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, State, callback, split_entity_id) +from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME +from homeassistant.components.homekit.const import ( + ATTR_DISPLAY_NAME, ATTR_VALUE, DOMAIN as DOMAIN_HOMEKIT, + EVENT_HOMEKIT_CHANGED) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_MESSAGE = 'message' + +CONF_DOMAINS = 'domains' +CONF_ENTITIES = 'entities' +CONTINUOUS_DOMAINS = ['proximity', 'sensor'] + +DEPENDENCIES = ['recorder', 'frontend'] + +DOMAIN = 'logbook' + +GROUP_BY_MINUTES = 15 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + CONF_EXCLUDE: vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + CONF_INCLUDE: vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }) + }), +}, extra=vol.ALLOW_EXTRA) + +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + EVENT_ALEXA_SMART_HOME, EVENT_HOMEKIT_CHANGED, + EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED +] + +LOG_MESSAGE_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_MESSAGE): cv.template, + vol.Optional(ATTR_DOMAIN): cv.slug, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, +}) + + +@bind_hass +def log_entry(hass, name, message, domain=None, entity_id=None): + """Add an entry to the logbook.""" + hass.add_job(async_log_entry, hass, name, message, domain, entity_id) + + +@bind_hass +def async_log_entry(hass, name, message, domain=None, entity_id=None): + """Add an entry to the logbook.""" + data = { + ATTR_NAME: name, + ATTR_MESSAGE: message + } + + if domain is not None: + data[ATTR_DOMAIN] = domain + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) + + +async def async_setup(hass, config): + """Listen for download events to download files.""" + @callback + def log_message(service): + """Handle sending notification message service calls.""" + message = service.data[ATTR_MESSAGE] + name = service.data[ATTR_NAME] + domain = service.data.get(ATTR_DOMAIN) + entity_id = service.data.get(ATTR_ENTITY_ID) + + message.hass = hass + message = message.async_render() + async_log_entry(hass, name, message, domain, entity_id) + + hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) + + await hass.components.frontend.async_register_built_in_panel( + 'logbook', 'logbook', 'hass:format-list-bulleted-type') + + hass.services.async_register( + DOMAIN, 'log', log_message, schema=LOG_MESSAGE_SCHEMA) + return True + + +class LogbookView(HomeAssistantView): + """Handle logbook view requests.""" + + url = '/api/logbook' + name = 'api:logbook' + extra_urls = ['/api/logbook/{datetime}'] + + def __init__(self, config): + """Initialize the logbook view.""" + self.config = config + + async def get(self, request, datetime=None): + """Retrieve logbook entries.""" + if datetime: + datetime = dt_util.parse_datetime(datetime) + + if datetime is None: + return self.json_message('Invalid datetime', HTTP_BAD_REQUEST) + else: + datetime = dt_util.start_of_local_day() + + period = request.query.get('period') + if period is None: + period = 1 + else: + period = int(period) + + entity_id = request.query.get('entity') + start_day = dt_util.as_utc(datetime) - timedelta(days=period - 1) + end_day = start_day + timedelta(days=period) + hass = request.app['hass'] + + def json_events(): + """Fetch events and generate JSON.""" + return self.json(list( + _get_events(hass, self.config, start_day, end_day, entity_id))) + + return await hass.async_add_job(json_events) + + +def humanify(hass, events): + """Generate a converted list of events into Entry objects. + + Will try to group events if possible: + - if 2+ sensor updates in GROUP_BY_MINUTES, show last + - if home assistant stop and start happen in same minute call it restarted + """ + domain_prefixes = tuple('{}.'.format(dom) for dom in CONTINUOUS_DOMAINS) + + # Group events in batches of GROUP_BY_MINUTES + for _, g_events in groupby( + events, + lambda event: event.time_fired.minute // GROUP_BY_MINUTES): + + events_batch = list(g_events) + + # Keep track of last sensor states + last_sensor_event = {} + + # Group HA start/stop events + # Maps minute of event to 1: stop, 2: stop + start + start_stop_events = {} + + # Process events + for event in events_batch: + if event.event_type == EVENT_STATE_CHANGED: + entity_id = event.data.get('entity_id') + + if entity_id.startswith(domain_prefixes): + last_sensor_event[entity_id] = event + + elif event.event_type == EVENT_HOMEASSISTANT_STOP: + if event.time_fired.minute in start_stop_events: + continue + + start_stop_events[event.time_fired.minute] = 1 + + elif event.event_type == EVENT_HOMEASSISTANT_START: + if event.time_fired.minute not in start_stop_events: + continue + + start_stop_events[event.time_fired.minute] = 2 + + # Yield entries + for event in events_batch: + if event.event_type == EVENT_STATE_CHANGED: + + to_state = State.from_dict(event.data.get('new_state')) + + domain = to_state.domain + + # Skip all but the last sensor state + if domain in CONTINUOUS_DOMAINS and \ + event != last_sensor_event[to_state.entity_id]: + continue + + # Don't show continuous sensor value changes in the logbook + if domain in CONTINUOUS_DOMAINS and \ + to_state.attributes.get('unit_of_measurement'): + continue + + yield { + 'when': event.time_fired, + 'name': to_state.name, + 'message': _entry_message_from_state(domain, to_state), + 'domain': domain, + 'entity_id': to_state.entity_id, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_HOMEASSISTANT_START: + if start_stop_events.get(event.time_fired.minute) == 2: + continue + + yield { + 'when': event.time_fired, + 'name': "Home Assistant", + 'message': "started", + 'domain': HA_DOMAIN, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_HOMEASSISTANT_STOP: + if start_stop_events.get(event.time_fired.minute) == 2: + action = "restarted" + else: + action = "stopped" + + yield { + 'when': event.time_fired, + 'name': "Home Assistant", + 'message': action, + 'domain': HA_DOMAIN, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_LOGBOOK_ENTRY: + domain = event.data.get(ATTR_DOMAIN) + entity_id = event.data.get(ATTR_ENTITY_ID) + if domain is None and entity_id is not None: + try: + domain = split_entity_id(str(entity_id))[0] + except IndexError: + pass + + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': event.data.get(ATTR_MESSAGE), + 'domain': domain, + 'entity_id': entity_id, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_ALEXA_SMART_HOME: + data = event.data + entity_id = data['request'].get('entity_id') + + if entity_id: + state = hass.states.get(entity_id) + name = state.name if state else entity_id + message = "send command {}/{} for {}".format( + data['request']['namespace'], + data['request']['name'], name) + else: + message = "send command {}/{}".format( + data['request']['namespace'], data['request']['name']) + + yield { + 'when': event.time_fired, + 'name': 'Amazon Alexa', + 'message': message, + 'domain': 'alexa', + 'entity_id': entity_id, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_HOMEKIT_CHANGED: + data = event.data + entity_id = data.get(ATTR_ENTITY_ID) + value = data.get(ATTR_VALUE) + + value_msg = " to {}".format(value) if value else '' + message = "send command {}{} for {}".format( + data[ATTR_SERVICE], value_msg, data[ATTR_DISPLAY_NAME]) + + yield { + 'when': event.time_fired, + 'name': 'HomeKit', + 'message': message, + 'domain': DOMAIN_HOMEKIT, + 'entity_id': entity_id, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_AUTOMATION_TRIGGERED: + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': "has been triggered", + 'domain': 'automation', + 'entity_id': event.data.get(ATTR_ENTITY_ID), + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_SCRIPT_STARTED: + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': 'started', + 'domain': 'script', + 'entity_id': event.data.get(ATTR_ENTITY_ID), + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + +def _get_related_entity_ids(session, entity_filter): + from homeassistant.components.recorder.models import States + from homeassistant.components.recorder.util import \ + RETRIES, QUERY_RETRY_WAIT + from sqlalchemy.exc import SQLAlchemyError + import time + + timer_start = time.perf_counter() + + query = session.query(States).with_entities(States.entity_id).distinct() + + for tryno in range(0, RETRIES): + try: + result = [ + row.entity_id for row in query + if entity_filter(row.entity_id)] + + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug( + 'fetching %d distinct domain/entity_id pairs took %fs', + len(result), + elapsed) + + return result + except SQLAlchemyError as err: + _LOGGER.error("Error executing query: %s", err) + + if tryno == RETRIES - 1: + raise + else: + time.sleep(QUERY_RETRY_WAIT) + + +def _generate_filter_from_config(config): + from homeassistant.helpers.entityfilter import generate_filter + + excluded_entities = [] + excluded_domains = [] + included_entities = [] + included_domains = [] + + exclude = config.get(CONF_EXCLUDE) + if exclude: + excluded_entities = exclude.get(CONF_ENTITIES, []) + excluded_domains = exclude.get(CONF_DOMAINS, []) + include = config.get(CONF_INCLUDE) + if include: + included_entities = include.get(CONF_ENTITIES, []) + included_domains = include.get(CONF_DOMAINS, []) + + return generate_filter(included_domains, included_entities, + excluded_domains, excluded_entities) + + +def _get_events(hass, config, start_day, end_day, entity_id=None): + """Get events for a period of time.""" + from homeassistant.components.recorder.models import Events, States + from homeassistant.components.recorder.util import ( + execute, session_scope) + + entities_filter = _generate_filter_from_config(config) + + with session_scope(hass=hass) as session: + if entity_id is not None: + entity_ids = [entity_id.lower()] + else: + entity_ids = _get_related_entity_ids(session, entities_filter) + + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter(((States.last_updated == States.last_changed) & + States.entity_id.in_(entity_ids)) + | (States.state_id.is_(None))) + + events = execute(query) + + return humanify(hass, _exclude_events(events, entities_filter)) + + +def _exclude_events(events, entities_filter): + filtered_events = [] + for event in events: + domain, entity_id = None, None + + if event.event_type == EVENT_STATE_CHANGED: + entity_id = event.data.get('entity_id') + + if entity_id is None: + continue + + # Do not report on new entities + if event.data.get('old_state') is None: + continue + + new_state = event.data.get('new_state') + + # Do not report on entity removal + if not new_state: + continue + + attributes = new_state.get('attributes', {}) + + # If last_changed != last_updated only attributes have changed + # we do not report on that yet. + last_changed = new_state.get('last_changed') + last_updated = new_state.get('last_updated') + if last_changed != last_updated: + continue + + domain = split_entity_id(entity_id)[0] + + # Also filter auto groups. + if domain == 'group' and attributes.get('auto', False): + continue + + # exclude entities which are customized hidden + hidden = attributes.get(ATTR_HIDDEN, False) + if hidden: + continue + + elif event.event_type == EVENT_LOGBOOK_ENTRY: + domain = event.data.get(ATTR_DOMAIN) + entity_id = event.data.get(ATTR_ENTITY_ID) + + elif event.event_type == EVENT_AUTOMATION_TRIGGERED: + domain = 'automation' + entity_id = event.data.get(ATTR_ENTITY_ID) + + elif event.event_type == EVENT_SCRIPT_STARTED: + domain = 'script' + entity_id = event.data.get(ATTR_ENTITY_ID) + + elif event.event_type == EVENT_ALEXA_SMART_HOME: + domain = 'alexa' + + elif event.event_type == EVENT_HOMEKIT_CHANGED: + domain = DOMAIN_HOMEKIT + + if not entity_id and domain: + entity_id = "%s." % (domain, ) + + if not entity_id or entities_filter(entity_id): + filtered_events.append(event) + + return filtered_events + + +def _entry_message_from_state(domain, state): + """Convert a state to a message for the logbook.""" + # We pass domain in so we don't have to split entity_id again + if domain == 'device_tracker': + if state.state == STATE_NOT_HOME: + return 'is away' + return 'is at {}'.format(state.state) + + if domain == 'sun': + if state.state == sun.STATE_ABOVE_HORIZON: + return 'has risen' + return 'has set' + + if state.state == STATE_ON: + # Future: combine groups and its entity entries ? + return "turned on" + + if state.state == STATE_OFF: + return "turned off" + + return "changed to {}".format(state.state) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/logentries.py b/homeassistant-0.88.0.dev0/homeassistant/components/logentries.py new file mode 100644 index 00000000000000..6dc76d8d932d81 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/logentries.py @@ -0,0 +1,65 @@ +""" +Support for sending data to Logentries webhook endpoint. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/logentries/ +""" +import json +import logging +import requests + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_TOKEN, EVENT_STATE_CHANGED) +from homeassistant.helpers import state as state_helper + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'logentries' + +DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_TOKEN): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Logentries component.""" + conf = config[DOMAIN] + token = conf.get(CONF_TOKEN) + le_wh = '{}{}'.format(DEFAULT_HOST, token) + + def logentries_event_listener(event): + """Listen for new messages on the bus and sends them to Logentries.""" + state = event.data.get('new_state') + if state is None: + return + try: + _state = state_helper.state_as_number(state) + except ValueError: + _state = state.state + json_body = [ + { + 'domain': state.domain, + 'entity_id': state.object_id, + 'attributes': dict(state.attributes), + 'time': str(event.time_fired), + 'value': _state, + } + ] + try: + payload = { + "host": le_wh, + "event": json_body + } + requests.post(le_wh, data=json.dumps(payload), timeout=10) + except requests.exceptions.RequestException as error: + _LOGGER.exception("Error sending to Logentries: %s", error) + + hass.bus.listen(EVENT_STATE_CHANGED, logentries_event_listener) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/logger.py b/homeassistant-0.88.0.dev0/homeassistant/components/logger.py new file mode 100644 index 00000000000000..21ae7595ab8f59 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/logger.py @@ -0,0 +1,133 @@ +""" +Component that will help set the level of logging for components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/logger/ +""" +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'logger' + +DATA_LOGGER = 'logger' + +SERVICE_SET_DEFAULT_LEVEL = 'set_default_level' +SERVICE_SET_LEVEL = 'set_level' + +LOGSEVERITY = { + 'CRITICAL': 50, + 'FATAL': 50, + 'ERROR': 40, + 'WARNING': 30, + 'WARN': 30, + 'INFO': 20, + 'DEBUG': 10, + 'NOTSET': 0 +} + +LOGGER_DEFAULT = 'default' +LOGGER_LOGS = 'logs' + +ATTR_LEVEL = 'level' + +_VALID_LOG_LEVEL = vol.All(vol.Upper, vol.In(LOGSEVERITY)) + +SERVICE_SET_DEFAULT_LEVEL_SCHEMA = vol.Schema({ATTR_LEVEL: _VALID_LOG_LEVEL}) +SERVICE_SET_LEVEL_SCHEMA = vol.Schema({cv.string: _VALID_LOG_LEVEL}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(LOGGER_DEFAULT): _VALID_LOG_LEVEL, + vol.Optional(LOGGER_LOGS): vol.Schema({cv.string: _VALID_LOG_LEVEL}), + }), +}, extra=vol.ALLOW_EXTRA) + + +class HomeAssistantLogFilter(logging.Filter): + """A log filter.""" + + def __init__(self, logfilter): + """Initialize the filter.""" + super().__init__() + + self.logfilter = logfilter + + def filter(self, record): + """Filter the log entries.""" + # Log with filtered severity + if LOGGER_LOGS in self.logfilter: + for filtername in self.logfilter[LOGGER_LOGS]: + logseverity = self.logfilter[LOGGER_LOGS][filtername] + if record.name.startswith(filtername): + return record.levelno >= logseverity + + # Log with default severity + default = self.logfilter[LOGGER_DEFAULT] + return record.levelno >= default + + +async def async_setup(hass, config): + """Set up the logger component.""" + logfilter = {} + + def set_default_log_level(level): + """Set the default log level for components.""" + logfilter[LOGGER_DEFAULT] = LOGSEVERITY[level] + + def set_log_levels(logpoints): + """Set the specified log levels.""" + logs = {} + + # Preserve existing logs + if LOGGER_LOGS in logfilter: + logs.update(logfilter[LOGGER_LOGS]) + + # Add new logpoints mapped to correct severity + for key, value in logpoints.items(): + logs[key] = LOGSEVERITY[value] + + logfilter[LOGGER_LOGS] = OrderedDict( + sorted( + logs.items(), + key=lambda t: len(t[0]), + reverse=True + ) + ) + + # Set default log severity + if LOGGER_DEFAULT in config.get(DOMAIN): + set_default_log_level(config.get(DOMAIN)[LOGGER_DEFAULT]) + else: + set_default_log_level('DEBUG') + + logger = logging.getLogger('') + logger.setLevel(logging.NOTSET) + + # Set log filter for all log handler + for handler in logging.root.handlers: + handler.setLevel(logging.NOTSET) + handler.addFilter(HomeAssistantLogFilter(logfilter)) + + if LOGGER_LOGS in config.get(DOMAIN): + set_log_levels(config.get(DOMAIN)[LOGGER_LOGS]) + + async def async_service_handler(service): + """Handle logger services.""" + if service.service == SERVICE_SET_DEFAULT_LEVEL: + set_default_log_level(service.data.get(ATTR_LEVEL)) + else: + set_log_levels(service.data) + + hass.services.async_register( + DOMAIN, SERVICE_SET_DEFAULT_LEVEL, async_service_handler, + schema=SERVICE_SET_DEFAULT_LEVEL_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_SET_LEVEL, async_service_handler, + schema=SERVICE_SET_LEVEL_SCHEMA) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/map.py b/homeassistant-0.88.0.dev0/homeassistant/components/map.py new file mode 100644 index 00000000000000..d30a756845295e --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/map.py @@ -0,0 +1,14 @@ +""" +Provides a map panel for showing device locations. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/map/ +""" +DOMAIN = 'map' + + +async def async_setup(hass, config): + """Register the built-in map panel.""" + await hass.components.frontend.async_register_built_in_panel( + 'map', 'map', 'hass:tooltip-account') + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/media_extractor.py b/homeassistant-0.88.0.dev0/homeassistant/components/media_extractor.py new file mode 100644 index 00000000000000..333f62a9aa7d73 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/media_extractor.py @@ -0,0 +1,169 @@ +""" +Decorator service for the media_player.play_media service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/media_extractor/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + ATTR_ENTITY_ID, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MEDIA_PLAYER_DOMAIN, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, + SERVICE_PLAY_MEDIA) +from homeassistant.helpers import config_validation as cv + +REQUIREMENTS = ['youtube_dl==2019.01.24'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CUSTOMIZE_ENTITIES = 'customize' +CONF_DEFAULT_STREAM_QUERY = 'default_query' + +DEFAULT_STREAM_QUERY = 'best' +DEPENDENCIES = ['media_player'] +DOMAIN = 'media_extractor' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEFAULT_STREAM_QUERY): cv.string, + vol.Optional(CONF_CUSTOMIZE_ENTITIES): + vol.Schema({cv.entity_id: vol.Schema({cv.string: cv.string})}), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the media extractor service.""" + def play_media(call): + """Get stream URL and send it to the play_media service.""" + MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() + + hass.services.register(DOMAIN, SERVICE_PLAY_MEDIA, play_media, + schema=MEDIA_PLAYER_PLAY_MEDIA_SCHEMA) + + return True + + +class MEDownloadException(Exception): + """Media extractor download exception.""" + + pass + + +class MEQueryException(Exception): + """Media extractor query exception.""" + + pass + + +class MediaExtractor: + """Class which encapsulates all extraction logic.""" + + def __init__(self, hass, component_config, call_data): + """Initialize media extractor.""" + self.hass = hass + self.config = component_config + self.call_data = call_data + + def get_media_url(self): + """Return media content url.""" + return self.call_data.get(ATTR_MEDIA_CONTENT_ID) + + def get_entities(self): + """Return list of entities.""" + return self.call_data.get(ATTR_ENTITY_ID, []) + + def extract_and_send(self): + """Extract exact stream format for each entity_id and play it.""" + try: + stream_selector = self.get_stream_selector() + except MEDownloadException: + _LOGGER.error("Could not retrieve data for the URL: %s", + self.get_media_url()) + else: + entities = self.get_entities() + + if not entities: + self.call_media_player_service(stream_selector, None) + + for entity_id in entities: + self.call_media_player_service(stream_selector, entity_id) + + def get_stream_selector(self): + """Return format selector for the media URL.""" + from youtube_dl import YoutubeDL + from youtube_dl.utils import DownloadError, ExtractorError + + ydl = YoutubeDL({'quiet': True, 'logger': _LOGGER}) + + try: + all_media = ydl.extract_info(self.get_media_url(), process=False) + except DownloadError: + # This exception will be logged by youtube-dl itself + raise MEDownloadException() + + if 'entries' in all_media: + _LOGGER.warning( + "Playlists are not supported, looking for the first video") + entries = list(all_media['entries']) + if entries: + selected_media = entries[0] + else: + _LOGGER.error("Playlist is empty") + raise MEDownloadException() + else: + selected_media = all_media + + def stream_selector(query): + """Find stream URL that matches query.""" + try: + ydl.params['format'] = query + requested_stream = ydl.process_ie_result( + selected_media, download=False) + except (ExtractorError, DownloadError): + _LOGGER.error( + "Could not extract stream for the query: %s", query) + raise MEQueryException() + + return requested_stream['url'] + + return stream_selector + + def call_media_player_service(self, stream_selector, entity_id): + """Call Media player play_media service.""" + stream_query = self.get_stream_query_for_entity(entity_id) + + try: + stream_url = stream_selector(stream_query) + except MEQueryException: + _LOGGER.error("Wrong query format: %s", stream_query) + return + else: + data = {k: v for k, v in self.call_data.items() + if k != ATTR_ENTITY_ID} + data[ATTR_MEDIA_CONTENT_ID] = stream_url + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + self.hass.async_create_task( + self.hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data) + ) + + def get_stream_query_for_entity(self, entity_id): + """Get stream format query for entity.""" + default_stream_query = self.config.get( + CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY) + + if entity_id: + media_content_type = self.call_data.get(ATTR_MEDIA_CONTENT_TYPE) + + return self.config \ + .get(CONF_CUSTOMIZE_ENTITIES, {}) \ + .get(entity_id, {}) \ + .get(media_content_type, default_stream_query) + + return default_stream_query diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/melissa.py b/homeassistant-0.88.0.dev0/homeassistant/components/melissa.py new file mode 100644 index 00000000000000..638d8c55bd5650 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/melissa.py @@ -0,0 +1,44 @@ +""" +Support for Melissa climate. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/melissa/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ["py-melissa-climate==2.0.0"] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "melissa" +DATA_MELISSA = 'MELISSA' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Melissa Climate component.""" + import melissa + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + api = melissa.AsyncMelissa(username=username, password=password) + await api.async_connect() + hass.data[DATA_MELISSA] = api + + hass.async_create_task( + async_load_platform(hass, 'climate', DOMAIN, {}, config)) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/microsoft_face.py b/homeassistant-0.88.0.dev0/homeassistant/components/microsoft_face.py new file mode 100644 index 00000000000000..9be2f8eadf590f --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/microsoft_face.py @@ -0,0 +1,326 @@ +""" +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 aiohttp +from aiohttp.hdrs import CONTENT_TYPE +import async_timeout +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT, ATTR_NAME +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.util import slugify + +_LOGGER = logging.getLogger(__name__) + +ATTR_CAMERA_ENTITY = 'camera_entity' +ATTR_GROUP = 'group' +ATTR_PERSON = 'person' + +CONF_AZURE_REGION = 'azure_region' + +DATA_MICROSOFT_FACE = 'microsoft_face' +DEFAULT_TIMEOUT = 10 +DEPENDENCIES = ['camera'] +DOMAIN = 'microsoft_face' + +FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" + +SERVICE_CREATE_GROUP = 'create_group' +SERVICE_CREATE_PERSON = 'create_person' +SERVICE_DELETE_GROUP = 'delete_group' +SERVICE_DELETE_PERSON = 'delete_person' +SERVICE_FACE_PERSON = 'face_person' +SERVICE_TRAIN_GROUP = 'train_group' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_AZURE_REGION, default="westus"): 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, +}) + + +async def async_setup(hass, config): + """Set up Microsoft Face.""" + entities = {} + face = MicrosoftFace( + hass, + config[DOMAIN].get(CONF_AZURE_REGION), + config[DOMAIN].get(CONF_API_KEY), + config[DOMAIN].get(CONF_TIMEOUT), + entities + ) + + try: + # read exists group/person from cloud and create entities + await 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 + + async def async_create_group(service): + """Create a new person group.""" + name = service.data[ATTR_NAME] + g_id = slugify(name) + + try: + await face.call_api( + 'put', "persongroups/{0}".format(g_id), {'name': name}) + face.store[g_id] = {} + + entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) + await 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, + schema=SCHEMA_GROUP_SERVICE) + + async def async_delete_group(service): + """Delete a person group.""" + g_id = slugify(service.data[ATTR_NAME]) + + try: + await face.call_api('delete', "persongroups/{0}".format(g_id)) + face.store.pop(g_id) + + entity = entities.pop(g_id) + hass.states.async_remove(entity.entity_id) + 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, + schema=SCHEMA_GROUP_SERVICE) + + async def async_train_group(service): + """Train a person group.""" + g_id = service.data[ATTR_GROUP] + + try: + await 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, + schema=SCHEMA_TRAIN_SERVICE) + + async 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 = await face.call_api( + 'post', "persongroups/{0}/persons".format(g_id), {'name': name} + ) + + face.store[g_id][name] = user_data['personId'] + await 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, + schema=SCHEMA_PERSON_SERVICE) + + async 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: + await face.call_api( + 'delete', "persongroups/{0}/persons/{1}".format(g_id, p_id)) + + face.store[g_id].pop(name) + await 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, + schema=SCHEMA_PERSON_SERVICE) + + async 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 = hass.components.camera + + try: + image = await camera.async_get_image(hass, camera_entity) + + await face.call_api( + 'post', + "persongroups/{0}/persons/{1}/persistedFaces".format( + g_id, p_id), + image.content, + 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, + 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: + """Microsoft Face api for HomeAssistant.""" + + def __init__(self, hass, server_loc, 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._server_url = "https://{0}.{1}".format(server_loc, FACE_API_URL) + self._store = {} + self._entities = entities + + @property + def store(self): + """Store group/person data and IDs.""" + return self._store + + async def update_store(self): + """Load all group/person data into local store.""" + groups = await 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 = await 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: + await asyncio.wait(tasks, loop=self.hass.loop) + + async def call_api(self, method, function, data=None, binary=False, + params=None): + """Make an api call.""" + headers = {"Ocp-Apim-Subscription-Key": self._api_key} + url = self._server_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 + + try: + with async_timeout.timeout(self.timeout, loop=self.hass.loop): + response = await getattr(self.websession, method)( + url, data=payload, headers=headers, params=params) + + answer = await response.json() + + _LOGGER.debug("Read from microsoft face api: %s", answer) + if response.status < 300: + return answer + + _LOGGER.warning("Error %d microsoft face api %s", + response.status, response.url) + raise HomeAssistantError(answer['error']['message']) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to microsoft face api") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from microsoft face api %s", response.url) + + raise HomeAssistantError("Network error on microsoft face api.") diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/mqtt_eventstream.py b/homeassistant-0.88.0.dev0/homeassistant/components/mqtt_eventstream.py new file mode 100644 index 00000000000000..2cde7825734f72 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/mqtt_eventstream.py @@ -0,0 +1,109 @@ +""" +Connect two Home Assistant instances via MQTT. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt_eventstream/ +""" +import asyncio +import json + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.mqtt import ( + valid_publish_topic, valid_subscribe_topic) +from homeassistant.const import ( + ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) +from homeassistant.core import EventOrigin, State +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import JSONEncoder + +DOMAIN = 'mqtt_eventstream' +DEPENDENCIES = ['mqtt'] + +CONF_PUBLISH_TOPIC = 'publish_topic' +CONF_SUBSCRIBE_TOPIC = 'subscribe_topic' +CONF_PUBLISH_EVENTSTREAM_RECEIVED = 'publish_eventstream_received' +CONF_IGNORE_EVENT = 'ignore_event' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_PUBLISH_TOPIC): valid_publish_topic, + vol.Optional(CONF_SUBSCRIBE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PUBLISH_EVENTSTREAM_RECEIVED, default=False): + cv.boolean, + vol.Optional(CONF_IGNORE_EVENT, default=[]): cv.ensure_list + }), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the MQTT eventstream component.""" + mqtt = hass.components.mqtt + conf = config.get(DOMAIN, {}) + pub_topic = conf.get(CONF_PUBLISH_TOPIC) + sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) + ignore_event = conf.get(CONF_IGNORE_EVENT) + + @callback + def _event_publisher(event): + """Handle events by publishing them on the MQTT queue.""" + if event.origin != EventOrigin.local: + return + if event.event_type == EVENT_TIME_CHANGED: + return + + # User-defined events to ignore + if event.event_type in ignore_event: + return + + # Filter out the events that were triggered by publishing + # to the MQTT topic, or you will end up in an infinite loop. + if event.event_type == EVENT_CALL_SERVICE: + if ( + event.data.get('domain') == mqtt.DOMAIN and + event.data.get('service') == mqtt.SERVICE_PUBLISH and + event.data[ATTR_SERVICE_DATA].get('topic') == pub_topic + ): + return + + event_info = {'event_type': event.event_type, 'event_data': event.data} + msg = json.dumps(event_info, cls=JSONEncoder) + mqtt.async_publish(pub_topic, msg) + + # Only listen for local events if you are going to publish them. + if pub_topic: + hass.bus.async_listen(MATCH_ALL, _event_publisher) + + # Process events from a remote server that are received on a queue. + @callback + def _event_receiver(topic, payload, qos): + """Receive events published by and fire them on this hass instance.""" + event = json.loads(payload) + event_type = event.get('event_type') + event_data = event.get('event_data') + + # Special case handling for event STATE_CHANGED + # We will try to convert state dicts back to State objects + # Copied over from the _handle_api_post_events_event method + # of the api component. + if event_type == EVENT_STATE_CHANGED and event_data: + for key in ('old_state', 'new_state'): + state = State.from_dict(event_data.get(key)) + + if state: + event_data[key] = state + + hass.bus.async_fire( + event_type, + event_data=event_data, + origin=EventOrigin.remote + ) + + # Only subscribe if you specified a topic. + if sub_topic: + yield from mqtt.async_subscribe(sub_topic, _event_receiver) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/mqtt_statestream.py b/homeassistant-0.88.0.dev0/homeassistant/components/mqtt_statestream.py new file mode 100644 index 00000000000000..3a0e5d39ff0d22 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/mqtt_statestream.py @@ -0,0 +1,94 @@ +""" +Publish simple item state changes via MQTT. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt_statestream/ +""" +import json + +import voluptuous as vol + +from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, MATCH_ALL) +from homeassistant.core import callback +from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.entityfilter import generate_filter +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.json import JSONEncoder +import homeassistant.helpers.config_validation as cv + +CONF_BASE_TOPIC = 'base_topic' +CONF_PUBLISH_ATTRIBUTES = 'publish_attributes' +CONF_PUBLISH_TIMESTAMPS = 'publish_timestamps' +DEPENDENCIES = ['mqtt'] +DOMAIN = 'mqtt_statestream' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Required(CONF_BASE_TOPIC): valid_publish_topic, + vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean, + vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the MQTT state feed.""" + conf = config.get(DOMAIN, {}) + base_topic = conf.get(CONF_BASE_TOPIC) + pub_include = conf.get(CONF_INCLUDE, {}) + pub_exclude = conf.get(CONF_EXCLUDE, {}) + publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) + publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + publish_filter = generate_filter(pub_include.get(CONF_DOMAINS, []), + pub_include.get(CONF_ENTITIES, []), + pub_exclude.get(CONF_DOMAINS, []), + pub_exclude.get(CONF_ENTITIES, [])) + if not base_topic.endswith('/'): + base_topic = base_topic + '/' + + @callback + def _state_publisher(entity_id, old_state, new_state): + if new_state is None: + return + + if not publish_filter(entity_id): + return + + payload = new_state.state + + mybase = base_topic + entity_id.replace('.', '/') + '/' + hass.components.mqtt.async_publish(mybase + 'state', payload, 1, True) + + if publish_timestamps: + if new_state.last_updated: + hass.components.mqtt.async_publish( + mybase + 'last_updated', + new_state.last_updated.isoformat(), + 1, + True) + if new_state.last_changed: + hass.components.mqtt.async_publish( + mybase + 'last_changed', + new_state.last_changed.isoformat(), + 1, + True) + + if publish_attributes: + for key, val in new_state.attributes.items(): + encoded_val = json.dumps(val, cls=JSONEncoder) + hass.components.mqtt.async_publish(mybase + key, + encoded_val, 1, True) + + async_track_state_change(hass, MATCH_ALL, _state_publisher) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/mycroft.py b/homeassistant-0.88.0.dev0/homeassistant/components/mycroft.py new file mode 100644 index 00000000000000..834572bc551f1a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/mycroft.py @@ -0,0 +1,35 @@ +""" +Support for Mycroft AI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mycroft +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['mycroftapi==2.0'] + +_LOGGER = logging.getLogger(__name__) + + +DOMAIN = 'mycroft' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Mycroft component.""" + hass.data[DOMAIN] = config[DOMAIN][CONF_HOST] + discovery.load_platform(hass, 'notify', DOMAIN, {}, config) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/mythicbeastsdns.py b/homeassistant-0.88.0.dev0/homeassistant/components/mythicbeastsdns.py new file mode 100644 index 00000000000000..d73e4619c789ec --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/mythicbeastsdns.py @@ -0,0 +1,58 @@ +""" +Integrate with Mythic Beasts Dynamic DNS service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mythicbeastsdns/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_HOST, CONF_DOMAIN, CONF_PASSWORD, \ + CONF_UPDATE_INTERVAL +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['mbddns==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'mythicbeastsdns' + +DEFAULT_INTERVAL = timedelta(minutes=10) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta), + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Initialize the Mythic Beasts component.""" + import mbddns + + domain = config[DOMAIN][CONF_DOMAIN] + password = config[DOMAIN][CONF_PASSWORD] + host = config[DOMAIN][CONF_HOST] + update_interval = config[DOMAIN][CONF_UPDATE_INTERVAL] + + session = async_get_clientsession(hass) + + result = await mbddns.update(domain, password, host, session=session) + + if not result: + return False + + async def update_domain_interval(now): + """Update the DNS entry.""" + await mbddns.update(domain, password, host, session=session) + + async_track_time_interval(hass, update_domain_interval, update_interval) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/namecheapdns.py b/homeassistant-0.88.0.dev0/homeassistant/components/namecheapdns.py new file mode 100644 index 00000000000000..f817544ca77be9 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/namecheapdns.py @@ -0,0 +1,77 @@ +""" +Integrate with namecheap DNS services. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/namecheapdns/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_DOMAIN +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['defusedxml==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'namecheapdns' + +INTERVAL = timedelta(minutes=5) + +UPDATE_URL = 'https://dynamicdns.park-your-domain.com/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default='@'): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Initialize the namecheap DNS component.""" + host = config[DOMAIN][CONF_HOST] + domain = config[DOMAIN][CONF_DOMAIN] + password = config[DOMAIN][CONF_PASSWORD] + + session = async_get_clientsession(hass) + + result = await _update_namecheapdns(session, host, domain, password) + + if not result: + return False + + async def update_domain_interval(now): + """Update the namecheap DNS entry.""" + await _update_namecheapdns(session, host, domain, password) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + + return result + + +async def _update_namecheapdns(session, host, domain, password): + """Update namecheap DNS entry.""" + import defusedxml.ElementTree as ET + + params = { + 'host': host, + 'domain': domain, + 'password': password, + } + + resp = await session.get(UPDATE_URL, params=params) + xml_string = await resp.text() + root = ET.fromstring(xml_string) + err_count = root.find('ErrCount').text + + if int(err_count) != 0: + _LOGGER.warning("Updating namecheap domain failed: %s", domain) + return False + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ness_alarm.py b/homeassistant-0.88.0.dev0/homeassistant/components/ness_alarm.py new file mode 100644 index 00000000000000..e97ee903abccf8 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ness_alarm.py @@ -0,0 +1,121 @@ +""" +Support for Ness D8X/D16X devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ness_alarm/ +""" +import logging +from collections import namedtuple + +import voluptuous as vol + +from homeassistant.components.binary_sensor import DEVICE_CLASSES +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['nessclient==0.9.9'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'ness_alarm' +DATA_NESS = 'ness_alarm' + +CONF_DEVICE_HOST = 'host' +CONF_DEVICE_PORT = 'port' +CONF_ZONES = 'zones' +CONF_ZONE_NAME = 'name' +CONF_ZONE_TYPE = 'type' +CONF_ZONE_ID = 'id' +ATTR_CODE = 'code' +ATTR_OUTPUT_ID = 'output_id' +ATTR_STATE = 'state' +DEFAULT_ZONES = [] + +SIGNAL_ZONE_CHANGED = 'ness_alarm.zone_changed' +SIGNAL_ARMING_STATE_CHANGED = 'ness_alarm.arming_state_changed' + +ZoneChangedData = namedtuple('ZoneChangedData', ['zone_id', 'state']) + +DEFAULT_ZONE_TYPE = 'motion' +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_NAME): cv.string, + vol.Required(CONF_ZONE_ID): cv.positive_int, + vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): + vol.In(DEVICE_CLASSES)}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE_HOST): cv.string, + vol.Required(CONF_DEVICE_PORT): cv.port, + vol.Optional(CONF_ZONES, default=DEFAULT_ZONES): + vol.All(cv.ensure_list, [ZONE_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_PANIC = 'panic' +SERVICE_AUX = 'aux' + +SERVICE_SCHEMA_PANIC = vol.Schema({ + vol.Required(ATTR_CODE): cv.string, +}) +SERVICE_SCHEMA_AUX = vol.Schema({ + vol.Required(ATTR_OUTPUT_ID): cv.positive_int, + vol.Optional(ATTR_STATE, default=True): cv.boolean, +}) + + +async def async_setup(hass, config): + """Set up the Ness Alarm platform.""" + from nessclient import Client, ArmingState + conf = config[DOMAIN] + + zones = conf[CONF_ZONES] + host = conf[CONF_DEVICE_HOST] + port = conf[CONF_DEVICE_PORT] + + client = Client(host=host, port=port, loop=hass.loop) + hass.data[DATA_NESS] = client + + async def _close(event): + await client.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close) + + hass.async_create_task( + async_load_platform(hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, + config)) + hass.async_create_task( + async_load_platform(hass, 'alarm_control_panel', DOMAIN, {}, config)) + + def on_zone_change(zone_id: int, state: bool): + """Receives and propagates zone state updates.""" + async_dispatcher_send(hass, SIGNAL_ZONE_CHANGED, ZoneChangedData( + zone_id=zone_id, + state=state, + )) + + def on_state_change(arming_state: ArmingState): + """Receives and propagates arming state updates.""" + async_dispatcher_send(hass, SIGNAL_ARMING_STATE_CHANGED, arming_state) + + client.on_zone_change(on_zone_change) + client.on_state_change(on_state_change) + + # Force update for current arming status and current zone states + hass.loop.create_task(client.keepalive()) + hass.loop.create_task(client.update()) + + async def handle_panic(call): + await client.panic(call.data[ATTR_CODE]) + + async def handle_aux(call): + await client.aux(call.data[ATTR_OUTPUT_ID], call.data[ATTR_STATE]) + + hass.services.async_register(DOMAIN, SERVICE_PANIC, handle_panic, + schema=SERVICE_SCHEMA_PANIC) + hass.services.async_register(DOMAIN, SERVICE_AUX, handle_aux, + schema=SERVICE_SCHEMA_AUX) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/no_ip.py b/homeassistant-0.88.0.dev0/homeassistant/components/no_ip.py new file mode 100644 index 00000000000000..beb11ed738f460 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/no_ip.py @@ -0,0 +1,113 @@ +""" +Integrate with NO-IP Dynamic DNS service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/no_ip/ +""" +import asyncio +import base64 +from datetime import timedelta +import logging + +import aiohttp +from aiohttp.hdrs import USER_AGENT, AUTHORIZATION +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + CONF_DOMAIN, CONF_TIMEOUT, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'no_ip' + +# We should set a dedicated address for the user agent. +EMAIL = 'hello@home-assistant.io' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +NO_IP_ERRORS = { + 'nohost': "Hostname supplied does not exist under specified account", + 'badauth': "Invalid username password combination", + 'badagent': "Client disabled", + '!donator': + "An update request was sent with a feature that is not available", + 'abuse': "Username is blocked due to abuse", + '911': "A fatal error on NO-IP's side such as a database outage", +} + +UPDATE_URL = 'https://dynupdate.noip.com/nic/update' +HA_USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Initialize the NO-IP component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + auth_str = base64.b64encode('{}:{}'.format(user, password).encode('utf-8')) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = await _update_no_ip( + hass, session, domain, auth_str, timeout) + + if not result: + return False + + async def update_domain_interval(now): + """Update the NO-IP entry.""" + await _update_no_ip(hass, session, domain, auth_str, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +async def _update_no_ip(hass, session, domain, auth_str, timeout): + """Update NO-IP.""" + url = UPDATE_URL + + params = { + 'hostname': domain, + } + + headers = { + AUTHORIZATION: "Basic {}".format(auth_str.decode('utf-8')), + USER_AGENT: HA_USER_AGENT, + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = await session.get(url, params=params, headers=headers) + body = await resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning("Updating NO-IP failed: %s => %s", domain, + NO_IP_ERRORS[body.strip()]) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to NO-IP API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain) + + return False diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/nuheat.py b/homeassistant-0.88.0.dev0/homeassistant/components/nuheat.py new file mode 100644 index 00000000000000..fb14f119dbde59 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/nuheat.py @@ -0,0 +1,45 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/nuheat/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ["nuheat==0.3.0"] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "nuheat" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the NuHeat thermostat component.""" + import nuheat + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + devices = conf.get(CONF_DEVICES) + + api = nuheat.NuHeat(username, password) + api.authenticate() + hass.data[DOMAIN] = (api, devices) + + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/nuimo_controller.py b/homeassistant-0.88.0.dev0/homeassistant/components/nuimo_controller.py new file mode 100644 index 00000000000000..0f8fbb3907316c --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/nuimo_controller.py @@ -0,0 +1,186 @@ +""" +Component that connects to a Nuimo device over Bluetooth LE. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/nuimo_controller/ +""" +import logging +import threading +import time + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = [ + '--only-binary=all ' # avoid compilation of gattlib + 'nuimo==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'nuimo_controller' +EVENT_NUIMO = 'nuimo_input' + +DEFAULT_NAME = 'None' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MAC): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_NUIMO = 'led_matrix' +DEFAULT_INTERVAL = 2.0 + +SERVICE_NUIMO_SCHEMA = vol.Schema({ + vol.Required('matrix'): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional('interval', default=DEFAULT_INTERVAL): float +}) + +DEFAULT_ADAPTER = 'hci0' + + +def setup(hass, config): + """Set up the Nuimo component.""" + conf = config[DOMAIN] + mac = conf.get(CONF_MAC) + name = conf.get(CONF_NAME) + NuimoThread(hass, mac, name).start() + return True + + +class NuimoLogger: + """Handle Nuimo Controller event callbacks.""" + + def __init__(self, hass, name): + """Initialize Logger object.""" + self._hass = hass + self._name = name + + def received_gesture_event(self, event): + """Input Event received.""" + _LOGGER.debug("Received event: name=%s, gesture_id=%s,value=%s", + event.name, event.gesture, event.value) + self._hass.bus.fire(EVENT_NUIMO, + {'type': event.name, 'value': event.value, + 'name': self._name}) + + +class NuimoThread(threading.Thread): + """Manage one Nuimo controller.""" + + def __init__(self, hass, mac, name): + """Initialize thread object.""" + super(NuimoThread, self).__init__() + self._hass = hass + self._mac = mac + self._name = name + self._hass_is_running = True + self._nuimo = None + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + + def run(self): + """Set up the connection or be idle.""" + while self._hass_is_running: + if not self._nuimo or not self._nuimo.is_connected(): + self._attach() + self._connect() + else: + time.sleep(1) + + if self._nuimo: + self._nuimo.disconnect() + self._nuimo = None + + def stop(self, event): + """Terminate Thread by unsetting flag.""" + _LOGGER.debug('Stopping thread for Nuimo %s', self._mac) + self._hass_is_running = False + + def _attach(self): + """Create a Nuimo object from MAC address or discovery.""" + # pylint: disable=import-error + from nuimo import NuimoController, NuimoDiscoveryManager + + if self._nuimo: + self._nuimo.disconnect() + self._nuimo = None + + if self._mac: + self._nuimo = NuimoController(self._mac) + else: + nuimo_manager = NuimoDiscoveryManager( + bluetooth_adapter=DEFAULT_ADAPTER, delegate=DiscoveryLogger()) + nuimo_manager.start_discovery() + # Were any Nuimos found? + if not nuimo_manager.nuimos: + _LOGGER.debug("No Nuimo devices detected") + return + # Take the first Nuimo found. + self._nuimo = nuimo_manager.nuimos[0] + self._mac = self._nuimo.addr + + def _connect(self): + """Build up connection and set event delegator and service.""" + if not self._nuimo: + return + + try: + self._nuimo.connect() + _LOGGER.debug("Connected to %s", self._mac) + except RuntimeError as error: + _LOGGER.error("Could not connect to %s: %s", self._mac, error) + time.sleep(1) + return + + nuimo_event_delegate = NuimoLogger(self._hass, self._name) + self._nuimo.set_delegate(nuimo_event_delegate) + + def handle_write_matrix(call): + """Handle led matrix service.""" + matrix = call.data.get('matrix', None) + name = call.data.get(CONF_NAME, DEFAULT_NAME) + interval = call.data.get('interval', DEFAULT_INTERVAL) + if self._name == name and matrix: + self._nuimo.write_matrix(matrix, interval) + + self._hass.services.register( + DOMAIN, SERVICE_NUIMO, handle_write_matrix, + schema=SERVICE_NUIMO_SCHEMA) + + self._nuimo.write_matrix(HOMEASSIST_LOGO, 2.0) + + +# must be 9x9 matrix +HOMEASSIST_LOGO = ( + " . " + + " ... " + + " ..... " + + " ....... " + + "..... ..." + + " ....... " + + " .. .... " + + " .. .... " + + ".........") + + +class DiscoveryLogger: + """Handle Nuimo Discovery callbacks.""" + + # pylint: disable=no-self-use + def discovery_started(self): + """Discovery started.""" + _LOGGER.info("Started discovery") + + # pylint: disable=no-self-use + def discovery_finished(self): + """Discovery finished.""" + _LOGGER.info("Finished discovery") + + # pylint: disable=no-self-use + def controller_added(self, nuimo): + """Return that a controller was found.""" + _LOGGER.info("Added Nuimo: %s", nuimo) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/panel_custom.py b/homeassistant-0.88.0.dev0/homeassistant/components/panel_custom.py new file mode 100644 index 00000000000000..740a28a9dec4a0 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/panel_custom.py @@ -0,0 +1,170 @@ +""" +Register a custom front end panel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/panel_custom/ +""" +import logging +import os + +import voluptuous as vol + +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'panel_custom' +DEPENDENCIES = ['frontend'] + +CONF_COMPONENT_NAME = 'name' +CONF_SIDEBAR_TITLE = 'sidebar_title' +CONF_SIDEBAR_ICON = 'sidebar_icon' +CONF_URL_PATH = 'url_path' +CONF_CONFIG = 'config' +CONF_WEBCOMPONENT_PATH = 'webcomponent_path' +CONF_JS_URL = 'js_url' +CONF_MODULE_URL = 'module_url' +CONF_EMBED_IFRAME = 'embed_iframe' +CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script' +CONF_URL_EXCLUSIVE_GROUP = 'url_exclusive_group' + +MSG_URL_CONFLICT = \ + 'Pass in only one of webcomponent_path, module_url or js_url' + +DEFAULT_EMBED_IFRAME = False +DEFAULT_TRUST_EXTERNAL = False + +DEFAULT_ICON = 'mdi:bookmark' +LEGACY_URL = '/api/panel_custom/{}' + +PANEL_DIR = 'panels' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_COMPONENT_NAME): cv.string, + vol.Optional(CONF_SIDEBAR_TITLE): cv.string, + vol.Optional(CONF_SIDEBAR_ICON, default=DEFAULT_ICON): cv.icon, + vol.Optional(CONF_URL_PATH): cv.string, + vol.Optional(CONF_CONFIG): dict, + vol.Exclusive(CONF_WEBCOMPONENT_PATH, CONF_URL_EXCLUSIVE_GROUP, + msg=MSG_URL_CONFLICT): cv.string, + vol.Exclusive(CONF_JS_URL, CONF_URL_EXCLUSIVE_GROUP, + msg=MSG_URL_CONFLICT): cv.string, + vol.Exclusive(CONF_MODULE_URL, CONF_URL_EXCLUSIVE_GROUP, + msg=MSG_URL_CONFLICT): cv.string, + vol.Optional(CONF_EMBED_IFRAME, + default=DEFAULT_EMBED_IFRAME): cv.boolean, + vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT, + default=DEFAULT_TRUST_EXTERNAL): cv.boolean, + })]) +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +async def async_register_panel( + hass, + # The url to serve the panel + frontend_url_path, + # The webcomponent name that loads your panel + webcomponent_name, + # Title/icon for sidebar + sidebar_title=None, + sidebar_icon=None, + # HTML source of your panel + html_url=None, + # JS source of your panel + js_url=None, + # JS module of your panel + module_url=None, + # If your panel should be run inside an iframe + embed_iframe=DEFAULT_EMBED_IFRAME, + # Should user be asked for confirmation when loading external source + trust_external=DEFAULT_TRUST_EXTERNAL, + # Configuration to be passed to the panel + config=None): + """Register a new custom panel.""" + if js_url is None and html_url is None and module_url is None: + raise ValueError('Either js_url, module_url or html_url is required.') + elif (js_url and html_url) or (module_url and html_url): + raise ValueError('Pass in only one of JS url, Module url or HTML url.') + + if config is not None and not isinstance(config, dict): + raise ValueError('Config needs to be a dictionary.') + + custom_panel_config = { + 'name': webcomponent_name, + 'embed_iframe': embed_iframe, + 'trust_external': trust_external, + } + + if js_url is not None: + custom_panel_config['js_url'] = js_url + + if module_url is not None: + custom_panel_config['module_url'] = module_url + + if html_url is not None: + custom_panel_config['html_url'] = html_url + + if config is not None: + # Make copy because we're mutating it + config = dict(config) + else: + config = {} + + config['_panel_custom'] = custom_panel_config + + await hass.components.frontend.async_register_built_in_panel( + component_name='custom', + sidebar_title=sidebar_title, + sidebar_icon=sidebar_icon, + frontend_url_path=frontend_url_path, + config=config + ) + + +async def async_setup(hass, config): + """Initialize custom panel.""" + success = False + + for panel in config.get(DOMAIN): + name = panel[CONF_COMPONENT_NAME] + + kwargs = { + 'webcomponent_name': panel[CONF_COMPONENT_NAME], + 'frontend_url_path': panel.get(CONF_URL_PATH, name), + 'sidebar_title': panel.get(CONF_SIDEBAR_TITLE), + 'sidebar_icon': panel.get(CONF_SIDEBAR_ICON), + 'config': panel.get(CONF_CONFIG), + 'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT], + 'embed_iframe': panel[CONF_EMBED_IFRAME], + } + + panel_path = panel.get(CONF_WEBCOMPONENT_PATH) + + if panel_path is None: + panel_path = hass.config.path( + PANEL_DIR, '{}.html'.format(name)) + + if CONF_JS_URL in panel: + kwargs['js_url'] = panel[CONF_JS_URL] + + elif CONF_MODULE_URL in panel: + kwargs['module_url'] = panel[CONF_MODULE_URL] + + elif not await hass.async_add_job(os.path.isfile, panel_path): + _LOGGER.error('Unable to find webcomponent for %s: %s', + name, panel_path) + continue + + else: + url = LEGACY_URL.format(name) + hass.http.register_static_path(url, panel_path) + kwargs['html_url'] = url + + await async_register_panel(hass, **kwargs) + + success = True + + return success diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/panel_iframe.py b/homeassistant-0.88.0.dev0/homeassistant/components/panel_iframe.py new file mode 100644 index 00000000000000..030fbbf93248ff --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/panel_iframe.py @@ -0,0 +1,44 @@ +""" +Register an iFrame front end panel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/panel_iframe/ +""" +import voluptuous as vol + +from homeassistant.const import (CONF_ICON, CONF_URL) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['frontend'] + +DOMAIN = 'panel_iframe' + +CONF_TITLE = 'title' + +CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." +CONF_RELATIVE_URL_REGEX = r'\A/' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys( + vol.Schema({ + # pylint: disable=no-value-for-parameter + vol.Optional(CONF_TITLE): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Required(CONF_URL): vol.Any( + vol.Match( + CONF_RELATIVE_URL_REGEX, + msg=CONF_RELATIVE_URL_ERROR_MSG), + vol.Url()), + }) + ) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the iFrame frontend panels.""" + for url_path, info in config[DOMAIN].items(): + await hass.components.frontend.async_register_built_in_panel( + 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), + url_path, {'url': info[CONF_URL]}) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/plant.py b/homeassistant-0.88.0.dev0/homeassistant/components/plant.py new file mode 100644 index 00000000000000..ed43562d221670 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/plant.py @@ -0,0 +1,382 @@ +"""Component to monitor plants. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/plant/ +""" +import logging +from datetime import datetime, timedelta +from collections import deque +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ( + STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, TEMP_CELSIUS, ATTR_TEMPERATURE, + CONF_SENSORS, ATTR_UNIT_OF_MEASUREMENT) +from homeassistant.components import group +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change +from homeassistant.components.recorder.util import session_scope, execute + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'plant' + +READING_BATTERY = 'battery' +READING_TEMPERATURE = ATTR_TEMPERATURE +READING_MOISTURE = 'moisture' +READING_CONDUCTIVITY = 'conductivity' +READING_BRIGHTNESS = 'brightness' + +ATTR_PROBLEM = 'problem' +ATTR_SENSORS = 'sensors' +PROBLEM_NONE = 'none' +ATTR_MAX_BRIGHTNESS_HISTORY = 'max_brightness' + +# we're not returning only one value, we're returning a dict here. So we need +# to have a separate literal for it to avoid confusion. +ATTR_DICT_OF_UNITS_OF_MEASUREMENT = 'unit_of_measurement_dict' + +CONF_MIN_BATTERY_LEVEL = 'min_' + READING_BATTERY +CONF_MIN_TEMPERATURE = 'min_' + READING_TEMPERATURE +CONF_MAX_TEMPERATURE = 'max_' + READING_TEMPERATURE +CONF_MIN_MOISTURE = 'min_' + READING_MOISTURE +CONF_MAX_MOISTURE = 'max_' + READING_MOISTURE +CONF_MIN_CONDUCTIVITY = 'min_' + READING_CONDUCTIVITY +CONF_MAX_CONDUCTIVITY = 'max_' + READING_CONDUCTIVITY +CONF_MIN_BRIGHTNESS = 'min_' + READING_BRIGHTNESS +CONF_MAX_BRIGHTNESS = 'max_' + READING_BRIGHTNESS +CONF_CHECK_DAYS = 'check_days' + +CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY +CONF_SENSOR_MOISTURE = READING_MOISTURE +CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY +CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE +CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS + +DEFAULT_MIN_BATTERY_LEVEL = 20 +DEFAULT_MIN_MOISTURE = 20 +DEFAULT_MAX_MOISTURE = 60 +DEFAULT_MIN_CONDUCTIVITY = 500 +DEFAULT_MAX_CONDUCTIVITY = 3000 +DEFAULT_CHECK_DAYS = 3 + +SCHEMA_SENSORS = vol.Schema({ + vol.Optional(CONF_SENSOR_BATTERY_LEVEL): cv.entity_id, + vol.Optional(CONF_SENSOR_MOISTURE): cv.entity_id, + vol.Optional(CONF_SENSOR_CONDUCTIVITY): cv.entity_id, + vol.Optional(CONF_SENSOR_TEMPERATURE): cv.entity_id, + vol.Optional(CONF_SENSOR_BRIGHTNESS): cv.entity_id, +}) + +PLANT_SCHEMA = vol.Schema({ + vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS), + vol.Optional(CONF_MIN_BATTERY_LEVEL, + default=DEFAULT_MIN_BATTERY_LEVEL): cv.positive_int, + vol.Optional(CONF_MIN_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_MIN_MOISTURE, + default=DEFAULT_MIN_MOISTURE): cv.positive_int, + vol.Optional(CONF_MAX_MOISTURE, + default=DEFAULT_MAX_MOISTURE): cv.positive_int, + vol.Optional(CONF_MIN_CONDUCTIVITY, + default=DEFAULT_MIN_CONDUCTIVITY): cv.positive_int, + vol.Optional(CONF_MAX_CONDUCTIVITY, + default=DEFAULT_MAX_CONDUCTIVITY): cv.positive_int, + vol.Optional(CONF_MIN_BRIGHTNESS): cv.positive_int, + vol.Optional(CONF_MAX_BRIGHTNESS): cv.positive_int, + vol.Optional(CONF_CHECK_DAYS, + default=DEFAULT_CHECK_DAYS): cv.positive_int, +}) + +DOMAIN = 'plant' +DEPENDENCIES = ['zone', 'group'] + +GROUP_NAME_ALL_PLANTS = 'all plants' +ENTITY_ID_ALL_PLANTS = group.ENTITY_ID_FORMAT.format('all_plants') + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: PLANT_SCHEMA + }, +}, extra=vol.ALLOW_EXTRA) + + +# Flag for enabling/disabling the loading of the history from the database. +# This feature is turned off right now as its tests are not 100% stable. +ENABLE_LOAD_HISTORY = False + + +async def async_setup(hass, config): + """Set up the Plant component.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, + group_name=GROUP_NAME_ALL_PLANTS) + + entities = [] + for plant_name, plant_config in config[DOMAIN].items(): + _LOGGER.info("Added plant %s", plant_name) + entity = Plant(plant_name, plant_config) + entities.append(entity) + + await component.async_add_entities(entities) + return True + + +class Plant(Entity): + """Plant monitors the well-being of a plant. + + It also checks the measurements against + configurable min and max values. + """ + + READINGS = { + READING_BATTERY: { + ATTR_UNIT_OF_MEASUREMENT: '%', + 'min': CONF_MIN_BATTERY_LEVEL, + }, + READING_TEMPERATURE: { + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + 'min': CONF_MIN_TEMPERATURE, + 'max': CONF_MAX_TEMPERATURE, + }, + READING_MOISTURE: { + ATTR_UNIT_OF_MEASUREMENT: '%', + 'min': CONF_MIN_MOISTURE, + 'max': CONF_MAX_MOISTURE, + }, + READING_CONDUCTIVITY: { + ATTR_UNIT_OF_MEASUREMENT: 'µS/cm', + 'min': CONF_MIN_CONDUCTIVITY, + 'max': CONF_MAX_CONDUCTIVITY, + }, + READING_BRIGHTNESS: { + ATTR_UNIT_OF_MEASUREMENT: 'lux', + 'min': CONF_MIN_BRIGHTNESS, + 'max': CONF_MAX_BRIGHTNESS, + } + } + + def __init__(self, name, config): + """Initialize the Plant component.""" + self._config = config + self._sensormap = dict() + self._readingmap = dict() + self._unit_of_measurement = dict() + for reading, entity_id in config['sensors'].items(): + self._sensormap[entity_id] = reading + self._readingmap[reading] = entity_id + self._state = None + self._name = name + self._battery = None + self._moisture = None + self._conductivity = None + self._temperature = None + self._brightness = None + self._problems = PROBLEM_NONE + + self._conf_check_days = 3 # default check interval: 3 days + if CONF_CHECK_DAYS in self._config: + self._conf_check_days = self._config[CONF_CHECK_DAYS] + self._brightness_history = DailyHistory(self._conf_check_days) + + @callback + def state_changed(self, entity_id, _, new_state): + """Update the sensor status. + + This callback is triggered, when the sensor state changes. + """ + value = new_state.state + _LOGGER.debug("Received callback from %s with value %s", + entity_id, value) + if value == STATE_UNKNOWN: + return + + reading = self._sensormap[entity_id] + if reading == READING_MOISTURE: + self._moisture = int(float(value)) + elif reading == READING_BATTERY: + self._battery = int(float(value)) + elif reading == READING_TEMPERATURE: + self._temperature = float(value) + elif reading == READING_CONDUCTIVITY: + self._conductivity = int(float(value)) + elif reading == READING_BRIGHTNESS: + self._brightness = int(float(value)) + self._brightness_history.add_measurement(self._brightness, + new_state.last_updated) + else: + raise HomeAssistantError( + "Unknown reading from sensor {}: {}".format(entity_id, value)) + if ATTR_UNIT_OF_MEASUREMENT in new_state.attributes: + self._unit_of_measurement[reading] = \ + new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._update_state() + + def _update_state(self): + """Update the state of the class based sensor data.""" + result = [] + for sensor_name in self._sensormap.values(): + params = self.READINGS[sensor_name] + value = getattr(self, '_{}'.format(sensor_name)) + if value is not None: + if sensor_name == READING_BRIGHTNESS: + result.append(self._check_min( + sensor_name, self._brightness_history.max, params)) + else: + result.append(self._check_min(sensor_name, value, params)) + result.append(self._check_max(sensor_name, value, params)) + + result = [r for r in result if r is not None] + + if result: + self._state = STATE_PROBLEM + self._problems = ', '.join(result) + else: + self._state = STATE_OK + self._problems = PROBLEM_NONE + _LOGGER.debug("New data processed") + self.async_schedule_update_ha_state() + + def _check_min(self, sensor_name, value, params): + """If configured, check the value against the defined minimum value.""" + if 'min' in params and params['min'] in self._config: + min_value = self._config[params['min']] + if value < min_value: + return '{} low'.format(sensor_name) + + def _check_max(self, sensor_name, value, params): + """If configured, check the value against the defined maximum value.""" + if 'max' in params and params['max'] in self._config: + max_value = self._config[params['max']] + if value > max_value: + return '{} high'.format(sensor_name) + return None + + async def async_added_to_hass(self): + """After being added to hass, load from history.""" + if ENABLE_LOAD_HISTORY and 'recorder' in self.hass.config.components: + # only use the database if it's configured + self.hass.async_add_job(self._load_history_from_db) + + async_track_state_change(self.hass, list(self._sensormap), + self.state_changed) + + for entity_id in self._sensormap: + state = self.hass.states.get(entity_id) + if state is not None: + self.state_changed(entity_id, None, state) + + async def _load_history_from_db(self): + """Load the history of the brightness values from the database. + + This only needs to be done once during startup. + """ + from homeassistant.components.recorder.models import States + start_date = datetime.now() - timedelta(days=self._conf_check_days) + entity_id = self._readingmap.get(READING_BRIGHTNESS) + if entity_id is None: + _LOGGER.debug("not reading the history from the database as " + "there is no brightness sensor configured.") + return + + _LOGGER.debug("initializing values for %s from the database", + self._name) + with session_scope(hass=self.hass) as session: + query = session.query(States).filter( + (States.entity_id == entity_id.lower()) and + (States.last_updated > start_date) + ).order_by(States.last_updated.asc()) + states = execute(query) + + for state in states: + # filter out all None, NaN and "unknown" states + # only keep real values + try: + self._brightness_history.add_measurement( + int(state.state), state.last_updated) + except ValueError: + pass + _LOGGER.debug("initializing from database completed") + self.async_schedule_update_ha_state() + + @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 state(self): + """Return the state of the entity.""" + return self._state + + @property + def state_attributes(self): + """Return the attributes of the entity. + + Provide the individual measurements from the + sensor in the attributes of the device. + """ + attrib = { + ATTR_PROBLEM: self._problems, + ATTR_SENSORS: self._readingmap, + ATTR_DICT_OF_UNITS_OF_MEASUREMENT: self._unit_of_measurement, + } + + for reading in self._sensormap.values(): + attrib[reading] = getattr(self, '_{}'.format(reading)) + + if self._brightness_history.max is not None: + attrib[ATTR_MAX_BRIGHTNESS_HISTORY] = self._brightness_history.max + + return attrib + + +class DailyHistory: + """Stores one measurement per day for a maximum number of days. + + At the moment only the maximum value per day is kept. + """ + + def __init__(self, max_length): + """Create new DailyHistory with a maximum length of the history.""" + self.max_length = max_length + self._days = None + self._max_dict = dict() + self.max = None + + def add_measurement(self, value, timestamp=None): + """Add a new measurement for a certain day.""" + day = (timestamp or datetime.now()).date() + if value is None: + return + if self._days is None: + self._days = deque() + self._add_day(day, value) + else: + current_day = self._days[-1] + if day == current_day: + self._max_dict[day] = max(value, self._max_dict[day]) + elif day > current_day: + self._add_day(day, value) + else: + _LOGGER.warning('received old measurement, not storing it!') + + self.max = max(self._max_dict.values()) + + def _add_day(self, day, value): + """Add a new day to the history. + + Deletes the oldest day, if the queue becomes too long. + """ + if len(self._days) == self.max_length: + oldest = self._days.popleft() + del self._max_dict[oldest] + self._days.append(day) + self._max_dict[day] = value diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/prometheus.py b/homeassistant-0.88.0.dev0/homeassistant/components/prometheus.py new file mode 100644 index 00000000000000..dc868530f88b9b --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/prometheus.py @@ -0,0 +1,282 @@ +""" +Support for Prometheus metrics export. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/prometheus/ +""" +import logging + +import voluptuous as vol +from aiohttp import web + +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) +from homeassistant import core as hacore +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import entityfilter, state as state_helper +from homeassistant.util.temperature import fahrenheit_to_celsius + +REQUIREMENTS = ['prometheus_client==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +API_ENDPOINT = '/api/prometheus' + +DOMAIN = 'prometheus' +DEPENDENCIES = ['http'] + +CONF_FILTER = 'filter' +CONF_PROM_NAMESPACE = 'namespace' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All({ + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_PROM_NAMESPACE): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Activate Prometheus component.""" + import prometheus_client + + hass.http.register_view(PrometheusView(prometheus_client)) + + conf = config[DOMAIN] + entity_filter = conf[CONF_FILTER] + namespace = conf.get(CONF_PROM_NAMESPACE) + climate_units = hass.config.units.temperature_unit + metrics = PrometheusMetrics(prometheus_client, entity_filter, namespace, + climate_units) + + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) + return True + + +class PrometheusMetrics: + """Model all of the metrics which should be exposed to Prometheus.""" + + def __init__(self, prometheus_client, entity_filter, namespace, + climate_units): + """Initialize Prometheus Metrics.""" + self.prometheus_client = prometheus_client + self._filter = entity_filter + if namespace: + self.metrics_prefix = "{}_".format(namespace) + else: + self.metrics_prefix = "" + self._metrics = {} + self._climate_units = climate_units + + def handle_event(self, event): + """Listen for new messages on the bus, and add them to Prometheus.""" + state = event.data.get('new_state') + if state is None: + return + + entity_id = state.entity_id + _LOGGER.debug("Handling state update for %s", entity_id) + domain, _ = hacore.split_entity_id(entity_id) + + if not self._filter(state.entity_id): + return + + handler = '_handle_{}'.format(domain) + + if hasattr(self, handler): + getattr(self, handler)(state) + + metric = self._metric( + 'state_change', + self.prometheus_client.Counter, + 'The number of state changes', + ) + metric.labels(**self._labels(state)).inc() + + def _metric(self, metric, factory, documentation, labels=None): + if labels is None: + labels = ['entity', 'friendly_name', 'domain'] + + try: + return self._metrics[metric] + except KeyError: + full_metric_name = "{}{}".format(self.metrics_prefix, metric) + self._metrics[metric] = factory( + full_metric_name, documentation, labels) + return self._metrics[metric] + + @staticmethod + def _labels(state): + return { + 'entity': state.entity_id, + 'domain': state.domain, + 'friendly_name': state.attributes.get('friendly_name'), + } + + def _battery(self, state): + if 'battery_level' in state.attributes: + metric = self._metric( + 'battery_level_percent', + self.prometheus_client.Gauge, + 'Battery level as a percentage of its capacity', + ) + try: + value = float(state.attributes['battery_level']) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + def _handle_binary_sensor(self, state): + metric = self._metric( + 'binary_sensor_state', + self.prometheus_client.Gauge, + 'State of the binary sensor (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_input_boolean(self, state): + metric = self._metric( + 'input_boolean_state', + self.prometheus_client.Gauge, + 'State of the input boolean (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_device_tracker(self, state): + metric = self._metric( + 'device_tracker_state', + self.prometheus_client.Gauge, + 'State of the device tracker (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_light(self, state): + metric = self._metric( + 'light_state', + self.prometheus_client.Gauge, + 'Load level of a light (0..1)', + ) + + try: + if 'brightness' in state.attributes: + value = state.attributes['brightness'] / 255.0 + else: + value = state_helper.state_as_number(state) + value = value * 100 + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + def _handle_lock(self, state): + metric = self._metric( + 'lock_state', + self.prometheus_client.Gauge, + 'State of the lock (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_climate(self, state): + temp = state.attributes.get(ATTR_TEMPERATURE) + if temp: + if self._climate_units == TEMP_FAHRENHEIT: + temp = fahrenheit_to_celsius(temp) + metric = self._metric( + 'temperature_c', self.prometheus_client.Gauge, + 'Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(temp) + + current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if current_temp: + if self._climate_units == TEMP_FAHRENHEIT: + current_temp = fahrenheit_to_celsius(current_temp) + metric = self._metric( + 'current_temperature_c', self.prometheus_client.Gauge, + 'Current Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(current_temp) + + metric = self._metric( + 'climate_state', self.prometheus_client.Gauge, + 'State of the thermostat (0/1)') + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + def _handle_sensor(self, state): + + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + metric = state.entity_id.split(".")[1] + + if '_' not in str(metric): + metric = state.entity_id.replace('.', '_') + + try: + int(metric.split("_")[-1]) + metric = "_".join(metric.split("_")[:-1]) + except ValueError: + pass + + _metric = self._metric(metric, self.prometheus_client.Gauge, + state.entity_id) + + try: + value = state_helper.state_as_number(state) + if unit == TEMP_FAHRENHEIT: + value = fahrenheit_to_celsius(value) + _metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + self._battery(state) + + def _handle_switch(self, state): + metric = self._metric( + 'switch_state', + self.prometheus_client.Gauge, + 'State of the switch (0/1)', + ) + + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + def _handle_zwave(self, state): + self._battery(state) + + def _handle_automation(self, state): + metric = self._metric( + 'automation_triggered_count', + self.prometheus_client.Counter, + 'Count of times an automation has been triggered', + ) + + metric.labels(**self._labels(state)).inc() + + +class PrometheusView(HomeAssistantView): + """Handle Prometheus requests.""" + + url = API_ENDPOINT + name = 'api:prometheus' + + def __init__(self, prometheus_client): + """Initialize Prometheus view.""" + self.prometheus_client = prometheus_client + + async def get(self, request): + """Handle request for Prometheus metrics.""" + _LOGGER.debug("Received Prometheus metrics request") + + return web.Response( + body=self.prometheus_client.generate_latest(), + content_type=CONTENT_TYPE_TEXT_PLAIN) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/proximity.py b/homeassistant-0.88.0.dev0/homeassistant/components/proximity.py new file mode 100644 index 00000000000000..e8d86d480e5247 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/proximity.py @@ -0,0 +1,256 @@ +""" +Support for tracking the proximity of a device. + +Component to monitor the proximity of devices to a particular zone and the +direction of travel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/proximity/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_ZONE, CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_state_change +from homeassistant.util.distance import convert +from homeassistant.util.location import distance + +_LOGGER = logging.getLogger(__name__) + +ATTR_DIR_OF_TRAVEL = 'dir_of_travel' +ATTR_DIST_FROM = 'dist_to_zone' +ATTR_NEAREST = 'nearest' + +CONF_IGNORED_ZONES = 'ignored_zones' +CONF_TOLERANCE = 'tolerance' + +DEFAULT_DIR_OF_TRAVEL = 'not set' +DEFAULT_DIST_TO_ZONE = 'not set' +DEFAULT_NEAREST = 'not set' +DEFAULT_PROXIMITY_ZONE = 'home' +DEFAULT_TOLERANCE = 1 +DEPENDENCIES = ['zone', 'device_tracker'] +DOMAIN = 'proximity' + +UNITS = ['km', 'm', 'mi', 'ft'] + +ZONE_SCHEMA = vol.Schema({ + vol.Optional(CONF_ZONE, default=DEFAULT_PROXIMITY_ZONE): cv.string, + vol.Optional(CONF_DEVICES, default=[]): + vol.All(cv.ensure_list, [cv.entity_id]), + vol.Optional(CONF_IGNORED_ZONES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): cv.positive_int, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(cv.string, vol.In(UNITS)), +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA), +}, extra=vol.ALLOW_EXTRA) + + +def setup_proximity_component(hass, name, config): + """Set up the individual proximity component.""" + ignored_zones = config.get(CONF_IGNORED_ZONES) + proximity_devices = config.get(CONF_DEVICES) + tolerance = config.get(CONF_TOLERANCE) + proximity_zone = name + unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit) + zone_id = 'zone.{}'.format(config.get(CONF_ZONE)) + + proximity = Proximity(hass, proximity_zone, DEFAULT_DIST_TO_ZONE, + DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, + ignored_zones, proximity_devices, tolerance, + zone_id, unit_of_measurement) + proximity.entity_id = '{}.{}'.format(DOMAIN, proximity_zone) + + proximity.schedule_update_ha_state() + + track_state_change( + hass, proximity_devices, proximity.check_proximity_state_change) + + return True + + +def setup(hass, config): + """Get the zones and offsets from configuration.yaml.""" + for zone, proximity_config in config[DOMAIN].items(): + setup_proximity_component(hass, zone, proximity_config) + + return True + + +class Proximity(Entity): + """Representation of a Proximity.""" + + def __init__(self, hass, zone_friendly_name, dist_to, dir_of_travel, + nearest, ignored_zones, proximity_devices, tolerance, + proximity_zone, unit_of_measurement): + """Initialize the proximity.""" + self.hass = hass + self.friendly_name = zone_friendly_name + self.dist_to = dist_to + self.dir_of_travel = dir_of_travel + self.nearest = nearest + self.ignored_zones = ignored_zones + self.proximity_devices = proximity_devices + self.tolerance = tolerance + self.proximity_zone = proximity_zone + self._unit_of_measurement = unit_of_measurement + + @property + def name(self): + """Return the name of the entity.""" + return self.friendly_name + + @property + def state(self): + """Return the state.""" + return self.dist_to + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_DIR_OF_TRAVEL: self.dir_of_travel, + ATTR_NEAREST: self.nearest, + } + + def check_proximity_state_change(self, entity, old_state, new_state): + """Perform the proximity checking.""" + entity_name = new_state.name + devices_to_calculate = False + devices_in_zone = '' + + zone_state = self.hass.states.get(self.proximity_zone) + proximity_latitude = zone_state.attributes.get('latitude') + proximity_longitude = zone_state.attributes.get('longitude') + + # Check for devices in the monitored zone. + for device in self.proximity_devices: + device_state = self.hass.states.get(device) + + if device_state is None: + devices_to_calculate = True + continue + + if device_state.state not in self.ignored_zones: + devices_to_calculate = True + + # Check the location of all devices. + if (device_state.state).lower() == (self.friendly_name).lower(): + device_friendly = device_state.name + if devices_in_zone != '': + devices_in_zone = devices_in_zone + ', ' + devices_in_zone = devices_in_zone + device_friendly + + # No-one to track so reset the entity. + if not devices_to_calculate: + self.dist_to = 'not set' + self.dir_of_travel = 'not set' + self.nearest = 'not set' + self.schedule_update_ha_state() + return + + # At least one device is in the monitored zone so update the entity. + if devices_in_zone != '': + self.dist_to = 0 + self.dir_of_travel = 'arrived' + self.nearest = devices_in_zone + self.schedule_update_ha_state() + return + + # We can't check proximity because latitude and longitude don't exist. + if 'latitude' not in new_state.attributes: + return + + # Collect distances to the zone for all devices. + distances_to_zone = {} + for device in self.proximity_devices: + # Ignore devices in an ignored zone. + device_state = self.hass.states.get(device) + if device_state.state in self.ignored_zones: + continue + + # Ignore devices if proximity cannot be calculated. + if 'latitude' not in device_state.attributes: + continue + + # Calculate the distance to the proximity zone. + dist_to_zone = distance(proximity_latitude, + proximity_longitude, + device_state.attributes['latitude'], + device_state.attributes['longitude']) + + # Add the device and distance to a dictionary. + distances_to_zone[device] = round( + convert(dist_to_zone, 'm', self.unit_of_measurement), 1) + + # Loop through each of the distances collected and work out the + # closest. + closest_device = None # type: str + dist_to_zone = None # type: float + + for device in distances_to_zone: + if not dist_to_zone or distances_to_zone[device] < dist_to_zone: + closest_device = device + dist_to_zone = distances_to_zone[device] + + # If the closest device is one of the other devices. + if closest_device != entity: + self.dist_to = round(distances_to_zone[closest_device]) + self.dir_of_travel = 'unknown' + device_state = self.hass.states.get(closest_device) + self.nearest = device_state.name + self.schedule_update_ha_state() + return + + # Stop if we cannot calculate the direction of travel (i.e. we don't + # have a previous state and a current LAT and LONG). + if old_state is None or 'latitude' not in old_state.attributes: + self.dist_to = round(distances_to_zone[entity]) + self.dir_of_travel = 'unknown' + self.nearest = entity_name + self.schedule_update_ha_state() + return + + # Reset the variables + distance_travelled = 0 + + # Calculate the distance travelled. + old_distance = distance(proximity_latitude, proximity_longitude, + old_state.attributes['latitude'], + old_state.attributes['longitude']) + new_distance = distance(proximity_latitude, proximity_longitude, + new_state.attributes['latitude'], + new_state.attributes['longitude']) + distance_travelled = round(new_distance - old_distance, 1) + + # Check for tolerance + if distance_travelled < self.tolerance * -1: + direction_of_travel = 'towards' + elif distance_travelled > self.tolerance: + direction_of_travel = 'away_from' + else: + direction_of_travel = 'stationary' + + # Update the proximity entity + self.dist_to = round(dist_to_zone) + self.dir_of_travel = direction_of_travel + self.nearest = entity_name + self.schedule_update_ha_state() + _LOGGER.debug('proximity.%s update entity: distance=%s: direction=%s: ' + 'device=%s', self.friendly_name, round(dist_to_zone), + direction_of_travel, entity_name) + + _LOGGER.info('%s: proximity calculation complete', entity_name) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/python_script.py b/homeassistant-0.88.0.dev0/homeassistant/components/python_script.py new file mode 100644 index 00000000000000..3cfa0696644991 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/python_script.py @@ -0,0 +1,211 @@ +""" +Component to allow running Python scripts. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/python_script/ +""" +import datetime +import glob +import logging +import os +import time + +import voluptuous as vol + +from homeassistant.const import SERVICE_RELOAD +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass +from homeassistant.util import sanitize_filename +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['restrictedpython==4.0b7'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'python_script' + +FOLDER = 'python_scripts' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema(dict) +}, extra=vol.ALLOW_EXTRA) + +ALLOWED_HASS = set(['bus', 'services', 'states']) +ALLOWED_EVENTBUS = set(['fire']) +ALLOWED_STATEMACHINE = set(['entity_ids', 'all', 'get', 'is_state', + 'is_state_attr', 'remove', 'set']) +ALLOWED_SERVICEREGISTRY = set(['services', 'has_service', 'call']) +ALLOWED_TIME = set(['sleep', 'strftime', 'strptime', 'gmtime', 'localtime', + 'ctime', 'time', 'mktime']) +ALLOWED_DATETIME = set(['date', 'time', 'datetime', 'timedelta', 'tzinfo']) +ALLOWED_DT_UTIL = set([ + 'utcnow', 'now', 'as_utc', 'as_timestamp', 'as_local', + 'utc_from_timestamp', 'start_of_local_day', 'parse_datetime', 'parse_date', + 'get_age']) + + +class ScriptError(HomeAssistantError): + """When a script error occurs.""" + + pass + + +def setup(hass, config): + """Initialize the Python script component.""" + path = hass.config.path(FOLDER) + + if not os.path.isdir(path): + _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) + return False + + discover_scripts(hass) + + def reload_scripts_handler(call): + """Handle reload service calls.""" + discover_scripts(hass) + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler) + + return True + + +def discover_scripts(hass): + """Discover python scripts in folder.""" + path = hass.config.path(FOLDER) + + if not os.path.isdir(path): + _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) + return False + + def python_script_service_handler(call): + """Handle python script service calls.""" + execute_script(hass, call.service, call.data) + + existing = hass.services.services.get(DOMAIN, {}).keys() + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.remove(DOMAIN, existing_service) + + for fil in glob.iglob(os.path.join(path, '*.py')): + name = os.path.splitext(os.path.basename(fil))[0] + hass.services.register(DOMAIN, name, python_script_service_handler) + + +@bind_hass +def execute_script(hass, name, data=None): + """Execute a script.""" + filename = '{}.py'.format(name) + with open(hass.config.path(FOLDER, sanitize_filename(filename))) as fil: + source = fil.read() + execute(hass, filename, source, data) + + +@bind_hass +def execute(hass, filename, source, data=None): + """Execute Python source.""" + from RestrictedPython import compile_restricted_exec + from RestrictedPython.Guards import safe_builtins, full_write_guard, \ + guarded_iter_unpack_sequence, guarded_unpack_sequence + from RestrictedPython.Utilities import utility_builtins + from RestrictedPython.Eval import default_guarded_getitem + + compiled = compile_restricted_exec(source, filename=filename) + + if compiled.errors: + _LOGGER.error("Error loading script %s: %s", filename, + ", ".join(compiled.errors)) + return + + if compiled.warnings: + _LOGGER.warning("Warning loading script %s: %s", filename, + ", ".join(compiled.warnings)) + + def protected_getattr(obj, name, default=None): + """Restricted method to get attributes.""" + # pylint: disable=too-many-boolean-expressions + if name.startswith('async_'): + raise ScriptError("Not allowed to access async methods") + elif (obj is hass and name not in ALLOWED_HASS or + obj is hass.bus and name not in ALLOWED_EVENTBUS or + obj is hass.states and name not in ALLOWED_STATEMACHINE or + obj is hass.services and name not in ALLOWED_SERVICEREGISTRY or + obj is dt_util and name not in ALLOWED_DT_UTIL or + obj is datetime and name not in ALLOWED_DATETIME or + isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME): + raise ScriptError("Not allowed to access {}.{}".format( + obj.__class__.__name__, name)) + + return getattr(obj, name, default) + + builtins = safe_builtins.copy() + builtins.update(utility_builtins) + builtins['datetime'] = datetime + builtins['sorted'] = sorted + builtins['time'] = TimeWrapper() + builtins['dt_util'] = dt_util + restricted_globals = { + '__builtins__': builtins, + '_print_': StubPrinter, + '_getattr_': protected_getattr, + '_write_': full_write_guard, + '_getiter_': iter, + '_getitem_': default_guarded_getitem, + '_iter_unpack_sequence_': guarded_iter_unpack_sequence, + '_unpack_sequence_': guarded_unpack_sequence, + } + logger = logging.getLogger('{}.{}'.format(__name__, filename)) + local = { + 'hass': hass, + 'data': data or {}, + 'logger': logger + } + + try: + _LOGGER.info("Executing %s: %s", filename, data) + # pylint: disable=exec-used + exec(compiled.code, restricted_globals, local) + except ScriptError as err: + logger.error("Error executing script: %s", err) + except Exception as err: # pylint: disable=broad-except + logger.exception("Error executing script: %s", err) + + +class StubPrinter: + """Class to handle printing inside scripts.""" + + def __init__(self, _getattr_): + """Initialize our printer.""" + pass + + def _call_print(self, *objects, **kwargs): + """Print text.""" + # pylint: disable=no-self-use + _LOGGER.warning( + "Don't use print() inside scripts. Use logger.info() instead") + + +class TimeWrapper: + """Wrap the time module.""" + + # Class variable, only going to warn once per Home Assistant run + warned = False + + # pylint: disable=no-self-use + def sleep(self, *args, **kwargs): + """Sleep method that warns once.""" + if not TimeWrapper.warned: + TimeWrapper.warned = True + _LOGGER.warning("Using time.sleep can reduce the performance of " + "Home Assistant") + + time.sleep(*args, **kwargs) + + def __getattr__(self, attr): + """Fetch an attribute from Time module.""" + attribute = getattr(time, attr) + if callable(attribute): + def wrapper(*args, **kw): + """Wrap to return callable method if callable.""" + return attribute(*args, **kw) + return wrapper + return attribute diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/rainbird.py b/homeassistant-0.88.0.dev0/homeassistant/components/rainbird.py new file mode 100644 index 00000000000000..bbce7f752af970 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/rainbird.py @@ -0,0 +1,47 @@ +""" +Support for Rain Bird Irrigation system LNK WiFi Module. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainbird/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_PASSWORD) + +REQUIREMENTS = ['pyrainbird==0.1.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RAINBIRD = 'rainbird' +DOMAIN = 'rainbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Rain Bird component.""" + conf = config[DOMAIN] + server = conf.get(CONF_HOST) + password = conf.get(CONF_PASSWORD) + + from pyrainbird import RainbirdController + controller = RainbirdController() + controller.setConfig(server, password) + + _LOGGER.debug("Rain Bird Controller set to: %s", server) + + initial_status = controller.currentIrrigation() + if initial_status == -1: + _LOGGER.error("Error getting state. Possible configuration issues") + return False + + hass.data[DATA_RAINBIRD] = controller + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/rest_command.py b/homeassistant-0.88.0.dev0/homeassistant/components/rest_command.py new file mode 100644 index 00000000000000..c1ccc73b81c2df --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/rest_command.py @@ -0,0 +1,126 @@ +""" +Exposes regular rest commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rest_command/ +""" +import asyncio +import logging + +import aiohttp +from aiohttp import hdrs +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + CONF_TIMEOUT, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_PAYLOAD, + CONF_METHOD, CONF_HEADERS, CONF_VERIFY_SSL) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'rest_command' + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 10 +DEFAULT_METHOD = 'get' +DEFAULT_VERIFY_SSL = True + +SUPPORT_REST_METHODS = [ + 'get', + 'post', + 'put', + 'delete', +] + +CONF_CONTENT_TYPE = 'content_type' + +COMMAND_SCHEMA = vol.Schema({ + vol.Required(CONF_URL): cv.template, + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): + vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_PAYLOAD): cv.template, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + vol.Optional(CONF_CONTENT_TYPE): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys(COMMAND_SCHEMA), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the REST command component.""" + def async_register_rest_command(name, command_config): + """Create service for rest command.""" + websession = async_get_clientsession( + hass, + command_config.get(CONF_VERIFY_SSL) + ) + timeout = command_config[CONF_TIMEOUT] + method = command_config[CONF_METHOD] + + template_url = command_config[CONF_URL] + template_url.hass = hass + + auth = None + if CONF_USERNAME in command_config: + username = command_config[CONF_USERNAME] + password = command_config.get(CONF_PASSWORD, '') + auth = aiohttp.BasicAuth(username, password=password) + + template_payload = None + if CONF_PAYLOAD in command_config: + template_payload = command_config[CONF_PAYLOAD] + template_payload.hass = hass + + headers = None + if CONF_HEADERS in command_config: + headers = command_config[CONF_HEADERS] + + if CONF_CONTENT_TYPE in command_config: + content_type = command_config[CONF_CONTENT_TYPE] + if headers is None: + headers = {} + headers[hdrs.CONTENT_TYPE] = content_type + + async def async_service_handler(service): + """Execute a shell command service.""" + payload = None + if template_payload: + payload = bytes( + template_payload.async_render(variables=service.data), + 'utf-8') + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + request = await getattr(websession, method)( + template_url.async_render(variables=service.data), + data=payload, + auth=auth, + headers=headers + ) + + if request.status < 400: + _LOGGER.info("Success call %s.", request.url) + else: + _LOGGER.warning( + "Error %d on call %s.", request.status, request.url) + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout call %s.", request.url) + + except aiohttp.ClientError: + _LOGGER.error("Client error %s.", request.url) + + # register services + hass.services.async_register(DOMAIN, name, async_service_handler) + + for command, command_config in config[DOMAIN].items(): + async_register_rest_command(command, command_config) + + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/rflink.py b/homeassistant-0.88.0.dev0/homeassistant/components/rflink.py new file mode 100644 index 00000000000000..b91ff251bd1129 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/rflink.py @@ -0,0 +1,550 @@ +""" +Support for Rflink components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rflink/ +""" +import asyncio +from collections import defaultdict +import logging +import async_timeout + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, + STATE_ON, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import CoreState, callback +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.deprecation import get_deprecated +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) +from homeassistant.helpers.restore_state import RestoreEntity + +REQUIREMENTS = ['rflink==0.0.37'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_EVENT = 'event' +ATTR_STATE = 'state' + +CONF_ALIASES = 'aliases' +CONF_ALIASSES = 'aliasses' +CONF_GROUP_ALIASES = 'group_aliases' +CONF_GROUP_ALIASSES = 'group_aliasses' +CONF_GROUP = 'group' +CONF_NOGROUP_ALIASES = 'nogroup_aliases' +CONF_NOGROUP_ALIASSES = 'nogroup_aliasses' +CONF_DEVICE_DEFAULTS = 'device_defaults' +CONF_DEVICE_ID = 'device_id' +CONF_DEVICES = 'devices' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_FIRE_EVENT = 'fire_event' +CONF_IGNORE_DEVICES = 'ignore_devices' +CONF_RECONNECT_INTERVAL = 'reconnect_interval' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_WAIT_FOR_ACK = 'wait_for_ack' + +DATA_DEVICE_REGISTER = 'rflink_device_register' +DATA_ENTITY_LOOKUP = 'rflink_entity_lookup' +DATA_ENTITY_GROUP_LOOKUP = 'rflink_entity_group_only_lookup' +DEFAULT_RECONNECT_INTERVAL = 10 +DEFAULT_SIGNAL_REPETITIONS = 1 +CONNECTION_TIMEOUT = 10 + +EVENT_BUTTON_PRESSED = 'button_pressed' +EVENT_KEY_COMMAND = 'command' +EVENT_KEY_ID = 'id' +EVENT_KEY_SENSOR = 'sensor' +EVENT_KEY_UNIT = 'unit' + +RFLINK_GROUP_COMMANDS = ['allon', 'alloff'] + +DOMAIN = 'rflink' + +SERVICE_SEND_COMMAND = 'send_command' + +SIGNAL_AVAILABILITY = 'rflink_device_available' +SIGNAL_HANDLE_EVENT = 'rflink_handle_event_{}' + +TMP_ENTITY = 'tmp.{}' + +DEVICE_DEFAULTS_SCHEMA = vol.Schema({ + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS, + default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int), +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): vol.Any(cv.port, cv.string), + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean, + vol.Optional(CONF_RECONNECT_INTERVAL, + default=DEFAULT_RECONNECT_INTERVAL): int, + vol.Optional(CONF_IGNORE_DEVICES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + }), +}, extra=vol.ALLOW_EXTRA) + +SEND_COMMAND_SCHEMA = vol.Schema({ + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_COMMAND): cv.string, +}) + + +def identify_event_type(event): + """Look at event to determine type of device. + + Async friendly. + """ + if EVENT_KEY_COMMAND in event: + return EVENT_KEY_COMMAND + if EVENT_KEY_SENSOR in event: + return EVENT_KEY_SENSOR + return 'unknown' + + +async def async_setup(hass, config): + """Set up the Rflink component.""" + from rflink.protocol import create_rflink_connection + import serial + + # Allow entities to register themselves by device_id to be looked up when + # new rflink events arrive to be handled + hass.data[DATA_ENTITY_LOOKUP] = { + EVENT_KEY_COMMAND: defaultdict(list), + EVENT_KEY_SENSOR: defaultdict(list), + } + hass.data[DATA_ENTITY_GROUP_LOOKUP] = { + EVENT_KEY_COMMAND: defaultdict(list), + } + + # Allow platform to specify function to register new unknown devices + hass.data[DATA_DEVICE_REGISTER] = {} + + async def async_send_command(call): + """Send Rflink command.""" + _LOGGER.debug('Rflink command for %s', str(call.data)) + if not (await RflinkCommand.send_command( + call.data.get(CONF_DEVICE_ID), + call.data.get(CONF_COMMAND))): + _LOGGER.error('Failed Rflink command for %s', str(call.data)) + + hass.services.async_register( + DOMAIN, SERVICE_SEND_COMMAND, async_send_command, + schema=SEND_COMMAND_SCHEMA) + + @callback + def event_callback(event): + """Handle incoming Rflink events. + + Rflink events arrive as dictionaries of varying content + depending on their type. Identify the events and distribute + accordingly. + """ + event_type = identify_event_type(event) + _LOGGER.debug('event of type %s: %s', event_type, event) + + # Don't propagate non entity events (eg: version string, ack response) + if event_type not in hass.data[DATA_ENTITY_LOOKUP]: + _LOGGER.debug('unhandled event of type: %s', event_type) + return + + # Lookup entities who registered this device id as device id or alias + event_id = event.get(EVENT_KEY_ID, None) + + is_group_event = (event_type == EVENT_KEY_COMMAND and + event[EVENT_KEY_COMMAND] in RFLINK_GROUP_COMMANDS) + if is_group_event: + entity_ids = hass.data[DATA_ENTITY_GROUP_LOOKUP][event_type].get( + event_id, []) + else: + entity_ids = hass.data[DATA_ENTITY_LOOKUP][event_type][event_id] + + _LOGGER.debug('entity_ids: %s', entity_ids) + if entity_ids: + # Propagate event to every entity matching the device id + for entity in entity_ids: + _LOGGER.debug('passing event to %s', entity) + async_dispatcher_send(hass, + SIGNAL_HANDLE_EVENT.format(entity), + event) + elif not is_group_event: + # If device is not yet known, register with platform (if loaded) + if event_type in hass.data[DATA_DEVICE_REGISTER]: + _LOGGER.debug('device_id not known, adding new device') + # Add bogus event_id first to avoid race if we get another + # event before the device is created + # Any additional events received before the device has been + # created will thus be ignored. + hass.data[DATA_ENTITY_LOOKUP][event_type][ + event_id].append(TMP_ENTITY.format(event_id)) + hass.async_create_task( + hass.data[DATA_DEVICE_REGISTER][event_type](event)) + else: + _LOGGER.debug('device_id not known and automatic add disabled') + + # When connecting to tcp host instead of serial port (optional) + host = config[DOMAIN].get(CONF_HOST) + # TCP port when host configured, otherwise serial port + port = config[DOMAIN][CONF_PORT] + + @callback + def reconnect(exc=None): + """Schedule reconnect after connection has been unexpectedly lost.""" + # Reset protocol binding before starting reconnect + RflinkCommand.set_rflink_protocol(None) + + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) + + # If HA is not stopping, initiate new connection + if hass.state != CoreState.stopping: + _LOGGER.warning('disconnected from Rflink, reconnecting') + hass.async_create_task(connect()) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info('Initiating Rflink connection') + + # Rflink create_rflink_connection decides based on the value of host + # (string or None) if serial or tcp mode should be used + + # Initiate serial/tcp connection to Rflink gateway + connection = create_rflink_connection( + port=port, + host=host, + event_callback=event_callback, + disconnect_callback=reconnect, + loop=hass.loop, + ignore=config[DOMAIN][CONF_IGNORE_DEVICES] + ) + + try: + with async_timeout.timeout(CONNECTION_TIMEOUT, + loop=hass.loop): + transport, protocol = await connection + + except (serial.serialutil.SerialException, ConnectionRefusedError, + TimeoutError, OSError, asyncio.TimeoutError) as exc: + reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] + _LOGGER.exception( + "Error connecting to Rflink, reconnecting in %s", + reconnect_interval) + # Connection to Rflink device is lost, make entities unavailable + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) + + hass.loop.call_later(reconnect_interval, reconnect, exc) + return + + # There is a valid connection to a Rflink device now so + # mark entities as available + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, True) + + # Bind protocol to command class to allow entities to send commands + RflinkCommand.set_rflink_protocol( + protocol, config[DOMAIN][CONF_WAIT_FOR_ACK]) + + # handle shutdown of Rflink asyncio transport + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + lambda x: transport.close()) + + _LOGGER.info('Connected to Rflink') + + hass.async_create_task(connect()) + return True + + +class RflinkDevice(Entity): + """Representation of a Rflink device. + + Contains the common logic for Rflink entities. + """ + + platform = None + _state = None + _available = True + + def __init__(self, device_id, initial_event=None, name=None, aliases=None, + group=True, group_aliases=None, nogroup_aliases=None, + fire_event=False, + signal_repetitions=DEFAULT_SIGNAL_REPETITIONS): + """Initialize the device.""" + # Rflink specific attributes for every component type + self._initial_event = initial_event + self._device_id = device_id + if name: + self._name = name + else: + self._name = device_id + + self._aliases = aliases + self._group = group + self._group_aliases = group_aliases + self._nogroup_aliases = nogroup_aliases + self._should_fire_event = fire_event + self._signal_repetitions = signal_repetitions + + @callback + def handle_event_callback(self, event): + """Handle incoming event for device type.""" + # Call platform specific event handler + self._handle_event(event) + + # Propagate changes through ha + self.async_schedule_update_ha_state() + + # Put command onto bus for user to subscribe to + if self._should_fire_event and identify_event_type( + event) == EVENT_KEY_COMMAND: + self.hass.bus.async_fire(EVENT_BUTTON_PRESSED, { + ATTR_ENTITY_ID: self.entity_id, + ATTR_STATE: event[EVENT_KEY_COMMAND], + }) + _LOGGER.debug("Fired bus event for %s: %s", + self.entity_id, event[EVENT_KEY_COMMAND]) + + def _handle_event(self, event): + """Platform specific event handler.""" + raise NotImplementedError() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return a name for the device.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + if self.assumed_state: + return False + return self._state + + @property + def assumed_state(self): + """Assume device state until first device event sets state.""" + return self._state is None + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self._available = availability + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + # Remove temporary bogus entity_id if added + tmp_entity = TMP_ENTITY.format(self._device_id) + if tmp_entity in self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][self._device_id]: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][self._device_id].remove(tmp_entity) + + # Register id and aliases + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][self._device_id].append(self.entity_id) + if self._group: + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][self._device_id].append(self.entity_id) + # aliases respond to both normal and group commands (allon/alloff) + if self._aliases: + for _id in self._aliases: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + # group_aliases only respond to group commands (allon/alloff) + if self._group_aliases: + for _id in self._group_aliases: + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + # nogroup_aliases only respond to normal commands + if self._nogroup_aliases: + for _id in self._nogroup_aliases: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY, + self._availability_callback) + async_dispatcher_connect(self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback) + + # Process the initial event now that the entity is created + if self._initial_event: + self.handle_event_callback(self._initial_event) + + +class RflinkCommand(RflinkDevice): + """Singleton class to make Rflink command interface available to entities. + + This class is to be inherited by every Entity class that is actionable + (switches/lights). It exposes the Rflink command interface for these + entities. + + The Rflink interface is managed as a class level and set during setup (and + reset on reconnect). + """ + + # Keep repetition tasks to cancel if state is changed before repetitions + # are sent + _repetition_task = None + + _protocol = None + + @classmethod + def set_rflink_protocol(cls, protocol, wait_ack=None): + """Set the Rflink asyncio protocol as a class variable.""" + cls._protocol = protocol + if wait_ack is not None: + cls._wait_ack = wait_ack + + @classmethod + def is_connected(cls): + """Return connection status.""" + return bool(cls._protocol) + + @classmethod + async def send_command(cls, device_id, action): + """Send device command to Rflink and wait for acknowledgement.""" + return await cls._protocol.send_command_ack(device_id, action) + + async def _async_handle_command(self, command, *args): + """Do bookkeeping for command, send it to rflink and update state.""" + self.cancel_queued_send_commands() + + if command == 'turn_on': + cmd = 'on' + self._state = True + + elif command == 'turn_off': + cmd = 'off' + self._state = False + + elif command == 'dim': + # convert brightness to rflink dim level + cmd = str(int(args[0] / 17)) + self._state = True + + elif command == 'toggle': + cmd = 'on' + # if the state is unknown or false, it gets set as true + # if the state is true, it gets set as false + self._state = self._state in [None, False] + + # Cover options for RFlink + elif command == 'close_cover': + cmd = 'DOWN' + self._state = False + + elif command == 'open_cover': + cmd = 'UP' + self._state = True + + elif command == 'stop_cover': + cmd = 'STOP' + self._state = True + + # Send initial command and queue repetitions. + # This allows the entity state to be updated quickly and not having to + # wait for all repetitions to be sent + await self._async_send_command(cmd, self._signal_repetitions) + + # Update state of entity + await self.async_update_ha_state() + + def cancel_queued_send_commands(self): + """Cancel queued signal repetition commands. + + For example when user changed state while repetitions are still + queued for broadcast. Or when an incoming Rflink command (remote + switch) changes the state. + """ + # cancel any outstanding tasks from the previous state change + if self._repetition_task: + self._repetition_task.cancel() + + async def _async_send_command(self, cmd, repetitions): + """Send a command for device to Rflink gateway.""" + _LOGGER.debug( + "Sending command: %s to Rflink device: %s", cmd, self._device_id) + + if not self.is_connected(): + raise HomeAssistantError('Cannot send command, not connected!') + + if self._wait_ack: + # Puts command on outgoing buffer then waits for Rflink to confirm + # the command has been send out in the ether. + await self._protocol.send_command_ack(self._device_id, cmd) + else: + # Puts command on outgoing buffer and returns straight away. + # Rflink protocol/transport handles asynchronous writing of buffer + # to serial/tcp device. Does not wait for command send + # confirmation. + self._protocol.send_command(self._device_id, cmd) + + if repetitions > 1: + self._repetition_task = self.hass.async_create_task( + self._async_send_command(cmd, repetitions - 1)) + + +class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): + """Rflink entity which can switch on/off (eg: light, switch).""" + + async def async_added_to_hass(self): + """Restore RFLink device state (ON/OFF).""" + await super().async_added_to_hass() + + old_state = await self.async_get_last_state() + if old_state is not None: + self._state = old_state.state == STATE_ON + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event['command'] + if command in ['on', 'allon']: + self._state = True + elif command in ['off', 'alloff']: + self._state = False + + def async_turn_on(self, **kwargs): + """Turn the device on.""" + return self._async_handle_command("turn_on") + + def async_turn_off(self, **kwargs): + """Turn the device off.""" + return self._async_handle_command("turn_off") + + +DEPRECATED_CONFIG_OPTIONS = [ + CONF_ALIASSES, + CONF_GROUP_ALIASSES, + CONF_NOGROUP_ALIASSES] +REPLACEMENT_CONFIG_OPTIONS = [ + CONF_ALIASES, + CONF_GROUP_ALIASES, + CONF_NOGROUP_ALIASES] + + +def remove_deprecated(config): + """Remove deprecated config options from device config.""" + for index, deprecated_option in enumerate(DEPRECATED_CONFIG_OPTIONS): + if deprecated_option in config: + replacement_option = REPLACEMENT_CONFIG_OPTIONS[index] + # generate deprecation warning + get_deprecated(config, replacement_option, deprecated_option) + # remove old config value replacing new one + config[replacement_option] = config.pop(deprecated_option) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/ring.py b/homeassistant-0.88.0.dev0/homeassistant/components/ring.py new file mode 100644 index 00000000000000..2e048caa52f80a --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/ring.py @@ -0,0 +1,60 @@ +""" +Support for Ring Doorbell/Chimes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/ring/ +""" +import logging +from requests.exceptions import HTTPError, ConnectTimeout + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + +REQUIREMENTS = ['ring_doorbell==0.2.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by Ring.com" + +NOTIFICATION_ID = 'ring_notification' +NOTIFICATION_TITLE = 'Ring Setup' + +DATA_RING = 'ring' +DOMAIN = 'ring' +DEFAULT_CACHEDB = '.ring_cache.pickle' +DEFAULT_ENTITY_NAMESPACE = 'ring' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Ring component.""" + conf = config[DOMAIN] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + + try: + from ring_doorbell import Ring + + cache = hass.config.path(DEFAULT_CACHEDB) + ring = Ring(username=username, password=password, cache_file=cache) + if not ring.is_connected: + return False + hass.data['ring'] = ring + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Ring service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + return True diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/route53.py b/homeassistant-0.88.0.dev0/homeassistant/components/route53.py new file mode 100644 index 00000000000000..4c35983feed873 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/route53.py @@ -0,0 +1,135 @@ +""" +Update the IP addresses of your Route53 DNS records. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/route53/ +""" +from datetime import timedelta +import logging +from typing import List + +import voluptuous as vol + +from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['boto3==1.9.16', 'ipify==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_RECORDS = 'records' + +DOMAIN = 'route53' + +INTERVAL = timedelta(minutes=60) +DEFAULT_TTL = 300 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_KEY_ID): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_SECRET_ACCESS_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Optional(CONF_TTL, default=DEFAULT_TTL): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Route53 component.""" + domain = config[DOMAIN][CONF_DOMAIN] + records = config[DOMAIN][CONF_RECORDS] + zone = config[DOMAIN][CONF_ZONE] + aws_access_key_id = config[DOMAIN][CONF_ACCESS_KEY_ID] + aws_secret_access_key = config[DOMAIN][CONF_SECRET_ACCESS_KEY] + ttl = config[DOMAIN][CONF_TTL] + + def update_records_interval(now): + """Set up recurring update.""" + _update_route53( + aws_access_key_id, + aws_secret_access_key, + zone, + domain, + records, + ttl + ) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_route53( + aws_access_key_id, + aws_secret_access_key, + zone, + domain, + records, + ttl + ) + + track_time_interval(hass, update_records_interval, INTERVAL) + + hass.services.register(DOMAIN, 'update_records', update_records_service) + return True + + +def _update_route53( + aws_access_key_id: str, + aws_secret_access_key: str, + zone: str, + domain: str, + records: List[str], + ttl: int, +): + import boto3 + from ipify import get_ip + from ipify import exceptions + + _LOGGER.debug("Starting update for zone %s", zone) + + client = boto3.client( + DOMAIN, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + + # Get the IP Address and build an array of changes + try: + ipaddress = get_ip() + + except exceptions.ConnectionError: + _LOGGER.warning("Unable to reach the ipify service") + return + + except exceptions.ServiceError: + _LOGGER.warning("Unable to complete the ipfy request") + return + + changes = [] + for record in records: + _LOGGER.debug("Processing record: %s", record) + + changes.append({ + 'Action': 'UPSERT', + 'ResourceRecordSet': { + 'Name': '{}.{}'.format(record, domain), + 'Type': 'A', + 'TTL': ttl, + 'ResourceRecords': [ + {'Value': ipaddress}, + ], + } + }) + + _LOGGER.debug("Submitting the following changes to Route53") + _LOGGER.debug(changes) + + response = client.change_resource_record_sets( + HostedZoneId=zone, ChangeBatch={'Changes': changes}) + _LOGGER.debug("Response is %s", response) + + if response['ResponseMetadata']['HTTPStatusCode'] != 200: + _LOGGER.warning(response) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/rss_feed_template.py b/homeassistant-0.88.0.dev0/homeassistant/components/rss_feed_template.py new file mode 100644 index 00000000000000..34bee1ec5fcf99 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/rss_feed_template.py @@ -0,0 +1,102 @@ +""" +Exports sensor values via RSS feed. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rss_feed_template/ +""" + +from html import escape +from aiohttp import web + +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +import homeassistant.helpers.config_validation as cv + +CONTENT_TYPE_XML = 'text/xml' +DEPENDENCIES = ['http'] + +DOMAIN = 'rss_feed_template' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.match_all: vol.Schema({ + vol.Optional('requires_api_password', default=True): cv.boolean, + vol.Optional('title'): cv.template, + vol.Required('items'): vol.All( + cv.ensure_list, + [{ + vol.Optional('title'): cv.template, + vol.Optional('description'): cv.template, + }] + ) + }) + }) + }, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the RSS feed template component.""" + for (feeduri, feedconfig) in config[DOMAIN].items(): + url = '/api/rss_template/%s' % feeduri + + requires_auth = feedconfig.get('requires_api_password') + + title = feedconfig.get('title') + if title is not None: + title.hass = hass + + items = feedconfig.get('items') + for item in items: + if 'title' in item: + item['title'].hass = hass + if 'description' in item: + item['description'].hass = hass + + rss_view = RssView(url, requires_auth, title, items) + hass.http.register_view(rss_view) + + return True + + +class RssView(HomeAssistantView): + """Export states and other values as RSS.""" + + requires_auth = True + url = None + name = 'rss_template' + _title = None + _items = None + + def __init__(self, url, requires_auth, title, items): + """Initialize the rss view.""" + self.url = url + self.requires_auth = requires_auth + self._title = title + self._items = items + + async def get(self, request, entity_id=None): + """Generate the RSS view XML.""" + response = '\n\n' + + response += '\n' + if self._title is not None: + response += (' %s\n' % + escape(self._title.async_render())) + + for item in self._items: + response += ' \n' + if 'title' in item: + response += ' ' + response += escape(item['title'].async_render()) + response += '\n' + if 'description' in item: + response += ' ' + response += escape(item['description'].async_render()) + response += '\n' + response += ' \n' + + response += '\n' + + return web.Response( + body=response, content_type=CONTENT_TYPE_XML, status=200) diff --git a/homeassistant-0.88.0.dev0/homeassistant/components/script.py b/homeassistant-0.88.0.dev0/homeassistant/components/script.py new file mode 100644 index 00000000000000..15df6907468073 --- /dev/null +++ b/homeassistant-0.88.0.dev0/homeassistant/components/script.py @@ -0,0 +1,193 @@ +""" +Support for scripts. + +Scripts are a sequence of actions that can be triggered manually +by the user or automatically based upon automation events, etc. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/script/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS, + EVENT_SCRIPT_STARTED, ATTR_NAME) +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +import homeassistant.helpers.config_validation as cv + +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'script' +DEPENDENCIES = ['group'] + +ATTR_CAN_CANCEL = 'can_cancel' +ATTR_LAST_ACTION = 'last_action' +ATTR_LAST_TRIGGERED = 'last_triggered' +ATTR_VARIABLES = 'variables' + +CONF_SEQUENCE = 'sequence' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +GROUP_NAME_ALL_SCRIPTS = 'all scripts' + +SCRIPT_ENTRY_SCHEMA = vol.Schema({ + CONF_ALIAS: cv.string, + vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: cv.schema_with_slug_keys(SCRIPT_ENTRY_SCHEMA) +}, extra=vol.ALLOW_EXTRA) + +SCRIPT_SERVICE_SCHEMA = vol.Schema(dict) +SCRIPT_TURN_ONOFF_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_VARIABLES): dict, +}) +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + + +@bind_hass +def is_on(hass, entity_id): + """Return if the script is on based on the statemachine.""" + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass, config): + """Load the scripts from the configuration.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, group_name=GROUP_NAME_ALL_SCRIPTS) + + await _async_process_config(hass, config, component) + + async def reload_service(service): + """Call a service to reload scripts.""" + conf = await component.async_prepare_reload() + if conf is None: + return + + await _async_process_config(hass, conf, component) + + async def turn_on_service(service): + """Call a service to turn script on.""" + # We could turn on script directly here, but we only want to offer + # one way to do it. Otherwise no easy way to detect invocations. + var = service.data.get(ATTR_VARIABLES) + for script in component.async_extract_from_service(service): + await hass.services.async_call(DOMAIN, script.object_id, var, + context=service.context) + + async def turn_off_service(service): + """Cancel a script.""" + # Stopping a script is ok to be done in parallel + await asyncio.wait( + [script.async_turn_off() for script + in component.async_extract_from_service(service)], loop=hass.loop) + + async def toggle_service(service): + """Toggle a script.""" + for script in component.async_extract_from_service(service): + await script.async_toggle(context=service.context) + + hass.services.async_register(DOMAIN, SERVICE_RELOAD, reload_service, + schema=RELOAD_SERVICE_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_TURN_ON, turn_on_service, + schema=SCRIPT_TURN_ONOFF_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_TURN_OFF, turn_off_service, + schema=SCRIPT_TURN_ONOFF_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_TOGGLE, toggle_service, + schema=SCRIPT_TURN_ONOFF_SCHEMA) + + return True + + +async def _async_process_config(hass, config, component): + """Process script configuration.""" + async def service_handler(service): + """Execute a service call to script.