From 7e15f179c678b5797102e50c517d44baffb8392c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 8 Mar 2018 17:52:41 -0800 Subject: [PATCH 01/35] Bump release 0.65 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1d90e530702..d8f7e00959c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 65 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 7410bc90f0090ce8829827027952ba3ab45c780d Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Thu, 8 Mar 2018 22:31:52 -0500 Subject: [PATCH 02/35] Get zha switch and binary_sensor state on startup (#11672) * Get zha switch and binary_sensor state on startup * Removed unused var * Make zha switch report status * Use right method name * Formatting fix * Updates to match latest dev * PR feedback updates * Use async for cluster commands --- homeassistant/components/binary_sensor/zha.py | 33 ++++++++++---- homeassistant/components/switch/zha.py | 43 ++++++++++++++----- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index de7896e595b..bf038a62465 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -4,7 +4,6 @@ Binary sensors on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.zha/ """ -import asyncio import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice @@ -25,8 +24,8 @@ CLASS_MAPPING = { } -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Zigbee Home Automation binary sensors.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: @@ -39,19 +38,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device_class = None cluster = in_clusters[IasZone.cluster_id] if discovery_info['new_join']: - yield from cluster.bind() + await cluster.bind() ieee = cluster.endpoint.device.application.ieee - yield from cluster.write_attributes({'cie_addr': ieee}) + await cluster.write_attributes({'cie_addr': ieee}) try: - zone_type = yield from cluster['zone_type'] + zone_type = await cluster['zone_type'] device_class = CLASS_MAPPING.get(zone_type, None) except Exception: # pylint: disable=broad-except # If we fail to read from the device, use a non-specific class pass sensor = BinarySensor(device_class, **discovery_info) - async_add_devices([sensor]) + async_add_devices([sensor], update_before_add=True) class BinarySensor(zha.Entity, BinarySensorDevice): @@ -66,6 +65,11 @@ class BinarySensor(zha.Entity, BinarySensorDevice): from zigpy.zcl.clusters.security import IasZone self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id] + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False + @property def is_on(self) -> bool: """Return True if entity is on.""" @@ -83,7 +87,18 @@ class BinarySensor(zha.Entity, BinarySensorDevice): if command_id == 0: self._state = args[0] & 3 _LOGGER.debug("Updated alarm state: %s", self._state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() elif command_id == 1: _LOGGER.debug("Enroll requested") - self.hass.add_job(self._ias_zone_cluster.enroll_response(0, 0)) + res = self._ias_zone_cluster.enroll_response(0, 0) + self.hass.async_add_job(res) + + async def async_update(self): + """Retrieve latest state.""" + from bellows.types.basic import uint16_t + + result = await zha.safe_read(self._endpoint.ias_zone, + ['zone_status']) + state = result.get('zone_status', self._state) + if isinstance(state, (int, uint16_t)): + self._state = result.get('zone_status', self._state) & 3 diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index c98db2e894e..7de9f1459b1 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -4,7 +4,6 @@ Switches on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/switch.zha/ """ -import asyncio import logging from homeassistant.components.switch import DOMAIN, SwitchDevice @@ -15,19 +14,39 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['zha'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Zigbee Home Automation switches.""" +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Zigbee Home Automation switches.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return - add_devices([Switch(**discovery_info)]) + from zigpy.zcl.clusters.general import OnOff + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await cluster.bind() + await cluster.configure_reporting(0, 0, 600, 1,) + + async_add_devices([Switch(**discovery_info)], update_before_add=True) class Switch(zha.Entity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN + value_attribute = 0 + + def attribute_updated(self, attribute, value): + """Handle attribute update from device.""" + _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) + if attribute == self.value_attribute: + self._state = value + self.async_schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False @property def is_on(self) -> bool: @@ -36,14 +55,18 @@ class Switch(zha.Entity, SwitchDevice): return False return bool(self._state) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on.""" - yield from self._endpoint.on_off.on() + await self._endpoint.on_off.on() self._state = 1 - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the entity off.""" - yield from self._endpoint.on_off.off() + await self._endpoint.on_off.off() self._state = 0 + + async def async_update(self): + """Retrieve latest state.""" + result = await zha.safe_read(self._endpoint.on_off, + ['on_off']) + self._state = result.get('on_off', self._state) From b2210f429e0f40b900bf8d0177b1c8d26bbee29f Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Thu, 8 Mar 2018 18:23:52 -0800 Subject: [PATCH 03/35] Add camera proxy (#12006) * Add camera proxy * Fix additional tox linting issues * Trivial cleanup * update to new async/await methods rather than decorators. Other minor fixes from code review --- homeassistant/components/camera/proxy.py | 262 +++++++++++++++++++++++ requirements_all.txt | 3 + 2 files changed, 265 insertions(+) create mode 100644 homeassistant/components/camera/proxy.py diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py new file mode 100644 index 00000000000..56b9db5c0ec --- /dev/null +++ b/homeassistant/components/camera/proxy.py @@ -0,0 +1,262 @@ +""" +Proxy camera platform that enables image processing of camera data. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/proxy +""" +import logging +import asyncio +import aiohttp +import async_timeout + +import voluptuous as vol + +from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.helpers import config_validation as cv + +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + CONF_NAME, CONF_ENTITY_ID, HTTP_HEADER_HA_AUTH) +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_web) + +REQUIREMENTS = ['pillow==5.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_MAX_IMAGE_WIDTH = "max_image_width" +CONF_IMAGE_QUALITY = "image_quality" +CONF_IMAGE_REFRESH_RATE = "image_refresh_rate" +CONF_FORCE_RESIZE = "force_resize" +CONF_MAX_STREAM_WIDTH = "max_stream_width" +CONF_STREAM_QUALITY = "stream_quality" +CONF_CACHE_IMAGES = "cache_images" + +DEFAULT_BASENAME = "Camera Proxy" +DEFAULT_QUALITY = 75 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_IMAGE_QUALITY): int, + vol.Optional(CONF_IMAGE_REFRESH_RATE): float, + vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_MAX_STREAM_WIDTH): int, + vol.Optional(CONF_STREAM_QUALITY): int, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Proxy camera platform.""" + async_add_devices([ProxyCamera(hass, config)]) + + +async def _read_frame(req): + """Read a single frame from an MJPEG stream.""" + # based on https://gist.github.com/russss/1143799 + import cgi + # Read in HTTP headers: + stream = req.content + # multipart/x-mixed-replace; boundary=--frameboundary + _mimetype, options = cgi.parse_header(req.headers['content-type']) + boundary = options.get('boundary').encode('utf-8') + if not boundary: + _LOGGER.error("Malformed MJPEG missing boundary") + raise Exception("Can't find content-type") + + line = await stream.readline() + # Seek ahead to the first chunk + while line.strip() != boundary: + line = await stream.readline() + # Read in chunk headers + while line.strip() != b'': + parts = line.split(b':') + if len(parts) > 1 and parts[0].lower() == b'content-length': + # Grab chunk length + length = int(parts[1].strip()) + line = await stream.readline() + image = await stream.read(length) + return image + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + if not opts: + return image + + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width + + img = Image.open(io.BytesIO(image)) + imgfmt = str(img.format) + if imgfmt != 'PNG' and imgfmt != 'JPEG': + _LOGGER.debug("Image is of unsupported type: %s", imgfmt) + return image + + (old_width, old_height) = img.size + old_size = len(image) + if old_width <= new_width: + if opts.quality is None: + _LOGGER.debug("Image is smaller-than / equal-to requested width") + return image + new_width = old_width + + scale = new_width / float(old_width) + new_height = int((float(old_height)*float(scale))) + + img = img.resize((new_width, new_height), Image.ANTIALIAS) + imgbuf = io.BytesIO() + img.save(imgbuf, "JPEG", optimize=True, quality=quality) + newimage = imgbuf.getvalue() + if not opts.force_resize and len(newimage) >= old_size: + _LOGGER.debug("Using original image(%d bytes) " + "because resized image (%d bytes) is not smaller", + old_size, len(newimage)) + return image + + _LOGGER.debug("Resized image " + "from (%dx%d - %d bytes) " + "to (%dx%d - %d bytes)", + old_width, old_height, old_size, + new_width, new_height, len(newimage)) + return newimage + + +class ImageOpts(): + """The representation of image options.""" + + def __init__(self, max_width, quality, force_resize): + """Initialize image options.""" + self.max_width = max_width + self.quality = quality + self.force_resize = force_resize + + def __bool__(self): + """Bool evalution rules.""" + return bool(self.max_width or self.quality) + + +class ProxyCamera(Camera): + """The representation of a Proxy camera.""" + + def __init__(self, hass, config): + """Initialize a proxy camera component.""" + super().__init__() + self.hass = hass + self._proxied_camera = config.get(CONF_ENTITY_ID) + self._name = ( + config.get(CONF_NAME) or + "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) + self._image_opts = ImageOpts( + config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_IMAGE_QUALITY), + config.get(CONF_FORCE_RESIZE)) + + self._stream_opts = ImageOpts( + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_STREAM_QUALITY), + True) + + self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) + self._cache_images = bool( + config.get(CONF_IMAGE_REFRESH_RATE) + or config.get(CONF_CACHE_IMAGES)) + self._last_image_time = 0 + self._last_image = None + self._headers = ( + {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} + if self.hass.config.api.api_password is not None + else None) + + def camera_image(self): + """Return camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + now = dt_util.utcnow() + + if (self._image_refresh_rate and + now < self._last_image_time + self._image_refresh_rate): + return self._last_image + + self._last_image_time = now + url = "{}/api/camera_proxy/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(10, loop=self.hass.loop): + response = await websession.get(url, headers=self._headers) + image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image") + return self._last_image + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image: %s", err) + return self._last_image + + image = await self.hass.async_add_job( + _resize_image, image, self._image_opts) + + if self._cache_images: + self._last_image = image + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from camera images.""" + websession = async_get_clientsession(self.hass) + url = "{}/api/camera_proxy_stream/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + stream_coro = websession.get(url, headers=self._headers) + + if not self._stream_opts: + await async_aiohttp_proxy_web(self.hass, request, stream_coro) + return + + response = aiohttp.web.StreamResponse() + response.content_type = ('multipart/x-mixed-replace; ' + 'boundary=--frameboundary') + await response.prepare(request) + + def write(img_bytes): + """Write image to stream.""" + response.write(bytes( + '--frameboundary\r\n' + 'Content-Type: {}\r\n' + 'Content-Length: {}\r\n\r\n'.format( + self.content_type, len(img_bytes)), + 'utf-8') + img_bytes + b'\r\n') + + with async_timeout.timeout(10, loop=self.hass.loop): + req = await stream_coro + + try: + while True: + image = await _read_frame(req) + if not image: + break + image = await self.hass.async_add_job( + _resize_image, image, self._stream_opts) + write(image) + except asyncio.CancelledError: + _LOGGER.debug("Stream closed by frontend.") + req.close() + response = None + + finally: + if response is not None: + await response.write_eof() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/requirements_all.txt b/requirements_all.txt index e6eeb18fafc..b7af19f0d66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -580,6 +580,9 @@ piglow==1.2.4 # homeassistant.components.pilight pilight==0.1.1 +# homeassistant.components.camera.proxy +pillow==5.0.0 + # homeassistant.components.dominos pizzapi==0.0.3 From 16d72d23512eb402babbeee19f32b2a1f254690a Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 9 Mar 2018 05:34:24 +0200 Subject: [PATCH 04/35] check_config script evolution (#12792) * Initial async_check_ha_config_file * check_ha_config_file * Various fixes * feedback - return the config * move_to_check_config --- homeassistant/bootstrap.py | 11 +- homeassistant/config.py | 31 ++- homeassistant/scripts/check_config.py | 285 ++++++++++++++++---------- tests/scripts/test_check_config.py | 239 ++++++++++----------- tests/test_config.py | 4 +- 5 files changed, 316 insertions(+), 254 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4971cbccc9c..2f093f061d9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -112,18 +112,13 @@ def async_from_config_dict(config: Dict[str, Any], if not loader.PREPARED: yield from hass.async_add_job(loader.prepare, hass) + # Make a copy because we are mutating it. + config = OrderedDict(config) + # Merge packages conf_util.merge_packages_config( config, core_config.get(conf_util.CONF_PACKAGES, {})) - # Make a copy because we are mutating it. - # Use OrderedDict in case original one was one. - # Convert values to dictionaries if they are None - new_config = OrderedDict() - for key, value in config.items(): - new_config[key] = value or {} - config = new_config - hass.config_entries = config_entries.ConfigEntries(hass, config) yield from hass.config_entries.async_load() diff --git a/homeassistant/config.py b/homeassistant/config.py index 1c8ca10f8c6..5f2c6cf1625 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -41,9 +41,9 @@ VERSION_FILE = '.HA_VERSION' CONFIG_DIR_NAME = '.homeassistant' DATA_CUSTOMIZE = 'hass_customize' -FILE_MIGRATION = [ - ['ios.conf', '.ios.conf'], -] +FILE_MIGRATION = ( + ('ios.conf', '.ios.conf'), +) DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) @@ -304,6 +304,9 @@ def load_yaml_config_file(config_path): _LOGGER.error(msg) raise HomeAssistantError(msg) + # Convert values to dictionaries if they are None + for key, value in conf_dict.items(): + conf_dict[key] = value or {} return conf_dict @@ -345,14 +348,22 @@ def process_ha_config_upgrade(hass): @callback def async_log_exception(ex, domain, config, hass): + """Log an error for configuration validation. + + This method must be run in the event loop. + """ + if hass is not None: + async_notify_setup_error(hass, domain, True) + _LOGGER.error(_format_config_error(ex, domain, config)) + + +@callback +def _format_config_error(ex, domain, config): """Generate log exception for configuration validation. This method must be run in the event loop. """ message = "Invalid config for [{}]: ".format(domain) - if hass is not None: - async_notify_setup_error(hass, domain, True) - if 'extra keys not allowed' in ex.error_message: message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ .format(ex.path[-1], domain, domain, @@ -369,7 +380,7 @@ def async_log_exception(ex, domain, config, hass): message += ('Please check the docs at ' 'https://home-assistant.io/components/{}/'.format(domain)) - _LOGGER.error(message) + return message async def async_process_ha_core_config(hass, config): @@ -497,7 +508,7 @@ async def async_process_ha_core_config(hass, config): def _log_pkg_error(package, component, config, message): - """Log an error while merging.""" + """Log an error while merging packages.""" message = "Package {} setup failed. Component {} {}".format( package, component, message) @@ -523,7 +534,7 @@ def _identify_config_schema(module): return '', schema -def merge_packages_config(config, packages): +def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): """Merge packages into the top-level configuration. Mutate config.""" # pylint: disable=too-many-nested-blocks PACKAGES_CONFIG_SCHEMA(packages) @@ -589,7 +600,7 @@ def merge_packages_config(config, packages): def async_process_component_config(hass, config, domain): """Check component configuration and return processed configuration. - Raise a vol.Invalid exception on error. + Returns None on error. This method must be run in the event loop. """ diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ecbd7ca22eb..4e80b3c6536 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -1,17 +1,23 @@ -"""Script to ensure a configuration file exists.""" +"""Script to check the configuration file.""" import argparse import logging import os -from collections import OrderedDict +from collections import OrderedDict, namedtuple from glob import glob from platform import system from unittest.mock import patch +import attr from typing import Dict, List, Sequence +import voluptuous as vol -from homeassistant.core import callback -from homeassistant import bootstrap, loader, setup, config as config_util +from homeassistant import bootstrap, core, loader +from homeassistant.config import ( + get_default_config_dir, CONF_CORE, CORE_CONFIG_SCHEMA, + CONF_PACKAGES, merge_packages_config, _format_config_error, + find_config_file, load_yaml_config_file, get_component, + extract_domain_configs, config_per_platform, get_platform) import homeassistant.util.yaml as yaml from homeassistant.exceptions import HomeAssistantError @@ -24,35 +30,18 @@ _LOGGER = logging.getLogger(__name__) MOCKS = { 'load': ("homeassistant.util.yaml.load_yaml", yaml.load_yaml), 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml), - 'get': ("homeassistant.loader.get_component", loader.get_component), 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml), - 'except': ("homeassistant.config.async_log_exception", - config_util.async_log_exception), - 'package_error': ("homeassistant.config._log_pkg_error", - config_util._log_pkg_error), - 'logger_exception': ("homeassistant.setup._LOGGER.error", - setup._LOGGER.error), - 'logger_exception_bootstrap': ("homeassistant.bootstrap._LOGGER.error", - bootstrap._LOGGER.error), } SILENCE = ( - 'homeassistant.bootstrap.async_enable_logging', # callback - 'homeassistant.bootstrap.clear_secret_cache', - 'homeassistant.bootstrap.async_register_signal_handling', # callback - 'homeassistant.config.process_ha_config_upgrade', + 'homeassistant.scripts.check_config.yaml.clear_secret_cache', ) + PATCHES = {} C_HEAD = 'bold' ERROR_STR = 'General Errors' -@callback -def mock_cb(*args): - """Callback that returns None.""" - return None - - def color(the_color, *args, reset=None): """Color helper.""" from colorlog.escape_codes import escape_codes, parse_colors @@ -74,11 +63,11 @@ def run(script_args: List) -> int: '--script', choices=['check_config']) parser.add_argument( '-c', '--config', - default=config_util.get_default_config_dir(), + default=get_default_config_dir(), help="Directory that contains the Home Assistant configuration") parser.add_argument( - '-i', '--info', - default=None, + '-i', '--info', nargs='?', + default=None, const='all', help="Show a portion of the config") parser.add_argument( '-f', '--files', @@ -89,21 +78,20 @@ def run(script_args: List) -> int: action='store_true', help="Show secret information") - args = parser.parse_args() + args, unknown = parser.parse_known_args() + if unknown: + print(color('red', "Unknown arguments:", ', '.join(unknown))) config_dir = os.path.join(os.getcwd(), args.config) - config_path = os.path.join(config_dir, 'configuration.yaml') - if not os.path.isfile(config_path): - print('Config does not exist:', config_path) - return 1 print(color('bold', "Testing configuration at", config_dir)) + res = check(config_dir, args.secrets) + domain_info = [] if args.info: domain_info = args.info.split(',') - res = check(config_path) if args.files: print(color(C_HEAD, 'yaml files'), '(used /', color('red', 'not used') + ')') @@ -158,59 +146,23 @@ def run(script_args: List) -> int: return len(res['except']) -def check(config_path): +def check(config_dir, secrets=False): """Perform a check by mocking hass load functions.""" - logging.getLogger('homeassistant.core').setLevel(logging.WARNING) - logging.getLogger('homeassistant.loader').setLevel(logging.WARNING) - logging.getLogger('homeassistant.setup').setLevel(logging.WARNING) - logging.getLogger('homeassistant.bootstrap').setLevel(logging.ERROR) - logging.getLogger('homeassistant.util.yaml').setLevel(logging.INFO) + logging.getLogger('homeassistant.loader').setLevel(logging.CRITICAL) res = { 'yaml_files': OrderedDict(), # yaml_files loaded 'secrets': OrderedDict(), # secret cache and secrets loaded 'except': OrderedDict(), # exceptions raised (with config) - 'components': OrderedDict(), # successful components - 'secret_cache': OrderedDict(), + 'components': None, # successful components + 'secret_cache': None, } # pylint: disable=unused-variable def mock_load(filename): - """Mock hass.util.load_yaml to save config files.""" + """Mock hass.util.load_yaml to save config file names.""" res['yaml_files'][filename] = True return MOCKS['load'][1](filename) - # pylint: disable=unused-variable - def mock_get(comp_name): - """Mock hass.loader.get_component to replace setup & setup_platform.""" - async def mock_async_setup(*args): - """Mock setup, only record the component name & config.""" - assert comp_name not in res['components'], \ - "Components should contain a list of platforms" - res['components'][comp_name] = args[1].get(comp_name) - return True - module = MOCKS['get'][1](comp_name) - - if module is None: - # Ensure list - msg = '{} not found: {}'.format( - 'Platform' if '.' in comp_name else 'Component', comp_name) - res['except'].setdefault(ERROR_STR, []).append(msg) - return None - - # Test if platform/component and overwrite setup - if '.' in comp_name: - module.async_setup_platform = mock_async_setup - - if hasattr(module, 'setup_platform'): - del module.setup_platform - else: - module.async_setup = mock_async_setup - - if hasattr(module, 'setup'): - del module.setup - - return module - # pylint: disable=unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" @@ -221,37 +173,14 @@ def check(config_path): res['secrets'][node.value] = val return val - def mock_except(ex, domain, config, # pylint: disable=unused-variable - hass=None): - """Mock config.log_exception.""" - MOCKS['except'][1](ex, domain, config, hass) - res['except'][domain] = config.get(domain, config) - - def mock_package_error( # pylint: disable=unused-variable - package, component, config, message): - """Mock config_util._log_pkg_error.""" - MOCKS['package_error'][1](package, component, config, message) - - pkg_key = 'homeassistant.packages.{}'.format(package) - res['except'][pkg_key] = config.get('homeassistant', {}) \ - .get('packages', {}).get(package) - - def mock_logger_exception(msg, *params): - """Log logger.exceptions.""" - res['except'].setdefault(ERROR_STR, []).append(msg % params) - MOCKS['logger_exception'][1](msg, *params) - - def mock_logger_exception_bootstrap(msg, *params): - """Log logger.exceptions.""" - res['except'].setdefault(ERROR_STR, []).append(msg % params) - MOCKS['logger_exception_bootstrap'][1](msg, *params) - # Patches to skip functions for sil in SILENCE: - PATCHES[sil] = patch(sil, return_value=mock_cb()) + PATCHES[sil] = patch(sil) # Patches with local mock functions for key, val in MOCKS.items(): + if not secrets and key == 'secrets': + continue # The * in the key is removed to find the mock_function (side_effect) # This allows us to use one side_effect to patch multiple locations mock_function = locals()['mock_' + key.replace('*', '')] @@ -260,22 +189,42 @@ def check(config_path): # Start all patches for pat in PATCHES.values(): pat.start() - # Ensure !secrets point to the patched function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + + if secrets: + # Ensure !secrets point to the patched function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) try: - with patch('homeassistant.util.logging.AsyncHandler._process'): - bootstrap.from_config_file(config_path, skip_pip=True) - res['secret_cache'] = dict(yaml.__SECRET_CACHE) + class HassConfig(): + """Hass object with config.""" + + def __init__(self, conf_dir): + """Init the config_dir.""" + self.config = core.Config() + self.config.config_dir = conf_dir + + loader.prepare(HassConfig(config_dir)) + + res['components'] = check_ha_config_file(config_dir) + + res['secret_cache'] = OrderedDict(yaml.__SECRET_CACHE) + + for err in res['components'].errors: + domain = err.domain or ERROR_STR + res['except'].setdefault(domain, []).append(err.message) + if err.config: + res['except'].setdefault(domain, []).append(err.config) + except Exception as err: # pylint: disable=broad-except print(color('red', 'Fatal error while loading config:'), str(err)) - res['except'].setdefault(ERROR_STR, []).append(err) + res['except'].setdefault(ERROR_STR, []).append(str(err)) finally: # Stop all patches for pat in PATCHES.values(): pat.stop() - # Ensure !secrets point to the original function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + if secrets: + # Ensure !secrets point to the original function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) bootstrap.clear_secret_cache() return res @@ -317,3 +266,125 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): dump_dict(i, indent_count + 2, True) else: print(' ', indent_str, i) + + +CheckConfigError = namedtuple( # pylint: disable=invalid-name + 'CheckConfigError', "message domain config") + + +@attr.s +class HomeAssistantConfig(OrderedDict): + """Configuration result with errors attribute.""" + + errors = attr.ib(default=attr.Factory(list)) + + def add_error(self, message, domain=None, config=None): + """Add a single error.""" + self.errors.append(CheckConfigError(str(message), domain, config)) + return self + + +def check_ha_config_file(config_dir): + """Check if Home Assistant configuration file is valid.""" + result = HomeAssistantConfig() + + def _pack_error(package, component, config, message): + """Handle errors from packages: _log_pkg_error.""" + message = "Package {} setup failed. Component {} {}".format( + package, component, message) + domain = 'homeassistant.packages.{}.{}'.format(package, component) + pack_config = core_config[CONF_PACKAGES].get(package, config) + result.add_error(message, domain, pack_config) + + def _comp_error(ex, domain, config): + """Handle errors from components: async_log_exception.""" + result.add_error( + _format_config_error(ex, domain, config), domain, config) + + # Load configuration.yaml + try: + config_path = find_config_file(config_dir) + if not config_path: + return result.add_error("File configuration.yaml not found.") + config = load_yaml_config_file(config_path) + except HomeAssistantError as err: + return result.add_error(err) + finally: + yaml.clear_secret_cache() + + # Extract and validate core [homeassistant] config + try: + core_config = config.pop(CONF_CORE, {}) + core_config = CORE_CONFIG_SCHEMA(core_config) + result[CONF_CORE] = core_config + except vol.Invalid as err: + result.add_error(err, CONF_CORE, core_config) + core_config = {} + + # Merge packages + merge_packages_config( + config, core_config.get(CONF_PACKAGES, {}), _pack_error) + del core_config[CONF_PACKAGES] + + # Filter out repeating config sections + components = set(key.split(' ')[0] for key in config.keys()) + + # Process and validate config + for domain in components: + component = get_component(domain) + if not component: + result.add_error("Component not found: {}".format(domain)) + continue + + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + result[domain] = config[domain] + except vol.Invalid as ex: + _comp_error(ex, domain, config) + continue + + if not hasattr(component, 'PLATFORM_SCHEMA'): + continue + + platforms = [] + for p_name, p_config in config_per_platform(config, domain): + # Validate component specific platform schema + try: + p_validated = component.PLATFORM_SCHEMA(p_config) + except vol.Invalid as ex: + _comp_error(ex, domain, config) + continue + + # Not all platform components follow same pattern for platforms + # So if p_name is None we are not going to validate platform + # (the automation component is one of them) + if p_name is None: + platforms.append(p_validated) + continue + + platform = get_platform(domain, p_name) + + if platform is None: + result.add_error( + "Platform not found: {}.{}".format(domain, p_name)) + continue + + # Validate platform specific schema + if hasattr(platform, 'PLATFORM_SCHEMA'): + # pylint: disable=no-member + try: + p_validated = platform.PLATFORM_SCHEMA(p_validated) + except vol.Invalid as ex: + _comp_error( + ex, '{}.{}'.format(domain, p_name), p_validated) + continue + + platforms.append(p_validated) + + # Remove config for current component and add validated config back in. + for filter_comp in extract_domain_configs(config, domain): + del config[filter_comp] + result[domain] = platforms + + return result diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 728e683a43a..677ed8de110 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -1,9 +1,12 @@ """Test check_config script.""" import asyncio import logging +import os # noqa: F401 pylint: disable=unused-import import unittest +from unittest.mock import patch import homeassistant.scripts.check_config as check_config +from homeassistant.config import YAML_CONFIG_FILE from homeassistant.loader import set_component from tests.common import patch_yaml_files, get_test_config_dir @@ -21,21 +24,14 @@ BASE_CONFIG = ( ) -def change_yaml_files(check_dict): - """Change the ['yaml_files'] property and remove the configuration path. - - Also removes other files like service.yaml that gets loaded. - """ +def normalize_yaml_files(check_dict): + """Remove configuration path from ['yaml_files'].""" root = get_test_config_dir() - keys = check_dict['yaml_files'].keys() - check_dict['yaml_files'] = [] - for key in sorted(keys): - if not key.startswith('/'): - check_dict['yaml_files'].append(key) - if key.startswith(root): - check_dict['yaml_files'].append('...' + key[len(root):]) + return [key.replace(root, '...') + for key in sorted(check_dict['yaml_files'].keys())] +# pylint: disable=unsubscriptable-object class TestCheckConfig(unittest.TestCase): """Tests for the homeassistant.scripts.check_config module.""" @@ -51,176 +47,165 @@ class TestCheckConfig(unittest.TestCase): asyncio.set_event_loop(asyncio.new_event_loop()) # Will allow seeing full diff - self.maxDiff = None + self.maxDiff = None # pylint: disable=invalid-name # pylint: disable=no-self-use,invalid-name - def test_config_platform_valid(self): + @patch('os.path.isfile', return_value=True) + def test_config_platform_valid(self, isfile_patch): """Test a valid platform setup.""" files = { - 'light.yaml': BASE_CONFIG + 'light:\n platform: demo', + YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: demo', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('light.yaml')) - change_yaml_files(res) - self.assertDictEqual({ - 'components': {'light': [{'platform': 'demo'}], 'group': None}, - 'except': {}, - 'secret_cache': {}, - 'secrets': {}, - 'yaml_files': ['.../light.yaml'] - }, res) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant', 'light'} + assert res['components']['light'] == [{'platform': 'demo'}] + assert res['except'] == {} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_config_component_platform_fail_validation(self): + @patch('os.path.isfile', return_value=True) + def test_config_component_platform_fail_validation(self, isfile_patch): """Test errors if component & platform not found.""" files = { - 'component.yaml': BASE_CONFIG + 'http:\n password: err123', + YAML_CONFIG_FILE: BASE_CONFIG + 'http:\n password: err123', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('component.yaml')) - change_yaml_files(res) - - self.assertDictEqual({}, res['components']) - res['except'].pop(check_config.ERROR_STR) - self.assertDictEqual( - {'http': {'password': 'err123'}}, - res['except'] - ) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../component.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant'} + assert res['except'].keys() == {'http'} + assert res['except']['http'][1] == {'http': {'password': 'err123'}} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 files = { - 'platform.yaml': (BASE_CONFIG + 'mqtt:\n\n' - 'light:\n platform: mqtt_json'), + YAML_CONFIG_FILE: (BASE_CONFIG + 'mqtt:\n\n' + 'light:\n platform: mqtt_json'), } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('platform.yaml')) - change_yaml_files(res) - self.assertDictEqual( - {'mqtt': { - 'keepalive': 60, - 'port': 1883, - 'protocol': '3.1.1', - 'discovery': False, - 'discovery_prefix': 'homeassistant', - 'tls_version': 'auto', - }, - 'light': [], - 'group': None}, - res['components'] - ) - self.assertDictEqual( - {'light.mqtt_json': {'platform': 'mqtt_json'}}, - res['except'] - ) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../platform.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == { + 'homeassistant', 'light', 'mqtt'} + assert res['components']['light'] == [] + assert res['components']['mqtt'] == { + 'keepalive': 60, + 'port': 1883, + 'protocol': '3.1.1', + 'discovery': False, + 'discovery_prefix': 'homeassistant', + 'tls_version': 'auto', + } + assert res['except'].keys() == {'light.mqtt_json'} + assert res['except']['light.mqtt_json'][1] == { + 'platform': 'mqtt_json'} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_component_platform_not_found(self): + @patch('os.path.isfile', return_value=True) + def test_component_platform_not_found(self, isfile_patch): """Test errors if component or platform not found.""" # Make sure they don't exist set_component('beer', None) - set_component('light.beer', None) files = { - 'badcomponent.yaml': BASE_CONFIG + 'beer:', - 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer', + YAML_CONFIG_FILE: BASE_CONFIG + 'beer:', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('badcomponent.yaml')) - change_yaml_files(res) - self.assertDictEqual({}, res['components']) - self.assertDictEqual({ - check_config.ERROR_STR: [ - 'Component not found: beer', - 'Setup failed for beer: Component not found.'] - }, res['except']) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../badcomponent.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant'} + assert res['except'] == { + check_config.ERROR_STR: ['Component not found: beer']} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - res = check_config.check(get_test_config_dir('badplatform.yaml')) - change_yaml_files(res) - assert res['components'] == {'light': [], 'group': None} + set_component('light.beer', None) + files = { + YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: beer', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant', 'light'} + assert res['components']['light'] == [] assert res['except'] == { check_config.ERROR_STR: [ 'Platform not found: light.beer', ]} - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../badplatform.yaml'], res['yaml_files']) + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_secrets(self): + @patch('os.path.isfile', return_value=True) + def test_secrets(self, isfile_patch): """Test secrets config checking method.""" + secrets_path = get_test_config_dir('secrets.yaml') + files = { - get_test_config_dir('secret.yaml'): ( - BASE_CONFIG + + get_test_config_dir(YAML_CONFIG_FILE): BASE_CONFIG + ( 'http:\n' ' api_password: !secret http_pw'), - 'secrets.yaml': ('logger: debug\n' - 'http_pw: abc123'), + secrets_path: ( + 'logger: debug\n' + 'http_pw: abc123'), } with patch_yaml_files(files): - config_path = get_test_config_dir('secret.yaml') - secrets_path = get_test_config_dir('secrets.yaml') - res = check_config.check(config_path) - change_yaml_files(res) + res = check_config.check(get_test_config_dir(), True) - # convert secrets OrderedDict to dict for assertequal - for key, val in res['secret_cache'].items(): - res['secret_cache'][key] = dict(val) + assert res['except'] == {} + assert res['components'].keys() == {'homeassistant', 'http'} + assert res['components']['http'] == { + 'api_password': 'abc123', + 'cors_allowed_origins': [], + 'ip_ban_enabled': True, + 'login_attempts_threshold': -1, + 'server_host': '0.0.0.0', + 'server_port': 8123, + 'trusted_networks': [], + 'use_x_forwarded_for': False} + assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}} + assert res['secrets'] == {'http_pw': 'abc123'} + assert normalize_yaml_files(res) == [ + '.../configuration.yaml', '.../secrets.yaml'] - self.assertDictEqual({ - 'components': {'http': {'api_password': 'abc123', - 'cors_allowed_origins': [], - 'ip_ban_enabled': True, - 'login_attempts_threshold': -1, - 'server_host': '0.0.0.0', - 'server_port': 8123, - 'trusted_networks': [], - 'use_x_forwarded_for': False}}, - 'except': {}, - 'secret_cache': {secrets_path: {'http_pw': 'abc123'}}, - 'secrets': {'http_pw': 'abc123'}, - 'yaml_files': ['.../secret.yaml', '.../secrets.yaml'] - }, res) - - def test_package_invalid(self): \ + @patch('os.path.isfile', return_value=True) + def test_package_invalid(self, isfile_patch): \ # pylint: disable=no-self-use,invalid-name """Test a valid platform setup.""" files = { - 'bad.yaml': BASE_CONFIG + (' packages:\n' - ' p1:\n' - ' group: ["a"]'), + YAML_CONFIG_FILE: BASE_CONFIG + ( + ' packages:\n' + ' p1:\n' + ' group: ["a"]'), } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('bad.yaml')) - change_yaml_files(res) + res = check_config.check(get_test_config_dir()) - err = res['except'].pop('homeassistant.packages.p1') - assert res['except'] == {} - assert err == {'group': ['a']} - assert res['yaml_files'] == ['.../bad.yaml'] - - assert res['components'] == {} + assert res['except'].keys() == {'homeassistant.packages.p1.group'} + assert res['except']['homeassistant.packages.p1.group'][1] == \ + {'group': ['a']} + assert len(res['except']) == 1 + assert res['components'].keys() == {'homeassistant'} + assert len(res['components']) == 1 assert res['secret_cache'] == {} assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 def test_bootstrap_error(self): \ # pylint: disable=no-self-use,invalid-name """Test a valid platform setup.""" files = { - 'badbootstrap.yaml': BASE_CONFIG + 'automation: !include no.yaml', + YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('badbootstrap.yaml')) - change_yaml_files(res) - + res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE)) err = res['except'].pop(check_config.ERROR_STR) assert len(err) == 1 assert res['except'] == {} - assert res['components'] == {} + assert res['components'] == {} # No components, load failed assert res['secret_cache'] == {} assert res['secrets'] == {} + assert res['yaml_files'] == {} diff --git a/tests/test_config.py b/tests/test_config.py index 541eaf4f79e..99c21493711 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -158,11 +158,11 @@ class TestConfig(unittest.TestCase): def test_load_yaml_config_preserves_key_order(self): """Test removal of library.""" with open(YAML_PATH, 'w') as f: - f.write('hello: 0\n') + f.write('hello: 2\n') f.write('world: 1\n') self.assertEqual( - [('hello', 0), ('world', 1)], + [('hello', 2), ('world', 1)], list(config_util.load_yaml_config_file(YAML_PATH).items())) @mock.patch('homeassistant.util.location.detect_location_info', From ebf4be3711426e099f3a1454f0210ff5302a299f Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Fri, 9 Mar 2018 16:50:21 +0000 Subject: [PATCH 05/35] Plex mark devices unavailable if they 'vanish' and clear media (#12811) * Marks Devices unavailable if they 'vanish' and clears media * Fixed PEP8 complaint * Fixed Linting * Lint Fix * Fix redine of id * More lint fixes * Removed redundant loop for setting availability of client Renamed '_is_device_available' to '_available' Renamed 'available_ids' to 'available_client_ids' * removed whitespace per houndCI --- homeassistant/components/media_player/plex.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a63bf8525ed..caa81424377 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -154,11 +154,14 @@ def setup_plexserver( return new_plex_clients = [] + available_client_ids = [] for device in devices: # For now, let's allow all deviceClass types if device.deviceClass in ['badClient']: continue + available_client_ids.append(device.machineIdentifier) + if device.machineIdentifier not in plex_clients: new_client = PlexClient(config, device, None, plex_sessions, update_devices, @@ -186,6 +189,9 @@ def setup_plexserver( if client.session is None: client.force_idle() + client.set_availability(client.machine_identifier + in available_client_ids) + if new_plex_clients: add_devices_callback(new_plex_clients) @@ -259,6 +265,7 @@ class PlexClient(MediaPlayerDevice): """Initialize the Plex device.""" self._app_name = '' self._device = None + self._available = False self._device_protocol_capabilities = None self._is_player_active = False self._is_player_available = False @@ -407,6 +414,12 @@ class PlexClient(MediaPlayerDevice): self._media_image_url = thumb_url + def set_availability(self, available): + """Set the device as available/unavailable noting time.""" + if not available: + self._clear_media_details() + self._available = available + def _set_player_state(self): if self._player_state == 'playing': self._is_player_active = True @@ -468,6 +481,11 @@ class PlexClient(MediaPlayerDevice): """Return the id of this plex client.""" return self.machine_identifier + @property + def available(self): + """Return the availability of the client.""" + return self._available + @property def name(self): """Return the name of the device.""" From efdc7042df2fe95a84a6ff20ea35435c7bbafd5a Mon Sep 17 00:00:00 2001 From: mueslo Date: Fri, 9 Mar 2018 08:57:21 +0100 Subject: [PATCH 06/35] Add consider_home and source_type to device_tracker.see service (#12849) * Add consider_home and source_type to device_tracker.see service * Use schema instead of manual validation * Extend schema to validate all keys * Fix style * Set battery level to int --- .../components/device_tracker/__init__.py | 64 ++++++++++++------- homeassistant/helpers/config_validation.py | 1 + 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 19ab77350f3..196c11a614f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -77,11 +77,14 @@ ATTR_MAC = 'mac' ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' ATTR_VENDOR = 'vendor' +ATTR_CONSIDER_HOME = 'consider_home' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' SOURCE_TYPE_BLUETOOTH = 'bluetooth' SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' +SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, @@ -96,6 +99,19 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA }) +SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), { + ATTR_MAC: cv.string, + ATTR_DEV_ID: cv.string, + ATTR_HOST_NAME: cv.string, + ATTR_LOCATION_NAME: cv.string, + ATTR_GPS: cv.gps, + ATTR_GPS_ACCURACY: cv.positive_int, + ATTR_BATTERY: cv.positive_int, + ATTR_ATTRIBUTES: dict, + ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES), + ATTR_CONSIDER_HOME: cv.time_period, + })) @bind_hass @@ -109,7 +125,7 @@ def is_on(hass: HomeAssistantType, entity_id: str = None): def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, host_name: str = None, location_name: str = None, gps: GPSType = None, gps_accuracy=None, - battery=None, attributes: dict = None): + battery: int = None, attributes: dict = None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -203,12 +219,10 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): @asyncio.coroutine def async_see_service(call): """Service to see a device.""" - args = {key: value for key, value in call.data.items() if key in - (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} - yield from tracker.async_see(**args) + yield from tracker.async_see(**call.data) - hass.services.async_register(DOMAIN, SERVICE_SEE, async_see_service) + hass.services.async_register( + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) # restore yield from tracker.async_setup_tracked_device() @@ -240,23 +254,26 @@ class DeviceTracker(object): dev.mac) def see(self, mac: str = None, dev_id: str = None, host_name: str = None, - location_name: str = None, gps: GPSType = None, gps_accuracy=None, - battery: str = None, attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS, picture: str = None, - icon: str = None): + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device.""" self.hass.add_job( self.async_see(mac, dev_id, host_name, location_name, gps, gps_accuracy, battery, attributes, source_type, - picture, icon) + picture, icon, consider_home) ) @asyncio.coroutine - def async_see(self, mac: str = None, dev_id: str = None, - host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=None, battery: str = None, - attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, icon: str = None): + def async_see( + self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device. This method is a coroutine. @@ -275,7 +292,7 @@ class DeviceTracker(object): if device: yield from device.async_seen( host_name, location_name, gps, gps_accuracy, battery, - attributes, source_type) + attributes, source_type, consider_home) if device.track: yield from device.async_update_ha_state() return @@ -283,7 +300,7 @@ class DeviceTracker(object): # If no device can be found, create it dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) device = Device( - self.hass, self.consider_home, self.track_new, + self.hass, consider_home or self.consider_home, self.track_new, dev_id, mac, (host_name or dev_id).replace('_', ' '), picture=picture, icon=icon, hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) @@ -384,9 +401,10 @@ class Device(Entity): host_name = None # type: str location_name = None # type: str gps = None # type: GPSType - gps_accuracy = 0 + gps_accuracy = 0 # type: int last_seen = None # type: dt_util.dt.datetime - battery = None # type: str + consider_home = None # type: dt_util.dt.timedelta + battery = None # type: int attributes = None # type: dict vendor = None # type: str icon = None # type: str @@ -476,14 +494,16 @@ class Device(Entity): @asyncio.coroutine def async_seen(self, host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=0, battery: str = None, + gps: GPSType = None, gps_accuracy=0, battery: int = None, attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS): + source_type: str = SOURCE_TYPE_GPS, + consider_home: timedelta = None): """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name + self.consider_home = consider_home or self.consider_home if battery: self.battery = battery diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f8f08fd118f..4b7c58f6e66 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -36,6 +36,7 @@ latitude = vol.All(vol.Coerce(float), vol.Range(min=-90, max=90), msg='invalid latitude') longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180), msg='invalid longitude') +gps = vol.ExactSequence([latitude, longitude]) sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)) port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) From 4152ac4aa21d51d3848203956325e9fdba5dcb07 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 9 Mar 2018 15:15:39 +0100 Subject: [PATCH 07/35] Clean up Light Groups (#12962) * Clean up Light Groups * Fix tests * Remove light group from .coveragerc * async_schedule_update_ha_state called anyway --- .coveragerc | 1 - homeassistant/components/light/group.py | 39 ++++++------- tests/components/light/test_group.py | 76 ++++++++++++------------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/.coveragerc b/.coveragerc index 83d143f83cb..07d84523780 100644 --- a/.coveragerc +++ b/.coveragerc @@ -412,7 +412,6 @@ omit = homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py homeassistant/components/light/greenwave.py - homeassistant/components/light/group.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py homeassistant/components/light/iglo.py diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py index 768754ca1af..b4a5e9dddfb 100644 --- a/homeassistant/components/light/group.py +++ b/homeassistant/components/light/group.py @@ -1,5 +1,5 @@ """ -This component allows several lights to be grouped into one light. +This platform allows several lights to be grouped into one light. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.group/ @@ -29,11 +29,11 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Group Light' +DEFAULT_NAME = 'Light Group' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_ENTITIES): cv.entities_domain('light') + vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN) }) SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT @@ -44,15 +44,15 @@ SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_devices, discovery_info=None) -> None: """Initialize light.group platform.""" - async_add_devices([GroupLight(config.get(CONF_NAME), - config[CONF_ENTITIES])], True) + async_add_devices([LightGroup(config.get(CONF_NAME), + config[CONF_ENTITIES])]) -class GroupLight(light.Light): - """Representation of a group light.""" +class LightGroup(light.Light): + """Representation of a light group.""" def __init__(self, name: str, entity_ids: List[str]) -> None: - """Initialize a group light.""" + """Initialize a light group.""" self._name = name # type: str self._entity_ids = entity_ids # type: List[str] self._is_on = False # type: bool @@ -79,10 +79,11 @@ class GroupLight(light.Light): self._async_unsub_state_changed = async_track_state_change( self.hass, self._entity_ids, async_state_changed_listener) + await self.async_update() async def async_will_remove_from_hass(self): """Callback when removed from HASS.""" - if self._async_unsub_state_changed: + if self._async_unsub_state_changed is not None: self._async_unsub_state_changed() self._async_unsub_state_changed = None @@ -93,17 +94,17 @@ class GroupLight(light.Light): @property def is_on(self) -> bool: - """Return the on/off state of the light.""" + """Return the on/off state of the light group.""" return self._is_on @property def available(self) -> bool: - """Return whether the light is available.""" + """Return whether the light group is available.""" return self._available @property def brightness(self) -> Optional[int]: - """Return the brightness of this light between 0..255.""" + """Return the brightness of this light group between 0..255.""" return self._brightness @property @@ -123,17 +124,17 @@ class GroupLight(light.Light): @property def min_mireds(self) -> Optional[int]: - """Return the coldest color_temp that this light supports.""" + """Return the coldest color_temp that this light group supports.""" return self._min_mireds @property def max_mireds(self) -> Optional[int]: - """Return the warmest color_temp that this light supports.""" + """Return the warmest color_temp that this light group supports.""" return self._max_mireds @property def white_value(self) -> Optional[int]: - """Return the white value of this light between 0..255.""" + """Return the white value of this light group between 0..255.""" return self._white_value @property @@ -153,11 +154,11 @@ class GroupLight(light.Light): @property def should_poll(self) -> bool: - """No polling needed for a group light.""" + """No polling needed for a light group.""" return False async def async_turn_on(self, **kwargs): - """Forward the turn_on command to all lights in the group.""" + """Forward the turn_on command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} if ATTR_BRIGHTNESS in kwargs: @@ -188,7 +189,7 @@ class GroupLight(light.Light): light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True) async def async_turn_off(self, **kwargs): - """Forward the turn_off command to all lights in the group.""" + """Forward the turn_off command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} if ATTR_TRANSITION in kwargs: @@ -198,7 +199,7 @@ class GroupLight(light.Light): light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True) async def async_update(self): - """Query all members and determine the group state.""" + """Query all members and determine the light group state.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] states = list(filter(None, all_states)) on_states = [state for state in states if state.state == STATE_ON] diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py index ac19f407066..3c94fa2af3e 100644 --- a/tests/components/light/test_group.py +++ b/tests/components/light/test_group.py @@ -37,22 +37,22 @@ async def test_state_reporting(hass): hass.states.async_set('light.test1', 'on') hass.states.async_set('light.test2', 'unavailable') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'on' + assert hass.states.get('light.light_group').state == 'on' hass.states.async_set('light.test1', 'on') hass.states.async_set('light.test2', 'off') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'on' + assert hass.states.get('light.light_group').state == 'on' hass.states.async_set('light.test1', 'off') hass.states.async_set('light.test2', 'off') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'off' + assert hass.states.get('light.light_group').state == 'off' hass.states.async_set('light.test1', 'unavailable') hass.states.async_set('light.test2', 'unavailable') await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'unavailable' + assert hass.states.get('light.light_group').state == 'unavailable' async def test_brightness(hass): @@ -64,7 +64,7 @@ async def test_brightness(hass): hass.states.async_set('light.test1', 'on', {'brightness': 255, 'supported_features': 1}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 1 assert state.attributes['brightness'] == 255 @@ -72,14 +72,14 @@ async def test_brightness(hass): hass.states.async_set('light.test2', 'on', {'brightness': 100, 'supported_features': 1}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['brightness'] == 177 hass.states.async_set('light.test1', 'off', {'brightness': 255, 'supported_features': 1}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 1 assert state.attributes['brightness'] == 100 @@ -94,7 +94,7 @@ async def test_xy_color(hass): hass.states.async_set('light.test1', 'on', {'xy_color': (1.0, 1.0), 'supported_features': 64}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 64 assert state.attributes['xy_color'] == (1.0, 1.0) @@ -102,14 +102,14 @@ async def test_xy_color(hass): hass.states.async_set('light.test2', 'on', {'xy_color': (0.5, 0.5), 'supported_features': 64}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['xy_color'] == (0.75, 0.75) hass.states.async_set('light.test1', 'off', {'xy_color': (1.0, 1.0), 'supported_features': 64}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['xy_color'] == (0.5, 0.5) @@ -123,7 +123,7 @@ async def test_rgb_color(hass): hass.states.async_set('light.test1', 'on', {'rgb_color': (255, 0, 0), 'supported_features': 16}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 16 assert state.attributes['rgb_color'] == (255, 0, 0) @@ -132,13 +132,13 @@ async def test_rgb_color(hass): {'rgb_color': (255, 255, 255), 'supported_features': 16}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['rgb_color'] == (255, 127, 127) hass.states.async_set('light.test1', 'off', {'rgb_color': (255, 0, 0), 'supported_features': 16}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['rgb_color'] == (255, 255, 255) @@ -151,19 +151,19 @@ async def test_white_value(hass): hass.states.async_set('light.test1', 'on', {'white_value': 255, 'supported_features': 128}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['white_value'] == 255 hass.states.async_set('light.test2', 'on', {'white_value': 100, 'supported_features': 128}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['white_value'] == 177 hass.states.async_set('light.test1', 'off', {'white_value': 255, 'supported_features': 128}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['white_value'] == 100 @@ -176,19 +176,19 @@ async def test_color_temp(hass): hass.states.async_set('light.test1', 'on', {'color_temp': 2, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['color_temp'] == 2 hass.states.async_set('light.test2', 'on', {'color_temp': 1000, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['color_temp'] == 501 hass.states.async_set('light.test1', 'off', {'color_temp': 2, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['color_temp'] == 1000 @@ -202,7 +202,7 @@ async def test_min_max_mireds(hass): {'min_mireds': 2, 'max_mireds': 5, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['min_mireds'] == 2 assert state.attributes['max_mireds'] == 5 @@ -210,7 +210,7 @@ async def test_min_max_mireds(hass): {'min_mireds': 7, 'max_mireds': 1234567890, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['min_mireds'] == 2 assert state.attributes['max_mireds'] == 1234567890 @@ -218,7 +218,7 @@ async def test_min_max_mireds(hass): {'min_mireds': 1, 'max_mireds': 2, 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['min_mireds'] == 1 assert state.attributes['max_mireds'] == 1234567890 @@ -232,21 +232,21 @@ async def test_effect_list(hass): hass.states.async_set('light.test1', 'on', {'effect_list': ['None', 'Random', 'Colorloop']}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop'} hass.states.async_set('light.test2', 'on', {'effect_list': ['None', 'Random', 'Rainbow']}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop', 'Rainbow'} hass.states.async_set('light.test1', 'off', {'effect_list': ['None', 'Colorloop', 'Seven']}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop', 'Seven', 'Rainbow'} @@ -261,19 +261,19 @@ async def test_effect(hass): hass.states.async_set('light.test1', 'on', {'effect': 'None', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test2', 'on', {'effect': 'None', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test3', 'on', {'effect': 'Random', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test1', 'off', @@ -281,7 +281,7 @@ async def test_effect(hass): hass.states.async_set('light.test2', 'off', {'effect': 'None', 'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'Random' @@ -294,25 +294,25 @@ async def test_supported_features(hass): hass.states.async_set('light.test1', 'on', {'supported_features': 0}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 0 hass.states.async_set('light.test2', 'on', {'supported_features': 2}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 2 hass.states.async_set('light.test1', 'off', {'supported_features': 41}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 43 hass.states.async_set('light.test2', 'off', {'supported_features': 256}) await hass.async_block_till_done() - state = hass.states.get('light.group_light') + state = hass.states.get('light.light_group') assert state.attributes['supported_features'] == 41 @@ -326,29 +326,29 @@ async def test_service_calls(hass): ]}) await hass.async_block_till_done() - assert hass.states.get('light.group_light').state == 'on' - light.async_toggle(hass, 'light.group_light') + assert hass.states.get('light.light_group').state == 'on' + light.async_toggle(hass, 'light.light_group') await hass.async_block_till_done() assert hass.states.get('light.bed_light').state == 'off' assert hass.states.get('light.ceiling_lights').state == 'off' assert hass.states.get('light.kitchen_lights').state == 'off' - light.async_turn_on(hass, 'light.group_light') + light.async_turn_on(hass, 'light.light_group') await hass.async_block_till_done() assert hass.states.get('light.bed_light').state == 'on' assert hass.states.get('light.ceiling_lights').state == 'on' assert hass.states.get('light.kitchen_lights').state == 'on' - light.async_turn_off(hass, 'light.group_light') + light.async_turn_off(hass, 'light.light_group') await hass.async_block_till_done() assert hass.states.get('light.bed_light').state == 'off' assert hass.states.get('light.ceiling_lights').state == 'off' assert hass.states.get('light.kitchen_lights').state == 'off' - light.async_turn_on(hass, 'light.group_light', brightness=128, + light.async_turn_on(hass, 'light.light_group', brightness=128, effect='Random', rgb_color=(42, 255, 255)) await hass.async_block_till_done() From d19a8ec7da88a55bcff975064638ba5defe406ff Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Fri, 9 Mar 2018 16:50:39 +0000 Subject: [PATCH 08/35] Updated to plexapi 3.0.6 (#13005) --- homeassistant/components/media_player/plex.py | 2 +- homeassistant/components/sensor/plex.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index caa81424377..48e532074f7 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['plexapi==3.0.5'] +REQUIREMENTS = ['plexapi==3.0.6'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index b0c40e8f007..87af51d2bbd 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['plexapi==3.0.5'] +REQUIREMENTS = ['plexapi==3.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b7af19f0d66..c233e528403 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -588,7 +588,7 @@ pizzapi==0.0.3 # homeassistant.components.media_player.plex # homeassistant.components.sensor.plex -plexapi==3.0.5 +plexapi==3.0.6 # homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm From 34c694c20e84f827dc1343716fb2b94521adea4c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 9 Mar 2018 19:39:50 -0800 Subject: [PATCH 09/35] allow ios device tracker see calls to go through (#13020) --- .../components/device_tracker/__init__.py | 9 ++++++++- tests/components/device_tracker/test_init.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 196c11a614f..9fea2bc104d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -111,6 +111,9 @@ SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All( ATTR_ATTRIBUTES: dict, ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES), ATTR_CONSIDER_HOME: cv.time_period, + # Temp workaround for iOS app introduced in 0.65 + vol.Optional('battery_status'): str, + vol.Optional('hostname'): str, })) @@ -219,7 +222,11 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): @asyncio.coroutine def async_see_service(call): """Service to see a device.""" - yield from tracker.async_see(**call.data) + # Temp workaround for iOS, introduced in 0.65 + data = dict(call.data) + data.pop('hostname', None) + data.pop('battery_status', None) + yield from tracker.async_see(**data) hass.services.async_register( DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index ebf568309ad..9d122fa17b6 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -730,3 +730,18 @@ async def test_old_style_track_new_is_skipped(mock_device_tracker_conf, hass): await hass.async_block_till_done() assert len(mock_device_tracker_conf) == 1 assert mock_device_tracker_conf[0].track is False + + +def test_see_schema_allowing_ios_calls(): + """Test SEE service schema allows extra keys. + + Temp work around because the iOS app sends incorrect data. + """ + device_tracker.SERVICE_SEE_PAYLOAD_SCHEMA({ + 'dev_id': 'Test', + "battery": 35, + "battery_status": 'Unplugged', + "gps": [10.0, 10.0], + "gps_accuracy": 300, + "hostname": 'beer', + }) From 8f807a3006d6e6822683a68c2a530873a5a38bd9 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 10 Mar 2018 01:52:21 +0200 Subject: [PATCH 10/35] Safe fix for #13015 (#13024) --- homeassistant/bootstrap.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2f093f061d9..50d8502bbd1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -113,7 +113,10 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(loader.prepare, hass) # Make a copy because we are mutating it. - config = OrderedDict(config) + new_config = OrderedDict() + for key, value in config.items(): + new_config[key] = value or {} + config = new_config # Merge packages conf_util.merge_packages_config( From 6ffc53b290ef9634f1133c76cdc9b8d88c0f73e2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 9 Mar 2018 19:38:51 -0800 Subject: [PATCH 11/35] Make Throttle async aware (#13027) * Make Throttle async aware * Lint --- .../components/media_player/bluesound.py | 22 ++++++++----------- .../components/media_player/volumio.py | 5 ++--- homeassistant/components/sensor/fido.py | 5 ++--- .../components/sensor/hydroquebec.py | 5 ++--- homeassistant/components/sensor/luftdaten.py | 13 ++++------- homeassistant/components/sensor/sabnzbd.py | 5 ++--- homeassistant/components/sensor/startca.py | 7 +++--- homeassistant/components/sensor/teksavvy.py | 7 +++--- .../components/sensor/wunderground.py | 7 +++--- homeassistant/util/__init__.py | 15 +++++++++++-- tests/util/test_init.py | 11 ++++++++++ 11 files changed, 54 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index d308b94e64c..a07e577c969 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -423,19 +423,17 @@ class BluesoundPlayer(MediaPlayerDevice): for player in self._hass.data[DATA_BLUESOUND]: yield from player.force_update_sync_status() - @asyncio.coroutine @Throttle(SYNC_STATUS_INTERVAL) - def async_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + async def async_update_sync_status(self, on_updated_cb=None, + raise_timeout=False): """Update sync status.""" - yield from self.force_update_sync_status( + await self.force_update_sync_status( on_updated_cb, raise_timeout=False) - @asyncio.coroutine @Throttle(UPDATE_CAPTURE_INTERVAL) - def async_update_captures(self): + async def async_update_captures(self): """Update Capture sources.""" - resp = yield from self.send_bluesound_command( + resp = await self.send_bluesound_command( 'RadioBrowse?service=Capture') if not resp: return @@ -459,11 +457,10 @@ class BluesoundPlayer(MediaPlayerDevice): return self._capture_items - @asyncio.coroutine @Throttle(UPDATE_PRESETS_INTERVAL) - def async_update_presets(self): + async def async_update_presets(self): """Update Presets.""" - resp = yield from self.send_bluesound_command('Presets') + resp = await self.send_bluesound_command('Presets') if not resp: return self._preset_items = [] @@ -488,11 +485,10 @@ class BluesoundPlayer(MediaPlayerDevice): return self._preset_items - @asyncio.coroutine @Throttle(UPDATE_SERVICES_INTERVAL) - def async_update_services(self): + async def async_update_services(self): """Update Services.""" - resp = yield from self.send_bluesound_command('Services') + resp = await self.send_bluesound_command('Services') if not resp: return self._services_items = [] diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py index 84b957533fe..0a940c0aa9d 100644 --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -253,8 +253,7 @@ class Volumio(MediaPlayerDevice): return self.send_volumio_msg('commands', params={'cmd': 'clearQueue'}) - @asyncio.coroutine @Throttle(PLAYLIST_UPDATE_INTERVAL) - def _async_update_playlists(self, **kwargs): + async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = yield from self.send_volumio_msg('listplaylists') + self._playlists = await self.send_volumio_msg('listplaylists') diff --git a/homeassistant/components/sensor/fido.py b/homeassistant/components/sensor/fido.py index 4fc79745b99..25a104bf259 100644 --- a/homeassistant/components/sensor/fido.py +++ b/homeassistant/components/sensor/fido.py @@ -157,13 +157,12 @@ class FidoData(object): REQUESTS_TIMEOUT, httpsession) self.data = {} - @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def async_update(self): + async def async_update(self): """Get the latest data from Fido.""" from pyfido.client import PyFidoError try: - yield from self.client.fetch_data() + await self.client.fetch_data() except PyFidoError as exp: _LOGGER.error("Error on receive last Fido data: %s", exp) return False diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index e10abc14ff1..3678ac9268f 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -182,13 +182,12 @@ class HydroquebecData(object): return self.client.get_contracts() return [] - @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _fetch_data(self): + async def _fetch_data(self): """Fetch latest data from HydroQuebec.""" from pyhydroquebec.client import PyHydroQuebecError try: - yield from self.client.fetch_data() + await self.client.fetch_data() except PyHydroQuebecError as exp: _LOGGER.error("Error on receive last Hydroquebec data: %s", exp) return False diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index 72ee8a7ce93..c5e0b12b0e0 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -133,13 +133,9 @@ class LuftdatenSensor(Entity): except KeyError: return - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the latest data from luftdaten.info and update the state.""" - try: - yield from self.luftdaten.async_update() - except TypeError: - pass + await self.luftdaten.async_update() class LuftdatenData(object): @@ -150,12 +146,11 @@ class LuftdatenData(object): self.data = data @Throttle(MIN_TIME_BETWEEN_UPDATES) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the latest data from luftdaten.info.""" from luftdaten.exceptions import LuftdatenError try: - yield from self.data.async_get_data() + await self.data.async_get_data() except LuftdatenError: _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index 632e1ed5c1d..c5dd09e0ccc 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -75,15 +75,14 @@ def setup_sabnzbd(base_url, apikey, name, config, for variable in monitored]) -@asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) -def async_update_queue(sab_api): +async def async_update_queue(sab_api): """ Throttled function to update SABnzbd queue. This ensures that the queue info only gets updated once for all sensors """ - yield from sab_api.refresh_queue() + await sab_api.refresh_queue() def request_configuration(host, name, hass, config, async_add_devices, diff --git a/homeassistant/components/sensor/startca.py b/homeassistant/components/sensor/startca.py index a5908812b6c..aefbc2d4626 100644 --- a/homeassistant/components/sensor/startca.py +++ b/homeassistant/components/sensor/startca.py @@ -140,21 +140,20 @@ class StartcaData(object): """ return float(value) * 10 ** -9 - @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def async_update(self): + async def async_update(self): """Get the Start.ca bandwidth data from the web service.""" import xmltodict _LOGGER.debug("Updating Start.ca usage data") url = 'https://www.start.ca/support/usage/api?key=' + \ self.api_key with async_timeout.timeout(REQUEST_TIMEOUT, loop=self.loop): - req = yield from self.websession.get(url) + req = await self.websession.get(url) if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) return False - data = yield from req.text() + data = await req.text() try: xml_data = xmltodict.parse(data) except ExpatError: diff --git a/homeassistant/components/sensor/teksavvy.py b/homeassistant/components/sensor/teksavvy.py index 9c4263422ff..0bf1ef4caff 100644 --- a/homeassistant/components/sensor/teksavvy.py +++ b/homeassistant/components/sensor/teksavvy.py @@ -132,22 +132,21 @@ class TekSavvyData(object): self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \ else {"limit": float('inf')} - @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def async_update(self): + async def async_update(self): """Get the TekSavvy bandwidth data from the web service.""" headers = {"TekSavvy-APIKey": self.api_key} _LOGGER.debug("Updating TekSavvy data") url = "https://api.teksavvy.com/"\ "web/Usage/UsageSummaryRecords?$filter=IsCurrent%20eq%20true" with async_timeout.timeout(REQUEST_TIMEOUT, loop=self.loop): - req = yield from self.websession.get(url, headers=headers) + req = await self.websession.get(url, headers=headers) if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) return False try: - data = yield from req.json() + data = await req.json() for (api, ha_name) in API_HA_MAP: self.data[ha_name] = float(data["value"][0][api]) on_peak_download = self.data["onpeak_download"] diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index edcc1c92bf9..0375bb1344c 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -777,14 +777,13 @@ class WUndergroundData(object): return url + '.json' - @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def async_update(self): + async def async_update(self): """Get the latest data from WUnderground.""" try: with async_timeout.timeout(10, loop=self._hass.loop): - response = yield from self._session.get(self._build_url()) - result = yield from response.json() + response = await self._session.get(self._build_url()) + result = await response.json() if "error" in result['response']: raise ValueError(result['response']["error"]["description"]) self.data = result diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 75721a37466..a869251dc3c 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -1,4 +1,5 @@ """Helper methods for various modules.""" +import asyncio from collections.abc import MutableSet from itertools import chain import threading @@ -276,6 +277,16 @@ class Throttle(object): is_func = (not hasattr(method, '__self__') and '.' not in method.__qualname__.split('..')[-1]) + # Make sure we return a coroutine if the method is async. + if asyncio.iscoroutinefunction(method): + async def throttled_value(): + """Stand-in function for when real func is being throttled.""" + return None + else: + def throttled_value(): + """Stand-in function for when real func is being throttled.""" + return None + @wraps(method) def wrapper(*args, **kwargs): """Wrap that allows wrapped to be called only once per min_time. @@ -298,7 +309,7 @@ class Throttle(object): throttle = host._throttle[id(self)] if not throttle[0].acquire(False): - return None + return throttled_value() # Check if method is never called or no_throttle is given force = kwargs.pop('no_throttle', False) or not throttle[1] @@ -309,7 +320,7 @@ class Throttle(object): throttle[1] = utcnow() return result - return None + return throttled_value() finally: throttle[0].release() diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 2902cb62517..5493843c246 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -280,3 +280,14 @@ class TestUtil(unittest.TestCase): mock_random.SystemRandom.return_value = generator assert util.get_random_string(length=3) == 'ABC' + + +async def test_throttle_async(): + """Test Throttle decorator with async method.""" + @util.Throttle(timedelta(seconds=2)) + async def test_method(): + """Only first call should return a value.""" + return True + + assert (await test_method()) is True + assert (await test_method()) is None From 3c41c0c46e6f9d8d3fdff91507f0875eae2d4a0e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 9 Mar 2018 19:38:33 -0800 Subject: [PATCH 12/35] Add support for input boolean to Google Assistant (#13030) --- .../components/google_assistant/trait.py | 2 + .../components/google_assistant/test_trait.py | 40 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index dd7b761e782..c78d70e21e6 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -5,6 +5,7 @@ from homeassistant.components import ( cover, group, fan, + input_boolean, media_player, light, scene, @@ -182,6 +183,7 @@ class OnOffTrait(_Trait): """Test if state is supported.""" return domain in ( group.DOMAIN, + input_boolean.DOMAIN, switch.DOMAIN, fan.DOMAIN, light.DOMAIN, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 90dd5d33581..4ffb273662e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -9,8 +9,9 @@ from homeassistant.components import ( climate, cover, fan, - media_player, + input_boolean, light, + media_player, scene, script, switch, @@ -138,6 +139,43 @@ async def test_onoff_group(hass): } +async def test_onoff_input_boolean(hass): + """Test OnOff trait support for input_boolean domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('input_boolean.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('input_boolean.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'input_boolean.bla', + } + + off_calls = async_mock_service(hass, input_boolean.DOMAIN, + SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'input_boolean.bla', + } + + async def test_onoff_switch(hass): """Test OnOff trait support for switch domain.""" assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) From a58d8fc68b1020ea5b2cbddca37110f029ebadd6 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sat, 10 Mar 2018 04:41:59 +0100 Subject: [PATCH 13/35] HomeKit Bugfix: names (#13031) * Fix display_names, changed default port (+1) * Revert port change --- .../components/homekit/accessories.py | 9 +++++---- homeassistant/components/homekit/const.py | 1 + tests/components/homekit/test_accessories.py | 20 ++++++++++--------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 689bcb3377c..1cd94070289 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -5,16 +5,17 @@ from pyhap.accessory import Accessory, Bridge, Category from .const import ( SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, MANUFACTURER, - CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) + CHAR_MODEL, CHAR_MANUFACTURER, CHAR_NAME, CHAR_SERIAL_NUMBER) _LOGGER = logging.getLogger(__name__) -def set_accessory_info(acc, model, manufacturer=MANUFACTURER, +def set_accessory_info(acc, name, model, manufacturer=MANUFACTURER, serial_number='0000'): """Set the default accessory information.""" service = acc.get_service(SERV_ACCESSORY_INFO) + service.get_characteristic(CHAR_NAME).set_value(name) service.get_characteristic(CHAR_MODEL).set_value(model) service.get_characteristic(CHAR_MANUFACTURER).set_value(manufacturer) service.get_characteristic(CHAR_SERIAL_NUMBER).set_value(serial_number) @@ -49,7 +50,7 @@ class HomeAccessory(Accessory): def __init__(self, display_name, model, category='OTHER', **kwargs): """Initialize a Accessory object.""" super().__init__(display_name, **kwargs) - set_accessory_info(self, model) + set_accessory_info(self, display_name, model) self.category = getattr(Category, category, Category.OTHER) def _set_services(self): @@ -62,7 +63,7 @@ class HomeBridge(Bridge): def __init__(self, display_name, model, pincode, **kwargs): """Initialize a Bridge object.""" super().__init__(display_name, pincode=pincode, **kwargs) - set_accessory_info(self, model) + set_accessory_info(self, display_name, model) def _set_services(self): add_preload_service(self, SERV_ACCESSORY_INFO) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 35bd25eabd3..73dfbf69049 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -22,6 +22,7 @@ CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' CHAR_LINK_QUALITY = 'LinkQuality' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' +CHAR_NAME = 'Name' CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' CHAR_REACHABLE = 'Reachable' diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index a45aa82d981..6f39a8c792b 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -13,7 +13,7 @@ from homeassistant.components.homekit.accessories import ( HomeAccessory, HomeBridge) from homeassistant.components.homekit.const import ( SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, - CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) + CHAR_MODEL, CHAR_MANUFACTURER, CHAR_NAME, CHAR_SERIAL_NUMBER) from tests.mock.homekit import ( get_patch_paths, mock_preload_service, @@ -69,21 +69,23 @@ def test_override_properties(): def test_set_accessory_info(): """Test setting of basic accessory information with MockAccessory.""" acc = MockAccessory('Accessory') - set_accessory_info(acc, 'model', 'manufacturer', '0000') + set_accessory_info(acc, 'name', 'model', 'manufacturer', '0000') assert len(acc.services) == 1 serv = acc.services[0] assert serv.display_name == SERV_ACCESSORY_INFO - assert len(serv.characteristics) == 3 + assert len(serv.characteristics) == 4 chars = serv.characteristics - assert chars[0].display_name == CHAR_MODEL - assert chars[0].value == 'model' - assert chars[1].display_name == CHAR_MANUFACTURER - assert chars[1].value == 'manufacturer' - assert chars[2].display_name == CHAR_SERIAL_NUMBER - assert chars[2].value == '0000' + assert chars[0].display_name == CHAR_NAME + assert chars[0].value == 'name' + assert chars[1].display_name == CHAR_MODEL + assert chars[1].value == 'model' + assert chars[2].display_name == CHAR_MANUFACTURER + assert chars[2].value == 'manufacturer' + assert chars[3].display_name == CHAR_SERIAL_NUMBER + assert chars[3].value == '0000' @patch(PATH_ACC, side_effect=mock_preload_service) From bf430ad14b685c0c5a2648c50e2bffddf6203048 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 9 Mar 2018 19:42:52 -0800 Subject: [PATCH 14/35] Version bump to 0.65.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d8f7e00959c..d2a0bc43a8d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 65 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From c1bb7d5cc2631bd6d80588acc57d246f80ec9853 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 10 Mar 2018 10:19:49 -0800 Subject: [PATCH 15/35] Update frontend to 20180310.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 501537e3ed3..1f5a7576302 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180309.0'] +REQUIREMENTS = ['home-assistant-frontend==20180310.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index c233e528403..c4aa5da8815 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180309.0 +home-assistant-frontend==20180310.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65e94172553..460e70cbca5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,7 +78,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180309.0 +home-assistant-frontend==20180310.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 266b13b3cbc714ff7b3e27a5a11d245b8c647ac9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 10 Mar 2018 10:20:30 -0800 Subject: [PATCH 16/35] Version bump to 0.65.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d2a0bc43a8d..2fee845f70a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 65 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 5726159dd481cef3ebb5ebdae2c844c25ee160fe Mon Sep 17 00:00:00 2001 From: John Allen Date: Sat, 10 Mar 2018 03:52:45 -0500 Subject: [PATCH 17/35] Fix sensibo's min/max_temp properties (#12996) The super class has these as properties, not regular methods --- homeassistant/components/climate/sensibo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 7b4c8ed8b1b..68b5eee35ef 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -240,13 +240,13 @@ class SensiboClimate(ClimateDevice): def min_temp(self): """Return the minimum temperature.""" return self._temperatures_list[0] \ - if self._temperatures_list else super().min_temp() + if self._temperatures_list else super().min_temp @property def max_temp(self): """Return the maximum temperature.""" return self._temperatures_list[-1] \ - if self._temperatures_list else super().max_temp() + if self._temperatures_list else super().max_temp @asyncio.coroutine def async_set_temperature(self, **kwargs): From c7c47a18a9391211861a77ec39bc5dda3de3d794 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Sat, 10 Mar 2018 00:36:20 -0800 Subject: [PATCH 18/35] Use request.query (#13037) Fixes #13036 --- homeassistant/components/wink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 78f3042aefb..eab67c18aed 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -554,7 +554,7 @@ class WinkAuthCallbackView(HomeAssistantView): from aiohttp import web hass = request.app['hass'] - data = request.GET + data = request.query response_message = """Wink has been successfully authorized! You can close this window now! For the best results you should reboot From 9a7a0f28b18af0d7f013ad9eea42b2fc03338735 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 10 Mar 2018 20:02:04 +0200 Subject: [PATCH 19/35] Ensure we have valid config AFTER merging packages #13015 (#13038) * Ensure we have valid config AFTER merging packages #13015 * also fix packages --- homeassistant/bootstrap.py | 10 ++++++---- homeassistant/config.py | 3 +++ homeassistant/scripts/check_config.py | 5 +++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 50d8502bbd1..34eab679581 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -113,15 +113,17 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(loader.prepare, hass) # Make a copy because we are mutating it. - new_config = OrderedDict() - for key, value in config.items(): - new_config[key] = value or {} - config = new_config + config = OrderedDict(config) # Merge packages conf_util.merge_packages_config( config, core_config.get(conf_util.CONF_PACKAGES, {})) + # Ensure we have no None values after merge + for key, value in config.items(): + if not value: + config[key] = {} + hass.config_entries = config_entries.ConfigEntries(hass, config) yield from hass.config_entries.async_load() diff --git a/homeassistant/config.py b/homeassistant/config.py index 5f2c6cf1625..e94fc297f48 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -562,6 +562,9 @@ def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): continue if merge_type == 'dict': + if comp_conf is None: + comp_conf = OrderedDict() + if not isinstance(comp_conf, dict): _log_pkg_error( pack_name, comp_name, config, diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 4e80b3c6536..641693501ff 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -326,6 +326,11 @@ def check_ha_config_file(config_dir): config, core_config.get(CONF_PACKAGES, {}), _pack_error) del core_config[CONF_PACKAGES] + # Ensure we have no None values after merge + for key, value in config.items(): + if not value: + config[key] = {} + # Filter out repeating config sections components = set(key.split(' ')[0] for key in config.keys()) From b2d8feb979874a147b6aaf731bcf7a3213a29967 Mon Sep 17 00:00:00 2001 From: Jerad Meisner Date: Sat, 10 Mar 2018 00:27:13 -0800 Subject: [PATCH 20/35] Bump pysabnzbd version (#13042) --- homeassistant/components/sensor/sabnzbd.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index c5dd09e0ccc..194ff71222a 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle from homeassistant.util.json import load_json, save_json import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysabnzbd==0.0.3'] +REQUIREMENTS = ['pysabnzbd==1.0.1'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,7 @@ async def async_update_queue(sab_api): This ensures that the queue info only gets updated once for all sensors """ - await sab_api.refresh_queue() + await sab_api.refresh_data() def request_configuration(host, name, hass, config, async_add_devices, diff --git a/requirements_all.txt b/requirements_all.txt index c4aa5da8815..2d60582f46f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -863,7 +863,7 @@ pyqwikswitch==0.4 pyrainbird==0.1.3 # homeassistant.components.sensor.sabnzbd -pysabnzbd==0.0.3 +pysabnzbd==1.0.1 # homeassistant.components.climate.sensibo pysensibo==1.0.2 From 70064e4c6942875836f2de60637dea3fe77c3a88 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 10 Mar 2018 11:07:02 +0100 Subject: [PATCH 21/35] Fix async lifx_set_state (#13045) --- homeassistant/components/light/lifx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 18bc39d88d2..0bb65a78c6e 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -221,7 +221,7 @@ class LIFXManager(object): tasks = [] for light in self.service_to_entities(service): if service.service == SERVICE_LIFX_SET_STATE: - task = light.async_set_state(**service.data) + task = light.set_state(**service.data) tasks.append(self.hass.async_add_job(task)) if tasks: await asyncio.wait(tasks, loop=self.hass.loop) From 5281892e693f59706f7ad5acd26a1e4483191b34 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 10 Mar 2018 18:10:50 +0100 Subject: [PATCH 22/35] Yeelight version bumped. (#13056) --- homeassistant/components/light/yeelight.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 33c84df14be..ca10d246ce8 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -26,7 +26,7 @@ from homeassistant.components.light import ( Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yeelight==0.3.3'] +REQUIREMENTS = ['yeelight==0.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2d60582f46f..03560ac79b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1292,7 +1292,7 @@ yahoo-finance==1.4.0 yahooweather==0.10 # homeassistant.components.light.yeelight -yeelight==0.3.3 +yeelight==0.4.0 # homeassistant.components.light.yeelightsunflower yeelightsunflower==0.0.8 From b98d2e24852e2df5a02a96fa01d792c0ffbf10dd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 10 Mar 2018 10:02:16 -0800 Subject: [PATCH 23/35] Don't call async from sync (#13057) --- homeassistant/components/xiaomi_aqara.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index b6e04d867fa..244605a7b97 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -182,19 +182,19 @@ def setup(hass, config): gateway_only_schema = _add_gateway_to_schema(xiaomi, vol.Schema({})) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_PLAY_RINGTONE, play_ringtone_service, schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_PLAY_RINGTONE)) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_STOP_RINGTONE, stop_ringtone_service, schema=gateway_only_schema) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_ADD_DEVICE, add_device_service, schema=gateway_only_schema) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_REMOVE_DEVICE, remove_device_service, schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_REMOVE_DEVICE)) From 42359b3b48b942c471772ed715c3116fbcf288f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 10 Mar 2018 10:40:28 -0800 Subject: [PATCH 24/35] Convert decimals from SQL results (#13059) --- homeassistant/components/sensor/sql.py | 8 ++++++-- tests/components/sensor/test_sql.py | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 50d60bfc426..402076c6fd8 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -4,6 +4,7 @@ Sensor from an SQL Query. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sql/ """ +import decimal import logging import voluptuous as vol @@ -131,17 +132,20 @@ class SQLSensor(Entity): try: sess = self.sessionmaker() result = sess.execute(self._query) + self._attributes = {} if not result.returns_rows or result.rowcount == 0: _LOGGER.warning("%s returned no results", self._query) self._state = None - self._attributes = {} return for res in result: _LOGGER.debug("result = %s", res.items()) data = res[self._column_name] - self._attributes = {k: v for k, v in res.items()} + for key, value in res.items(): + if isinstance(value, decimal.Decimal): + value = float(decimal) + self._attributes[key] = value except sqlalchemy.exc.SQLAlchemyError as err: _LOGGER.error("Error executing query %s: %s", self._query, err) return diff --git a/tests/components/sensor/test_sql.py b/tests/components/sensor/test_sql.py index 5e639b9f338..7665b5c9037 100644 --- a/tests/components/sensor/test_sql.py +++ b/tests/components/sensor/test_sql.py @@ -29,7 +29,7 @@ class TestSQLSensor(unittest.TestCase): 'db_url': 'sqlite://', 'queries': [{ 'name': 'count_tables', - 'query': 'SELECT count(*) value FROM sqlite_master;', + 'query': 'SELECT 5 as value', 'column': 'value', }] } @@ -38,7 +38,8 @@ class TestSQLSensor(unittest.TestCase): assert setup_component(self.hass, 'sensor', config) state = self.hass.states.get('sensor.count_tables') - self.assertEqual(state.state, '0') + assert state.state == '5' + assert state.attributes['value'] == 5 def test_invalid_query(self): """Test the SQL sensor for invalid queries.""" From e6683b4c849f960487c80d4b142dbe161d61b10d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Mar 2018 12:51:40 -0700 Subject: [PATCH 25/35] Bump version to 0.65.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2fee845f70a..203a9c63d95 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 65 -PATCH_VERSION = '2' +PATCH_VERSION = '3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From a7f34bbce9e564e648751e9cc8e9896887741745 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Mar 2018 09:49:28 -0700 Subject: [PATCH 26/35] Implement Hue available property (#12939) --- homeassistant/components/light/hue.py | 11 ++++--- tests/components/light/test_hue.py | 42 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index bee6840f346..75825683928 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -261,10 +261,13 @@ class HueLight(Light): """Return true if device is on.""" if self.is_group: return self.info['state']['any_on'] - elif self.allow_unreachable: - return self.info['state']['on'] - return self.info['state']['reachable'] and \ - self.info['state']['on'] + return self.info['state']['on'] + + @property + def available(self): + """Return if light is available.""" + return (self.is_group or self.allow_unreachable or + self.info['state']['reachable']) @property def supported_features(self): diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 5c28ea9988f..559467d5e9a 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -501,3 +501,45 @@ class TestHueLight(unittest.TestCase): light = self.buildLight(info={}, is_group=True) self.assertIsNone(light.unique_id) + + +def test_available(): + """Test available property.""" + light = hue_light.HueLight( + info={'state': {'reachable': False}}, + allow_unreachable=False, + is_group=False, + + light_id=None, + bridge=mock.Mock(), + update_lights_cb=None, + allow_in_emulated_hue=False, + ) + + assert light.available is False + + light = hue_light.HueLight( + info={'state': {'reachable': False}}, + allow_unreachable=True, + is_group=False, + + light_id=None, + bridge=mock.Mock(), + update_lights_cb=None, + allow_in_emulated_hue=False, + ) + + assert light.available is True + + light = hue_light.HueLight( + info={'state': {'reachable': False}}, + allow_unreachable=False, + is_group=True, + + light_id=None, + bridge=mock.Mock(), + update_lights_cb=None, + allow_in_emulated_hue=False, + ) + + assert light.available is True From 7018806802e8905b51d000c93bce3e379eb240af Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Mar 2018 12:32:12 -0700 Subject: [PATCH 27/35] Run asyncio event loop in debug mode during tests (#13058) * Run asyncio event loop in debug mode during tests * Remove debug mode again --- homeassistant/components/camera/arlo.py | 5 ++--- homeassistant/components/climate/generic_thermostat.py | 2 +- tests/components/cover/test_template.py | 4 ++-- tests/components/zwave/test_init.py | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 4d461b0e0b5..f3e70c2bdd7 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -49,8 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP Camera.""" arlo = hass.data.get(DATA_ARLO) if not arlo: @@ -60,7 +59,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for camera in arlo.cameras: cameras.append(ArloCam(hass, camera, config)) - async_add_devices(cameras, True) + add_devices(cameras, True) class ArloCam(Camera): diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index b97dc221298..b5d3c3f7c25 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -229,7 +229,7 @@ class GenericThermostat(ClimateDevice): """List of available operation modes.""" return self._operation_list - def set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" if operation_mode == STATE_HEAT: self._current_operation = STATE_HEAT diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index 3d7aa3ce618..b786b463dad 100644 --- a/tests/components/cover/test_template.py +++ b/tests/components/cover/test_template.py @@ -135,7 +135,7 @@ class TestTemplateCover(unittest.TestCase): entity = self.hass.states.get('cover.test') attrs = dict() attrs['position'] = 42 - self.hass.states.async_set( + self.hass.states.set( entity.entity_id, entity.state, attributes=attrs) self.hass.block_till_done() @@ -148,7 +148,7 @@ class TestTemplateCover(unittest.TestCase): self.hass.block_till_done() entity = self.hass.states.get('cover.test') attrs['position'] = 0.0 - self.hass.states.async_set( + self.hass.states.set( entity.entity_id, entity.state, attributes=attrs) self.hass.block_till_done() diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 828385b9ded..527101f6c61 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -309,7 +309,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): 'current_temperature'] is None def mock_update(self): - self.hass.async_add_job(self.async_update_ha_state) + self.hass.add_job(self.async_update_ha_state) with patch.object(zwave.node_entity.ZWaveBaseEntity, 'maybe_schedule_update', new=mock_update): @@ -356,7 +356,7 @@ def test_power_schemes(hass, mock_openzwave): 'switch.mock_node_mock_value').attributes def mock_update(self): - self.hass.async_add_job(self.async_update_ha_state) + self.hass.add_job(self.async_update_ha_state) with patch.object(zwave.node_entity.ZWaveBaseEntity, 'maybe_schedule_update', new=mock_update): From 7935c2504eceb2f2a5b81be9dc62b1af00c5a7e3 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sun, 11 Mar 2018 05:26:21 +0100 Subject: [PATCH 28/35] Fixes KNX fire event problem, issue https://github.com/home-assistant/home-assistant/issues/13049 (#13062) --- homeassistant/components/knx.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index f6f41619ca8..61f8ca90137 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.8.4'] +REQUIREMENTS = ['xknx==0.8.5'] DOMAIN = "knx" DATA_KNX = "data_knx" @@ -241,7 +241,7 @@ class KNXModule(object): async def telegram_received_cb(self, telegram): """Call invoked after a KNX telegram was received.""" self.hass.bus.fire('knx_event', { - 'address': telegram.group_address.str(), + 'address': str(telegram.group_address), 'data': telegram.payload.value }) # False signals XKNX to proceed with processing telegrams. diff --git a/requirements_all.txt b/requirements_all.txt index 03560ac79b0..a3a5662822e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1274,7 +1274,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.8.4 +xknx==0.8.5 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca From 85fa88c8b334b49fad93383396c12def6b153e7e Mon Sep 17 00:00:00 2001 From: Jesse Hills Date: Sun, 11 Mar 2018 17:31:57 +1300 Subject: [PATCH 29/35] - Bump iGlo Version (#13063) - Use effect list as a method --- homeassistant/components/light/iglo.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index 1e110b5c397..9717993f77d 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['iglo==1.2.6'] +REQUIREMENTS = ['iglo==1.2.7'] _LOGGER = logging.getLogger(__name__) @@ -89,7 +89,7 @@ class IGloLamp(Light): @property def effect_list(self): """Return the list of supported effects.""" - return self._lamp.effect_list + return self._lamp.effect_list() @property def supported_features(self): diff --git a/requirements_all.txt b/requirements_all.txt index a3a5662822e..ad221e0c66a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -398,7 +398,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # i2csense==0.0.4 # homeassistant.components.light.iglo -iglo==1.2.6 +iglo==1.2.7 # homeassistant.components.ihc ihcsdk==2.2.0 From e37619acc13f0f3e93954c48b2986c92ece452b0 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 10 Mar 2018 22:27:58 +0000 Subject: [PATCH 30/35] Convert decimals from SQL results --- homeassistant/components/sensor/sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 402076c6fd8..5d5d61ff822 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -144,7 +144,7 @@ class SQLSensor(Entity): data = res[self._column_name] for key, value in res.items(): if isinstance(value, decimal.Decimal): - value = float(decimal) + value = float(value) self._attributes[key] = value except sqlalchemy.exc.SQLAlchemyError as err: _LOGGER.error("Error executing query %s: %s", self._query, err) From e3d176f479e2df32c727ee8dc9edeb06cb41b60a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Mar 2018 12:33:07 -0700 Subject: [PATCH 31/35] Fix Tado doing async wrong (#13078) * Fix Tado doing async wrong * Remove last coroutine decorator --- .../components/device_tracker/tado.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py index dcf06036ea0..11d12322ff5 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -68,22 +68,18 @@ class TadoDeviceScanner(DeviceScanner): self.websession = async_create_clientsession( hass, cookie_jar=aiohttp.CookieJar(unsafe=True, loop=hass.loop)) - self.success_init = self._update_info() + self.success_init = asyncio.run_coroutine_threadsafe( + self._async_update_info(), hass.loop + ).result() + _LOGGER.info("Scanner initialized") - @asyncio.coroutine - def async_scan_devices(self): + async def async_scan_devices(self): """Scan for devices and return a list containing found device ids.""" - info = self._update_info() - - # Don't yield if we got None - if info is not None: - yield from info - + await self._async_update_info() return [device.mac for device in self.last_results] - @asyncio.coroutine - def async_get_device_name(self, device): + async def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" filter_named = [result.name for result in self.last_results if result.mac == device] @@ -93,7 +89,7 @@ class TadoDeviceScanner(DeviceScanner): return None @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): + async def _async_update_info(self): """ Query Tado for device marked as at home. @@ -111,14 +107,14 @@ class TadoDeviceScanner(DeviceScanner): home_id=self.home_id, username=self.username, password=self.password) - response = yield from self.websession.get(url) + response = await self.websession.get(url) if response.status != 200: _LOGGER.warning( "Error %d on %s.", response.status, self.tadoapiurl) - return + return False - tado_json = yield from response.json() + tado_json = await response.json() except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Cannot load Tado data") @@ -139,7 +135,7 @@ class TadoDeviceScanner(DeviceScanner): self.last_results = last_results - _LOGGER.info( + _LOGGER.debug( "Tado presence query successful, %d device(s) at home", len(self.last_results) ) From 8e51c12010b66637e41b38f0b26fba9fa0401353 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Sun, 11 Mar 2018 13:33:36 -0600 Subject: [PATCH 32/35] Integrated with py-synology:0.2.0 which has fix to auto-renew session when it's expired (#13079) --- homeassistant/components/camera/synology.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index fca9cbbc7a5..8bbb3e8c632 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import ( async_get_clientsession) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['py-synology==0.1.5'] +REQUIREMENTS = ['py-synology==0.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ad221e0c66a..cbbd797db2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -632,7 +632,7 @@ py-cpuinfo==3.3.0 py-melissa-climate==1.0.6 # homeassistant.components.camera.synology -py-synology==0.1.5 +py-synology==0.2.0 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From a34786fb2dbfedcbaa998ab06282d7e5fe4660b2 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 11 Mar 2018 20:42:58 +0100 Subject: [PATCH 33/35] Revert "Cast automatically drop connection (#12635)" (#13094) This reverts commit e14893416fb98d3ef9a37d816e0ee719105b33a1. --- homeassistant/components/media_player/cast.py | 9 +++------ tests/components/media_player/test_cast.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index dbcb53ec185..579f9b62864 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/media_player.cast/ # pylint: disable=import-error import logging import threading -import functools import voluptuous as vol @@ -35,7 +34,6 @@ CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' DEFAULT_PORT = 8009 -SOCKET_CLIENT_RETRIES = 10 SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ @@ -78,7 +76,7 @@ def _setup_internal_discovery(hass: HomeAssistantType) -> None: try: # pylint: disable=protected-access chromecast = pychromecast._get_chromecast_from_host( - mdns, blocking=True, tries=SOCKET_CLIENT_RETRIES) + mdns, blocking=True) except pychromecast.ChromecastConnectionError: _LOGGER.debug("Can't set up cast with mDNS info %s. " "Assuming it's not a Chromecast", mdns) @@ -183,9 +181,8 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, else: # Manually add a "normal" Chromecast, we can do that without discovery. try: - func = functools.partial(pychromecast.Chromecast, *want_host, - tries=SOCKET_CLIENT_RETRIES) - chromecast = await hass.async_add_job(func) + chromecast = await hass.async_add_job( + pychromecast.Chromecast, *want_host) except pychromecast.ChromecastConnectionError as err: _LOGGER.warning("Can't set up chromecast on %s: %s", want_host[0], err) diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index aaaad47d8dc..2075b4cf6e6 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -123,7 +123,7 @@ def test_internal_discovery_callback_only_generates_once(hass): return_value=chromecast) as gen_chromecast: discover_cast('the-service', chromecast) mdns = (chromecast.host, chromecast.port, chromecast.uuid, None, None) - gen_chromecast.assert_called_once_with(mdns, blocking=True, tries=10) + gen_chromecast.assert_called_once_with(mdns, blocking=True) discover_cast('the-service', chromecast) gen_chromecast.reset_mock() From 8603f1a047dafe69e0c0ce309ce22646e52b7653 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sun, 11 Mar 2018 19:43:28 +0000 Subject: [PATCH 34/35] Bump pyvera to 0.2.42. Improve event loop robustness. (#13095) --- homeassistant/components/vera.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index a7c10462e0d..5cc4de0d5ca 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.41'] +REQUIREMENTS = ['pyvera==0.2.42'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cbbd797db2d..300952e061f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1021,7 +1021,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.41 +pyvera==0.2.42 # homeassistant.components.media_player.vizio pyvizio==0.0.2 From 31130f902b36f72d8dcc73c9707d70fa673e33b1 Mon Sep 17 00:00:00 2001 From: tadly Date: Sun, 11 Mar 2018 20:46:16 +0100 Subject: [PATCH 35/35] Updated jsonrpc-websocket to 0.6 (#13096) Fix Kodi by updating jsonrpc-websocket to 0.6 --- homeassistant/components/media_player/kodi.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index d14bf0fadaf..6450b2f5b35 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -31,7 +31,7 @@ from homeassistant.helpers import script, config_validation as cv from homeassistant.helpers.template import Template from homeassistant.util.yaml import dump -REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.5'] +REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 300952e061f..cc4e7dbd708 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,7 +421,7 @@ jsonpath==0.75 jsonrpc-async==0.6 # homeassistant.components.media_player.kodi -jsonrpc-websocket==0.5 +jsonrpc-websocket==0.6 # homeassistant.scripts.keyring keyring==11.0.0