diff --git a/.coveragerc b/.coveragerc index 028aacead28..734a5c7b78d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -38,6 +38,9 @@ omit = homeassistant/components/octoprint.py homeassistant/components/*/octoprint.py + homeassistant/components/qwikswitch.py + homeassistant/components/*/qwikswitch.py + homeassistant/components/rpi_gpio.py homeassistant/components/*/rpi_gpio.py @@ -111,18 +114,24 @@ omit = homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py homeassistant/components/media_player/firetv.py + homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py + homeassistant/components/media_player/lg_netcast.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/panasonic_viera.py homeassistant/components/media_player/pioneer.py homeassistant/components/media_player/plex.py + homeassistant/components/media_player/roku.py homeassistant/components/media_player/samsungtv.py homeassistant/components/media_player/snapcast.py homeassistant/components/media_player/sonos.py homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/yamaha.py + homeassistant/components/notify/aws_lambda.py + homeassistant/components/notify/aws_sns.py + homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py homeassistant/components/notify/googlevoice.py @@ -138,6 +147,7 @@ omit = homeassistant/components/notify/smtp.py homeassistant/components/notify/syslog.py homeassistant/components/notify/telegram.py + homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py homeassistant/components/scene/hunterdouglas_powerview.py @@ -153,6 +163,7 @@ omit = homeassistant/components/sensor/glances.py homeassistant/components/sensor/google_travel_time.py homeassistant/components/sensor/gtfs.py + homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/netatmo.py homeassistant/components/sensor/neurio_energy.py @@ -163,6 +174,7 @@ omit = homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/speedtest.py homeassistant/components/sensor/steam_online.py + homeassistant/components/sensor/supervisord.py homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/temper.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bb40754c97a..484fe20f11f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,8 @@ **Related issue (if applicable):** # +**Pull request in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) with documentation (if applicable):** home-assistant/home-assistant.io# + **Example entry for `configuration.yaml` (if applicable):** ```yaml @@ -10,6 +12,9 @@ **Checklist:** +If user exposed functionality or configuration variables are added/changed: + - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) + If code communicates with devices: - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass** - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). diff --git a/.gitignore b/.gitignore index 58b27eb7d49..f049564253f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ venv # vimmy stuff *.swp *.swo + +ctags.tmp diff --git a/Dockerfile b/Dockerfile index 013afcca674..9257a2be7d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,14 @@ RUN script/build_python_openzwave && \ COPY requirements_all.txt requirements_all.txt RUN pip3 install --no-cache-dir -r requirements_all.txt +RUN wget http://www.openssl.org/source/openssl-1.0.2h.tar.gz && \ + tar -xvzf openssl-1.0.2h.tar.gz && \ + cd openssl-1.0.2h && \ + ./config --prefix=/usr/ && \ + make && \ + make install && \ + rm -rf openssl-1.0.2h* + # Copy source COPY . . diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index f12758a354d..9494c2a02d1 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -3,11 +3,12 @@ from __future__ import print_function import argparse import os +import platform import signal +import subprocess import sys import threading import time -from multiprocessing import Process from homeassistant.const import ( __version__, @@ -87,8 +88,7 @@ def get_arguments(): parser.add_argument( '--debug', action='store_true', - help='Start Home Assistant in debug mode. Runs in single process to ' - 'enable use of interactive debuggers.') + help='Start Home Assistant in debug mode') parser.add_argument( '--open-ui', action='store_true', @@ -123,15 +123,20 @@ def get_arguments(): '--restart-osx', action='store_true', help='Restarts on OS X.') - if os.name != "nt": + parser.add_argument( + '--runner', + action='store_true', + help='On restart exit with code {}'.format(RESTART_EXIT_CODE)) + if os.name == "posix": parser.add_argument( '--daemon', action='store_true', help='Run Home Assistant as daemon') arguments = parser.parse_args() - if os.name == "nt": + if os.name != "posix" or arguments.debug or arguments.runner: arguments.daemon = False + return arguments @@ -144,13 +149,21 @@ def daemonize(): # Decouple fork os.setsid() - os.umask(0) # 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): """Check that HA is not already running.""" @@ -161,6 +174,10 @@ def check_pid(pid_file): # 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: @@ -220,29 +237,61 @@ def uninstall_osx(): print("Home Assistant has been uninstalled.") -def setup_and_run_hass(config_dir, args, top_process=False): - """Setup HASS and run. +def closefds_osx(min_fd, max_fd): + """Make sure file descriptors get closed when we restart. - Block until stopped. Will assume it is running in a subprocess unless - top_process is set to true. + 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(): + """Collect path and arguments to re-execute the current hass instance.""" + if sys.argv[0].endswith('/__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'] + + +def setup_and_run_hass(config_dir, args): + """Setup HASS and run.""" from homeassistant import bootstrap + # Run a simple daemon runner process on Windows to handle restarts + if os.name == 'nt' and '--runner' not in sys.argv: + args = cmdline() + ['--runner'] + while True: + try: + subprocess.check_call(args) + sys.exit(0) + except subprocess.CalledProcessError as exc: + if exc.returncode != RESTART_EXIT_CODE: + sys.exit(exc.returncode) + if args.demo_mode: config = { 'frontend': {}, 'demo': {} } hass = bootstrap.from_config_dict( - config, config_dir=config_dir, daemon=args.daemon, - verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + config, config_dir=config_dir, verbose=args.verbose, + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( - config_file, daemon=args.daemon, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + config_file, verbose=args.verbose, skip_pip=args.skip_pip, + log_rotate_days=args.log_rotate_days) if hass is None: return @@ -256,42 +305,68 @@ def setup_and_run_hass(config_dir, args, top_process=False): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser) + print('Starting Home-Assistant') hass.start() exit_code = int(hass.block_till_stopped()) - if not top_process: - sys.exit(exit_code) return exit_code -def run_hass_process(hass_proc): - """Run a child hass process. Returns True if it should be restarted.""" - requested_stop = threading.Event() - hass_proc.daemon = True +def try_to_restart(): + """Attempt to clean up state and start a new homeassistant 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') - def request_stop(*args): - """Request hass stop, *args is for signal handler callback.""" - requested_stop.set() - hass_proc.terminate() + # 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. + nthreads = sum(thread.isAlive() and not thread.isDaemon() + for thread in threading.enumerate()) + if nthreads > 1: + sys.stderr.write("Found {} non-daemonic threads.\n".format(nthreads)) - try: - signal.signal(signal.SIGTERM, request_stop) - except ValueError: - print('Could not bind to SIGTERM. Are you running in a thread?') + # Send terminate signal to all processes in our process group which + # should be any children that have not themselves changed the process + # group id. Don't bother if couldn't even call setpgid. + if hasattr(os, 'setpgid'): + sys.stderr.write("Signalling child processes to terminate...\n") + os.kill(0, signal.SIGTERM) - hass_proc.start() - try: - hass_proc.join() - except KeyboardInterrupt: - request_stop() + # wait for child processes to terminate try: - hass_proc.join() - except KeyboardInterrupt: - return False + while True: + time.sleep(1) + if os.waitpid(0, os.WNOHANG) == (0, 0): + break + except OSError: + pass - return (not requested_stop.isSet() and - hass_proc.exitcode == RESTART_EXIT_CODE, - hass_proc.exitcode) + elif os.name == 'nt': + # Maybe one of the following will work, but how do we indicate which + # processes are our children if there is no process group? + # os.kill(0, signal.CTRL_C_EVENT) + # os.kill(0, signal.CTRL_BREAK_EVENT) + pass + + # 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(): @@ -325,21 +400,17 @@ def main(): if args.pid_file: write_pid(args.pid_file) - # Run hass in debug mode if requested - if args.debug: - sys.stderr.write('Running in debug mode. ' - 'Home Assistant will not be able to restart.\n') - exit_code = setup_and_run_hass(config_dir, args, top_process=True) - if exit_code == RESTART_EXIT_CODE: - sys.stderr.write('Home Assistant requested a ' - 'restart in debug mode.\n') - return exit_code + # Create new process group if we can + if hasattr(os, 'setpgid'): + try: + os.setpgid(0, 0) + except PermissionError: + pass + + exit_code = setup_and_run_hass(config_dir, args) + if exit_code == RESTART_EXIT_CODE and not args.runner: + try_to_restart() - # Run hass as child process. Restart if necessary. - keep_running = True - while keep_running: - hass_proc = Process(target=setup_and_run_hass, args=(config_dir, args)) - keep_running, exit_code = run_hass_process(hass_proc) return exit_code diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index a8e85ca3bd3..99382bebe74 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -215,7 +215,7 @@ def mount_local_lib_path(config_dir): # pylint: disable=too-many-branches, too-many-statements, too-many-arguments def from_config_dict(config, hass=None, config_dir=None, enable_log=True, - verbose=False, daemon=False, skip_pip=False, + verbose=False, skip_pip=False, log_rotate_days=None): """Try to configure Home Assistant from a config dict. @@ -240,7 +240,7 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, process_ha_config_upgrade(hass) if enable_log: - enable_logging(hass, verbose, daemon, log_rotate_days) + enable_logging(hass, verbose, log_rotate_days) hass.config.skip_pip = skip_pip if skip_pip: @@ -278,8 +278,8 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, return hass -def from_config_file(config_path, hass=None, verbose=False, daemon=False, - skip_pip=True, log_rotate_days=None): +def from_config_file(config_path, hass=None, verbose=False, skip_pip=True, + log_rotate_days=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -293,7 +293,7 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False, hass.config.config_dir = config_dir mount_local_lib_path(config_dir) - enable_logging(hass, verbose, daemon, log_rotate_days) + enable_logging(hass, verbose, log_rotate_days) try: config_dict = config_util.load_yaml_config_file(config_path) @@ -304,28 +304,27 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False, skip_pip=skip_pip) -def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): +def enable_logging(hass, verbose=False, log_rotate_days=None): """Setup the logging.""" - if not daemon: - logging.basicConfig(level=logging.INFO) - fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) " - "[%(name)s] %(message)s%(reset)s") - try: - from colorlog import ColoredFormatter - logging.getLogger().handlers[0].setFormatter(ColoredFormatter( - fmt, - datefmt='%y-%m-%d %H:%M:%S', - reset=True, - log_colors={ - 'DEBUG': 'cyan', - 'INFO': 'green', - 'WARNING': 'yellow', - 'ERROR': 'red', - 'CRITICAL': 'red', - } - )) - except ImportError: - pass + logging.basicConfig(level=logging.INFO) + fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) " + "[%(name)s] %(message)s%(reset)s") + try: + from colorlog import ColoredFormatter + logging.getLogger().handlers[0].setFormatter(ColoredFormatter( + fmt, + datefmt='%y-%m-%d %H:%M:%S', + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + except ImportError: + pass # Log errors to a file if we have write access to file or config dir err_log_path = hass.config.path(ERROR_LOG_FILENAME) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 8a5c993b683..3a148b0880f 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -16,7 +16,7 @@ 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_id, + vol.Required(CONF_ENTITY_ID): cv.entity_ids, CONF_BELOW: vol.Coerce(float), CONF_ABOVE: vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -41,7 +41,7 @@ def trigger(hass, config, action): variables = { 'trigger': { 'platform': 'numeric_state', - 'entity_id': entity_id, + 'entity_id': entity, 'below': below, 'above': above, } diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index aecaa1cfadc..8705f9ce077 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -19,6 +19,8 @@ SERVICE_CONFIGURE = "configure" STATE_CONFIGURE = "configure" STATE_CONFIGURED = "configured" +ATTR_LINK_NAME = "link_name" +ATTR_LINK_URL = "link_url" ATTR_CONFIGURE_ID = "configure_id" ATTR_DESCRIPTION = "description" ATTR_DESCRIPTION_IMAGE = "description_image" @@ -34,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=too-many-arguments def request_config( hass, name, callback, description=None, description_image=None, - submit_caption=None, fields=None): + submit_caption=None, fields=None, link_name=None, link_url=None): """Create a new request for configuration. Will return an ID to be used for sequent calls. @@ -43,7 +45,8 @@ def request_config( request_id = instance.request_config( name, callback, - description, description_image, submit_caption, fields) + description, description_image, submit_caption, + fields, link_name, link_url) _REQUESTS[request_id] = instance @@ -100,7 +103,8 @@ class Configurator(object): # pylint: disable=too-many-arguments def request_config( self, name, callback, - description, description_image, submit_caption, fields): + description, description_image, submit_caption, + fields, link_name, link_url): """Setup a request for configuration.""" entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass) @@ -121,6 +125,8 @@ class Configurator(object): (ATTR_DESCRIPTION, description), (ATTR_DESCRIPTION_IMAGE, description_image), (ATTR_SUBMIT_CAPTION, submit_caption), + (ATTR_LINK_NAME, link_name), + (ATTR_LINK_URL, link_url), ] if value is not None }) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 0570d20c262..81299479772 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -21,6 +21,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ 'camera', 'device_tracker', 'garage_door', + 'hvac', 'light', 'lock', 'media_player', diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index b4784505d2d..fb8a76a1488 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -19,13 +19,16 @@ from homeassistant.util import Throttle MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['pexpect==4.0.1'] +_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases' _LEASES_REGEX = re.compile( r'\w+\s' + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + r'(?P([^\s]+))') +_IP_NEIGH_CMD = 'ip neigh' _IP_NEIGH_REGEX = re.compile( r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + r'\w+\s' + @@ -55,6 +58,7 @@ class AsusWrtDeviceScanner(object): self.host = config[CONF_HOST] self.username = str(config[CONF_USERNAME]) self.password = str(config[CONF_PASSWORD]) + self.protocol = config.get('protocol') self.lock = threading.Lock() @@ -100,8 +104,26 @@ class AsusWrtDeviceScanner(object): self.last_results = active_clients return True - def get_asuswrt_data(self): - """Retrieve data from ASUSWRT and return parsed result.""" + def ssh_connection(self): + """Retrieve data from ASUSWRT via the ssh protocol.""" + from pexpect import pxssh + try: + ssh = pxssh.pxssh() + ssh.login(self.host, self.username, self.password) + ssh.sendline(_IP_NEIGH_CMD) + ssh.prompt() + neighbors = ssh.before.split(b'\n')[1:-1] + ssh.sendline(_LEASES_CMD) + ssh.prompt() + leases_result = ssh.before.split(b'\n')[1:-1] + ssh.logout() + return (neighbors, leases_result) + except pxssh.ExceptionPxssh as exc: + _LOGGER.exception('Unexpected response from router: %s', exc) + return ('', '') + + def telnet_connection(self): + """Retrieve data from ASUSWRT via the telnet protocol.""" try: telnet = telnetlib.Telnet(self.host) telnet.read_until(b'login: ') @@ -109,18 +131,26 @@ class AsusWrtDeviceScanner(object): telnet.read_until(b'Password: ') telnet.write((self.password + '\n').encode('ascii')) prompt_string = telnet.read_until(b'#').split(b'\n')[-1] - telnet.write('ip neigh\n'.encode('ascii')) + telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1] - telnet.write('cat /var/lib/misc/dnsmasq.leases\n'.encode('ascii')) + telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1] telnet.write('exit\n'.encode('ascii')) + return (neighbors, leases_result) except EOFError: _LOGGER.exception("Unexpected response from router") - return + return ('', '') except ConnectionRefusedError: - _LOGGER.exception("Connection refused by router," + + _LOGGER.exception("Connection refused by router," " is telnet enabled?") - return + return ('', '') + + def get_asuswrt_data(self): + """Retrieve data from ASUSWRT and return parsed result.""" + if self.protocol == 'telnet': + neighbors, leases_result = self.telnet_connection() + else: + neighbors, leases_result = self.ssh_connection() devices = {} for lease in leases_result: diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 8254285a98b..f59fc06c59a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -11,7 +11,7 @@ from collections import defaultdict import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME -from homeassistant.util import convert +from homeassistant.util import convert, slugify DEPENDENCIES = ['mqtt'] @@ -53,6 +53,12 @@ def setup_scanner(hass, config, see): 'accuracy %s is not met: %s', data_type, max_gps_accuracy, data) return None + if convert(data.get('acc'), float, 1.0) == 0.0: + _LOGGER.debug('Skipping %s update because GPS accuracy' + 'is zero', + data_type) + return None + return data def owntracks_location_update(topic, payload, qos): @@ -91,7 +97,7 @@ def setup_scanner(hass, config, see): return # OwnTracks uses - at the start of a beacon zone # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") + location = slugify(data['desc'].lstrip("-")) if location.lower() == 'home': location = STATE_HOME diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 1e3ee660bd6..fac94aacf69 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -15,10 +15,12 @@ from homeassistant.const import ( EVENT_PLATFORM_DISCOVERED) DOMAIN = "discovery" -REQUIREMENTS = ['netdisco==0.6.6'] +REQUIREMENTS = ['netdisco==0.6.7'] SCAN_INTERVAL = 300 # seconds +LOAD_PLATFORM = 'load_platform' + SERVICE_WEMO = 'belkin_wemo' SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' @@ -27,6 +29,7 @@ SERVICE_SONOS = 'sonos' SERVICE_PLEX = 'plex_mediaserver' SERVICE_SQUEEZEBOX = 'logitech_mediaserver' SERVICE_PANASONIC_VIERA = 'panasonic_viera' +SERVICE_ROKU = 'roku' SERVICE_HANDLERS = { SERVICE_WEMO: "wemo", @@ -37,6 +40,7 @@ SERVICE_HANDLERS = { SERVICE_PLEX: 'media_player', SERVICE_SQUEEZEBOX: 'media_player', SERVICE_PANASONIC_VIERA: 'media_player', + SERVICE_ROKU: 'media_player', } @@ -52,7 +56,7 @@ def listen(hass, service, callback): def discovery_event_listener(event): """Listen for discovery events.""" - if event.data[ATTR_SERVICE] in service: + if ATTR_SERVICE in event.data and event.data[ATTR_SERVICE] in service: callback(event.data[ATTR_SERVICE], event.data.get(ATTR_DISCOVERED)) hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) @@ -73,6 +77,32 @@ def discover(hass, service, discovered=None, component=None, hass_config=None): hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data) +def load_platform(hass, component, platform, info=None, hass_config=None): + """Helper method for generic platform loading. + + This method allows a platform to be loaded dynamically without it being + known at runtime (in the DISCOVERY_PLATFORMS list of the component). + Advantages of using this method: + - Any component & platforms combination can be dynamically added + - A component (i.e. light) does not have to import every component + that can dynamically add a platform (e.g. wemo, wink, insteon_hub) + - Custom user components can take advantage of discovery/loading + + Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be + fired to load the platform. The event will contain: + { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + ATTR_DISCOVERED = {LOAD_PLATFORM: <>} } + + * dev note: This listener can be found in entity_component.py + """ + if info is None: + info = {LOAD_PLATFORM: platform} + else: + info[LOAD_PLATFORM] = platform + discover(hass, LOAD_PLATFORM + '.' + component, info, component, + hass_config) + + def setup(hass, config): """Start a discovery service.""" logger = logging.getLogger(__name__) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 21dd73ea6e6..11c49fd44eb 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -22,7 +22,7 @@ HOLD_TEMP = 'hold_temp' REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' - '92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4'] + '4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 4847879d4d3..8bdf1755b04 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """DO NOT MODIFY. Auto-generated by build_frontend script.""" -VERSION = "77c51c270b0241ce7ba0d1df2d254d6f" +VERSION = "0a226e905af198b2dabf1ce154844568" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 367f302d4cd..09118970c8d 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,8 +1,8 @@ \ No newline at end of file + clear: both;white-space:pre-wrap} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 6a8e6a5a081..4a667eb77e2 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 6a8e6a5a081415690bf89e87697d15b6ce9ebf8b +Subproject commit 4a667eb77e28a27dc766ca6f7bbd04e3866124d9 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 5cb7633b20f..4346db8b9a0 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={i:r,l:!1,exports:{}};return e[r].call(s.exports,s,s.exports,t),s.l=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=192)}({192:function(e,t,n){var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){return n||fetch(e.request).then(function(e){return t.put(s,e.clone()),e})})}))})}}); \ No newline at end of file +!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={i:r,l:!1,exports:{}};return e[r].call(s.exports,s,s.exports,t),s.l=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=194)}({194:function(e,t,n){var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){return n||fetch(e.request).then(function(e){return t.put(s,e.clone()),e})})}))})}}); \ No newline at end of file diff --git a/homeassistant/components/hvac/__init__.py b/homeassistant/components/hvac/__init__.py index d514db364b8..c57cb4e23ff 100644 --- a/homeassistant/components/hvac/__init__.py +++ b/homeassistant/components/hvac/__init__.py @@ -29,7 +29,7 @@ SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_TEMPERATURE = "set_temperature" SERVICE_SET_FAN_MODE = "set_fan_mode" SERVICE_SET_OPERATION_MODE = "set_operation_mode" -SERVICE_SET_SWING = "set_swing_mode" +SERVICE_SET_SWING_MODE = "set_swing_mode" SERVICE_SET_HUMIDITY = "set_humidity" STATE_HEAT = "heat" @@ -40,17 +40,17 @@ STATE_DRY = "dry" STATE_FAN_ONLY = "fan_only" ATTR_CURRENT_TEMPERATURE = "current_temperature" -ATTR_CURRENT_HUMIDITY = "current_humidity" -ATTR_HUMIDITY = "humidity" -ATTR_AWAY_MODE = "away_mode" -ATTR_AUX_HEAT = "aux_heat" -ATTR_FAN = "fan" -ATTR_FAN_LIST = "fan_list" ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" +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_OPERATION = "operation_mode" +ATTR_OPERATION_MODE = "operation_mode" ATTR_OPERATION_LIST = "operation_list" ATTR_SWING_MODE = "swing_mode" ATTR_SWING_LIST = "swing_list" @@ -108,7 +108,7 @@ def set_humidity(hass, humidity, entity_id=None): def set_fan_mode(hass, fan, entity_id=None): """Turn all or specified hvac fan mode on.""" - data = {ATTR_FAN: fan} + data = {ATTR_FAN_MODE: fan} if entity_id: data[ATTR_ENTITY_ID] = entity_id @@ -118,7 +118,7 @@ def set_fan_mode(hass, fan, entity_id=None): def set_operation_mode(hass, operation_mode, entity_id=None): """Set new target operation mode.""" - data = {ATTR_OPERATION: operation_mode} + data = {ATTR_OPERATION_MODE: operation_mode} if entity_id is not None: data[ATTR_ENTITY_ID] = entity_id @@ -133,7 +133,7 @@ def set_swing_mode(hass, swing_mode, entity_id=None): if entity_id is not None: data[ATTR_ENTITY_ID] = entity_id - hass.services.call(DOMAIN, SERVICE_SET_SWING, data) + hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) # pylint: disable=too-many-branches @@ -247,12 +247,12 @@ def setup(hass, config): """Set fan mode on target hvacs.""" target_hvacs = component.extract_from_service(service) - fan = service.data.get(ATTR_FAN) + fan = service.data.get(ATTR_FAN_MODE) if fan is None: _LOGGER.error( "Received call to %s without attribute %s", - SERVICE_SET_FAN_MODE, ATTR_FAN) + SERVICE_SET_FAN_MODE, ATTR_FAN_MODE) return for hvac in target_hvacs: @@ -269,16 +269,16 @@ def setup(hass, config): """Set operating mode on the target hvacs.""" target_hvacs = component.extract_from_service(service) - operation_mode = service.data.get(ATTR_OPERATION) + operation_mode = service.data.get(ATTR_OPERATION_MODE) if operation_mode is None: _LOGGER.error( "Received call to %s without attribute %s", - SERVICE_SET_OPERATION_MODE, ATTR_OPERATION) + SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE) return for hvac in target_hvacs: - hvac.set_operation(operation_mode) + hvac.set_operation_mode(operation_mode) if hvac.should_poll: hvac.update_ha_state(True) @@ -296,18 +296,18 @@ def setup(hass, config): if swing_mode is None: _LOGGER.error( "Received call to %s without attribute %s", - SERVICE_SET_SWING, ATTR_SWING_MODE) + SERVICE_SET_SWING_MODE, ATTR_SWING_MODE) return for hvac in target_hvacs: - hvac.set_swing(swing_mode) + hvac.set_swing_mode(swing_mode) if hvac.should_poll: hvac.update_ha_state(True) hass.services.register( - DOMAIN, SERVICE_SET_SWING, swing_set_service, - descriptions.get(SERVICE_SET_SWING)) + DOMAIN, SERVICE_SET_SWING_MODE, swing_set_service, + descriptions.get(SERVICE_SET_SWING_MODE)) return True @@ -330,19 +330,30 @@ class HvacDevice(Entity): ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), ATTR_TEMPERATURE: self._convert_for_display(self.target_temperature), - ATTR_HUMIDITY: self.target_humidity, - ATTR_CURRENT_HUMIDITY: self.current_humidity, - ATTR_MIN_HUMIDITY: self.min_humidity, - ATTR_MAX_HUMIDITY: self.max_humidity, - ATTR_FAN_LIST: self.fan_list, - ATTR_OPERATION_LIST: self.operation_list, - ATTR_SWING_LIST: self.swing_list, - ATTR_OPERATION: self.current_operation, - ATTR_FAN: self.current_fan_mode, - ATTR_SWING_MODE: self.current_swing_mode, - } + humidity = self.target_humidity + if humidity is not None: + data[ATTR_HUMIDITY] = humidity + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + data[ATTR_MIN_HUMIDITY] = self.min_humidity + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + fan_mode = self.current_fan_mode + if fan_mode is not None: + data[ATTR_FAN_MODE] = fan_mode + data[ATTR_FAN_LIST] = self.fan_list + + operation_mode = self.current_operation + if operation_mode is not None: + data[ATTR_OPERATION_MODE] = operation_mode + data[ATTR_OPERATION_LIST] = self.operation_list + + swing_mode = self.current_swing_mode + if swing_mode is not None: + data[ATTR_SWING_MODE] = swing_mode + data[ATTR_SWING_LIST] = self.swing_list + is_away = self.is_away_mode_on if is_away is not None: data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF @@ -430,11 +441,11 @@ class HvacDevice(Entity): """Set new target fan mode.""" pass - def set_operation(self, operation_mode): + def set_operation_mode(self, operation_mode): """Set new target operation mode.""" pass - def set_swing(self, swing_mode): + def set_swing_mode(self, swing_mode): """Set new target swing operation.""" pass @@ -457,12 +468,12 @@ class HvacDevice(Entity): @property def min_temp(self): """Return the minimum temperature.""" - return self._convert_for_display(7) + return convert(7, TEMP_CELCIUS, self.unit_of_measurement) @property def max_temp(self): """Return the maximum temperature.""" - return self._convert_for_display(35) + return convert(35, TEMP_CELCIUS, self.unit_of_measurement) @property def min_humidity(self): diff --git a/homeassistant/components/hvac/demo.py b/homeassistant/components/hvac/demo.py index cb2f0c4b364..9e4f2c15d29 100644 --- a/homeassistant/components/hvac/demo.py +++ b/homeassistant/components/hvac/demo.py @@ -118,7 +118,7 @@ class DemoHvac(HvacDevice): self._target_humidity = humidity self.update_ha_state() - def set_swing(self, swing_mode): + def set_swing_mode(self, swing_mode): """Set new target temperature.""" self._current_swing_mode = swing_mode self.update_ha_state() @@ -128,7 +128,7 @@ class DemoHvac(HvacDevice): self._current_fan_mode = fan self.update_ha_state() - def set_operation(self, operation_mode): + def set_operation_mode(self, operation_mode): """Set new target temperature.""" self._current_operation = operation_mode self.update_ha_state() diff --git a/homeassistant/components/hvac/zwave.py b/homeassistant/components/hvac/zwave.py old mode 100644 new mode 100755 index f02a3e74f98..a170d3a9e79 --- a/homeassistant/components/hvac/zwave.py +++ b/homeassistant/components/hvac/zwave.py @@ -1,5 +1,9 @@ -"""ZWave Hvac device.""" +""" +Support for ZWave HVAC devices. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hvac.zwave/ +""" # Because we do not compile openzwave on CI # pylint: disable=import-error import logging @@ -19,6 +23,12 @@ REMOTEC = 0x5254 REMOTEC_ZXT_120 = 0x8377 REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120, 0) +COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31 +COMMAND_CLASS_THERMOSTAT_MODE = 0x40 +COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43 +COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44 +COMMAND_CLASS_CONFIGURATION = 0x70 + WORKAROUND_ZXT_120 = 'zxt_120' DEVICE_MAPPINGS = { @@ -96,22 +106,24 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): def update_properties(self): """Callback on data change for the registered node/value pair.""" # Set point - for value in self._node.get_values(class_id=0x43).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if int(value.data) != 0: self._target_temperature = int(value.data) # Operation Mode - for value in self._node.get_values(class_id=0x40).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): self._current_operation = value.data self._operation_list = list(value.data_items) _LOGGER.debug("self._operation_list=%s", self._operation_list) # Current Temp - for value in self._node.get_values(class_id=0x31).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_SENSOR_MULTILEVEL).values(): self._current_temperature = int(value.data) self._unit = value.units # Fan Mode - fan_class_id = 0x44 if self._zxt_120 else 0x42 - _LOGGER.debug("fan_class_id=%s", fan_class_id) - for value in self._node.get_values(class_id=fan_class_id).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): self._current_operation_state = value.data self._fan_list = list(value.data_items) _LOGGER.debug("self._fan_list=%s", self._fan_list) @@ -119,7 +131,8 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): self._current_operation_state) # Swing mode if self._zxt_120 == 1: - for value in self._node.get_values(class_id=0x70).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): if value.command_class == 112 and value.index == 33: self._current_swing_mode = value.data self._swing_list = [0, 1] @@ -184,7 +197,8 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): def set_temperature(self, temperature): """Set new target temperature.""" - for value in self._node.get_values(class_id=0x43).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if value.command_class != 67: continue if self._zxt_120: @@ -200,20 +214,23 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): def set_fan_mode(self, fan): """Set new target fan mode.""" - for value in self._node.get_values(class_id=0x44).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): if value.command_class == 68 and value.index == 0: value.data = bytes(fan, 'utf-8') - def set_operation(self, operation_mode): + def set_operation_mode(self, operation_mode): """Set new target operation mode.""" - for value in self._node.get_values(class_id=0x40).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): if value.command_class == 64 and value.index == 0: value.data = bytes(operation_mode, 'utf-8') - def set_swing(self, swing_mode): + def set_swing_mode(self, swing_mode): """Set new target swing mode.""" if self._zxt_120 == 1: - for value in self._node.get_values(class_id=0x70).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): if value.command_class == 112 and value.index == 33: value.data = int(swing_mode) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index f78e1dbdcba..d1fe0b93f4c 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -39,6 +39,7 @@ ATTR_TRANSITION = "transition" ATTR_RGB_COLOR = "rgb_color" ATTR_XY_COLOR = "xy_color" ATTR_COLOR_TEMP = "color_temp" +ATTR_COLOR_NAME = "color_name" # int with value 0 .. 255 representing brightness of the light. ATTR_BRIGHTNESS = "brightness" @@ -87,6 +88,7 @@ LIGHT_TURN_ON_SCHEMA = vol.Schema({ ATTR_PROFILE: str, ATTR_TRANSITION: VALID_TRANSITION, ATTR_BRIGHTNESS: cv.byte, + ATTR_COLOR_NAME: str, ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)), ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)), @@ -122,7 +124,7 @@ def is_on(hass, entity_id=None): # pylint: disable=too-many-arguments def turn_on(hass, entity_id=None, transition=None, brightness=None, rgb_color=None, xy_color=None, color_temp=None, profile=None, - flash=None, effect=None): + flash=None, effect=None, color_name=None): """Turn all or specified light on.""" data = { key: value for key, value in [ @@ -135,6 +137,7 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, (ATTR_COLOR_TEMP, color_temp), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), + (ATTR_COLOR_NAME, color_name), ] if value is not None } @@ -228,6 +231,11 @@ def setup(hass, config): params.setdefault(ATTR_XY_COLOR, profile[:2]) params.setdefault(ATTR_BRIGHTNESS, profile[2]) + color_name = params.pop(ATTR_COLOR_NAME, None) + + if color_name is not None: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + for light in target_lights: light.turn_on(**params) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 60c1e7f6605..614df66b133 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -235,14 +235,16 @@ class HueLight(Light): if ATTR_TRANSITION in kwargs: command['transitiontime'] = kwargs[ATTR_TRANSITION] * 10 - if ATTR_BRIGHTNESS in kwargs: - command['bri'] = kwargs[ATTR_BRIGHTNESS] - if ATTR_XY_COLOR in kwargs: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - command['xy'] = color_util.color_RGB_to_xy( + xyb = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + command['xy'] = xyb[0], xyb[1] + command['bri'] = xyb[2] + + if ATTR_BRIGHTNESS in kwargs: + command['bri'] = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP in kwargs: command['ct'] = kwargs[ATTR_COLOR_TEMP] diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py new file mode 100644 index 00000000000..e0681e68b87 --- /dev/null +++ b/homeassistant/components/light/qwikswitch.py @@ -0,0 +1,35 @@ +""" +Support for Qwikswitch Relays and Dimmers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.qwikswitch/ +""" +import logging +import homeassistant.components.qwikswitch as qwikswitch +from homeassistant.components.light import Light + +DEPENDENCIES = ['qwikswitch'] + + +class QSLight(qwikswitch.QSToggleEntity, Light): + """Light based on a Qwikswitch relay/dimmer module.""" + + pass + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Store add_devices for the light components.""" + if discovery_info is None or 'qsusb_id' not in discovery_info: + logging.getLogger(__name__).error( + 'Configure main Qwikswitch component') + return False + + qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']] + + for item in qsusb.ha_devices: + if item['type'] not in ['dim', 'rel']: + continue + dev = QSLight(item, qsusb) + add_devices([dev]) + qsusb.ha_objects[item['id']] = dev diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 8ad2ea97a6b..392be490dc3 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -16,6 +16,10 @@ turn_on: description: Color for the light in RGB-format example: '[255, 100, 100]' + color_name: + description: A human readable color name + example: 'red' + xy_color: description: Color for the light in XY-format example: '[0.52, 0.43]' diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index d2844826400..a4aa6686a17 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -105,6 +105,7 @@ class WemoLight(Light): elif ATTR_RGB_COLOR in kwargs: xycolor = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + kwargs.setdefault(ATTR_BRIGHTNESS, xycolor[2]) else: xycolor = None diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 86d2a29c21f..c7a7637b047 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -97,7 +97,9 @@ class WinkLight(Light): } if rgb_color: - state_kwargs['color_xy'] = color_util.color_RGB_to_xy(*rgb_color) + xyb = color_util.color_RGB_to_xy(*rgb_color) + state_kwargs['color_xy'] = xyb[0], xyb[1] + state_kwargs['brightness'] = xyb[2] if color_temp_mired: state_kwargs['color_kelvin'] = mired_to_kelvin(color_temp_mired) diff --git a/homeassistant/components/logentries.py b/homeassistant/components/logentries.py new file mode 100644 index 00000000000..5aaaf2df562 --- /dev/null +++ b/homeassistant/components/logentries.py @@ -0,0 +1,61 @@ +""" +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 homeassistant.util as util +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.helpers import state as state_helper +from homeassistant.helpers import validate_config + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "logentries" +DEPENDENCIES = [] + +DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/' + +CONF_TOKEN = 'token' + + +def setup(hass, config): + """Setup the Logentries component.""" + if not validate_config(config, {DOMAIN: ['token']}, _LOGGER): + _LOGGER.error("Logentries token not present") + return False + conf = config[DOMAIN] + token = util.convert(conf.get(CONF_TOKEN), str) + le_wh = 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/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ff8eb8113b9..c5e2eb00e57 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -36,6 +36,7 @@ DISCOVERY_PLATFORMS = { discovery.SERVICE_PLEX: 'plex', discovery.SERVICE_SQUEEZEBOX: 'squeezebox', discovery.SERVICE_PANASONIC_VIERA: 'panasonic_viera', + discovery.SERVICE_ROKU: 'roku', } SERVICE_PLAY_MEDIA = 'play_media' @@ -62,6 +63,7 @@ ATTR_APP_NAME = 'app_name' ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands' ATTR_INPUT_SOURCE = 'source' ATTR_INPUT_SOURCE_LIST = 'source_list' +ATTR_MEDIA_ENQUEUE = 'enqueue' MEDIA_TYPE_MUSIC = 'music' MEDIA_TYPE_TVSHOW = 'tvshow' @@ -144,6 +146,7 @@ MEDIA_PLAYER_MEDIA_SEEK_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, + ATTR_MEDIA_ENQUEUE: cv.boolean, }) MEDIA_PLAYER_SELECT_SOURCE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ @@ -255,7 +258,7 @@ def media_seek(hass, position, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_SEEK, data) -def play_media(hass, media_type, media_id, entity_id=None): +def play_media(hass, media_type, media_id, entity_id=None, enqueue=None): """Send the media player the command for playing media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} @@ -263,6 +266,9 @@ def play_media(hass, media_type, media_id, entity_id=None): if entity_id: data[ATTR_ENTITY_ID] = entity_id + if enqueue: + data[ATTR_MEDIA_ENQUEUE] = enqueue + hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data) @@ -363,9 +369,14 @@ def setup(hass, config): """Play specified media_id on the media player.""" media_type = service.data.get(ATTR_MEDIA_CONTENT_TYPE) media_id = service.data.get(ATTR_MEDIA_CONTENT_ID) + enqueue = service.data.get(ATTR_MEDIA_ENQUEUE) + + kwargs = { + ATTR_MEDIA_ENQUEUE: enqueue, + } for player in component.extract_from_service(service): - player.play_media(media_type, media_id) + player.play_media(media_type, media_id, **kwargs) if player.should_poll: player.update_ha_state(True) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 022a2d2d762..6c05984d9a4 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -253,7 +253,7 @@ class CastDevice(MediaPlayerDevice): """Seek the media to a specific location.""" self.cast.media_controller.seek(position) - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Play media from a URL.""" self.cast.media_controller.play_media(media_id, media_type) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index e4015cd5b71..ddc5b368d78 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -152,7 +152,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer): """Flag of media commands that are supported.""" return YOUTUBE_PLAYER_SUPPORT - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" self.youtube_id = media_id self.update_ha_state() diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py new file mode 100644 index 00000000000..8259d043cf3 --- /dev/null +++ b/homeassistant/components/media_player/gpmdp.py @@ -0,0 +1,158 @@ +""" +Support for Google Play Music Desktop Player. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.gpmdp/ +""" +import logging +import json +import socket + +from homeassistant.components.media_player import ( + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, + SUPPORT_PAUSE, MediaPlayerDevice) +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_OFF) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['websocket-client==0.35.0'] +SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the GPMDP platform.""" + from websocket import create_connection + + name = config.get("name", "GPM Desktop Player") + address = config.get("address") + + if address is None: + _LOGGER.error("Missing address in config") + return False + + add_devices([GPMDP(name, address, create_connection)]) + + +class GPMDP(MediaPlayerDevice): + """Representation of a GPMDP.""" + + # pylint: disable=too-many-public-methods, abstract-method + # pylint: disable=too-many-instance-attributes + def __init__(self, name, address, create_connection): + """Initialize the media player.""" + self._connection = create_connection + self._address = address + self._name = name + self._status = STATE_OFF + self._ws = None + self._title = None + self._artist = None + self._albumart = None + self.update() + + def get_ws(self): + """Check if the websocket is setup and connected.""" + if self._ws is None: + try: + self._ws = self._connection(("ws://" + self._address + + ":5672"), timeout=1) + except (socket.timeout, ConnectionRefusedError, + ConnectionResetError): + self._ws = None + elif self._ws.connected is True: + self._ws.close() + try: + self._ws = self._connection(("ws://" + self._address + + ":5672"), timeout=1) + except (socket.timeout, ConnectionRefusedError, + ConnectionResetError): + self._ws = None + return self._ws + + def update(self): + """Get the latest details from the player.""" + websocket = self.get_ws() + if websocket is None: + self._status = STATE_OFF + return + else: + state = websocket.recv() + state = ((json.loads(state))['payload']) + if state is True: + websocket.recv() + websocket.recv() + song = websocket.recv() + song = json.loads(song) + self._title = (song['payload']['title']) + self._artist = (song['payload']['artist']) + self._albumart = (song['payload']['albumArt']) + self._status = STATE_PLAYING + elif state is False: + self._status = STATE_PAUSED + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def state(self): + """Return the state of the device.""" + return self._status + + @property + def media_title(self): + """Title of current playing media.""" + return self._title + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + return self._artist + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._albumart + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + return SUPPORT_GPMDP + + def media_next_track(self): + """Send media_next command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "forward"}') + + def media_previous_track(self): + """Send media_previous command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "rewind"}') + + def media_play(self): + """Send media_play command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "playPause"}') + self._status = STATE_PAUSED + self.update_ha_state() + + def media_pause(self): + """Send media_pause command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "playPause"}') + self._status = STATE_PAUSED + self.update_ha_state() diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py index 9418d1c5703..60f12456812 100644 --- a/homeassistant/components/media_player/itunes.py +++ b/homeassistant/components/media_player/itunes.py @@ -320,7 +320,7 @@ class ItunesDevice(MediaPlayerDevice): response = self.client.previous() self.update_state(response) - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Send the play_media command to the media player.""" if media_type == MEDIA_TYPE_PLAYLIST: response = self.client.play_playlist(media_id) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index e1c4bd79c8f..4dc306d03e7 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -278,6 +278,6 @@ class KodiDevice(MediaPlayerDevice): self.update_ha_state() - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Send the play_media command to the media player.""" self._server.Player.Open({media_type: media_id}, {}) diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py new file mode 100644 index 00000000000..fa215731d0d --- /dev/null +++ b/homeassistant/components/media_player/lg_netcast.py @@ -0,0 +1,210 @@ +""" +Support for LG TV running on NetCast 3 or 4. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.lg_netcast/ +""" +from datetime import timedelta +import logging + +from requests import RequestException +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_SELECT_SOURCE, MEDIA_TYPE_CHANNEL, MediaPlayerDevice) +from homeassistant.const import ( + CONF_PLATFORM, CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, + STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) +import homeassistant.util as util + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' + 'v0.2.0.zip#pylgnetcast==0.2.0'] + +SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) + +DEFAULT_NAME = 'LG TV Remote' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "lg_netcast", + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the LG TV platform.""" + from pylgnetcast import LgNetCastClient + client = LgNetCastClient(config[CONF_HOST], config[CONF_ACCESS_TOKEN]) + add_devices([LgTVDevice(client, config[CONF_NAME])]) + + +# pylint: disable=too-many-public-methods, abstract-method +# pylint: disable=too-many-instance-attributes +class LgTVDevice(MediaPlayerDevice): + """Representation of a LG TV.""" + + def __init__(self, client, name): + """Initialize the LG TV device.""" + self._client = client + self._name = name + self._muted = False + # Assume that the TV is in Play mode + self._playing = True + self._volume = 0 + self._channel_name = '' + self._program_name = '' + self._state = STATE_UNKNOWN + self._sources = {} + self._source_names = [] + + self.update() + + def send_command(self, command): + """Send remote control commands to the TV.""" + from pylgnetcast import LgNetCastError + try: + with self._client as client: + client.send_command(command) + except (LgNetCastError, RequestException): + self._state = STATE_OFF + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update(self): + """Retrieve the latest data from the LG TV.""" + from pylgnetcast import LgNetCastError + try: + with self._client as client: + self._state = STATE_PLAYING + volume_info = client.query_data('volume_info') + if volume_info: + volume_info = volume_info[0] + self._volume = float(volume_info.find('level').text) + self._muted = volume_info.find('mute').text == 'true' + + channel_info = client.query_data('cur_channel') + if channel_info: + channel_info = channel_info[0] + self._channel_name = channel_info.find('chname').text + self._program_name = channel_info.find('progName').text + + channel_list = client.query_data('channel_list') + if channel_list: + channel_names = [str(c.find('chname').text) for + c in channel_list] + self._sources = dict(zip(channel_names, channel_list)) + # sort source names by the major channel number + source_tuples = [(k, self._sources[k].find('major').text) + for k in self._sources.keys()] + sorted_sources = sorted( + source_tuples, key=lambda channel: int(channel[1])) + self._source_names = [n for n, k in sorted_sources] + except (LgNetCastError, RequestException): + self._state = STATE_OFF + + @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 + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume / 100.0 + + @property + def source(self): + """Return the current input source.""" + return self._channel_name + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_CHANNEL + + @property + def media_channel(self): + """Channel currently playing.""" + return self._channel_name + + @property + def media_title(self): + """Title of current playing media.""" + return self._program_name + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + return SUPPORT_LGTV + + def turn_off(self): + """Turn off media player.""" + self.send_command(1) + + def volume_up(self): + """Volume up the media player.""" + self.send_command(24) + + def volume_down(self): + """Volume down media player.""" + self.send_command(25) + + def mute_volume(self, mute): + """Send mute command.""" + self.send_command(26) + + def select_source(self, source): + """Select input source.""" + self._client.change_channel(self._sources[source]) + + def media_play_pause(self): + """Simulate play pause media player.""" + if self._playing: + self.media_pause() + else: + self.media_play() + + def media_play(self): + """Send play command.""" + self._playing = True + self._state = STATE_PLAYING + self.send_command(33) + + def media_pause(self): + """Send media pause command to media player.""" + self._playing = False + self._state = STATE_PAUSED + self.send_command(34) + + def media_next_track(self): + """Send next track command.""" + self.send_command(36) + + def media_previous_track(self): + """Send the previous track command.""" + self.send_command(37) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 266203e52e7..c04184d6bda 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -89,7 +89,13 @@ class MpdDevice(MediaPlayerDevice): try: self.status = self.client.status() self.currentsong = self.client.currentsong() - except mpd.ConnectionError: + except (mpd.ConnectionError, BrokenPipeError, ValueError): + # Cleanly disconnect in case connection is not in valid state + try: + self.client.disconnect() + except mpd.ConnectionError: + pass + self.client.connect(self.server, self.port) if self.password is not None: @@ -206,7 +212,7 @@ class MpdDevice(MediaPlayerDevice): """Service to send the MPD the command for previous track.""" self.client.previous() - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" _LOGGER.info(str.format("Playing playlist: {0}", media_id)) if media_type == MEDIA_TYPE_PLAYLIST: diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 7810ac63444..d1b5282fa6e 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice) -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME REQUIREMENTS = ['https://github.com/danieljkemp/onkyo-eiscp/archive/' 'python3.zip#onkyo-eiscp==0.9.2'] @@ -17,29 +17,59 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE +KNOWN_HOSTS = [] +DEFAULT_SOURCES = {"tv": "TV", "bd": "Bluray", "game": "Game", "aux1": "Aux1", + "video1": "Video 1", "video2": "Video 2", + "video3": "Video 3", "video4": "Video 4", + "video5": "Video 5", "video6": "Video 6", + "video7": "Video 7"} +CONFIG_SOURCE_LIST = "sources" def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Onkyo platform.""" + import eiscp from eiscp import eISCP - add_devices(OnkyoDevice(receiver) - for receiver in eISCP.discover()) + hosts = [] + + if CONF_HOST in config and config[CONF_HOST] not in KNOWN_HOSTS: + try: + hosts.append(OnkyoDevice(eiscp.eISCP(config[CONF_HOST]), + config.get(CONFIG_SOURCE_LIST, + DEFAULT_SOURCES), + name=config[CONF_NAME])) + KNOWN_HOSTS.append(config[CONF_HOST]) + except OSError: + _LOGGER.error('Unable to connect to receiver at %s.', + config[CONF_HOST]) + else: + for receiver in eISCP.discover(): + if receiver.host not in KNOWN_HOSTS: + hosts.append(OnkyoDevice(receiver, + config.get(CONFIG_SOURCE_LIST, + DEFAULT_SOURCES))) + KNOWN_HOSTS.append(receiver.host) + add_devices(hosts) +# pylint: disable=too-many-instance-attributes class OnkyoDevice(MediaPlayerDevice): """Representation of a Onkyo device.""" # pylint: disable=too-many-public-methods, abstract-method - def __init__(self, receiver): + def __init__(self, receiver, sources, name=None): """Initialize the Onkyo Receiver.""" self._receiver = receiver self._muted = False self._volume = 0 self._pwstate = STATE_OFF - self.update() - self._name = '{}_{}'.format( + self._name = name or '{}_{}'.format( receiver.info['model_name'], receiver.info['identifier']) self._current_source = None + self._source_list = list(sources.values()) + self._source_mapping = sources + self._reverse_mapping = {value: key for key, value in sources.items()} + self.update() def update(self): """Get the latest details from the device.""" @@ -52,8 +82,13 @@ class OnkyoDevice(MediaPlayerDevice): volume_raw = self._receiver.command('volume query') mute_raw = self._receiver.command('audio-muting query') current_source_raw = self._receiver.command('input-selector query') - self._current_source = '_'.join('_'.join( - [i for i in current_source_raw[1]])) + for source in current_source_raw[1]: + if source in self._source_mapping: + self._current_source = self._source_mapping[source] + break + else: + self._current_source = '_'.join( + [i for i in current_source_raw[1]]) self._muted = bool(mute_raw[1] == 'on') self._volume = int(volume_raw[1], 16)/80.0 @@ -87,6 +122,11 @@ class OnkyoDevice(MediaPlayerDevice): """"Return the current input source of the device.""" return self._current_source + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + def turn_off(self): """Turn off media player.""" self._receiver.command('system-power standby') @@ -108,4 +148,6 @@ class OnkyoDevice(MediaPlayerDevice): def select_source(self, source): """Set the input source.""" + if source in self._source_list: + source = self._reverse_mapping[source] self._receiver.command('input-selector {}'.format(source)) diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py new file mode 100644 index 00000000000..3a196fe38d4 --- /dev/null +++ b/homeassistant/components/media_player/roku.py @@ -0,0 +1,187 @@ +""" +Support for the roku media player. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.roku/ +""" + +import logging + +from homeassistant.components.media_player import ( + MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_SELECT_SOURCE, MediaPlayerDevice) + +from homeassistant.const import ( + CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) + +REQUIREMENTS = [ + 'https://github.com/bah2830/python-roku/archive/3.1.1.zip' + '#python-roku==3.1.1'] + +KNOWN_HOSTS = [] +DEFAULT_PORT = 8060 + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ + SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ + SUPPORT_SELECT_SOURCE + + +# pylint: disable=abstract-method +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Roku platform.""" + hosts = [] + + if discovery_info and discovery_info in KNOWN_HOSTS: + return + + if discovery_info is not None: + _LOGGER.debug('Discovered Roku: %s', discovery_info[0]) + hosts.append(discovery_info[0]) + + elif CONF_HOST in config: + hosts.append(config[CONF_HOST]) + + rokus = [] + for host in hosts: + rokus.append(RokuDevice(host)) + KNOWN_HOSTS.append(host) + + add_devices(rokus) + + +class RokuDevice(MediaPlayerDevice): + """Representation of a Roku device on the network.""" + + # pylint: disable=abstract-method + # pylint: disable=too-many-public-methods + def __init__(self, host): + """Initialize the Roku device.""" + from roku import Roku + + self.roku = Roku(host) + self.update() + + def update(self): + """Retrieve latest state.""" + self.roku_name = "roku_" + self.roku.device_info.sernum + self.ip_address = self.roku.host + self.channels = self.get_source_list() + + if self.roku.current_app is not None: + self.current_app = self.roku.current_app + else: + self.current_app = None + + def get_source_list(self): + """Get the list of applications to be used as sources.""" + return ["Home"] + sorted(channel.name for channel in self.roku.apps) + + @property + def should_poll(self): + """Device should be polled.""" + return True + + @property + def name(self): + """Return the name of the device.""" + return self.roku_name + + @property + def state(self): + """Return the state of the device.""" + if self.current_app.name in ["Power Saver", "Default screensaver"]: + return STATE_IDLE + elif self.current_app.name == "Roku": + return STATE_HOME + elif self.current_app.name is not None: + return STATE_PLAYING + + return STATE_UNKNOWN + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + return SUPPORT_ROKU + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self.current_app is None: + return None + elif self.current_app.name == "Power Saver": + return None + elif self.current_app.name == "Roku": + return None + else: + return MEDIA_TYPE_VIDEO + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.current_app is None: + return None + elif self.current_app.name == "Roku": + return None + elif self.current_app.name == "Power Saver": + return None + elif self.current_app.id is None: + return None + + return 'http://{0}:{1}/query/icon/{2}'.format(self.ip_address, + DEFAULT_PORT, + self.current_app.id) + + @property + def app_name(self): + """Name of the current running app.""" + return self.current_app.name + + @property + def app_id(self): + """Return the ID of the current running app.""" + return self.current_app.id + + @property + def source(self): + """Return the current input source.""" + return self.current_app.name + + @property + def source_list(self): + """List of available input sources.""" + return self.channels + + def media_play_pause(self): + """Send play/pause command.""" + self.roku.play() + + def media_previous_track(self): + """Send previous track command.""" + self.roku.reverse() + + def media_next_track(self): + """Send next track command.""" + self.roku.forward() + + def mute_volume(self, mute): + """Mute the volume.""" + self.roku.volume_mute() + + def volume_up(self): + """Volume up media player.""" + self.roku.volume_up() + + def volume_down(self): + """Volume down media player.""" + self.roku.volume_down() + + def select_source(self, source): + """Select input source.""" + if source == "Home": + self.roku.home() + else: + channel = self.roku[source] + channel.launch() diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index ebf882825bb..5f1f35f065f 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -145,3 +145,11 @@ select_source: source: description: Name of the source to switch to. Platform dependent. example: 'video1' + +sonos_group_players: + description: Send Sonos media player the command for grouping all players into one (party mode). + + fields: + entity_id: + description: Name(s) of entites that will coordinate the grouping. Platform dependent. + example: 'media_player.living_room_sonos' diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 6f0513745f4..01e3f8d9efc 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -7,14 +7,15 @@ https://home-assistant.io/components/media_player.sonos/ import datetime import logging import socket +from os import path from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - MediaPlayerDevice) + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_OFF) +from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['SoCo==0.11.1'] @@ -32,6 +33,8 @@ SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA |\ SUPPORT_SEEK +SERVICE_GROUP_PLAYERS = 'sonos_group_players' + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -63,9 +66,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning('No Sonos speakers found.') return False - add_devices(SonosDevice(hass, p) for p in players) + devices = [SonosDevice(hass, p) for p in players] + add_devices(devices) _LOGGER.info('Added %s Sonos speakers', len(players)) + def group_players_service(service): + """Group media players, use player as coordinator.""" + entity_id = service.data.get('entity_id') + + if entity_id: + _devices = [device for device in devices + if device.entity_id == entity_id] + else: + _devices = devices + + for device in _devices: + device.group_players() + device.update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_GROUP_PLAYERS, + group_players_service, + descriptions.get(SERVICE_GROUP_PLAYERS)) + return True @@ -74,16 +99,26 @@ def only_if_coordinator(func): If used as decorator, avoid calling the decorated method if player is not a coordinator. If not, a grouped speaker (not in coordinator role) will - throw soco.exceptions.SoCoSlaveException + throw soco.exceptions.SoCoSlaveException. + + Also, partially catch exceptions like: + + soco.exceptions.SoCoUPnPException: UPnP Error 701 received: + Transition not available from """ def wrapper(*args, **kwargs): """Decorator wrapper.""" if args[0].is_coordinator: - return func(*args, **kwargs) + from soco.exceptions import SoCoUPnPException + try: + func(*args, **kwargs) + except SoCoUPnPException: + _LOGGER.error('command "%s" for Sonos device "%s" ' + 'not available in this mode', + func.__name__, args[0].name) else: - _LOGGER.debug('Ignore command "%s" for Sonos device "%s" ' - '(not coordinator)', - func.__name__, args[0].name) + _LOGGER.debug('Ignore command "%s" for Sonos device "%s" (%s)', + func.__name__, args[0].name, 'not coordinator') return wrapper @@ -104,7 +139,7 @@ class SonosDevice(MediaPlayerDevice): @property def should_poll(self): - """No polling needed.""" + """Polling needed.""" return True def update_sonos(self, now): @@ -258,9 +293,27 @@ class SonosDevice(MediaPlayerDevice): self._player.play() @only_if_coordinator - def play_media(self, media_type, media_id): - """Send the play_media command to the media player.""" - self._player.play_uri(media_id) + def play_media(self, media_type, media_id, **kwargs): + """ + Send the play_media command to the media player. + + If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. + """ + if kwargs.get(ATTR_MEDIA_ENQUEUE): + from soco.exceptions import SoCoUPnPException + try: + self._player.add_uri_to_queue(media_id) + except SoCoUPnPException: + _LOGGER.error('Error parsing media uri "%s", ' + "please check it's a valid media resource " + 'supported by Sonos', media_id) + else: + self._player.play_uri(media_id) + + @only_if_coordinator + def group_players(self): + """Group all players under this coordinator.""" + self._player.partymode() @property def available(self): diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 3498a9e5580..f5fa8cc486c 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -402,7 +402,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): data = {ATTR_MEDIA_SEEK_POSITION: position} self._call_service(SERVICE_MEDIA_SEEK, data) - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8d34c153682..e9087d9c578 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -388,6 +388,8 @@ class MQTT(object): def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" + _LOGGER.debug("received message on %s: %s", + msg.topic, msg.payload.decode('utf-8')) self.hass.bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, { ATTR_TOPIC: msg.topic, ATTR_QOS: msg.qos, diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 7b866919b73..afccc043223 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -1,5 +1,5 @@ """ -Support for Nest thermostats. +Support for Nest thermostats and protect smoke alarms. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/thermostat.nest/ @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['python-nest==2.6.0'] +REQUIREMENTS = ['python-nest==2.9.2'] DOMAIN = 'nest' NEST = None @@ -36,6 +36,16 @@ def devices(): _LOGGER.error("Connection error logging into the nest web service.") +def protect_devices(): + """Generator returning list of protect devices.""" + try: + for structure in NEST.structures: + for device in structure.protectdevices: + yield(structure, device) + except socket.error: + _LOGGER.error("Connection error logging into the nest web service.") + + # pylint: disable=unused-argument def setup(hass, config): """Setup the Nest thermostat component.""" diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py new file mode 100644 index 00000000000..68f0de7a934 --- /dev/null +++ b/homeassistant/components/notify/aws_lambda.py @@ -0,0 +1,91 @@ +""" +AWS Lambda platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.aws_lambda/ +""" +import logging +import json +import base64 +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) +from homeassistant.components.notify import ( + ATTR_TARGET, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.3.1"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" +CONF_CONTEXT = "context" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "aws_lambda", + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), + vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), + vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), + vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str), + vol.Optional(CONF_CONTEXT, default=dict()): vol.Coerce(dict) +}) + + +def get_service(hass, config): + """Get the AWS Lambda notification service.""" + context_str = json.dumps({'hass': hass.config.as_dict(), + 'custom': config[CONF_CONTEXT]}) + context_b64 = base64.b64encode(context_str.encode("utf-8")) + context = context_b64.decode("utf-8") + + # pylint: disable=import-error + import boto3 + + aws_config = config.copy() + + del aws_config[CONF_PLATFORM] + del aws_config[CONF_NAME] + del aws_config[CONF_CONTEXT] + + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) + del aws_config[CONF_PROFILE_NAME] + + lambda_client = boto3.client("lambda", **aws_config) + + return AWSLambda(lambda_client, context) + + +# pylint: disable=too-few-public-methods +class AWSLambda(BaseNotificationService): + """Implement the notification service for the AWS Lambda service.""" + + def __init__(self, lambda_client, context): + """Initialize the service.""" + self.client = lambda_client + self.context = context + + def send_message(self, message="", **kwargs): + """Send notification to specified LAMBDA ARN.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + for target in targets: + cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v) + payload = {"message": message} + payload.update(cleaned_kwargs) + + self.client.invoke(FunctionName=target, + Payload=json.dumps(payload), + ClientContext=self.context) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py new file mode 100644 index 00000000000..dec72b18633 --- /dev/null +++ b/homeassistant/components/notify/aws_sns.py @@ -0,0 +1,80 @@ +""" +AWS SNS platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.aws_sns/ +""" +import logging +import json +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) +from homeassistant.components.notify import ( + ATTR_TITLE, ATTR_TARGET, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.3.1"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "aws_sns", + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), + vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), + vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), + vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +}) + + +def get_service(hass, config): + """Get the AWS SNS notification service.""" + # pylint: disable=import-error + import boto3 + + aws_config = config.copy() + + del aws_config[CONF_PLATFORM] + del aws_config[CONF_NAME] + + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) + del aws_config[CONF_PROFILE_NAME] + + sns_client = boto3.client("sns", **aws_config) + + return AWSSNS(sns_client) + + +# pylint: disable=too-few-public-methods +class AWSSNS(BaseNotificationService): + """Implement the notification service for the AWS SNS service.""" + + def __init__(self, sns_client): + """Initialize the service.""" + self.client = sns_client + + def send_message(self, message="", **kwargs): + """Send notification to specified SNS ARN.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + message_attributes = {k: {"StringValue": json.dumps(v), + "DataType": "String"} + for k, v in kwargs.items() if v} + for target in targets: + self.client.publish(TargetArn=target, Message=message, + Subject=kwargs.get(ATTR_TITLE), + MessageAttributes=message_attributes) diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py new file mode 100644 index 00000000000..a600878cda7 --- /dev/null +++ b/homeassistant/components/notify/aws_sqs.py @@ -0,0 +1,84 @@ +""" +AWS SQS platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.aws_sqs/ +""" +import logging +import json +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) +from homeassistant.components.notify import ( + ATTR_TARGET, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.3.1"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "aws_sqs", + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), + vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), + vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), + vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +}) + + +def get_service(hass, config): + """Get the AWS SQS notification service.""" + # pylint: disable=import-error + import boto3 + + aws_config = config.copy() + + del aws_config[CONF_PLATFORM] + del aws_config[CONF_NAME] + + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) + del aws_config[CONF_PROFILE_NAME] + + sqs_client = boto3.client("sqs", **aws_config) + + return AWSSQS(sqs_client) + + +# pylint: disable=too-few-public-methods +class AWSSQS(BaseNotificationService): + """Implement the notification service for the AWS SQS service.""" + + def __init__(self, sqs_client): + """Initialize the service.""" + self.client = sqs_client + + def send_message(self, message="", **kwargs): + """Send notification to specified SQS ARN.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + for target in targets: + cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v) + message_body = {"message": message} + message_body.update(cleaned_kwargs) + message_attributes = {} + for key, val in cleaned_kwargs.items(): + message_attributes[key] = {"StringValue": json.dumps(val), + "DataType": "String"} + self.client.send_message(QueueUrl=target, + MessageBody=json.dumps(message_body), + MessageAttributes=message_attributes) diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py new file mode 100644 index 00000000000..861d5439e4c --- /dev/null +++ b/homeassistant/components/notify/ecobee.py @@ -0,0 +1,31 @@ +""" +Support for ecobee Send Message service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.ecobee/ +""" +import logging +from homeassistant.components import ecobee +from homeassistant.components.notify import BaseNotificationService + +DEPENDENCIES = ['ecobee'] +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config): + """Get the Ecobee notification service.""" + index = int(config['index']) if 'index' in config else 0 + return EcobeeNotificationService(index) + + +# pylint: disable=too-few-public-methods +class EcobeeNotificationService(BaseNotificationService): + """Implement the notification service for the Ecobee thermostat.""" + + def __init__(self, thermostat_index): + """Initialize the service.""" + self.thermostat_index = thermostat_index + + def send_message(self, message="", **kwargs): + """Send a message to a command line.""" + ecobee.NETWORK.ecobee.send_message(self.thermostat_index, message) diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py index 67e80e2c424..ed192256cfc 100644 --- a/homeassistant/components/notify/gntp.py +++ b/homeassistant/components/notify/gntp.py @@ -41,8 +41,9 @@ class GNTPNotificationService(BaseNotificationService): # pylint: disable=too-many-arguments def __init__(self, app_name, app_icon, hostname, password, port): """Initialize the service.""" - from gntp import notifier - self.gntp = notifier.GrowlNotifier( + import gntp.notifier + import gntp.errors + self.gntp = gntp.notifier.GrowlNotifier( applicationName=app_name, notifications=["Notification"], applicationIcon=app_icon, @@ -50,7 +51,11 @@ class GNTPNotificationService(BaseNotificationService): password=password, port=port ) - self.gntp.register() + try: + self.gntp.register() + except gntp.errors.NetworkError: + _LOGGER.error('Unable to register with the GNTP host.') + return def send_message(self, message="", **kwargs): """Send a message to a user.""" diff --git a/homeassistant/components/notify/googlevoice.py b/homeassistant/components/notify/googlevoice.py index 021496fa00b..3f1b9d641b0 100644 --- a/homeassistant/components/notify/googlevoice.py +++ b/homeassistant/components/notify/googlevoice.py @@ -2,7 +2,7 @@ Google Voice SMS platform for notify component. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.free_mobile/ +https://home-assistant.io/components/notify.google_voice/ """ import logging diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 624683af020..aba02c020b9 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -51,7 +51,7 @@ class SlackNotificationService(BaseNotificationService): """Send a message to a user.""" import slacker - channel = kwargs.get('target', self._default_channel) + channel = kwargs.get('target') or self._default_channel try: self.slack.chat.post_message(channel, message) except slacker.Error: diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index d005e434601..a446644ef04 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -14,7 +14,7 @@ from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==4.0.1'] +REQUIREMENTS = ['python-telegram-bot==4.1.1'] def get_service(hass, config): diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py new file mode 100644 index 00000000000..f7700240b67 --- /dev/null +++ b/homeassistant/components/notify/twilio_sms.py @@ -0,0 +1,62 @@ +""" +Twilio SMS platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.twilio_sms/ +""" +import logging + +from homeassistant.components.notify import ( + ATTR_TARGET, DOMAIN, BaseNotificationService) +from homeassistant.helpers import validate_config + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["twilio==5.4.0"] + +CONF_ACCOUNT_SID = "account_sid" +CONF_AUTH_TOKEN = "auth_token" +CONF_FROM_NUMBER = "from_number" + + +def get_service(hass, config): + """Get the Twilio SMS notification service.""" + if not validate_config({DOMAIN: config}, + {DOMAIN: [CONF_ACCOUNT_SID, + CONF_AUTH_TOKEN, + CONF_FROM_NUMBER]}, + _LOGGER): + return None + + # pylint: disable=import-error + from twilio.rest import TwilioRestClient + + twilio_client = TwilioRestClient(config[CONF_ACCOUNT_SID], + config[CONF_AUTH_TOKEN]) + + return TwilioSMSNotificationService(twilio_client, + config[CONF_FROM_NUMBER]) + + +# pylint: disable=too-few-public-methods +class TwilioSMSNotificationService(BaseNotificationService): + """Implement the notification service for the Twilio SMS service.""" + + def __init__(self, twilio_client, from_number): + """Initialize the service.""" + self.client = twilio_client + self.from_number = from_number + + def send_message(self, message="", **kwargs): + """Send SMS to specified target user cell.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + for target in targets: + self.client.messages.create(to=target, body=message, + from_=self.from_number) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py new file mode 100644 index 00000000000..1bb77ec1435 --- /dev/null +++ b/homeassistant/components/qwikswitch.py @@ -0,0 +1,150 @@ +""" +Support for Qwikswitch devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/qwikswitch/ +""" +import logging +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.discovery import load_platform + +REQUIREMENTS = ['https://github.com/kellerza/pyqwikswitch/archive/v0.3.zip' + '#pyqwikswitch==0.3'] +DEPENDENCIES = [] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'qwikswitch' +QSUSB = None + + +class QSToggleEntity(object): + """Representation of a Qwikswitch Entity. + + Implement base QS methods. Modeled around HA ToggleEntity[1] & should only + be used in a class that extends both QSToggleEntity *and* ToggleEntity. + + Implemented: + - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) + - QSSwitch extends QSToggleEntity and SwitchDevice[3] (ToggleEntity[1]) + + [1] /helpers/entity.py + [2] /components/light/__init__.py + [3] /components/switch/__init__.py + """ + + def __init__(self, qsitem, qsusb): + """Initialize the ToggleEntity.""" + from pyqwikswitch import (QS_ID, QS_NAME, QSType, PQS_VALUE, PQS_TYPE) + self._id = qsitem[QS_ID] + self._name = qsitem[QS_NAME] + self._value = qsitem[PQS_VALUE] + self._qsusb = qsusb + self._dim = qsitem[PQS_TYPE] == QSType.dimmer + + @property + def brightness(self): + """Return the brightness of this light between 0..100.""" + return self._value if self._dim else None + + # pylint: disable=no-self-use + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the light.""" + return self._name + + @property + def is_on(self): + """Check if device is on (non-zero).""" + return self._value > 0 + + def update_value(self, value): + """Decode the QSUSB value and update the Home assistant state.""" + if value != self._value: + self._value = value + # pylint: disable=no-member + super().update_ha_state() # Part of Entity/ToggleEntity + return self._value + + def turn_on(self, **kwargs): + """Turn the device on.""" + newvalue = 255 + if ATTR_BRIGHTNESS in kwargs: + newvalue = kwargs[ATTR_BRIGHTNESS] + if self._qsusb.set(self._id, round(min(newvalue, 255)/2.55)) >= 0: + self.update_value(newvalue) + + # pylint: disable=unused-argument + def turn_off(self, **kwargs): + """Turn the device off.""" + if self._qsusb.set(self._id, 0) >= 0: + self.update_value(0) + + +# pylint: disable=too-many-locals +def setup(hass, config): + """Setup the QSUSB component.""" + from pyqwikswitch import (QSUsb, CMD_BUTTONS, QS_NAME, QS_ID, QS_CMD, + QS_TYPE, PQS_VALUE, PQS_TYPE, QSType) + + # Override which cmd's in /&listen packets will fire events + # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] + cmd_buttons = config[DOMAIN].get('button_events', ','.join(CMD_BUTTONS)) + cmd_buttons = cmd_buttons.split(',') + + try: + url = config[DOMAIN].get('url', 'http://127.0.0.1:2020') + dimmer_adjust = float(config[DOMAIN].get('dimmer_adjust', '1')) + qsusb = QSUsb(url, _LOGGER, dimmer_adjust) + + # Ensure qsusb terminates threads correctly + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: qsusb.stop()) + except ValueError as val_err: + _LOGGER.error(str(val_err)) + return False + + qsusb.ha_devices = qsusb.devices() + qsusb.ha_objects = {} + + # Identify switches & remove ' Switch' postfix in name + for item in qsusb.ha_devices: + if item[PQS_TYPE] == QSType.relay and \ + item[QS_NAME].lower().endswith(' switch'): + item[QS_TYPE] = 'switch' + item[QS_NAME] = item[QS_NAME][:-7] + + global QSUSB + if QSUSB is None: + QSUSB = {} + QSUSB[id(qsusb)] = qsusb + + # Load sub-components for qwikswitch + for comp_name in ('switch', 'light'): + load_platform(hass, comp_name, 'qwikswitch', + {'qsusb_id': id(qsusb)}, config) + + def qs_callback(item): + """Typically a button press or update signal.""" + # If button pressed, fire a hass event + if item.get(QS_CMD, '') in cmd_buttons: + hass.bus.fire('qwikswitch.button.' + item.get(QS_ID, '@no_id')) + return + + # Update all ha_objects + qsreply = qsusb.devices() + if qsreply is False: + return + for item in qsreply: + if item[QS_ID] in qsusb.ha_objects: + qsusb.ha_objects[item[QS_ID]].update_value( + round(min(item[PQS_VALUE], 100) * 2.55)) + + qsusb.listen(callback=qs_callback, timeout=30) + return True diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index 0a9fe92cde8..0c7454ad694 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -13,7 +13,8 @@ import logging import queue import sqlite3 import threading -from datetime import date, datetime +from datetime import date, datetime, timedelta +import voluptuous as vol import homeassistant.util.dt as dt_util from homeassistant.const import ( @@ -21,6 +22,7 @@ from homeassistant.const import ( EVENT_TIME_CHANGED, MATCH_ALL) from homeassistant.core import Event, EventOrigin, State from homeassistant.remote import JSONEncoder +from homeassistant.helpers.event import track_point_in_utc_time DOMAIN = "recorder" @@ -30,6 +32,15 @@ RETURN_ROWCOUNT = "rowcount" RETURN_LASTROWID = "lastrowid" RETURN_ONE_ROW = "one_row" +CONF_PURGE_DAYS = "purge_days" +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_PURGE_DAYS): vol.All(vol.Coerce(int), + vol.Range(min=1)), + }) +}, extra=vol.ALLOW_EXTRA) + + _INSTANCE = None _LOGGER = logging.getLogger(__name__) @@ -102,14 +113,14 @@ def setup(hass, config): """Setup the recorder.""" # pylint: disable=global-statement global _INSTANCE - - _INSTANCE = Recorder(hass) + purge_days = config.get(DOMAIN, {}).get(CONF_PURGE_DAYS) + _INSTANCE = Recorder(hass, purge_days=purge_days) return True class RecorderRun(object): - """Representation of arecorder run.""" + """Representation of a recorder run.""" def __init__(self, row=None): """Initialize the recorder run.""" @@ -169,11 +180,12 @@ class Recorder(threading.Thread): """A threaded recorder class.""" # pylint: disable=too-many-instance-attributes - def __init__(self, hass): + def __init__(self, hass, purge_days): """Initialize the recorder.""" threading.Thread.__init__(self) self.hass = hass + self.purge_days = purge_days self.conn = None self.queue = queue.Queue() self.quit_object = object() @@ -194,6 +206,10 @@ class Recorder(threading.Thread): """Start processing events to save.""" self._setup_connection() self._setup_run() + if self.purge_days is not None: + track_point_in_utc_time(self.hass, + lambda now: self._purge_old_data(), + dt_util.utcnow() + timedelta(minutes=5)) while True: event = self.queue.get() @@ -475,6 +491,32 @@ class Recorder(threading.Thread): "UPDATE recorder_runs SET end=? WHERE start=?", (dt_util.utcnow(), self.recording_start)) + def _purge_old_data(self): + """Purge events and states older than purge_days ago.""" + if not self.purge_days or self.purge_days < 1: + _LOGGER.debug("purge_days set to %s, will not purge any old data.", + self.purge_days) + return + + purge_before = dt_util.utcnow() - timedelta(days=self.purge_days) + + _LOGGER.info("Purging events created before %s", purge_before) + deleted_rows = self.query( + sql_query="DELETE FROM events WHERE created < ?;", + data=(int(purge_before.timestamp()),), + return_value=RETURN_ROWCOUNT) + _LOGGER.debug("Deleted %s events", deleted_rows) + + _LOGGER.info("Purging states created before %s", purge_before) + deleted_rows = self.query( + sql_query="DELETE FROM states WHERE created < ?;", + data=(int(purge_before.timestamp()),), + return_value=RETURN_ROWCOUNT) + _LOGGER.debug("Deleted %s states", deleted_rows) + + # Execute sqlite vacuum command to free up space on disk + self.query("VACUUM;") + def _adapt_datetime(datetimestamp): """Turn a datetime into an integer for in the DB.""" diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py index 586f33b44b9..934d06e2122 100644 --- a/homeassistant/components/scene/hunterdouglas_powerview.py +++ b/homeassistant/components/scene/hunterdouglas_powerview.py @@ -2,7 +2,7 @@ Support for Powerview scenes from a Powerview hub. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/scene/ +https://home-assistant.io/components/scene.hunterdouglas_powerview/ """ import logging diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 7d122f857d7..eb9e6fdc00d 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -10,7 +10,7 @@ import logging import datetime import time -from homeassistant.const import HTTP_OK +from homeassistant.const import HTTP_OK, TEMP_CELSIUS from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component @@ -236,7 +236,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for resource in config.get("monitored_resources", FITBIT_DEFAULT_RESOURCE_LIST): - dev.append(FitbitSensor(authd_client, config_path, resource)) + dev.append(FitbitSensor(authd_client, config_path, resource, + hass.config.temperature_unit == + TEMP_CELSIUS)) add_devices(dev) else: @@ -314,8 +316,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class FitbitSensor(Entity): """Implementation of a Fitbit sensor.""" - def __init__(self, client, config_path, resource_type): - """Initialize the Uber sensor.""" + def __init__(self, client, config_path, resource_type, is_metric): + """Initialize the Fitbit sensor.""" self.client = client self.config_path = config_path self.resource_type = resource_type @@ -328,7 +330,13 @@ class FitbitSensor(Entity): unit_type = FITBIT_RESOURCES_LIST[self.resource_type] if unit_type == "": split_resource = self.resource_type.split("/") - measurement_system = FITBIT_MEASUREMENTS[self.client.system] + try: + measurement_system = FITBIT_MEASUREMENTS[self.client.system] + except KeyError: + if is_metric: + measurement_system = FITBIT_MEASUREMENTS["metric"] + else: + measurement_system = FITBIT_MEASUREMENTS["en_US"] unit_type = measurement_system[split_resource[-1]] self._unit_of_measurement = unit_type self._state = 0 diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 867a84b1f03..b8513fa9bb6 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -10,8 +10,10 @@ import logging import voluptuous as vol from homeassistant.helpers.entity import Entity -from homeassistant.const import CONF_API_KEY, TEMP_CELSIUS +from homeassistant.const import CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -23,47 +25,102 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) CONF_ORIGIN = 'origin' CONF_DESTINATION = 'destination' CONF_TRAVEL_MODE = 'travel_mode' +CONF_OPTIONS = 'options' +CONF_MODE = 'mode' +CONF_NAME = 'name' + +ALL_LANGUAGES = ['ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', + 'eu', 'fa', 'fi', 'fr', 'gl', 'gu', 'hi', 'hr', 'hu', 'id', + 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'nl', + 'no', 'pl', 'pt', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl', + 'sr', 'sv', 'ta', 'te', 'th', 'tl', 'tr', 'uk', 'vi', + 'zh-CN', 'zh-TW'] + +TRANSIT_PREFS = ['less_walking', 'fewer_transfers'] PLATFORM_SCHEMA = vol.Schema({ vol.Required('platform'): 'google_travel_time', + vol.Optional(CONF_NAME): vol.Coerce(str), vol.Required(CONF_API_KEY): vol.Coerce(str), vol.Required(CONF_ORIGIN): vol.Coerce(str), vol.Required(CONF_DESTINATION): vol.Coerce(str), - vol.Optional(CONF_TRAVEL_MODE, default='driving'): - vol.In(["driving", "walking", "bicycling", "transit"]) + vol.Optional(CONF_TRAVEL_MODE): + vol.In(["driving", "walking", "bicycling", "transit"]), + vol.Optional(CONF_OPTIONS, default=dict()): vol.All( + dict, vol.Schema({ + vol.Optional(CONF_MODE, default='driving'): + vol.In(["driving", "walking", "bicycling", "transit"]), + vol.Optional('language'): vol.In(ALL_LANGUAGES), + vol.Optional('avoid'): vol.In(['tolls', 'highways', + 'ferries', 'indoor']), + vol.Optional('units'): vol.In(['metric', 'imperial']), + vol.Exclusive('arrival_time', 'time'): cv.string, + vol.Exclusive('departure_time', 'time'): cv.string, + vol.Optional('traffic_model'): vol.In(['best_guess', + 'pessimistic', + 'optimistic']), + vol.Optional('transit_mode'): vol.In(['bus', 'subway', 'train', + 'tram', 'rail']), + vol.Optional('transit_routing_preference'): vol.In(TRANSIT_PREFS) + })) }) +def convert_time_to_utc(timestr): + """Take a string like 08:00:00 and convert it to a unix timestamp.""" + combined = datetime.combine(dt_util.start_of_local_day(), + dt_util.parse_time(timestr)) + if combined < datetime.now(): + combined = combined + timedelta(days=1) + return dt_util.as_timestamp(combined) + + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the travel time platform.""" # pylint: disable=too-many-locals + options = config.get(CONF_OPTIONS) - is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + if options.get('units') is None: + if hass.config.temperature_unit is TEMP_CELSIUS: + options['units'] = 'metric' + elif hass.config.temperature_unit is TEMP_FAHRENHEIT: + options['units'] = 'imperial' + + travel_mode = config.get(CONF_TRAVEL_MODE) + mode = options.get(CONF_MODE) + + if travel_mode is not None: + wstr = ("Google Travel Time: travel_mode is deprecated, please add " + "mode to the options dictionary instead!") + _LOGGER.warning(wstr) + if mode is None: + options[CONF_MODE] = travel_mode + + titled_mode = options.get(CONF_MODE).title() + formatted_name = "Google Travel Time - {}".format(titled_mode) + name = config.get(CONF_NAME, formatted_name) api_key = config.get(CONF_API_KEY) origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) - travel_mode = config.get(CONF_TRAVEL_MODE) - sensor = GoogleTravelTimeSensor(api_key, origin, destination, - travel_mode, is_metric) + sensor = GoogleTravelTimeSensor(name, api_key, origin, destination, + options) if sensor.valid_api_connection: add_devices_callback([sensor]) +# pylint: disable=too-many-instance-attributes class GoogleTravelTimeSensor(Entity): """Representation of a tavel time sensor.""" # pylint: disable=too-many-arguments - def __init__(self, api_key, origin, destination, travel_mode, is_metric): + def __init__(self, name, api_key, origin, destination, options): """Initialize the sensor.""" - if is_metric: - self._unit = 'metric' - else: - self._unit = 'imperial' + self._name = name + self._options = options self._origin = origin self._destination = destination - self._travel_mode = travel_mode self._matrix = None self.valid_api_connection = True @@ -79,17 +136,22 @@ class GoogleTravelTimeSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self._matrix['rows'][0]['elements'][0]['duration']['value']/60.0 + try: + res = self._matrix['rows'][0]['elements'][0]['duration']['value'] + return round(res/60) + except KeyError: + return None @property def name(self): """Get the name of the sensor.""" - return "Google Travel time" + return self._name @property def device_state_attributes(self): """Return the state attributes.""" res = self._matrix.copy() + res.update(self._options) del res['rows'] _data = self._matrix['rows'][0]['elements'][0] if 'duration_in_traffic' in _data: @@ -108,10 +170,15 @@ class GoogleTravelTimeSensor(Entity): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from Google.""" - now = datetime.now() + options_copy = self._options.copy() + dtime = options_copy.get('departure_time') + atime = options_copy.get('arrival_time') + if dtime is not None and ':' in dtime: + options_copy['departure_time'] = convert_time_to_utc(dtime) + + if atime is not None and ':' in atime: + options_copy['arrival_time'] = convert_time_to_utc(atime) + self._matrix = self._client.distance_matrix(self._origin, self._destination, - mode=self._travel_mode, - units=self._unit, - departure_time=now, - traffic_model="optimistic") + **options_copy) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index cdea0d24624..2355d03d34a 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -7,14 +7,15 @@ https://home-assistant.io/components/sensor.gtfs/ import os import logging import datetime +import threading from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" - "432414b720c580fb2667a0a48f539118a2d95969.zip#" - "pygtfs==0.1.2"] + "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" + "pygtfs==0.1.3"] ICON = "mdi:train" @@ -152,9 +153,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("The given GTFS data file/folder was not found!") return False + import pygtfs + + split_file_name = os.path.splitext(config["data"]) + + sqlite_file = "{}.sqlite".format(split_file_name[0]) + joined_path = os.path.join(gtfs_dir, sqlite_file) + gtfs = pygtfs.Schedule(joined_path) + + # pylint: disable=no-member + if len(gtfs.feeds) < 1: + pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, + config["data"])) + dev = [] - dev.append(GTFSDepartureSensor(config["data"], gtfs_dir, - config["origin"], config["destination"])) + dev.append(GTFSDepartureSensor(gtfs, config["origin"], + config["destination"])) add_devices(dev) # pylint: disable=too-many-instance-attributes,too-few-public-methods @@ -163,16 +177,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class GTFSDepartureSensor(Entity): """Implementation of an GTFS departures sensor.""" - def __init__(self, data_source, gtfs_folder, origin, destination): + def __init__(self, pygtfs, origin, destination): """Initialize the sensor.""" - self._data_source = data_source - self._gtfs_folder = gtfs_folder + self._pygtfs = pygtfs self.origin = origin self.destination = destination self._name = "GTFS Sensor" self._unit_of_measurement = "min" self._state = 0 self._attributes = {} + self.lock = threading.Lock() self.update() @property @@ -202,62 +216,52 @@ class GTFSDepartureSensor(Entity): def update(self): """Get the latest data from GTFS and update the states.""" - import pygtfs + with self.lock: + self._departure = get_next_departure(self._pygtfs, self.origin, + self.destination) + self._state = self._departure["minutes_until_departure"] - split_file_name = os.path.splitext(self._data_source) + origin_station = self._departure["origin_station"] + destination_station = self._departure["destination_station"] + origin_stop_time = self._departure["origin_stop_time"] + destination_stop_time = self._departure["destination_stop_time"] + agency = self._departure["agency"] + route = self._departure["route"] + trip = self._departure["trip"] - sqlite_file = "{}.sqlite".format(split_file_name[0]) - gtfs = pygtfs.Schedule(os.path.join(self._gtfs_folder, sqlite_file)) + name = "{} {} to {} next departure" + self._name = name.format(agency.agency_name, + origin_station.stop_id, + destination_station.stop_id) - # pylint: disable=no-member - if len(gtfs.feeds) < 1: - pygtfs.append_feed(gtfs, os.path.join(self._gtfs_folder, - self._data_source)) + # Build attributes - self._departure = get_next_departure(gtfs, self.origin, - self.destination) - self._state = self._departure["minutes_until_departure"] + self._attributes = {} - origin_station = self._departure["origin_station"] - destination_station = self._departure["destination_station"] - origin_stop_time = self._departure["origin_stop_time"] - destination_stop_time = self._departure["destination_stop_time"] - agency = self._departure["agency"] - route = self._departure["route"] - trip = self._departure["trip"] + def dict_for_table(resource): + """Return a dict for the SQLAlchemy resource given.""" + return dict((col, getattr(resource, col)) + for col in resource.__table__.columns.keys()) - name = "{} {} to {} next departure" - self._name = name.format(agency.agency_name, - origin_station.stop_id, - destination_station.stop_id) + def append_keys(resource, prefix=None): + """Properly format key val pairs to append to attributes.""" + for key, val in resource.items(): + if val == "" or val is None or key == "feed_id": + continue + pretty_key = key.replace("_", " ") + pretty_key = pretty_key.title() + pretty_key = pretty_key.replace("Id", "ID") + pretty_key = pretty_key.replace("Url", "URL") + if prefix is not None and \ + pretty_key.startswith(prefix) is False: + pretty_key = "{} {}".format(prefix, pretty_key) + self._attributes[pretty_key] = val - # Build attributes - - self._attributes = {} - - def dict_for_table(resource): - """Return a dict for the SQLAlchemy resource given.""" - return dict((col, getattr(resource, col)) - for col in resource.__table__.columns.keys()) - - def append_keys(resource, prefix=None): - """Properly format key val pairs to append to attributes.""" - for key, val in resource.items(): - if val == "" or val is None or key == "feed_id": - continue - pretty_key = key.replace("_", " ") - pretty_key = pretty_key.title() - pretty_key = pretty_key.replace("Id", "ID") - pretty_key = pretty_key.replace("Url", "URL") - if prefix is not None and \ - pretty_key.startswith(prefix) is False: - pretty_key = "{} {}".format(prefix, pretty_key) - self._attributes[pretty_key] = val - - append_keys(dict_for_table(agency), "Agency") - append_keys(dict_for_table(route), "Route") - append_keys(dict_for_table(trip), "Trip") - append_keys(dict_for_table(origin_station), "Origin Station") - append_keys(dict_for_table(destination_station), "Destination Station") - append_keys(origin_stop_time, "Origin Stop") - append_keys(destination_stop_time, "Destination Stop") + append_keys(dict_for_table(agency), "Agency") + append_keys(dict_for_table(route), "Route") + append_keys(dict_for_table(trip), "Trip") + append_keys(dict_for_table(origin_station), "Origin Station") + append_keys(dict_for_table(destination_station), + "Destination Station") + append_keys(origin_stop_time, "Origin Stop") + append_keys(destination_stop_time, "Destination Stop") diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py new file mode 100644 index 00000000000..3001171081e --- /dev/null +++ b/homeassistant/components/sensor/lastfm.py @@ -0,0 +1,90 @@ +""" +Sensor for Last.fm account status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.lastfm/ +""" +import re +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_API_KEY + +ICON = 'mdi:lastfm' + +REQUIREMENTS = ['pylast==1.6.0'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Last.fm platform.""" + import pylast as lastfm + network = lastfm.LastFMNetwork(api_key=config.get(CONF_API_KEY)) + add_devices( + [LastfmSensor(username, + network) for username in config.get("users", [])]) + + +class LastfmSensor(Entity): + """A class for the Last.fm account.""" + + # pylint: disable=abstract-method, too-many-instance-attributes + def __init__(self, user, lastfm): + """Initialize the sensor.""" + self._user = lastfm.get_user(user) + self._name = user + self._lastfm = lastfm + self._state = "Not Scrobbling" + self._playcount = None + self._lastplayed = None + self._topplayed = None + self._cover = None + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def entity_id(self): + """Return the entity ID.""" + return 'sensor.lastfm_{}'.format(self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + # pylint: disable=no-member + def update(self): + """Update device state.""" + self._cover = self._user.get_image() + self._playcount = self._user.get_playcount() + last = self._user.get_recent_tracks(limit=2)[0] + self._lastplayed = "{} - {}".format(last.track.artist, + last.track.title) + top = self._user.get_top_tracks(limit=1)[0] + toptitle = re.search("', '(.+?)',", str(top)) + topartist = re.search("'(.+?)',", str(top)) + self._topplayed = "{} - {}".format(topartist.group(1), + toptitle.group(1)) + if self._user.get_now_playing() is None: + self._state = "Not Scrobbling" + return + now = self._user.get_now_playing() + self._state = "{} - {}".format(now.artist, now.title) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {'Play Count': self._playcount, 'Last Played': + self._lastplayed, 'Top Played': self._topplayed} + + @property + def entity_picture(self): + """Avatar of the user.""" + return self._cover + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 31b957192d9..7b27b0e89a4 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "loopenergy" -REQUIREMENTS = ['pyloopenergy==0.0.10'] +REQUIREMENTS = ['pyloopenergy==0.0.12'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py new file mode 100644 index 00000000000..8f45647f5a2 --- /dev/null +++ b/homeassistant/components/sensor/mold_indicator.py @@ -0,0 +1,268 @@ +""" +Calculates mold growth indication from temperature and humidity. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mold_indicator/ +""" +import logging +import math + +import homeassistant.util as util +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_state_change +from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, TEMP_FAHRENHEIT) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Mold Indicator" +CONF_INDOOR_TEMP = "indoor_temp_sensor" +CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" +CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" +CONF_CALIBRATION_FACTOR = "calibration_factor" + +MAGNUS_K2 = 17.62 +MAGNUS_K3 = 243.12 + +ATTR_DEWPOINT = "Dewpoint" +ATTR_CRITICAL_TEMP = "Est. Crit. Temp" + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MoldIndicator sensor.""" + name = config.get('name', DEFAULT_NAME) + indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) + outdoor_temp_sensor = config.get(CONF_OUTDOOR_TEMP) + indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) + calib_factor = util.convert(config.get(CONF_CALIBRATION_FACTOR), + float, None) + + if None in (indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor): + _LOGGER.error('Missing required key %s, %s or %s', + CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP, + CONF_INDOOR_HUMIDITY) + return False + + add_devices_callback([MoldIndicator( + hass, name, indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor, + calib_factor)]) + + +# pylint: disable=too-many-instance-attributes +class MoldIndicator(Entity): + """Represents a MoldIndication sensor.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, indoor_temp_sensor, outdoor_temp_sensor, + indoor_humidity_sensor, calib_factor): + """Initialize the sensor.""" + self._state = None + self._name = name + self._indoor_temp_sensor = indoor_temp_sensor + self._indoor_humidity_sensor = indoor_humidity_sensor + self._outdoor_temp_sensor = outdoor_temp_sensor + self._calib_factor = calib_factor + self._is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + + self._dewpoint = None + self._indoor_temp = None + self._outdoor_temp = None + self._indoor_hum = None + self._crit_temp = None + + track_state_change(hass, indoor_temp_sensor, self._sensor_changed) + track_state_change(hass, outdoor_temp_sensor, self._sensor_changed) + track_state_change(hass, indoor_humidity_sensor, self._sensor_changed) + + # Read initial state + indoor_temp = hass.states.get(indoor_temp_sensor) + outdoor_temp = hass.states.get(outdoor_temp_sensor) + indoor_hum = hass.states.get(indoor_humidity_sensor) + + if indoor_temp: + self._indoor_temp = \ + MoldIndicator._update_temp_sensor(indoor_temp) + + if outdoor_temp: + self._outdoor_temp = \ + MoldIndicator._update_temp_sensor(outdoor_temp) + + if indoor_hum: + self._indoor_hum = \ + MoldIndicator._update_hum_sensor(indoor_hum) + + self.update() + + @staticmethod + def _update_temp_sensor(state): + """Parse temperature sensor value.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = util.convert(state.state, float) + + if temp is None: + _LOGGER.error('Unable to parse sensor temperature: %s', + state.state) + return None + + # convert to celsius if necessary + if unit == TEMP_FAHRENHEIT: + return util.temperature.fahrenheit_to_celcius(temp) + elif unit == TEMP_CELSIUS: + return temp + else: + _LOGGER.error("Temp sensor has unsupported unit: %s" + " (allowed: %s, %s)", + unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) + + return None + + @staticmethod + def _update_hum_sensor(state): + """Parse humidity sensor value.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + hum = util.convert(state.state, float) + + if hum is None: + _LOGGER.error('Unable to parse sensor humidity: %s', + state.state) + return None + + # check unit + if unit != "%": + _LOGGER.error( + "Humidity sensor has unsupported unit: %s %s", + unit, + " (allowed: %)") + + # check range + if hum > 100 or hum < 0: + _LOGGER.error( + "Humidity sensor out of range: %s %s", + hum, + " (allowed: 0-100%)") + + return hum + + def update(self): + """Calculate latest state.""" + # check all sensors + if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): + return + + # re-calculate dewpoint and mold indicator + self._calc_dewpoint() + self._calc_moldindicator() + + def _sensor_changed(self, entity_id, old_state, new_state): + """Called when sensor values change.""" + if new_state is None: + return + + if entity_id == self._indoor_temp_sensor: + # update the indoor temp sensor + self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) + + elif entity_id == self._outdoor_temp_sensor: + # update outdoor temp sensor + self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) + + elif entity_id == self._indoor_humidity_sensor: + # update humidity + self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) + + self.update() + self.update_ha_state() + + def _calc_dewpoint(self): + """Calculate the dewpoint for the indoor air.""" + # use magnus approximation to calculate the dew point + alpha = MAGNUS_K2 * self._indoor_temp / (MAGNUS_K3 + self._indoor_temp) + beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._indoor_temp) + + if self._indoor_hum == 0: + self._dewpoint = -50 # not defined, assume very low value + else: + self._dewpoint = \ + MAGNUS_K3 * (alpha + math.log(self._indoor_hum / 100.0)) / \ + (beta - math.log(self._indoor_hum / 100.0)) + _LOGGER.debug("Dewpoint: %f " + TEMP_CELSIUS, self._dewpoint) + + def _calc_moldindicator(self): + """Calculate the humidity at the (cold) calibration point.""" + if None in (self._dewpoint, self._calib_factor) or \ + self._calib_factor == 0: + + _LOGGER.debug("Invalid inputs - dewpoint: %s," + " calibration-factor: %s", + self._dewpoint, self._calib_factor) + self._state = None + return + + # first calculate the approximate temperature at the calibration point + self._crit_temp = \ + self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ + self._calib_factor + + _LOGGER.debug( + "Estimated Critical Temperature: %f " + + TEMP_CELSIUS, self._crit_temp) + + # Then calculate the humidity at this point + alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) + beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._crit_temp) + + crit_humidity = \ + math.exp( + (self._dewpoint * beta - MAGNUS_K3 * alpha) / + (self._dewpoint + MAGNUS_K3)) * 100.0 + + # check bounds and format + if crit_humidity > 100: + self._state = '100' + elif crit_humidity < 0: + self._state = '0' + else: + self._state = '{0:d}'.format(int(crit_humidity)) + + _LOGGER.debug('Mold indicator humidity: %s ', self._state) + + @property + def should_poll(self): + """Polling needed.""" + return False + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + if self._is_metric: + return { + ATTR_DEWPOINT: self._dewpoint, + ATTR_CRITICAL_TEMP: self._crit_temp, + } + else: + return { + ATTR_DEWPOINT: + util.temperature.celcius_to_fahrenheit( + self._dewpoint), + ATTR_CRITICAL_TEMP: + util.temperature.celcius_to_fahrenheit( + self._crit_temp), + } diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index c3b68430b99..7a89265a8bd 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -4,6 +4,8 @@ Support for Nest Thermostat Sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.nest/ """ +from itertools import chain + import voluptuous as vol import homeassistant.components.nest as nest @@ -29,9 +31,13 @@ WEATHER_VARS = {'weather_humidity': 'humidity', SENSOR_UNITS = {'humidity': '%', 'battery_level': 'V', 'kph': 'kph', 'temperature': '°C'} +PROTECT_VARS = ['co_status', + 'smoke_status', + 'battery_level'] + SENSOR_TEMP_TYPES = ['temperature', 'target'] -_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + \ +_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + PROTECT_VARS + \ list(WEATHER_VARS.keys()) PLATFORM_SCHEMA = vol.Schema({ @@ -44,20 +50,34 @@ PLATFORM_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Nest Sensor.""" - for structure, device in nest.devices(): + for structure, device in chain(nest.devices(), nest.protect_devices()): sensors = [NestBasicSensor(structure, device, variable) for variable in config[CONF_MONITORED_CONDITIONS] - if variable in SENSOR_TYPES] + if variable in SENSOR_TYPES and is_thermostat(device)] sensors += [NestTempSensor(structure, device, variable) for variable in config[CONF_MONITORED_CONDITIONS] - if variable in SENSOR_TEMP_TYPES] + if variable in SENSOR_TEMP_TYPES and is_thermostat(device)] sensors += [NestWeatherSensor(structure, device, WEATHER_VARS[variable]) for variable in config[CONF_MONITORED_CONDITIONS] - if variable in WEATHER_VARS] + if variable in WEATHER_VARS and is_thermostat(device)] + sensors += [NestProtectSensor(structure, device, variable) + for variable in config[CONF_MONITORED_CONDITIONS] + if variable in PROTECT_VARS and is_protect(device)] + add_devices(sensors) +def is_thermostat(device): + """Target devices that are Nest Thermostats.""" + return bool(device.__class__.__name__ == 'Device') + + +def is_protect(device): + """Target devices that are Nest Protect Smoke Alarms.""" + return bool(device.__class__.__name__ == 'ProtectDevice') + + class NestSensor(Entity): """Representation of a Nest sensor.""" @@ -130,3 +150,28 @@ class NestWeatherSensor(NestSensor): def unit_of_measurement(self): """Return the unit the value is expressed in.""" return SENSOR_UNITS.get(self.variable, None) + + +class NestProtectSensor(NestSensor): + """Return the state of nest protect.""" + + @property + def state(self): + """Return the state of the sensor.""" + state = getattr(self.device, self.variable) + if self.variable == 'battery_level': + return getattr(self.device, self.variable) + else: + if state == 0: + return 'Ok' + if state == 1 or state == 2: + return 'Warning' + if state == 3: + return 'Emergency' + + return 'Unknown' + + @property + def name(self): + """Return the name of the nest, if any.""" + return "{} {}".format(self.device.where.capitalize(), self.variable) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 8898259754a..ddb14f6af81 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/sensor.speedtest/ import logging import re import sys -from datetime import timedelta from subprocess import check_output +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import DOMAIN from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.helpers.event import track_time_change REQUIREMENTS = ['speedtest-cli==0.3.4'] _LOGGER = logging.getLogger(__name__) @@ -21,6 +22,7 @@ _SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' r'Upload:\s(\d+\.\d+)\sMbit/s[\r\n]+') CONF_MONITORED_CONDITIONS = 'monitored_conditions' +CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' CONF_DAY = 'day' @@ -30,13 +32,10 @@ SENSOR_TYPES = { 'upload': ['Upload', 'Mbit/s'], } -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Speedtest sensor.""" - data = SpeedtestData() + data = SpeedtestData(hass, config) dev = [] for sensor in config[CONF_MONITORED_CONDITIONS]: if sensor not in SENSOR_TYPES: @@ -46,6 +45,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(dev) + def update(call=None): + """Update service for manual updates.""" + data.update(dt_util.now()) + for sensor in dev: + sensor.update() + + hass.services.register(DOMAIN, 'update_speedtest', update) + # pylint: disable=too-few-public-methods class SpeedtestSensor(Entity): @@ -76,7 +83,6 @@ class SpeedtestSensor(Entity): def update(self): """Get the latest data and update the states.""" - self.speedtest_client.update() data = self.speedtest_client.data if data is None: return @@ -92,12 +98,16 @@ class SpeedtestSensor(Entity): class SpeedtestData(object): """Get the latest data from speedtest.net.""" - def __init__(self): + def __init__(self, hass, config): """Initialize the data object.""" self.data = None + track_time_change(hass, self.update, + second=config.get(CONF_SECOND, 0), + minute=config.get(CONF_MINUTE, 0), + hour=config.get(CONF_HOUR, None), + day=config.get(CONF_DAY, None)) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self, now): """Get the latest data from speedtest.net.""" import speedtest_cli diff --git a/homeassistant/components/sensor/supervisord.py b/homeassistant/components/sensor/supervisord.py new file mode 100644 index 00000000000..cebdfb83f14 --- /dev/null +++ b/homeassistant/components/sensor/supervisord.py @@ -0,0 +1,61 @@ +""" +Sensor for Supervisord process status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.supervisord/ +""" +import logging +import xmlrpc.client + +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Supervisord platform.""" + try: + supervisor_server = xmlrpc.client.ServerProxy( + config.get('url', 'http://localhost:9001/RPC2')) + except ConnectionRefusedError: + _LOGGER.error('Could not connect to Supervisord') + return + processes = supervisor_server.supervisor.getAllProcessInfo() + add_devices( + [SupervisorProcessSensor(info, supervisor_server) + for info in processes]) + + +class SupervisorProcessSensor(Entity): + """Represent a supervisor-monitored process.""" + + # pylint: disable=abstract-method + def __init__(self, info, server): + """Initialize the sensor.""" + self._info = info + self._server = server + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._info.get('name') + + @property + def state(self): + """Return the state of the sensor.""" + return self._info.get('statename') + + def update(self): + """Update device state.""" + self._info = self._server.supervisor.getProcessInfo( + self._info.get('name')) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'group': self._info.get('group'), + 'description': self._info.get('description') + } diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py old mode 100644 new mode 100755 index 0f2a60d6dd5..20af7e71a59 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -10,7 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['psutil==4.1.0'] +REQUIREMENTS = ['psutil==4.2.0'] SENSOR_TYPES = { 'disk_use_percent': ['Disk Use', '%', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -24,9 +24,9 @@ SENSOR_TYPES = { 'swap_use': ['Swap Use', 'GiB', 'mdi:harddisk'], 'swap_free': ['Swap Free', 'GiB', 'mdi:harddisk'], 'network_out': ['Sent', 'MiB', 'mdi:server-network'], - 'network_in': ['Recieved', 'MiB', 'mdi:server-network'], + 'network_in': ['Received', 'MiB', 'mdi:server-network'], 'packets_out': ['Packets sent', '', 'mdi:server-network'], - 'packets_in': ['Packets recieved', '', 'mdi:server-network'], + 'packets_in': ['Packets received', '', 'mdi:server-network'], 'ipv4_address': ['IPv4 address', '', 'mdi:server-network'], 'ipv6_address': ['IPv6 address', '', 'mdi:server-network'], 'last_boot': ['Last Boot', '', 'mdi:clock'], diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py new file mode 100644 index 00000000000..1041aa020e6 --- /dev/null +++ b/homeassistant/components/switch/qwikswitch.py @@ -0,0 +1,34 @@ +""" +Support for Qwikswitch relays. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.qwikswitch/ +""" +import logging +import homeassistant.components.qwikswitch as qwikswitch +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['qwikswitch'] + + +class QSSwitch(qwikswitch.QSToggleEntity, SwitchDevice): + """Switch based on a Qwikswitch relay module.""" + + pass + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Store add_devices for the switch components.""" + if discovery_info is None or 'qsusb_id' not in discovery_info: + logging.getLogger(__name__).error( + 'Configure main Qwikswitch component') + return False + + qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']] + + for item in qsusb.ha_devices: + if item['type'] == 'switch': + dev = QSSwitch(item, qsusb) + add_devices([dev]) + qsusb.ha_objects[item['id']] = dev diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 2b090af0320..b96a1d70dc5 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -14,7 +14,7 @@ REQUIREMENTS = ['rpi-rf==0.9.5'] _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument +# pylint: disable=unused-argument, import-error def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 756c6c6913a..abeda6be736 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -40,7 +40,6 @@ class Thermostat(ThermostatDevice): self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) self._name = self.thermostat['name'] - self._away = 'away' in self.thermostat['program']['currentClimateRef'] self.hold_temp = hold_temp def update(self): @@ -121,9 +120,7 @@ class Thermostat(ThermostatDevice): @property def mode(self): """Return current mode ie. home, away, sleep.""" - mode = self.thermostat['program']['currentClimateRef'] - self._away = 'away' in mode - return mode + return self.thermostat['program']['currentClimateRef'] @property def hvac_mode(self): @@ -144,11 +141,16 @@ class Thermostat(ThermostatDevice): @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self._away + mode = self.mode + events = self.thermostat['events'] + for event in events: + if event['running']: + mode = event['holdClimateRef'] + break + return 'away' in mode def turn_away_mode_on(self): """Turn away on.""" - self._away = True if self.hold_temp: self.data.ecobee.set_climate_hold(self.thermostat_index, "away", "indefinite") @@ -157,7 +159,6 @@ class Thermostat(ThermostatDevice): def turn_away_mode_off(self): """Turn away off.""" - self._away = False self.data.ecobee.resume_program(self.thermostat_index) def set_temperature(self, temperature): @@ -180,20 +181,16 @@ class Thermostat(ThermostatDevice): # def turn_home_mode_on(self): # """ Turns home mode on. """ - # self._away = False # self.data.ecobee.set_climate_hold(self.thermostat_index, "home") # def turn_home_mode_off(self): # """ Turns home mode off. """ - # self._away = False # self.data.ecobee.resume_program(self.thermostat_index) # def turn_sleep_mode_on(self): # """ Turns sleep mode on. """ - # self._away = False # self.data.ecobee.set_climate_hold(self.thermostat_index, "sleep") # def turn_sleep_mode_off(self): # """ Turns sleep mode off. """ - # self._away = False # self.data.ecobee.resume_program(self.thermostat_index) diff --git a/homeassistant/const.py b/homeassistant/const.py index e69b44786a9..d9396188487 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = "0.19.4" +__version__ = "0.20.0.dev0" REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' diff --git a/homeassistant/core.py b/homeassistant/core.py index 9a237cb58bd..ffaccdeae43 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -79,6 +79,7 @@ class HomeAssistant(object): def restart_homeassistant(*args): """Reset Home Assistant.""" + _LOGGER.warning('Home Assistant requested a restart.') request_restart.set() request_shutdown.set() @@ -92,14 +93,21 @@ class HomeAssistant(object): except ValueError: _LOGGER.warning( 'Could not bind to SIGTERM. Are you running in a thread?') - - while not request_shutdown.isSet(): - try: + try: + signal.signal(signal.SIGHUP, restart_homeassistant) + except ValueError: + _LOGGER.warning( + 'Could not bind to SIGHUP. Are you running in a thread?') + except AttributeError: + pass + try: + while not request_shutdown.isSet(): time.sleep(1) - except KeyboardInterrupt: - break + except KeyboardInterrupt: + pass + finally: + self.stop() - self.stop() return RESTART_EXIT_CODE if request_restart.isSet() else 0 def stop(self): diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 3ce72e62835..2a99b57da55 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -55,12 +55,23 @@ class EntityComponent(object): self._setup_platform(p_type, p_config) if self.discovery_platforms: + # Discovery listener for all items in discovery_platforms array + # passed from a component's setup method (e.g. light/__init__.py) discovery.listen( self.hass, self.discovery_platforms.keys(), lambda service, info: self._setup_platform(self.discovery_platforms[service], {}, info)) + # Generic discovery listener for loading platform dynamically + # Refer to: homeassistant.components.discovery.load_platform() + def load_platform_callback(service, info): + """Callback to load a platform.""" + platform = info.pop(discovery.LOAD_PLATFORM) + self._setup_platform(platform, {}, info if info else None) + discovery.listen(self.hass, discovery.LOAD_PLATFORM + '.' + + self.domain, load_platform_callback) + def extract_from_service(self, service): """Extract all known entities from a service call. diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index b8585621913..078bbb27b20 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -16,8 +16,8 @@ from homeassistant.components.thermostat import ( ATTR_AWAY_MODE, ATTR_FAN, SERVICE_SET_AWAY_MODE, SERVICE_SET_FAN_MODE, SERVICE_SET_TEMPERATURE) from homeassistant.components.hvac import ( - ATTR_HUMIDITY, ATTR_SWING_MODE, ATTR_OPERATION, ATTR_AUX_HEAT, - SERVICE_SET_HUMIDITY, SERVICE_SET_SWING, + ATTR_HUMIDITY, ATTR_SWING_MODE, ATTR_OPERATION_MODE, ATTR_AUX_HEAT, + SERVICE_SET_HUMIDITY, SERVICE_SET_SWING_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_AUX_HEAT) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, @@ -48,8 +48,8 @@ SERVICE_ATTRIBUTES = { SERVICE_SET_FAN_MODE: [ATTR_FAN], SERVICE_SET_TEMPERATURE: [ATTR_TEMPERATURE], SERVICE_SET_HUMIDITY: [ATTR_HUMIDITY], - SERVICE_SET_SWING: [ATTR_SWING_MODE], - SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION], + SERVICE_SET_SWING_MODE: [ATTR_SWING_MODE], + SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION_MODE], SERVICE_SET_AUX_HEAT: [ATTR_AUX_HEAT], SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], } diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8e039432728..41de1782a1f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -57,6 +57,7 @@ def render(hass, template, variables=None, **kwargs): 'states': AllStates(hass), 'utcnow': utcnow, 'as_timestamp': dt_util.as_timestamp, + 'relative_time': dt_util.get_age }).render(kwargs).strip() except jinja2.TemplateError as err: raise TemplateError(err) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 2a662b15f5e..23412344a85 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,48 +1,47 @@ """Color util methods.""" import math +# pylint: disable=unused-import +from webcolors import html5_parse_legacy_color as color_name_to_rgb # noqa HASS_COLOR_MAX = 500 # mireds (inverted) HASS_COLOR_MIN = 154 -# Taken from: http://www.cse.unr.edu/~quiroz/inc/colortransforms.py +# Taken from: +# http://www.developers.meethue.com/documentation/color-conversions-rgb-xy # License: Code is given as is. Use at your own risk and discretion. # pylint: disable=invalid-name def color_RGB_to_xy(R, G, B): """Convert from RGB color to XY color.""" if R + G + B == 0: - return 0, 0 + return 0, 0, 0 - var_R = (R / 255.) - var_G = (G / 255.) - var_B = (B / 255.) + R = R / 255 + B = B / 255 + G = G / 255 - if var_R > 0.04045: - var_R = ((var_R + 0.055) / 1.055) ** 2.4 - else: - var_R /= 12.92 + # Gamma correction + R = pow((R + 0.055) / (1.0 + 0.055), + 2.4) if (R > 0.04045) else (R / 12.92) + G = pow((G + 0.055) / (1.0 + 0.055), + 2.4) if (G > 0.04045) else (G / 12.92) + B = pow((B + 0.055) / (1.0 + 0.055), + 2.4) if (B > 0.04045) else (B / 12.92) - if var_G > 0.04045: - var_G = ((var_G + 0.055) / 1.055) ** 2.4 - else: - var_G /= 12.92 + # Wide RGB D65 conversion formula + X = R * 0.664511 + G * 0.154324 + B * 0.162028 + Y = R * 0.313881 + G * 0.668433 + B * 0.047685 + Z = R * 0.000088 + G * 0.072310 + B * 0.986039 - if var_B > 0.04045: - var_B = ((var_B + 0.055) / 1.055) ** 2.4 - else: - var_B /= 12.92 + # Convert XYZ to xy + x = X / (X + Y + Z) + y = Y / (X + Y + Z) - var_R *= 100 - var_G *= 100 - var_B *= 100 + # Brightness + Y = 1 if Y > 1 else Y + brightness = round(Y * 255) - # Observer. = 2 deg, Illuminant = D65 - X = var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805 - Y = var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722 - Z = var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505 - - # Convert XYZ to xy, see CIE 1931 color space on wikipedia - return X / (X + Y + Z), Y / (X + Y + Z) + return round(x, 3), round(y, 3), brightness # taken from diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 16e8dfebfd1..a875087fed6 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -152,3 +152,52 @@ def parse_time(time_str): except ValueError: # ValueError if value cannot be converted to an int or not in range return None + + +# Found in this gist: https://gist.github.com/zhangsen/1199964 +def get_age(date): + # pylint: disable=too-many-return-statements + """ + Take a datetime and return its "age" as a string. + + The age can be in second, minute, hour, day, month or year. Only the + biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will + be returned. + Make sure date is not in the future, or else it won't work. + """ + def formatn(number, unit): + """Add "unit" if it's plural.""" + if number == 1: + return "1 %s" % unit + elif number > 1: + return "%d %ss" % (number, unit) + + def q_n_r(first, second): + """Return quotient and remaining.""" + return first // second, first % second + + delta = now() - date + day = delta.days + second = delta.seconds + + year, day = q_n_r(day, 365) + if year > 0: + return formatn(year, 'year') + + month, day = q_n_r(day, 30) + if month > 0: + return formatn(month, 'month') + if day > 0: + return formatn(day, 'day') + + hour, second = q_n_r(second, 3600) + if hour > 0: + return formatn(hour, 'hour') + + minute, second = q_n_r(second, 60) + if minute > 0: + return formatn(minute, 'minute') + if second > 0: + return formatn(second, 'second') + + return "0 second" diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index bdf0c6d5c41..58458986063 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -3,6 +3,7 @@ import logging import os from collections import OrderedDict +import glob import yaml from homeassistant.exceptions import HomeAssistantError @@ -44,6 +45,44 @@ def _include_yaml(loader, node): return load_yaml(fname) +def _include_dir_named_yaml(loader, node): + """Load multiple files from dir as a dict.""" + mapping = OrderedDict() + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + for fname in glob.glob(files): + filename = os.path.splitext(os.path.basename(fname))[0] + mapping[filename] = load_yaml(fname) + return mapping + + +def _include_dir_merge_named_yaml(loader, node): + """Load multiple files from dir as a merged dict.""" + mapping = OrderedDict() + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + for fname in glob.glob(files): + loaded_yaml = load_yaml(fname) + if isinstance(loaded_yaml, dict): + mapping.update(loaded_yaml) + return mapping + + +def _include_dir_list_yaml(loader, node): + """Load multiple files from dir as a list.""" + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + return [load_yaml(f) for f in glob.glob(files)] + + +def _include_dir_merge_list_yaml(loader, node): + """Load multiple files from dir as a merged list.""" + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + merged_list = [] + for fname in glob.glob(files): + loaded_yaml = load_yaml(fname) + if isinstance(loaded_yaml, list): + merged_list.extend(loaded_yaml) + return merged_list + + def _ordered_dict(loader, node): """Load YAML mappings into an ordered dict to preserve key order.""" loader.flatten_mapping(node) @@ -84,3 +123,9 @@ yaml.SafeLoader.add_constructor('!include', _include_yaml) yaml.SafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) yaml.SafeLoader.add_constructor('!env_var', _env_var_yaml) +yaml.SafeLoader.add_constructor('!include_dir_list', _include_dir_list_yaml) +yaml.SafeLoader.add_constructor('!include_dir_merge_list', + _include_dir_merge_list_yaml) +yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml) +yaml.SafeLoader.add_constructor('!include_dir_merge_named', + _include_dir_merge_named_yaml) diff --git a/requirements_all.txt b/requirements_all.txt index ddfc5a0802f..1c441790028 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,6 +6,7 @@ pip>=7.0.0 vincenty==0.1.4 jinja2>=2.8 voluptuous==0.8.9 +webcolors==1.5 # homeassistant.components.isy994 PyISY==1.0.5 @@ -37,6 +38,11 @@ blockchain==1.3.1 # homeassistant.components.thermostat.eq3btsmart # bluepy_devices>=0.2.0 +# homeassistant.components.notify.aws_lambda +# homeassistant.components.notify.aws_sns +# homeassistant.components.notify.aws_sqs +boto3==1.3.1 + # homeassistant.components.notify.xmpp dnspython3==1.12.0 @@ -97,6 +103,9 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl # homeassistant.components.alarm_control_panel.alarmdotcom https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 +# homeassistant.components.media_player.roku +https://github.com/bah2830/python-roku/archive/3.1.1.zip#python-roku==3.1.1 + # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 @@ -109,8 +118,11 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 +# homeassistant.components.qwikswitch +https://github.com/kellerza/pyqwikswitch/archive/v0.3.zip#pyqwikswitch==0.3 + # homeassistant.components.ecobee -https://github.com/nkgilley/python-ecobee-api/archive/92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4 +https://github.com/nkgilley/python-ecobee-api/archive/4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5 # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 @@ -119,7 +131,7 @@ https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f https://github.com/rkabadi/temper-python/archive/3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip#temperusb==1.2.3 # homeassistant.components.sensor.gtfs -https://github.com/robbiet480/pygtfs/archive/432414b720c580fb2667a0a48f539118a2d95969.zip#pygtfs==0.1.2 +https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 # homeassistant.components.scene.hunterdouglas_powerview https://github.com/sander76/powerviewApi/archive/master.zip#powerviewApi==0.2 @@ -130,6 +142,9 @@ https://github.com/theolind/pymysensors/archive/cc5d0b325e13c2b623fa934f69eea7cd # homeassistant.components.notify.googlevoice https://github.com/w1ll1am23/pygooglevoice-sms/archive/7c5ee9969b97a7992fc86a753fe9f20e3ffa3f7c.zip#pygooglevoice-sms==0.0.1 +# homeassistant.components.media_player.lg_netcast +https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 + # homeassistant.components.influxdb influxdb==2.12.0 @@ -153,7 +168,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.discovery -netdisco==0.6.6 +netdisco==0.6.7 # homeassistant.components.sensor.neurio_energy neurio==0.2.10 @@ -168,6 +183,7 @@ paho-mqtt==1.1 panasonic_viera==0.2 # homeassistant.components.device_tracker.aruba +# homeassistant.components.device_tracker.asuswrt pexpect==4.0.1 # homeassistant.components.light.hue @@ -180,7 +196,7 @@ plexapi==1.1.0 proliphix==0.1.0 # homeassistant.components.sensor.systemmonitor -psutil==4.1.0 +psutil==4.2.0 # homeassistant.components.notify.pushbullet pushbullet.py==0.10.0 @@ -215,8 +231,11 @@ pyfttt==0.3 # homeassistant.components.device_tracker.icloud pyicloud==0.8.3 +# homeassistant.components.sensor.lastfm +pylast==1.6.0 + # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.10 +pyloopenergy==0.0.12 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 @@ -241,7 +260,7 @@ python-forecastio==1.3.4 python-mpd2==0.5.5 # homeassistant.components.nest -python-nest==2.6.0 +python-nest==2.9.2 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.0 @@ -253,7 +272,7 @@ python-pushover==0.2 python-statsd==1.7.2 # homeassistant.components.notify.telegram -python-telegram-bot==4.0.1 +python-telegram-bot==4.1.1 # homeassistant.components.sensor.twitch python-twitch==1.2.0 @@ -280,7 +299,7 @@ pywemo==0.4.2 radiotherm==1.2 # homeassistant.components.switch.rpi_rf -rpi-rf==0.9.5 +# rpi-rf==0.9.5 # homeassistant.components.media_player.yamaha rxv==0.1.11 @@ -326,6 +345,9 @@ tellive-py==0.5.2 # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.notify.twilio_sms +twilio==5.4.0 + # homeassistant.components.sensor.uber uber_rides==0.2.1 @@ -344,6 +366,9 @@ vsure==0.8.1 # homeassistant.components.switch.wake_on_lan wakeonlan==0.2.2 +# homeassistant.components.media_player.gpmdp +websocket-client==0.35.0 + # homeassistant.components.zigbee xbee-helper==0.0.7 diff --git a/requirements_test.txt b/requirements_test.txt index 6707d35171b..52fc23680b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -flake8>=2.5.1 +flake8>=2.5.4 pylint>=1.5.5 coveralls>=1.1 pytest>=2.9.1 diff --git a/script/build_python_openzwave b/script/build_python_openzwave index 2a5283c44bd..8f88cace558 100755 --- a/script/build_python_openzwave +++ b/script/build_python_openzwave @@ -15,7 +15,7 @@ if [ -d python-openzwave ]; then git pull --recurse-submodules=yes git submodule update --init --recursive else - git clone --recursive https://github.com/OpenZWave/python-openzwave.git + git clone --recursive --depth 1 https://github.com/OpenZWave/python-openzwave.git cd python-openzwave fi diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 32e7dc01dac..76ed3acba39 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -8,6 +8,7 @@ import sys COMMENT_REQUIREMENTS = [ 'RPi.GPIO', + 'rpi-rf', 'Adafruit_Python_DHT', 'fritzconnection', 'pybluez', diff --git a/script/home-assistant@.service b/script/home-assistant@.service index a36a6e743b1..8e520952db9 100644 --- a/script/home-assistant@.service +++ b/script/home-assistant@.service @@ -12,8 +12,9 @@ User=%i # Enable the following line if you get network-related HA errors during boot #ExecStartPre=/usr/bin/sleep 60 # Use `whereis hass` to determine the path of hass -ExecStart=/usr/bin/hass +ExecStart=/usr/bin/hass --runner SendSIGKILL=no +RestartForceExitStatus=100 [Install] WantedBy=multi-user.target diff --git a/setup.py b/setup.py index f5efbb46e25..d315ae7d386 100755 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ REQUIRES = [ 'vincenty==0.1.4', 'jinja2>=2.8', 'voluptuous==0.8.9', + 'webcolors==1.5', ] setup( diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 7fa290e4005..756f6271e59 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -55,6 +55,21 @@ LOCATION_MESSAGE_INACCURATE = { 'tst': 1, 'vel': 0} +LOCATION_MESSAGE_ZERO_ACCURACY = { + 'batt': 92, + 'cog': 248, + 'tid': 'user', + 'lon': 2.0, + 't': 'u', + 'alt': 27, + 'acc': 0, + 'p': 101.3977584838867, + 'vac': 4, + 'lat': 6.0, + '_type': 'location', + 'tst': 1, + 'vel': 0} + REGION_ENTER_MESSAGE = { 'lon': 1.0, 'event': 'enter', @@ -204,6 +219,14 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assert_location_latitude(2.0) self.assert_location_longitude(1.0) + def test_location_zero_accuracy_gps(self): + """Ignore the location for zero accuracy GPS information.""" + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + self.assert_location_latitude(2.0) + self.assert_location_longitude(1.0) + def test_event_entry_exit(self): """Test the entry event.""" self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) @@ -230,6 +253,20 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): # Left clean zone state self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + def test_event_with_spaces(self): + """Test the entry event.""" + message = REGION_ENTER_MESSAGE.copy() + message['desc'] = "inner 2" + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner_2') + + message = REGION_LEAVE_MESSAGE.copy() + message['desc'] = "inner 2" + self.send_message(EVENT_TOPIC, message) + + # Left clean zone state + self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + def test_event_entry_exit_inaccurate(self): """Test the event for inaccurate exit.""" self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) diff --git a/tests/components/hvac/test_demo.py b/tests/components/hvac/test_demo.py index 59a51d52011..0a9a1fdd99d 100644 --- a/tests/components/hvac/test_demo.py +++ b/tests/components/hvac/test_demo.py @@ -33,7 +33,7 @@ class TestDemoHvac(unittest.TestCase): self.assertEqual(21, state.attributes.get('temperature')) self.assertEqual('on', state.attributes.get('away_mode')) self.assertEqual(22, state.attributes.get('current_temperature')) - self.assertEqual("On High", state.attributes.get('fan')) + self.assertEqual("On High", state.attributes.get('fan_mode')) self.assertEqual(67, state.attributes.get('humidity')) self.assertEqual(54, state.attributes.get('current_humidity')) self.assertEqual("Off", state.attributes.get('swing_mode')) @@ -81,17 +81,17 @@ class TestDemoHvac(unittest.TestCase): def test_set_fan_mode_bad_attr(self): """Test setting fan mode without required attribute.""" state = self.hass.states.get(ENTITY_HVAC) - self.assertEqual("On High", state.attributes.get('fan')) + self.assertEqual("On High", state.attributes.get('fan_mode')) hvac.set_fan_mode(self.hass, None, ENTITY_HVAC) self.hass.pool.block_till_done() - self.assertEqual("On High", state.attributes.get('fan')) + self.assertEqual("On High", state.attributes.get('fan_mode')) def test_set_fan_mode(self): """Test setting of new fan mode.""" hvac.set_fan_mode(self.hass, "On Low", ENTITY_HVAC) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_HVAC) - self.assertEqual("On Low", state.attributes.get('fan')) + self.assertEqual("On Low", state.attributes.get('fan_mode')) def test_set_swing_mode_bad_attr(self): """Test setting swing mode without required attribute.""" diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py new file mode 100644 index 00000000000..da2798e2a4d --- /dev/null +++ b/tests/components/sensor/test_moldindicator.py @@ -0,0 +1,131 @@ +"""The tests for the MoldIndicator sensor.""" +import unittest + +import homeassistant.components.sensor as sensor +from homeassistant.components.sensor.mold_indicator import (ATTR_DEWPOINT, + ATTR_CRITICAL_TEMP) +from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS) + +from tests.common import get_test_home_assistant + + +class TestSensorMoldIndicator(unittest.TestCase): + """Test the MoldIndicator sensor.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.states.set('test.indoortemp', '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '50', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.hass.pool.block_till_done() + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup(self): + """Test the mold indicator sensor setup.""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert '%' == moldind.attributes.get('unit_of_measurement') + + def test_invalidhum(self): + """Test invalid sensor values.""" + self.hass.states.set('test.indoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '0', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + + # assert state + assert moldind.state == '0' + + def test_calculation(self): + """Test the mold indicator internal calculations.""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + + # assert dewpoint + dewpoint = moldind.attributes.get(ATTR_DEWPOINT) + assert dewpoint + assert dewpoint > 9.25 + assert dewpoint < 9.26 + + # assert temperature estimation + esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP) + assert esttemp + assert esttemp > 14.9 + assert esttemp < 15.1 + + # assert mold indicator value + state = moldind.state + assert state + assert state == '68' + + def test_sensor_changed(self): + """Test the sensor_changed function.""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + # Change indoor temp + self.hass.states.set('test.indoortemp', '30', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '90' + + # Change outdoor temp + self.hass.states.set('test.outdoortemp', '25', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '57' + + # Change humidity + self.hass.states.set('test.indoorhumidity', '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '23' diff --git a/tests/components/test_logentries.py b/tests/components/test_logentries.py new file mode 100644 index 00000000000..c5bad5332f4 --- /dev/null +++ b/tests/components/test_logentries.py @@ -0,0 +1,88 @@ +"""The tests for the Logentries component.""" + +import unittest +from unittest import mock + +import homeassistant.components.logentries as logentries +from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED + + +class TestLogentries(unittest.TestCase): + """Test the Logentries component.""" + + def test_setup_config_full(self): + """Test setup with all data.""" + config = { + 'logentries': { + 'host': 'host', + 'token': 'secret', + } + } + hass = mock.MagicMock() + self.assertTrue(logentries.setup(hass, config)) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + def test_setup_config_defaults(self): + """Test setup with defaults.""" + config = { + 'logentries': { + 'host': 'host', + 'token': 'token', + } + } + hass = mock.MagicMock() + self.assertTrue(logentries.setup(hass, config)) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + def _setup(self, mock_requests): + """Test the setup.""" + self.mock_post = mock_requests.post + self.mock_request_exception = Exception + mock_requests.exceptions.RequestException = self.mock_request_exception + config = { + 'logentries': { + 'host': 'https://webhook.logentries.com/noformat/logs/token', + 'token': 'token' + } + } + self.hass = mock.MagicMock() + logentries.setup(self.hass, config) + self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + + @mock.patch.object(logentries, 'requests') + @mock.patch('json.dumps') + def test_event_listener(self, mock_dump, mock_requests): + """Test event listener.""" + mock_dump.side_effect = lambda x: x + self._setup(mock_requests) + + valid = {'1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0, + 'foo': 'foo'} + for in_, out in valid.items(): + state = mock.MagicMock(state=in_, + domain='fake', + object_id='entity', + attributes={}) + event = mock.MagicMock(data={'new_state': state}, + time_fired=12345) + body = [{ + 'domain': 'fake', + 'entity_id': 'entity', + 'attributes': {}, + 'time': '12345', + 'value': out, + }] + payload = {'host': 'https://webhook.logentries.com/noformat/' + 'logs/token', + 'event': body} + self.handler_method(event) + self.mock_post.assert_called_once_with( + payload['host'], data=payload, timeout=10) + self.mock_post.reset_mock() diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py index 69c4478b121..0577ab27889 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/test_recorder.py @@ -1,6 +1,8 @@ """The tests for the Recorder component.""" # pylint: disable=too-many-public-methods,protected-access import unittest +import time +import json from unittest.mock import patch from homeassistant.const import MATCH_ALL @@ -10,7 +12,7 @@ from tests.common import get_test_home_assistant class TestRecorder(unittest.TestCase): - """Test the chromecast module.""" + """Test the recorder module.""" def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" @@ -25,6 +27,52 @@ class TestRecorder(unittest.TestCase): self.hass.stop() recorder._INSTANCE.block_till_done() + def _add_test_states(self): + """Add multiple states to the db for testing.""" + now = int(time.time()) + five_days_ago = now - (60*60*24*5) + attributes = {'test_attr': 5, 'test_attr_10': 'nice'} + + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + for event_id in range(5): + if event_id < 3: + timestamp = five_days_ago + state = 'purgeme' + else: + timestamp = now + state = 'dontpurgeme' + recorder.query("INSERT INTO states (" + "entity_id, domain, state, attributes," + "last_changed, last_updated, created," + "utc_offset, event_id)" + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ('test.recorder2', 'sensor', state, + json.dumps(attributes), timestamp, timestamp, + timestamp, -18000, event_id + 1000)) + + def _add_test_events(self): + """Add a few events for testing.""" + now = int(time.time()) + five_days_ago = now - (60*60*24*5) + event_data = {'test_attr': 5, 'test_attr_10': 'nice'} + + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + for event_id in range(5): + if event_id < 2: + timestamp = five_days_ago + event_type = 'EVENT_TEST_PURGE' + else: + timestamp = now + event_type = 'EVENT_TEST' + recorder.query("INSERT INTO events" + "(event_type, event_data, origin, created," + "time_fired, utc_offset)" + "VALUES (?, ?, ?, ?, ?, ?)", + (event_type, json.dumps(event_data), 'LOCAL', + timestamp, timestamp, -18000)) + def test_saving_state(self): """Test saving and restoring a state.""" entity_id = 'test.recorder' @@ -76,3 +124,56 @@ class TestRecorder(unittest.TestCase): # Recorder uses SQLite and stores datetimes as integer unix timestamps assert event.time_fired.replace(microsecond=0) == \ db_event.time_fired.replace(microsecond=0) + + def test_purge_old_states(self): + """Test deleting old states.""" + self._add_test_states() + # make sure we start with 5 states + states = recorder.query_states('SELECT * FROM states') + self.assertEqual(len(states), 5) + + # run purge_old_data() + recorder._INSTANCE.purge_days = 4 + recorder._INSTANCE._purge_old_data() + + # we should only have 2 states left after purging + states = recorder.query_states('SELECT * FROM states') + self.assertEqual(len(states), 2) + + def test_purge_old_events(self): + """Test deleting old events.""" + self._add_test_events() + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(events), 5) + + # run purge_old_data() + recorder._INSTANCE.purge_days = 4 + recorder._INSTANCE._purge_old_data() + + # now we should only have 3 events left + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(events), 3) + + def test_purge_disabled(self): + """Test leaving purge_days disabled.""" + self._add_test_states() + self._add_test_events() + # make sure we start with 5 states and events + states = recorder.query_states('SELECT * FROM states') + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(states), 5) + self.assertEqual(len(events), 5) + + # run purge_old_data() + recorder._INSTANCE.purge_days = None + recorder._INSTANCE._purge_old_data() + + # we should have all of our states still + states = recorder.query_states('SELECT * FROM states') + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(states), 5) + self.assertEqual(len(events), 5) diff --git a/tests/components/test_rfxtrx.py b/tests/components/test_rfxtrx.py index 9637f7666bc..3ad9522ec53 100644 --- a/tests/components/test_rfxtrx.py +++ b/tests/components/test_rfxtrx.py @@ -37,10 +37,10 @@ class TestRFXTRX(unittest.TestCase): 'automatic_add': True, 'devices': {}}})) - while len(rfxtrx.RFX_DEVICES) < 1: + while len(rfxtrx.RFX_DEVICES) < 2: time.sleep(0.1) - self.assertEqual(len(rfxtrx.RFXOBJECT.sensors()), 1) + self.assertEqual(len(rfxtrx.RFXOBJECT.sensors()), 2) def test_valid_config(self): """Test configuration.""" diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 06d413778cf..82222a0b8a1 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -9,16 +9,17 @@ class TestColorUtil(unittest.TestCase): # pylint: disable=invalid-name def test_color_RGB_to_xy(self): """Test color_RGB_to_xy.""" - self.assertEqual((0, 0), color_util.color_RGB_to_xy(0, 0, 0)) - self.assertEqual((0.3127159072215825, 0.3290014805066623), + self.assertEqual((0, 0, 0), color_util.color_RGB_to_xy(0, 0, 0)) + self.assertEqual((0.32, 0.336, 255), color_util.color_RGB_to_xy(255, 255, 255)) - self.assertEqual((0.15001662234042554, 0.060006648936170214), + self.assertEqual((0.136, 0.04, 12), color_util.color_RGB_to_xy(0, 0, 255)) - self.assertEqual((0.3, 0.6), color_util.color_RGB_to_xy(0, 255, 0)) + self.assertEqual((0.172, 0.747, 170), + color_util.color_RGB_to_xy(0, 255, 0)) - self.assertEqual((0.6400744994567747, 0.3299705106316933), + self.assertEqual((0.679, 0.321, 80), color_util.color_RGB_to_xy(255, 0, 0)) def test_color_xy_brightness_to_RGB(self): diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index da5b56d42a9..bf5284a0b04 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -133,3 +133,32 @@ class TestDateUtil(unittest.TestCase): def test_parse_datetime_returns_none_for_incorrect_format(self): """Test parse_datetime returns None if incorrect format.""" self.assertIsNone(dt_util.parse_datetime("not a datetime string")) + + def test_get_age(self): + """Test get_age.""" + diff = dt_util.now() - timedelta(seconds=0) + self.assertEqual(dt_util.get_age(diff), "0 second") + + diff = dt_util.now() - timedelta(seconds=30) + self.assertEqual(dt_util.get_age(diff), "30 seconds") + + diff = dt_util.now() - timedelta(minutes=5) + self.assertEqual(dt_util.get_age(diff), "5 minutes") + + diff = dt_util.now() - timedelta(minutes=1) + self.assertEqual(dt_util.get_age(diff), "1 minute") + + diff = dt_util.now() - timedelta(minutes=300) + self.assertEqual(dt_util.get_age(diff), "5 hours") + + diff = dt_util.now() - timedelta(minutes=320) + self.assertEqual(dt_util.get_age(diff), "5 hours") + + diff = dt_util.now() - timedelta(minutes=2*60*24) + self.assertEqual(dt_util.get_age(diff), "2 days") + + diff = dt_util.now() - timedelta(minutes=32*60*24) + self.assertEqual(dt_util.get_age(diff), "1 month") + + diff = dt_util.now() - timedelta(minutes=365*60*24) + self.assertEqual(dt_util.get_age(diff), "1 year") diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index e865b5bba32..244f9323334 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -1,6 +1,8 @@ """Test Home Assistant yaml loader.""" import io import unittest +import os +import tempfile from homeassistant.util import yaml @@ -32,3 +34,104 @@ class TestYaml(unittest.TestCase): pass else: assert 0 + + def test_enviroment_variable(self): + """Test config file with enviroment variable.""" + os.environ["PASSWORD"] = "secret_password" + conf = "password: !env_var PASSWORD" + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc['password'] == "secret_password" + del os.environ["PASSWORD"] + + def test_invalid_enviroment_variable(self): + """Test config file with no enviroment variable sat.""" + conf = "password: !env_var PASSWORD" + try: + with io.StringIO(conf) as f: + yaml.yaml.safe_load(f) + except Exception: + pass + else: + assert 0 + + def test_include_yaml(self): + """Test include yaml.""" + with tempfile.NamedTemporaryFile() as include_file: + include_file.write(b"value") + include_file.seek(0) + conf = "key: !include {}".format(include_file.name) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc["key"] == "value" + + def test_include_dir_list(self): + """Test include dir list yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"two") + file_2.close() + conf = "key: !include_dir_list {}".format(include_dir) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert sorted(doc["key"]) == sorted(["one", "two"]) + + def test_include_dir_named(self): + """Test include dir named yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"two") + file_2.close() + conf = "key: !include_dir_named {}".format(include_dir) + correct = {} + correct[os.path.splitext(os.path.basename(file_1.name))[0]] = "one" + correct[os.path.splitext(os.path.basename(file_2.name))[0]] = "two" + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc["key"] == correct + + def test_include_dir_merge_list(self): + """Test include dir merge list yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"- one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"- two\n- three") + file_2.close() + conf = "key: !include_dir_merge_list {}".format(include_dir) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert sorted(doc["key"]) == sorted(["one", "two", "three"]) + + def test_include_dir_merge_named(self): + """Test include dir merge named yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"key1: one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"key2: two\nkey3: three") + file_2.close() + conf = "key: !include_dir_merge_named {}".format(include_dir) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc["key"] == { + "key1": "one", + "key2": "two", + "key3": "three" + }