From 99f7b7f42dcb3965c80e4729728b7366f2f8dffd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Sep 2018 13:46:07 +0200 Subject: [PATCH 001/178] Version bump to 0.79.0dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3bb468c1b1eeed..d27b5e3a1b7967 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 78 +MINOR_VERSION = 79 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From d2d715faa8dd551c3fd4dd999d62314344a04685 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 10 Sep 2018 16:07:31 +0200 Subject: [PATCH 002/178] Upgrade wakeonlan to 1.1.6 (#16512) --- .../media_player/panasonic_viera.py | 2 +- .../components/media_player/samsungtv.py | 2 +- .../components/switch/wake_on_lan.py | 28 ++++++++----------- homeassistant/components/wake_on_lan.py | 5 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index efe04c7005b6ff..d3e56c4dfb1cc5 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -18,7 +18,7 @@ STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['panasonic_viera==0.3.1', 'wakeonlan==1.0.0'] +REQUIREMENTS = ['panasonic_viera==0.3.1', 'wakeonlan==1.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index cc966c0d263816..c0a5d617f19f02 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util -REQUIREMENTS = ['samsungctl[websocket]==0.7.1', 'wakeonlan==1.0.0'] +REQUIREMENTS = ['samsungctl[websocket]==0.7.1', 'wakeonlan==1.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index 06f86865064801..16bd700e1d57c2 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -10,26 +10,26 @@ import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_HOST, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -from homeassistant.const import (CONF_HOST, CONF_NAME) -REQUIREMENTS = ['wakeonlan==1.0.0'] +REQUIREMENTS = ['wakeonlan==1.1.6'] _LOGGER = logging.getLogger(__name__) +CONF_BROADCAST_ADDRESS = 'broadcast_address' CONF_MAC_ADDRESS = 'mac_address' CONF_OFF_ACTION = 'turn_off' -CONF_BROADCAST_ADDRESS = 'broadcast_address' DEFAULT_NAME = 'Wake on LAN' DEFAULT_PING_TIMEOUT = 1 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC_ADDRESS): cv.string, - vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_BROADCAST_ADDRESS): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, }) @@ -37,21 +37,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a wake on lan switch.""" - name = config.get(CONF_NAME) + broadcast_address = config.get(CONF_BROADCAST_ADDRESS) host = config.get(CONF_HOST) mac_address = config.get(CONF_MAC_ADDRESS) - broadcast_address = config.get(CONF_BROADCAST_ADDRESS) + name = config.get(CONF_NAME) off_action = config.get(CONF_OFF_ACTION) - add_entities([WOLSwitch(hass, name, host, mac_address, - off_action, broadcast_address)], True) + add_entities([WOLSwitch( + hass, name, host, mac_address, off_action, broadcast_address)], True) class WOLSwitch(SwitchDevice): """Representation of a wake on lan switch.""" - def __init__(self, hass, name, host, mac_address, - off_action, broadcast_address): + def __init__( + self, hass, name, host, mac_address, off_action, + broadcast_address): """Initialize the WOL switch.""" import wakeonlan self._hass = hass @@ -63,11 +64,6 @@ def __init__(self, hass, name, host, mac_address, self._state = False self._wol = wakeonlan - @property - def should_poll(self): - """Return the polling state.""" - return True - @property def is_on(self): """Return true if switch is on.""" diff --git a/homeassistant/components/wake_on_lan.py b/homeassistant/components/wake_on_lan.py index 4e729c7ccc7903..5bcb0d4dd79cee 100644 --- a/homeassistant/components/wake_on_lan.py +++ b/homeassistant/components/wake_on_lan.py @@ -13,11 +13,12 @@ from homeassistant.const import CONF_MAC import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['wakeonlan==1.0.0'] +REQUIREMENTS = ['wakeonlan==1.1.6'] -DOMAIN = "wake_on_lan" _LOGGER = logging.getLogger(__name__) +DOMAIN = 'wake_on_lan' + CONF_BROADCAST_ADDRESS = 'broadcast_address' SERVICE_SEND_MAGIC_PACKET = 'send_magic_packet' diff --git a/requirements_all.txt b/requirements_all.txt index 358f5ac224433c..af51d0870cd1b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1461,7 +1461,7 @@ vultr==0.1.2 # homeassistant.components.media_player.panasonic_viera # homeassistant.components.media_player.samsungtv # homeassistant.components.switch.wake_on_lan -wakeonlan==1.0.0 +wakeonlan==1.1.6 # homeassistant.components.sensor.waqi waqiasync==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f875118b2ec21..736ba9115e49c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ vultr==0.1.2 # homeassistant.components.media_player.panasonic_viera # homeassistant.components.media_player.samsungtv # homeassistant.components.switch.wake_on_lan -wakeonlan==1.0.0 +wakeonlan==1.1.6 # homeassistant.components.cloud warrant==0.6.1 From e96635b5c1171f68c6b55fd32ba7aa81064a3a3d Mon Sep 17 00:00:00 2001 From: vikramgorla Date: Mon, 10 Sep 2018 16:13:05 +0200 Subject: [PATCH 003/178] bugfix - incorrect camera type and missing sensors when multiple netatmo cameras (#16490) fixed get_camera_type as it was originally not consuming any input, was looping with all cameras and the first camera type was retutned, modified to call cameraType using provided camera name. --- homeassistant/components/netatmo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index c25b57fbd627ff..59b0a64f6e99e1 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -101,10 +101,10 @@ def get_module_names(self, camera_name): return self.module_names def get_camera_type(self, camera=None, home=None, cid=None): - """Return all module available on the API as a list.""" - for camera_name in self.camera_names: - self.camera_type = self.camera_data.cameraType(camera_name) - return self.camera_type + """Return camera type for a camera, cid has preference over camera.""" + self.camera_type = self.camera_data.cameraType(camera=camera, + home=home, cid=cid) + return self.camera_type @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): From f858938adad94439c9d333be5fc70d31b2ec4c94 Mon Sep 17 00:00:00 2001 From: mrosseel Date: Mon, 10 Sep 2018 16:19:17 +0200 Subject: [PATCH 004/178] Make the Qnap sensor more resilient if server is not reachable (#16445) * add CONF_ALLOW_UNREACHABLE option, retry connection if allowed * fix linting errors * Removing the config option, just using PlatformNotReady --- homeassistant/components/sensor/qnap.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index 29eb8cd6749e5b..a6a9c6e30d074a 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -15,6 +15,7 @@ CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, ATTR_NAME, CONF_VERIFY_SSL, CONF_TIMEOUT, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS) from homeassistant.util import Throttle +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['qnapstats==0.2.7'] @@ -107,14 +108,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): api = QNAPStatsAPI(config) api.update() + # QNAP is not available if not api.data: - hass.components.persistent_notification.create( - 'Error: Failed to set up QNAP sensor.
' - 'Check the logs for additional information. ' - 'You will need to restart hass after fixing.', - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + raise PlatformNotReady sensors = [] From dcd7b9a52941abaee6cb4eeb4633a77dbfbc80c2 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Mon, 10 Sep 2018 10:54:17 -0400 Subject: [PATCH 005/178] Fix insteon Hub v1 support (#16472) * Fix support for Hub version 1 (i.e. pre-2014 Hub model 2242) * Bump insteonplm to 0.14.1 * Code review changes * Clean up and better document set_default_port * Simplify set_default_port based on code review * Remove Callable type import * Simplify port setup --- homeassistant/components/insteon/__init__.py | 40 +++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index d79640b77ab107..749d167e6de10b 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -7,6 +7,8 @@ import asyncio import collections import logging +from typing import Dict + import voluptuous as vol from homeassistant.core import callback @@ -18,7 +20,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.13.1'] +REQUIREMENTS = ['insteonplm==0.14.2'] _LOGGER = logging.getLogger(__name__) @@ -27,9 +29,9 @@ CONF_IP_PORT = 'ip_port' CONF_HUB_USERNAME = 'username' CONF_HUB_PASSWORD = 'password' +CONF_HUB_VERSION = 'hub_version' CONF_OVERRIDE = 'device_override' -CONF_PLM_HUB_MSG = ('Must configure either a PLM port or a Hub host, username ' - 'and password') +CONF_PLM_HUB_MSG = 'Must configure either a PLM port or a Hub host' CONF_ADDRESS = 'address' CONF_CAT = 'cat' CONF_SUBCAT = 'subcat' @@ -66,6 +68,22 @@ EVENT_BUTTON_OFF = 'insteon.button_off' EVENT_CONF_BUTTON = 'button' + +def set_default_port(schema: Dict) -> Dict: + """Set the default port based on the Hub version.""" + # If the ip_port is found do nothing + # If it is not found the set the default + ip_port = schema.get(CONF_IP_PORT) + if not ip_port: + hub_version = schema.get(CONF_HUB_VERSION) + # Found hub_version but not ip_port + if hub_version == 1: + schema[CONF_IP_PORT] = 9761 + else: + schema[CONF_IP_PORT] = 25105 + return schema + + CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( cv.deprecated(CONF_PLATFORM), vol.Schema({ vol.Required(CONF_ADDRESS): cv.string, @@ -88,12 +106,13 @@ DOMAIN: vol.All( vol.Schema( {vol.Exclusive(CONF_PORT, 'plm_or_hub', - msg=CONF_PLM_HUB_MSG): cv.isdevice, + msg=CONF_PLM_HUB_MSG): cv.string, vol.Exclusive(CONF_HOST, 'plm_or_hub', msg=CONF_PLM_HUB_MSG): cv.string, - vol.Optional(CONF_IP_PORT, default=25105): int, + vol.Optional(CONF_IP_PORT): cv.port, vol.Optional(CONF_HUB_USERNAME): cv.string, vol.Optional(CONF_HUB_PASSWORD): cv.string, + vol.Optional(CONF_HUB_VERSION, default=2): vol.In([1, 2]), vol.Optional(CONF_OVERRIDE): vol.All( cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA]), vol.Optional(CONF_X10_ALL_UNITS_OFF): vol.In(HOUSECODES), @@ -103,14 +122,7 @@ [CONF_X10_SCHEMA]) }, extra=vol.ALLOW_EXTRA, required=True), cv.has_at_least_one_key(CONF_PORT, CONF_HOST), - vol.Schema( - {vol.Inclusive(CONF_HOST, 'hub', - msg=CONF_PLM_HUB_MSG): cv.string, - vol.Inclusive(CONF_HUB_USERNAME, 'hub', - msg=CONF_PLM_HUB_MSG): cv.string, - vol.Inclusive(CONF_HUB_PASSWORD, 'hub', - msg=CONF_PLM_HUB_MSG): cv.string, - }, extra=vol.ALLOW_EXTRA, required=True)) + set_default_port) }, extra=vol.ALLOW_EXTRA) @@ -151,6 +163,7 @@ def async_setup(hass, config): ip_port = conf.get(CONF_IP_PORT) username = conf.get(CONF_HUB_USERNAME) password = conf.get(CONF_HUB_PASSWORD) + hub_version = conf.get(CONF_HUB_VERSION) overrides = conf.get(CONF_OVERRIDE, []) x10_devices = conf.get(CONF_X10, []) x10_all_units_off_housecode = conf.get(CONF_X10_ALL_UNITS_OFF) @@ -284,6 +297,7 @@ def _fire_button_on_off_event(address, group, val): port=ip_port, username=username, password=password, + hub_version=hub_version, loop=hass.loop, workdir=hass.config.config_dir) else: diff --git a/requirements_all.txt b/requirements_all.txt index af51d0870cd1b0..b21c398961c2cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -486,7 +486,7 @@ ihcsdk==2.2.0 influxdb==5.0.0 # homeassistant.components.insteon -insteonplm==0.13.1 +insteonplm==0.14.2 # homeassistant.components.sensor.iperf3 iperf3==0.1.10 From cfe5db4350431af827c95974971bb57f1eeb04f4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Sep 2018 23:51:40 +0200 Subject: [PATCH 006/178] Fail fetch auth providers if onboarding required (#16454) --- homeassistant/components/auth/login_flow.py | 15 ++++++++++++--- tests/components/auth/test_login_flow.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index a518bdde4154c7..73a739c2960187 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -66,7 +66,7 @@ "version": 1 } """ -import aiohttp.web +from aiohttp import web import voluptuous as vol from homeassistant import data_entry_flow @@ -95,11 +95,20 @@ class AuthProvidersView(HomeAssistantView): async def get(self, request): """Get available auth providers.""" + hass = request.app['hass'] + + if not hass.components.onboarding.async_is_onboarded(): + return self.json_message( + message='Onboarding not finished', + status_code=400, + message_code='onboarding_required' + ) + return self.json([{ 'name': provider.name, 'id': provider.id, 'type': provider.type, - } for provider in request.app['hass'].auth.auth_providers]) + } for provider in hass.auth.auth_providers]) def _prepare_result_json(result): @@ -139,7 +148,7 @@ def __init__(self, flow_mgr): async def get(self, request): """Do not allow index of flows in progress.""" - return aiohttp.web.Response(status=405) + return web.Response(status=405) @RequestDataValidator(vol.Schema({ vol.Required('client_id'): str, diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 8b6108067c5221..d759bac74b7f3d 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -1,4 +1,6 @@ """Tests for the login flow.""" +from unittest.mock import patch + from . import async_setup_auth from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI @@ -16,6 +18,19 @@ async def test_fetch_auth_providers(hass, aiohttp_client): }] +async def test_fetch_auth_providers_onboarding(hass, aiohttp_client): + """Test fetching auth providers.""" + client = await async_setup_auth(hass, aiohttp_client) + with patch('homeassistant.components.onboarding.async_is_onboarded', + return_value=False): + resp = await client.get('/auth/providers') + assert resp.status == 400 + assert await resp.json() == { + 'message': 'Onboarding not finished', + 'code': 'onboarding_required', + } + + async def test_cannot_get_flows_in_progress(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client, []) From a059cc860a4c99e3edf3972af809b39fba191073 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Tue, 11 Sep 2018 05:55:02 +0200 Subject: [PATCH 007/178] Update PyRMVtransport version (#16547) * Update PyRMVtransport version * Update requirements. --- homeassistant/components/sensor/rmvtransport.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/rmvtransport.py b/homeassistant/components/sensor/rmvtransport.py index 3d7fd2aa3b70a9..0916765e12dfaf 100644 --- a/homeassistant/components/sensor/rmvtransport.py +++ b/homeassistant/components/sensor/rmvtransport.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) -REQUIREMENTS = ['PyRMVtransport==0.0.7'] +REQUIREMENTS = ['PyRMVtransport==0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b21c398961c2cc..50cc56f3443b4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -52,7 +52,7 @@ PyMata==2.14 PyQRCode==1.2.1 # homeassistant.components.sensor.rmvtransport -PyRMVtransport==0.0.7 +PyRMVtransport==0.1 # homeassistant.components.switch.switchbot PySwitchbot==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 736ba9115e49c2..42f2c11242e9e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ requests_mock==1.5.2 HAP-python==2.2.2 # homeassistant.components.sensor.rmvtransport -PyRMVtransport==0.0.7 +PyRMVtransport==0.1 # homeassistant.components.sonos SoCo==0.16 From ee696643cd7c63bf0cc88f5111c64b6e2d836640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 11 Sep 2018 12:21:48 +0300 Subject: [PATCH 008/178] Isort preparations (#16555) * Don't treat typing as an "in-between" module for import order That was a < 3.5 era thing. * Tighten scope of some pylint unused-import disables To avoid isort moving a top level one around, undesirably broadening its scope. --- homeassistant/__main__.py | 1 - homeassistant/bootstrap.py | 1 - homeassistant/components/apple_tv.py | 1 - homeassistant/components/geo_location/demo.py | 1 - homeassistant/components/google_assistant/__init__.py | 2 +- homeassistant/components/google_assistant/auth.py | 2 +- homeassistant/components/media_player/cast.py | 1 - homeassistant/components/media_player/webostv.py | 4 +--- homeassistant/components/rachio.py | 2 +- homeassistant/components/recorder/__init__.py | 1 - homeassistant/config.py | 3 +-- homeassistant/core.py | 3 +-- homeassistant/data_entry_flow.py | 2 +- homeassistant/helpers/__init__.py | 1 - homeassistant/helpers/config_validation.py | 1 - homeassistant/helpers/entity.py | 1 - homeassistant/helpers/json.py | 1 - homeassistant/loader.py | 4 +--- homeassistant/scripts/__init__.py | 1 - homeassistant/scripts/check_config.py | 2 +- homeassistant/scripts/influxdb_import.py | 1 - homeassistant/scripts/influxdb_migrator.py | 1 - homeassistant/util/__init__.py | 1 - homeassistant/util/dt.py | 4 +--- homeassistant/util/package.py | 1 - setup.cfg | 2 -- 26 files changed, 10 insertions(+), 35 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 65b1cd2ae1a616..80bc1dccff443f 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -7,7 +7,6 @@ import subprocess import sys import threading - from typing import List, Dict, Any # noqa pylint: disable=unused-import diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2051359c0baed1..2125ab46a8c531 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -5,7 +5,6 @@ import sys from time import time from collections import OrderedDict - from typing import Any, Optional, Dict import voluptuous as vol diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 97fb2363024fb2..21ff0e3286d6f5 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -6,7 +6,6 @@ """ import asyncio import logging - from typing import Sequence, TypeVar, Union import voluptuous as vol diff --git a/homeassistant/components/geo_location/demo.py b/homeassistant/components/geo_location/demo.py index 8e8d8211086053..ddec369e696aa6 100644 --- a/homeassistant/components/geo_location/demo.py +++ b/homeassistant/components/geo_location/demo.py @@ -8,7 +8,6 @@ import random from datetime import timedelta from math import pi, cos, sin, radians - from typing import Optional from homeassistant.components.geo_location import GeoLocationEvent diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 567a6d842339ca..22569af1f868a8 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -6,6 +6,7 @@ """ import asyncio import logging +from typing import Dict, Any import aiohttp import async_timeout @@ -14,7 +15,6 @@ # Typing imports from homeassistant.core import HomeAssistant -from typing import Dict, Any from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py index e80b2282066b73..5b98e25014db65 100644 --- a/homeassistant/components/google_assistant/auth.py +++ b/homeassistant/components/google_assistant/auth.py @@ -1,11 +1,11 @@ """Google Assistant OAuth View.""" import logging +from typing import Dict, Any # Typing imports # if False: from aiohttp.web import Request, Response -from typing import Dict, Any from homeassistant.core import HomeAssistant from homeassistant.components.http import HomeAssistantView diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 83b84f5c3bb642..83c3595ebd9c1e 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -7,7 +7,6 @@ import asyncio import logging import threading - from typing import Optional, Tuple import attr diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index b3cd07b9d35dd4..b5240bdb9f5f73 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -8,9 +8,7 @@ from datetime import timedelta import logging from urllib.parse import urlparse - -# pylint: disable=unused-import -from typing import Dict # noqa: F401 +from typing import Dict # noqa: F401 pylint: disable=unused-import import voluptuous as vol diff --git a/homeassistant/components/rachio.py b/homeassistant/components/rachio.py index 0e67e15d5c099a..cd80b7bec9be92 100644 --- a/homeassistant/components/rachio.py +++ b/homeassistant/components/rachio.py @@ -6,10 +6,10 @@ """ import asyncio import logging +from typing import Optional from aiohttp import web import voluptuous as vol -from typing import Optional from homeassistant.auth.util import generate_secret from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 47d6e181c8f82a..a3cd2eebd8c799 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -15,7 +15,6 @@ import queue import threading import time - from typing import Any, Dict, Optional # noqa: F401 import voluptuous as vol diff --git a/homeassistant/config.py b/homeassistant/config.py index d742e62660b908..5474b283494e39 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -6,8 +6,7 @@ import os import re import shutil -# pylint: disable=unused-import -from typing import ( # noqa: F401 +from typing import ( # noqa: F401 pylint: disable=unused-import Any, Tuple, Optional, Dict, List, Union, Callable, Sequence, Set) from types import ModuleType import voluptuous as vol diff --git a/homeassistant/core.py b/homeassistant/core.py index 2b7a2479471d61..fdbbe49ea05d69 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,8 +18,7 @@ import uuid from types import MappingProxyType -# pylint: disable=unused-import -from typing import ( # NOQA +from typing import ( # noqa: F401 pylint: disable=unused-import Optional, Any, Callable, List, TypeVar, Dict, Coroutine, Set, TYPE_CHECKING, Awaitable, Iterator) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index a54c07fc1b82bc..ecf9850a67c407 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -1,8 +1,8 @@ """Classes to help gather user submissions.""" import logging +from typing import Dict, Any, Callable, Hashable, List, Optional # noqa pylint: disable=unused-import import uuid import voluptuous as vol -from typing import Dict, Any, Callable, Hashable, List, Optional # noqa pylint: disable=unused-import from .core import callback, HomeAssistant from .exceptions import HomeAssistantError diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index ed489ed858b00a..abc3b7a2324345 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -1,6 +1,5 @@ """Helper methods for components within Home Assistant.""" import re - from typing import Any, Iterable, Tuple, Sequence, Dict from homeassistant.const import CONF_PLATFORM diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 90098a677a1476..3363b199b0beac 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -7,7 +7,6 @@ from socket import _GLOBAL_DEFAULT_TIMEOUT import logging import inspect - from typing import Any, Union, TypeVar, Callable, Sequence, Dict import voluptuous as vol diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 695da5bce9c7d1..e48af6a33657f0 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,7 +4,6 @@ import logging import functools as ft from timeit import default_timer as timer - from typing import Optional, List, Iterable from homeassistant.const import ( diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index c28ee8c5c2cb68..bdb82687a3208d 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -2,7 +2,6 @@ from datetime import datetime import json import logging - from typing import Any _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 3ac49e354b5d50..6fb003926e1d3b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -15,9 +15,7 @@ import logging import sys from types import ModuleType - -# pylint: disable=unused-import -from typing import Optional, Set, TYPE_CHECKING, Callable, Any, TypeVar # NOQA +from typing import Optional, Set, TYPE_CHECKING, Callable, Any, TypeVar # noqa pylint: disable=unused-import from homeassistant.const import PLATFORM_FORMAT from homeassistant.util import OrderedSet diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 7aba3b2561cbaa..02cc0bff362241 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -5,7 +5,6 @@ import logging import os import sys - from typing import List from homeassistant.bootstrap import async_mount_local_lib_path diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index e0c933df5bbcf3..94add794651af6 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -6,10 +6,10 @@ from collections import OrderedDict, namedtuple from glob import glob from platform import system +from typing import Dict, List, Sequence from unittest.mock import patch import attr -from typing import Dict, List, Sequence import voluptuous as vol from homeassistant import bootstrap, core, loader diff --git a/homeassistant/scripts/influxdb_import.py b/homeassistant/scripts/influxdb_import.py index 031df1d3a72a38..a6dd90920c3b04 100644 --- a/homeassistant/scripts/influxdb_import.py +++ b/homeassistant/scripts/influxdb_import.py @@ -3,7 +3,6 @@ import json import os import sys - from typing import List import homeassistant.config as config_util diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py index a4c0df74b091ac..04d54cd3fa8c50 100644 --- a/homeassistant/scripts/influxdb_migrator.py +++ b/homeassistant/scripts/influxdb_migrator.py @@ -2,7 +2,6 @@ import argparse import sys - from typing import List diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 1e74c500fc16da..17849154ff766c 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -11,7 +11,6 @@ from functools import wraps from types import MappingProxyType from unicodedata import normalize - from typing import (Any, Optional, TypeVar, Callable, KeysView, Union, # noqa Iterable, List, Dict, Iterator, Coroutine, MutableSet) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 729195fb3fd44a..5d4b10454a7bad 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -1,9 +1,7 @@ """Helper methods to handle the time in Home Assistant.""" import datetime as dt import re - -# pylint: disable=unused-import -from typing import Any, Dict, Union, Optional, Tuple # NOQA +from typing import Any, Dict, Union, Optional, Tuple # noqa pylint: disable=unused-import import pytz import pytz.exceptions as pytzexceptions diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 422809f7594395..925755eb741d8a 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -4,7 +4,6 @@ import os from subprocess import PIPE, Popen import sys - from typing import Optional _LOGGER = logging.getLogger(__name__) diff --git a/setup.cfg b/setup.cfg index 7813cc5c0472ea..a8bd819c792134 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,4 @@ indent = " " not_skip = __init__.py # will group `import x` and `from x import` of the same module. force_sort_within_sections = true -# typing is stdlib on py35 but 3rd party on py34, let it hang in between -known_inbetweens = typing sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER From 6b08e6e769680b4d325c12ed7c7100be4d458407 Mon Sep 17 00:00:00 2001 From: Zellux Wang Date: Tue, 11 Sep 2018 03:25:38 -0600 Subject: [PATCH 009/178] Fix arlo intilization when no base station available (#16529) * Fix arlo intilization when no base station * Fix pylint for empty camera check * Fix typo * Minor change to trigger CI again --- homeassistant/components/arlo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index c6a414b9d91205..015e1e0d1fcf46 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -61,10 +61,12 @@ def setup(hass, config): arlo_base_station = next(( station for station in arlo.base_stations), None) - if arlo_base_station is None: + if arlo_base_station is not None: + arlo_base_station.refresh_rate = scan_interval.total_seconds() + elif not arlo.cameras: + _LOGGER.error("No Arlo camera or base station available.") return False - arlo_base_station.refresh_rate = scan_interval.total_seconds() hass.data[DATA_ARLO] = arlo except (ConnectTimeout, HTTPError) as ex: From 20f6cb7cc77fa420b1b6ba290769a34fb6002915 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 11 Sep 2018 10:30:20 +0100 Subject: [PATCH 010/178] Replace api_password in Camera.Push (#16339) * Use access_token and user provided token instead of api_password * address comments by @awarecan * new tests * add extra checks and test * lint * add comment --- homeassistant/components/camera/push.py | 32 ++++++++++-- tests/components/camera/test_push.py | 67 ++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py index 305e29d62d34e4..c9deca1309d699 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -13,8 +13,10 @@ from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ STATE_IDLE, STATE_RECORDING from homeassistant.core import callback -from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import CONF_NAME, CONF_TIMEOUT, HTTP_BAD_REQUEST +from homeassistant.components.http.view import KEY_AUTHENTICATED,\ + HomeAssistantView +from homeassistant.const import CONF_NAME, CONF_TIMEOUT,\ + HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.dt as dt_util @@ -25,11 +27,13 @@ CONF_BUFFER_SIZE = 'buffer' CONF_IMAGE_FIELD = 'field' +CONF_TOKEN = 'token' DEFAULT_NAME = "Push Camera" ATTR_FILENAME = 'filename' ATTR_LAST_TRIP = 'last_trip' +ATTR_TOKEN = 'token' PUSH_CAMERA_DATA = 'push_camera' @@ -39,6 +43,7 @@ vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( cv.time_period, cv.positive_timedelta), vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, + vol.Optional(CONF_TOKEN): vol.All(cv.string, vol.Length(min=8)), }) @@ -50,7 +55,8 @@ async def async_setup_platform(hass, config, async_add_entities, cameras = [PushCamera(config[CONF_NAME], config[CONF_BUFFER_SIZE], - config[CONF_TIMEOUT])] + config[CONF_TIMEOUT], + config.get(CONF_TOKEN))] hass.http.register_view(CameraPushReceiver(hass, config[CONF_IMAGE_FIELD])) @@ -63,6 +69,7 @@ class CameraPushReceiver(HomeAssistantView): url = "/api/camera_push/{entity_id}" name = 'api:camera_push:camera_entity' + requires_auth = False def __init__(self, hass, image_field): """Initialize CameraPushReceiver with camera entity.""" @@ -75,8 +82,21 @@ async def post(self, request, entity_id): if _camera is None: _LOGGER.error("Unknown %s", entity_id) + status = HTTP_NOT_FOUND if request[KEY_AUTHENTICATED]\ + else HTTP_UNAUTHORIZED return self.json_message('Unknown {}'.format(entity_id), - HTTP_BAD_REQUEST) + status) + + # Supports HA authentication and token based + # when token has been configured + authenticated = (request[KEY_AUTHENTICATED] or + (_camera.token is not None and + request.query.get('token') == _camera.token)) + + if not authenticated: + return self.json_message( + 'Invalid authorization credentials for {}'.format(entity_id), + HTTP_UNAUTHORIZED) try: data = await request.post() @@ -95,7 +115,7 @@ async def post(self, request, entity_id): class PushCamera(Camera): """The representation of a Push camera.""" - def __init__(self, name, buffer_size, timeout): + def __init__(self, name, buffer_size, timeout, token): """Initialize push camera component.""" super().__init__() self._name = name @@ -106,6 +126,7 @@ def __init__(self, name, buffer_size, timeout): self._timeout = timeout self.queue = deque([], buffer_size) self._current_image = None + self.token = token async def async_added_to_hass(self): """Call when entity is added to hass.""" @@ -168,5 +189,6 @@ def device_state_attributes(self): name: value for name, value in ( (ATTR_LAST_TRIP, self._last_trip), (ATTR_FILENAME, self._filename), + (ATTR_TOKEN, self.token), ) if value is not None } diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py index f9a3c62aa4aa6c..6d9688c10e62ab 100644 --- a/tests/components/camera/test_push.py +++ b/tests/components/camera/test_push.py @@ -6,7 +6,7 @@ from homeassistant import core as ha from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.components.auth import async_setup_auth +from homeassistant.components.http.auth import setup_auth async def test_bad_posting(aioclient_mock, hass, aiohttp_client): @@ -15,19 +15,69 @@ async def test_bad_posting(aioclient_mock, hass, aiohttp_client): 'camera': { 'platform': 'push', 'name': 'config_test', + 'token': '12345678' }}) - - client = await async_setup_auth(hass, aiohttp_client) + client = await aiohttp_client(hass.http.app) # missing file resp = await client.post('/api/camera_push/camera.config_test') assert resp.status == 400 - files = {'image': io.BytesIO(b'fake')} - # wrong entity + files = {'image': io.BytesIO(b'fake')} resp = await client.post('/api/camera_push/camera.wrong', data=files) - assert resp.status == 400 + assert resp.status == 404 + + +async def test_cases_with_no_auth(aioclient_mock, hass, aiohttp_client): + """Test cases where aiohttp_client is not auth.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + 'token': '12345678' + }}) + + setup_auth(hass.http.app, [], True, api_password=None) + client = await aiohttp_client(hass.http.app) + + # wrong token + files = {'image': io.BytesIO(b'fake')} + resp = await client.post('/api/camera_push/camera.config_test?token=1234', + data=files) + assert resp.status == 401 + + # right token + files = {'image': io.BytesIO(b'fake')} + resp = await client.post( + '/api/camera_push/camera.config_test?token=12345678', + data=files) + assert resp.status == 200 + + +async def test_no_auth_no_token(aioclient_mock, hass, aiohttp_client): + """Test cases where aiohttp_client is not auth.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + setup_auth(hass.http.app, [], True, api_password=None) + client = await aiohttp_client(hass.http.app) + + # no token + files = {'image': io.BytesIO(b'fake')} + resp = await client.post('/api/camera_push/camera.config_test', + data=files) + assert resp.status == 401 + + # fake token + files = {'image': io.BytesIO(b'fake')} + resp = await client.post( + '/api/camera_push/camera.config_test?token=12345678', + data=files) + assert resp.status == 401 async def test_posting_url(hass, aiohttp_client): @@ -36,6 +86,7 @@ async def test_posting_url(hass, aiohttp_client): 'camera': { 'platform': 'push', 'name': 'config_test', + 'token': '12345678' }}) client = await aiohttp_client(hass.http.app) @@ -46,7 +97,9 @@ async def test_posting_url(hass, aiohttp_client): assert camera_state.state == 'idle' # post image - resp = await client.post('/api/camera_push/camera.config_test', data=files) + resp = await client.post( + '/api/camera_push/camera.config_test?token=12345678', + data=files) assert resp.status == 200 # state recording From 50fb59477adb83e41ea338b9a002aca8212f15a7 Mon Sep 17 00:00:00 2001 From: Jerad Meisner Date: Tue, 11 Sep 2018 02:39:30 -0700 Subject: [PATCH 011/178] Store notifications in component. Add ws endpoint for fetching. (#16503) * Store notifications in component. Add ws endpoint for fetching. * Comments --- homeassistant/components/http/ban.py | 12 +- .../persistent_notification/__init__.py | 77 ++++++++++- .../persistent_notification/test_init.py | 125 +++++++++++++++++- 3 files changed, 203 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 015c386e836367..2a25de96edc1df 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.components import persistent_notification from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -92,9 +91,10 @@ async def process_wrong_login(request): msg = ('Login attempt or request with invalid authentication ' 'from {}'.format(remote_addr)) _LOGGER.warning(msg) - persistent_notification.async_create( - request.app['hass'], msg, 'Login attempt failed', - NOTIFICATION_ID_LOGIN) + + hass = request.app['hass'] + hass.components.persistent_notification.async_create( + msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN) # Check if ban middleware is loaded if (KEY_BANNED_IPS not in request.app or @@ -108,15 +108,13 @@ async def process_wrong_login(request): new_ban = IpBan(remote_addr) request.app[KEY_BANNED_IPS].append(new_ban) - hass = request.app['hass'] await hass.async_add_job( update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban) _LOGGER.warning( "Banned IP %s for too many login attempts", remote_addr) - persistent_notification.async_create( - hass, + hass.components.persistent_notification.async_create( 'Too many login attempts from {}'.format(remote_addr), 'Banning IP address', NOTIFICATION_ID_BAN) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 2850a5f96cd90b..6b8fd68bc26b01 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -6,10 +6,12 @@ """ import asyncio import logging +from collections import OrderedDict from typing import Awaitable import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.loader import bind_hass @@ -20,13 +22,17 @@ ATTR_MESSAGE = 'message' ATTR_NOTIFICATION_ID = 'notification_id' ATTR_TITLE = 'title' +ATTR_STATUS = 'status' DOMAIN = 'persistent_notification' ENTITY_ID_FORMAT = DOMAIN + '.{}' +EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = 'persistent_notifications_updated' + SERVICE_CREATE = 'create' SERVICE_DISMISS = 'dismiss' +SERVICE_MARK_READ = 'mark_read' SCHEMA_SERVICE_CREATE = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.template, @@ -38,11 +44,21 @@ vol.Required(ATTR_NOTIFICATION_ID): cv.string, }) +SCHEMA_SERVICE_MARK_READ = vol.Schema({ + vol.Required(ATTR_NOTIFICATION_ID): cv.string, +}) DEFAULT_OBJECT_ID = 'notification' _LOGGER = logging.getLogger(__name__) STATE = 'notifying' +STATUS_UNREAD = 'unread' +STATUS_READ = 'read' + +WS_TYPE_GET_NOTIFICATIONS = 'persistent_notification/get' +SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_NOTIFICATIONS, +}) @bind_hass @@ -76,7 +92,7 @@ def async_create(hass: HomeAssistant, message: str, title: str = None, @callback @bind_hass -def async_dismiss(hass, notification_id): +def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: """Remove a notification.""" data = {ATTR_NOTIFICATION_ID: notification_id} @@ -86,6 +102,9 @@ def async_dismiss(hass, notification_id): @asyncio.coroutine def async_setup(hass: HomeAssistant, config: dict) -> Awaitable[bool]: """Set up the persistent notification component.""" + persistent_notifications = OrderedDict() + hass.data[DOMAIN] = {'notifications': persistent_notifications} + @callback def create_service(call): """Handle a create notification service call.""" @@ -98,6 +117,8 @@ def create_service(call): else: entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass) + notification_id = entity_id.split('.')[1] + attr = {} if title is not None: try: @@ -120,18 +141,72 @@ def create_service(call): hass.states.async_set(entity_id, STATE, attr) + # Store notification and fire event + # This will eventually replace state machine storage + persistent_notifications[entity_id] = { + ATTR_MESSAGE: message, + ATTR_NOTIFICATION_ID: notification_id, + ATTR_STATUS: STATUS_UNREAD, + ATTR_TITLE: title, + } + + hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + @callback def dismiss_service(call): """Handle the dismiss notification service call.""" notification_id = call.data.get(ATTR_NOTIFICATION_ID) entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) + if entity_id not in persistent_notifications: + return + hass.states.async_remove(entity_id) + del persistent_notifications[entity_id] + hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + + @callback + def mark_read_service(call): + """Handle the mark_read notification service call.""" + notification_id = call.data.get(ATTR_NOTIFICATION_ID) + entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) + + if entity_id not in persistent_notifications: + _LOGGER.error('Marking persistent_notification read failed: ' + 'Notification ID %s not found.', notification_id) + return + + persistent_notifications[entity_id][ATTR_STATUS] = STATUS_READ + hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service, SCHEMA_SERVICE_CREATE) hass.services.async_register(DOMAIN, SERVICE_DISMISS, dismiss_service, SCHEMA_SERVICE_DISMISS) + hass.services.async_register(DOMAIN, SERVICE_MARK_READ, mark_read_service, + SCHEMA_SERVICE_MARK_READ) + + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_NOTIFICATIONS, websocket_get_notifications, + SCHEMA_WS_GET + ) + return True + + +@callback +def websocket_get_notifications( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Return a list of persistent_notifications.""" + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'], [ + { + key: data[key] for key in (ATTR_NOTIFICATION_ID, ATTR_MESSAGE, + ATTR_STATUS, ATTR_TITLE) + } + for data in hass.data[DOMAIN]['notifications'].values() + ]) + ) diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index a609247b839d9c..6acc796a108fe2 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,5 +1,6 @@ """The tests for the persistent notification component.""" -from homeassistant.setup import setup_component +from homeassistant.components import websocket_api +from homeassistant.setup import setup_component, async_setup_component import homeassistant.components.persistent_notification as pn from tests.common import get_test_home_assistant @@ -19,7 +20,9 @@ def teardown_method(self, method): def test_create(self): """Test creating notification without title or notification id.""" + notifications = self.hass.data[pn.DOMAIN]['notifications'] assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0 + assert len(notifications) == 0 pn.create(self.hass, 'Hello World {{ 1 + 1 }}', title='{{ 1 + 1 }} beers') @@ -27,54 +30,170 @@ def test_create(self): entity_ids = self.hass.states.entity_ids(pn.DOMAIN) assert len(entity_ids) == 1 + assert len(notifications) == 1 state = self.hass.states.get(entity_ids[0]) assert state.state == pn.STATE assert state.attributes.get('message') == 'Hello World 2' assert state.attributes.get('title') == '2 beers' + notification = notifications.get(entity_ids[0]) + assert notification['status'] == pn.STATUS_UNREAD + assert notification['message'] == 'Hello World 2' + assert notification['title'] == '2 beers' + notifications.clear() + def test_create_notification_id(self): """Ensure overwrites existing notification with same id.""" + notifications = self.hass.data[pn.DOMAIN]['notifications'] assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0 + assert len(notifications) == 0 pn.create(self.hass, 'test', notification_id='Beer 2') self.hass.block_till_done() assert len(self.hass.states.entity_ids()) == 1 - state = self.hass.states.get('persistent_notification.beer_2') + assert len(notifications) == 1 + + entity_id = 'persistent_notification.beer_2' + state = self.hass.states.get(entity_id) assert state.attributes.get('message') == 'test' + notification = notifications.get(entity_id) + assert notification['message'] == 'test' + assert notification['title'] is None + pn.create(self.hass, 'test 2', notification_id='Beer 2') self.hass.block_till_done() # We should have overwritten old one assert len(self.hass.states.entity_ids()) == 1 - state = self.hass.states.get('persistent_notification.beer_2') + state = self.hass.states.get(entity_id) assert state.attributes.get('message') == 'test 2' + notification = notifications.get(entity_id) + assert notification['message'] == 'test 2' + notifications.clear() + def test_create_template_error(self): """Ensure we output templates if contain error.""" + notifications = self.hass.data[pn.DOMAIN]['notifications'] assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0 + assert len(notifications) == 0 pn.create(self.hass, '{{ message + 1 }}', '{{ title + 1 }}') self.hass.block_till_done() entity_ids = self.hass.states.entity_ids(pn.DOMAIN) assert len(entity_ids) == 1 + assert len(notifications) == 1 state = self.hass.states.get(entity_ids[0]) assert state.attributes.get('message') == '{{ message + 1 }}' assert state.attributes.get('title') == '{{ title + 1 }}' + notification = notifications.get(entity_ids[0]) + assert notification['message'] == '{{ message + 1 }}' + assert notification['title'] == '{{ title + 1 }}' + notifications.clear() + def test_dismiss_notification(self): """Ensure removal of specific notification.""" + notifications = self.hass.data[pn.DOMAIN]['notifications'] assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0 + assert len(notifications) == 0 pn.create(self.hass, 'test', notification_id='Beer 2') self.hass.block_till_done() assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 1 + assert len(notifications) == 1 pn.dismiss(self.hass, notification_id='Beer 2') self.hass.block_till_done() assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0 + assert len(notifications) == 0 + notifications.clear() + + def test_mark_read(self): + """Ensure notification is marked as Read.""" + notifications = self.hass.data[pn.DOMAIN]['notifications'] + assert len(notifications) == 0 + + pn.create(self.hass, 'test', notification_id='Beer 2') + self.hass.block_till_done() + + entity_id = 'persistent_notification.beer_2' + assert len(notifications) == 1 + notification = notifications.get(entity_id) + assert notification['status'] == pn.STATUS_UNREAD + + self.hass.services.call(pn.DOMAIN, pn.SERVICE_MARK_READ, { + 'notification_id': 'Beer 2' + }) + self.hass.block_till_done() + + assert len(notifications) == 1 + notification = notifications.get(entity_id) + assert notification['status'] == pn.STATUS_READ + notifications.clear() + + +async def test_ws_get_notifications(hass, hass_ws_client): + """Test websocket endpoint for retrieving persistent notifications.""" + await async_setup_component(hass, pn.DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({ + 'id': 5, + 'type': 'persistent_notification/get' + }) + msg = await client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == websocket_api.TYPE_RESULT + assert msg['success'] + notifications = msg['result'] + assert len(notifications) == 0 + + # Create + hass.components.persistent_notification.async_create( + 'test', notification_id='Beer 2') + await client.send_json({ + 'id': 6, + 'type': 'persistent_notification/get' + }) + msg = await client.receive_json() + assert msg['id'] == 6 + assert msg['type'] == websocket_api.TYPE_RESULT + assert msg['success'] + notifications = msg['result'] + assert len(notifications) == 1 + notification = notifications[0] + assert notification['notification_id'] == 'Beer 2' + assert notification['message'] == 'test' + assert notification['title'] is None + assert notification['status'] == pn.STATUS_UNREAD + + # Mark Read + await hass.services.async_call(pn.DOMAIN, pn.SERVICE_MARK_READ, { + 'notification_id': 'Beer 2' + }) + await client.send_json({ + 'id': 7, + 'type': 'persistent_notification/get' + }) + msg = await client.receive_json() + notifications = msg['result'] + assert len(notifications) == 1 + assert notifications[0]['status'] == pn.STATUS_READ + + # Dismiss + hass.components.persistent_notification.async_dismiss('Beer 2') + await client.send_json({ + 'id': 8, + 'type': 'persistent_notification/get' + }) + msg = await client.receive_json() + notifications = msg['result'] + assert len(notifications) == 0 From 95839470129a8071b8d55ca099569c3b05aac0a8 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 11 Sep 2018 03:05:15 -0700 Subject: [PATCH 012/178] Long-lived access token (#16453) * Allow create refresh_token with specific access_token_expiration * Add token_type, client_name and client_icon * Add unit test * Add websocket API to create long-lived access token * Allow URL use as client_id for long-lived access token * Remove mutate_refresh_token method * Use client name as id for long_lived_access_token type refresh token * Minor change * Do not allow duplicate client name * Update docstring * Remove unnecessary `list` --- homeassistant/auth/__init__.py | 45 ++++++- homeassistant/auth/auth_store.py | 34 +++++- homeassistant/auth/models.py | 16 ++- homeassistant/components/auth/__init__.py | 104 +++++++++++++++- tests/auth/test_init.py | 141 +++++++++++++++++++++- tests/components/auth/test_init.py | 59 ++++++++- tests/components/conftest.py | 2 + 7 files changed, 385 insertions(+), 16 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 4ef8440de62a2b..b0cebb5fd6c296 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -2,11 +2,13 @@ import asyncio import logging from collections import OrderedDict +from datetime import timedelta from typing import Any, Dict, List, Optional, Tuple, cast import jwt from homeassistant import data_entry_flow +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.core import callback, HomeAssistant from homeassistant.util import dt as dt_util @@ -242,8 +244,12 @@ async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: modules[module_id] = module.name return modules - async def async_create_refresh_token(self, user: models.User, - client_id: Optional[str] = None) \ + async def async_create_refresh_token( + self, user: models.User, client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: Optional[str] = None, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ -> models.RefreshToken: """Create a new refresh token for a user.""" if not user.is_active: @@ -254,10 +260,36 @@ async def async_create_refresh_token(self, user: models.User, 'System generated users cannot have refresh tokens connected ' 'to a client.') - if not user.system_generated and client_id is None: + if token_type is None: + if user.system_generated: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL + + if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): + raise ValueError( + 'System generated users can only have system type ' + 'refresh tokens') + + if token_type == models.TOKEN_TYPE_NORMAL and client_id is None: raise ValueError('Client is required to generate a refresh token.') - return await self._store.async_create_refresh_token(user, client_id) + if (token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and + client_name is None): + raise ValueError('Client_name is required for long-lived access ' + 'token') + + if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: + for token in user.refresh_tokens.values(): + if (token.client_name == client_name and token.token_type == + models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN): + # Each client_name can only have one + # long_lived_access_token type of refresh token + raise ValueError('{} already exists'.format(client_name)) + + return await self._store.async_create_refresh_token( + user, client_id, client_name, client_icon, + token_type, access_token_expiration) async def async_get_refresh_token( self, token_id: str) -> Optional[models.RefreshToken]: @@ -280,10 +312,11 @@ def async_create_access_token(self, refresh_token: models.RefreshToken) -> str: """Create a new access token.""" # pylint: disable=no-self-use + now = dt_util.utcnow() return jwt.encode({ 'iss': refresh_token.id, - 'iat': dt_util.utcnow(), - 'exp': dt_util.utcnow() + refresh_token.access_token_expiration, + 'iat': now, + 'exp': now + refresh_token.access_token_expiration, }, refresh_token.jwt_key, algorithm='HS256').decode() async def async_validate_access_token( diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 0f12d69211c645..d78a1f4225ec16 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional # noqa: F401 import hmac +from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util @@ -128,11 +129,27 @@ async def async_remove_credentials( self._async_schedule_save() async def async_create_refresh_token( - self, user: models.User, client_id: Optional[str] = None) \ + self, user: models.User, client_id: Optional[str] = None, + client_name: Optional[str] = None, + client_icon: Optional[str] = None, + token_type: str = models.TOKEN_TYPE_NORMAL, + access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \ -> models.RefreshToken: """Create a new token for a user.""" - refresh_token = models.RefreshToken(user=user, client_id=client_id) + kwargs = { + 'user': user, + 'client_id': client_id, + 'token_type': token_type, + 'access_token_expiration': access_token_expiration + } # type: Dict[str, Any] + if client_name: + kwargs['client_name'] = client_name + if client_icon: + kwargs['client_icon'] = client_icon + + refresh_token = models.RefreshToken(**kwargs) user.refresh_tokens[refresh_token.id] = refresh_token + self._async_schedule_save() return refresh_token @@ -216,10 +233,20 @@ async def _async_load(self) -> None: 'Ignoring refresh token %(id)s with invalid created_at ' '%(created_at)s for user_id %(user_id)s', rt_dict) continue + token_type = rt_dict.get('token_type') + if token_type is None: + if rt_dict['clinet_id'] is None: + token_type = models.TOKEN_TYPE_SYSTEM + else: + token_type = models.TOKEN_TYPE_NORMAL token = models.RefreshToken( id=rt_dict['id'], user=users[rt_dict['user_id']], client_id=rt_dict['client_id'], + # use dict.get to keep backward compatibility + client_name=rt_dict.get('client_name'), + client_icon=rt_dict.get('client_icon'), + token_type=token_type, created_at=created_at, access_token_expiration=timedelta( seconds=rt_dict['access_token_expiration']), @@ -271,6 +298,9 @@ def _data_to_save(self) -> Dict: 'id': refresh_token.id, 'user_id': user.id, 'client_id': refresh_token.client_id, + 'client_name': refresh_token.client_name, + 'client_icon': refresh_token.client_icon, + 'token_type': refresh_token.token_type, 'created_at': refresh_token.created_at.isoformat(), 'access_token_expiration': refresh_token.access_token_expiration.total_seconds(), diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index a6500510e0d291..c5273d7fa1dd3d 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -7,9 +7,12 @@ from homeassistant.util import dt as dt_util -from .const import ACCESS_TOKEN_EXPIRATION from .util import generate_secret +TOKEN_TYPE_NORMAL = 'normal' +TOKEN_TYPE_SYSTEM = 'system' +TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' + @attr.s(slots=True) class User: @@ -37,11 +40,16 @@ class RefreshToken: """RefreshToken for a user to grant new access tokens.""" user = attr.ib(type=User) - client_id = attr.ib(type=str) # type: Optional[str] + client_id = attr.ib(type=Optional[str]) + access_token_expiration = attr.ib(type=timedelta) + client_name = attr.ib(type=Optional[str], default=None) + client_icon = attr.ib(type=Optional[str], default=None) + token_type = attr.ib(type=str, default=TOKEN_TYPE_NORMAL, + validator=attr.validators.in_(( + TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM, + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN))) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - access_token_expiration = attr.ib(type=timedelta, - default=ACCESS_TOKEN_EXPIRATION) token = attr.ib(type=str, default=attr.Factory(lambda: generate_secret(64))) jwt_key = attr.ib(type=str, diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index a87e646761c472..5839b7ec403e7e 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -12,6 +12,7 @@ Exchange the authorization code retrieved from the login flow for tokens. { + "client_id": "https://hassbian.local:8123/", "grant_type": "authorization_code", "code": "411ee2f916e648d691e937ae9344681e" } @@ -32,6 +33,7 @@ Request a new access token using a refresh token. { + "client_id": "https://hassbian.local:8123/", "grant_type": "refresh_token", "refresh_token": "IJKLMNOPQRST" } @@ -55,6 +57,67 @@ "action": "revoke" } +# Websocket API + +## Get current user + +Send websocket command `auth/current_user` will return current user of the +active websocket connection. + +{ + "id": 10, + "type": "auth/current_user", +} + +The result payload likes + +{ + "id": 10, + "type": "result", + "success": true, + "result": { + "id": "USER_ID", + "name": "John Doe", + "is_owner': true, + "credentials": [ + { + "auth_provider_type": "homeassistant", + "auth_provider_id": null + } + ], + "mfa_modules": [ + { + "id": "totp", + "name": "TOTP", + "enabled": true, + } + ] + } +} + +## Create a long-lived access token + +Send websocket command `auth/long_lived_access_token` will create +a long-lived access token for current user. Access token will not be saved in +Home Assistant. User need to record the token in secure place. + +{ + "id": 11, + "type": "auth/long_lived_access_token", + "client_name": "GPS Logger", + "client_icon": null, + "lifespan": 365 +} + +Result will be a long-lived access token: + +{ + "id": 11, + "type": "result", + "success": true, + "result": "ABCDEFGH" +} + """ import logging import uuid @@ -63,7 +126,8 @@ from aiohttp import web import voluptuous as vol -from homeassistant.auth.models import User, Credentials +from homeassistant.auth.models import User, Credentials, \ + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN from homeassistant.components import websocket_api from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator @@ -83,6 +147,15 @@ vol.Required('type'): WS_TYPE_CURRENT_USER, }) +WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token' +SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + vol.Required('lifespan'): int, # days + vol.Required('client_name'): str, + vol.Optional('client_icon'): str, + }) + RESULT_TYPE_CREDENTIALS = 'credentials' RESULT_TYPE_USER = 'user' @@ -100,6 +173,11 @@ async def async_setup(hass, config): WS_TYPE_CURRENT_USER, websocket_current_user, SCHEMA_WS_CURRENT_USER ) + hass.components.websocket_api.async_register_command( + WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + websocket_create_long_lived_access_token, + SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN + ) await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) @@ -343,3 +421,27 @@ async def async_get_current_user(user): })) hass.async_create_task(async_get_current_user(connection.user)) + + +@websocket_api.ws_require_user() +@callback +def websocket_create_long_lived_access_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Create or a long-lived access token.""" + async def async_create_long_lived_access_token(user): + """Create or a long-lived access token.""" + refresh_token = await hass.auth.async_create_refresh_token( + user, + client_name=msg['client_name'], + client_icon=msg.get('client_icon'), + token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=msg['lifespan'])) + + access_token = hass.auth.async_create_access_token( + refresh_token) + + connection.send_message_outside( + websocket_api.result_message(msg['id'], access_token)) + + hass.async_create_task( + async_create_long_lived_access_token(connection.user)) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 63b2b4408dd8af..765199b256c65e 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +import jwt import pytest import voluptuous as vol @@ -323,7 +324,7 @@ async def test_generating_system_user(hass): async def test_refresh_token_requires_client_for_user(hass): - """Test that we can add a system user.""" + """Test create refresh token for a user with client_id.""" manager = await auth.auth_manager_from_config(hass, [], []) user = MockUser().add_to_auth_manager(manager) assert user.system_generated is False @@ -334,10 +335,14 @@ async def test_refresh_token_requires_client_for_user(hass): token = await manager.async_create_refresh_token(user, CLIENT_ID) assert token is not None assert token.client_id == CLIENT_ID + assert token.token_type == auth_models.TOKEN_TYPE_NORMAL + # default access token expiration + assert token.access_token_expiration == \ + auth_const.ACCESS_TOKEN_EXPIRATION async def test_refresh_token_not_requires_client_for_system_user(hass): - """Test that we can add a system user.""" + """Test create refresh token for a system user w/o client_id.""" manager = await auth.auth_manager_from_config(hass, [], []) user = await manager.async_create_system_user('Hass.io') assert user.system_generated is True @@ -348,6 +353,56 @@ async def test_refresh_token_not_requires_client_for_system_user(hass): token = await manager.async_create_refresh_token(user) assert token is not None assert token.client_id is None + assert token.token_type == auth_models.TOKEN_TYPE_SYSTEM + + +async def test_refresh_token_with_specific_access_token_expiration(hass): + """Test create a refresh token with specific access token expiration.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + token = await manager.async_create_refresh_token( + user, CLIENT_ID, + access_token_expiration=timedelta(days=100)) + assert token is not None + assert token.client_id == CLIENT_ID + assert token.access_token_expiration == timedelta(days=100) + + +async def test_refresh_token_type(hass): + """Test create a refresh token with token type.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_create_refresh_token( + user, CLIENT_ID, token_type=auth_models.TOKEN_TYPE_SYSTEM) + + token = await manager.async_create_refresh_token( + user, CLIENT_ID, + token_type=auth_models.TOKEN_TYPE_NORMAL) + assert token is not None + assert token.client_id == CLIENT_ID + assert token.token_type == auth_models.TOKEN_TYPE_NORMAL + + +async def test_refresh_token_type_long_lived_access_token(hass): + """Test create a refresh token has long-lived access token type.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_create_refresh_token( + user, token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN) + + token = await manager.async_create_refresh_token( + user, client_name='GPS LOGGER', client_icon='mdi:home', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN) + assert token is not None + assert token.client_id is None + assert token.client_name == 'GPS LOGGER' + assert token.client_icon == 'mdi:home' + assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN async def test_cannot_deactive_owner(mock_hass): @@ -378,6 +433,88 @@ async def test_remove_refresh_token(mock_hass): ) +async def test_create_access_token(mock_hass): + """Test normal refresh_token's jwt_key keep same after used.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_NORMAL + jwt_key = refresh_token.jwt_key + access_token = manager.async_create_access_token(refresh_token) + assert access_token is not None + assert refresh_token.jwt_key == jwt_key + jwt_payload = jwt.decode(access_token, jwt_key, algorithm=['HS256']) + assert jwt_payload['iss'] == refresh_token.id + assert jwt_payload['exp'] - jwt_payload['iat'] == \ + timedelta(minutes=30).total_seconds() + + +async def test_create_long_lived_access_token(mock_hass): + """Test refresh_token's jwt_key changed for long-lived access token.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=300)) + assert refresh_token.token_type == \ + auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + jwt_payload = jwt.decode( + access_token, refresh_token.jwt_key, algorithm=['HS256']) + assert jwt_payload['iss'] == refresh_token.id + assert jwt_payload['exp'] - jwt_payload['iat'] == \ + timedelta(days=300).total_seconds() + + +async def test_one_long_lived_access_token_per_refresh_token(mock_hass): + """Test one refresh_token can only have one long-lived access token.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000)) + assert refresh_token.token_type == \ + auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + jwt_key = refresh_token.jwt_key + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + with pytest.raises(ValueError): + await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000)) + + await manager.async_remove_refresh_token(refresh_token) + assert refresh_token.id not in user.refresh_tokens + rt = await manager.async_validate_access_token(access_token) + assert rt is None, 'Previous issued access token has been invoked' + + refresh_token_2 = await manager.async_create_refresh_token( + user, client_name='GPS Logger', + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000)) + assert refresh_token_2.id != refresh_token.id + assert refresh_token_2.token_type == \ + auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token_2 = manager.async_create_access_token(refresh_token_2) + jwt_key_2 = refresh_token_2.jwt_key + + assert access_token != access_token_2 + assert jwt_key != jwt_key_2 + + rt = await manager.async_validate_access_token(access_token_2) + jwt_payload = jwt.decode( + access_token_2, rt.jwt_key, algorithm=['HS256']) + assert jwt_payload['iss'] == refresh_token_2.id + assert jwt_payload['exp'] - jwt_payload['iat'] == \ + timedelta(days=3000).total_seconds() + + async def test_login_with_auth_module(mock_hass): """Test login as existing user with auth module.""" manager = await auth.auth_manager_from_config(mock_hass, [{ diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 7b9dda6acb39e4..e0fe00bd9d8b91 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant import const +from homeassistant.auth import auth_manager_from_config from homeassistant.auth.models import Credentials from homeassistant.components.auth import RESULT_TYPE_USER from homeassistant.setup import async_setup_component @@ -10,7 +12,8 @@ from . import async_setup_auth -from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser, \ + ensure_auth_manager_loaded async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): @@ -267,3 +270,57 @@ async def test_revoking_refresh_token(hass, aiohttp_client): }) assert resp.status == 400 + + +async def test_ws_long_lived_access_token(hass, hass_ws_client): + """Test generate long-lived access token.""" + hass.auth = await auth_manager_from_config( + hass, provider_configs=[{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name', + }] + }], module_configs=[]) + ensure_auth_manager_loaded(hass.auth) + assert await async_setup_component(hass, 'auth', {'http': {}}) + assert await async_setup_component(hass, 'api', {'http': {}}) + + user = MockUser(id='mock-user').add_to_hass(hass) + cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( + {'username': 'test-user'}) + await hass.auth.async_link_user(user, cred) + + ws_client = await hass_ws_client(hass, hass.auth.async_create_access_token( + await hass.auth.async_create_refresh_token(user, CLIENT_ID))) + + # verify create long-lived access token + await ws_client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + 'client_name': 'GPS Logger', + 'lifespan': 365, + }) + + result = await ws_client.receive_json() + assert result['success'], result + + long_lived_access_token = result['result'] + assert long_lived_access_token is not None + + refresh_token = await hass.auth.async_validate_access_token( + long_lived_access_token) + assert refresh_token.client_id is None + assert refresh_token.client_name == 'GPS Logger' + assert refresh_token.client_icon is None + + # verify long-lived access token can be used as bearer token + api_client = ws_client.client + resp = await api_client.get(const.URL_API) + assert resp.status == 401 + + resp = await api_client.get(const.URL_API, headers={ + 'Authorization': 'Bearer {}'.format(long_lived_access_token) + }) + assert resp.status == 200 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index bb9b643296e678..7cec790c84712f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -34,6 +34,8 @@ async def create_client(hass, access_token=None): auth_ok = await websocket.receive_json() assert auth_ok['type'] == wapi.TYPE_AUTH_OK + # wrap in client + websocket.client = client return websocket return create_client From 4e3faf61089df91eb46df751a8540654a48ad996 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Sep 2018 12:55:05 +0200 Subject: [PATCH 013/178] Fix typo (#16556) --- homeassistant/auth/auth_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index d78a1f4225ec16..8e8d03253e54d3 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -235,7 +235,7 @@ async def _async_load(self) -> None: continue token_type = rt_dict.get('token_type') if token_type is None: - if rt_dict['clinet_id'] is None: + if rt_dict['client_id'] is None: token_type = models.TOKEN_TYPE_SYSTEM else: token_type = models.TOKEN_TYPE_NORMAL From 0db13a99aa1dc945bdcc2132a08b7b3bd2e1232f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Sep 2018 18:08:03 +0200 Subject: [PATCH 014/178] Add websocket commands for refresh tokens (#16559) * Add websocket commands for refresh tokens * Comment --- homeassistant/components/auth/__init__.py | 60 +++++++++++++++++ tests/components/auth/test_init.py | 82 ++++++++++++++--------- tests/components/conftest.py | 42 ++++++++---- 3 files changed, 140 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 5839b7ec403e7e..5fac423f27a2cd 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -156,6 +156,19 @@ vol.Optional('client_icon'): str, }) +WS_TYPE_REFRESH_TOKENS = 'auth/refresh_tokens' +SCHEMA_WS_REFRESH_TOKENS = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_REFRESH_TOKENS, + }) + +WS_TYPE_DELETE_REFRESH_TOKEN = 'auth/delete_refresh_token' +SCHEMA_WS_DELETE_REFRESH_TOKEN = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE_REFRESH_TOKEN, + vol.Required('refresh_token_id'): str, + }) + RESULT_TYPE_CREDENTIALS = 'credentials' RESULT_TYPE_USER = 'user' @@ -178,6 +191,16 @@ async def async_setup(hass, config): websocket_create_long_lived_access_token, SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN ) + hass.components.websocket_api.async_register_command( + WS_TYPE_REFRESH_TOKENS, + websocket_refresh_tokens, + SCHEMA_WS_REFRESH_TOKENS + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE_REFRESH_TOKEN, + websocket_delete_refresh_token, + SCHEMA_WS_DELETE_REFRESH_TOKEN + ) await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) @@ -445,3 +468,40 @@ async def async_create_long_lived_access_token(user): hass.async_create_task( async_create_long_lived_access_token(connection.user)) + + +@websocket_api.ws_require_user() +@callback +def websocket_refresh_tokens( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Return metadata of users refresh tokens.""" + connection.to_write.put_nowait(websocket_api.result_message(msg['id'], [{ + 'id': refresh.id, + 'client_id': refresh.client_id, + 'client_name': refresh.client_name, + 'client_icon': refresh.client_icon, + 'type': refresh.token_type, + 'created_at': refresh.created_at, + } for refresh in connection.user.refresh_tokens.values()])) + + +@websocket_api.ws_require_user() +@callback +def websocket_delete_refresh_token( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Handle a delete refresh token request.""" + async def async_delete_refresh_token(user, refresh_token_id): + """Delete a refresh token.""" + refresh_token = connection.user.refresh_tokens.get(refresh_token_id) + + if refresh_token is None: + return websocket_api.error_message( + msg['id'], 'invalid_token_id', 'Received invalid token') + + await hass.auth.async_remove_refresh_token(refresh_token) + + connection.send_message_outside( + websocket_api.result_message(msg['id'], {})) + + hass.async_create_task( + async_delete_refresh_token(connection.user, msg['refresh_token_id'])) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index e0fe00bd9d8b91..a8e95c73a36fa2 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -2,18 +2,15 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant import const -from homeassistant.auth import auth_manager_from_config from homeassistant.auth.models import Credentials from homeassistant.components.auth import RESULT_TYPE_USER from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from homeassistant.components import auth -from . import async_setup_auth +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser -from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser, \ - ensure_auth_manager_loaded +from . import async_setup_auth async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): @@ -272,28 +269,12 @@ async def test_revoking_refresh_token(hass, aiohttp_client): assert resp.status == 400 -async def test_ws_long_lived_access_token(hass, hass_ws_client): +async def test_ws_long_lived_access_token(hass, hass_ws_client, + hass_access_token): """Test generate long-lived access token.""" - hass.auth = await auth_manager_from_config( - hass, provider_configs=[{ - 'type': 'insecure_example', - 'users': [{ - 'username': 'test-user', - 'password': 'test-pass', - 'name': 'Test Name', - }] - }], module_configs=[]) - ensure_auth_manager_loaded(hass.auth) assert await async_setup_component(hass, 'auth', {'http': {}}) - assert await async_setup_component(hass, 'api', {'http': {}}) - user = MockUser(id='mock-user').add_to_hass(hass) - cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( - {'username': 'test-user'}) - await hass.auth.async_link_user(user, cred) - - ws_client = await hass_ws_client(hass, hass.auth.async_create_access_token( - await hass.auth.async_create_refresh_token(user, CLIENT_ID))) + ws_client = await hass_ws_client(hass, hass_access_token) # verify create long-lived access token await ws_client.send_json({ @@ -315,12 +296,51 @@ async def test_ws_long_lived_access_token(hass, hass_ws_client): assert refresh_token.client_name == 'GPS Logger' assert refresh_token.client_icon is None - # verify long-lived access token can be used as bearer token - api_client = ws_client.client - resp = await api_client.get(const.URL_API) - assert resp.status == 401 - resp = await api_client.get(const.URL_API, headers={ - 'Authorization': 'Bearer {}'.format(long_lived_access_token) +async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): + """Test fetching refresh token metadata.""" + assert await async_setup_component(hass, 'auth', {'http': {}}) + + ws_client = await hass_ws_client(hass, hass_access_token) + + await ws_client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_REFRESH_TOKENS, }) - assert resp.status == 200 + + result = await ws_client.receive_json() + assert result['success'], result + assert len(result['result']) == 1 + token = result['result'][0] + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + assert token['id'] == refresh_token.id + assert token['type'] == refresh_token.token_type + assert token['client_id'] == refresh_token.client_id + assert token['client_name'] == refresh_token.client_name + assert token['client_icon'] == refresh_token.client_icon + assert token['created_at'] == refresh_token.created_at.isoformat() + + +async def test_ws_delete_refresh_token(hass, hass_ws_client, + hass_access_token): + """Test deleting a refresh token.""" + assert await async_setup_component(hass, 'auth', {'http': {}}) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + ws_client = await hass_ws_client(hass, hass_access_token) + + # verify create long-lived access token + await ws_client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_DELETE_REFRESH_TOKEN, + 'refresh_token_id': refresh_token.id + }) + + result = await ws_client.receive_json() + assert result['success'], result + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + assert refresh_token is None diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 7cec790c84712f..232405a632c5eb 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,4 +1,6 @@ """Fixtures for component testing.""" +from unittest.mock import patch + import pytest from homeassistant.setup import async_setup_component @@ -16,23 +18,37 @@ async def create_client(hass, access_token=None): assert await async_setup_component(hass, 'websocket_api') client = await aiohttp_client(hass.http.app) - websocket = await client.ws_connect(wapi.URL) - auth_resp = await websocket.receive_json() - if auth_resp['type'] == wapi.TYPE_AUTH_OK: - assert access_token is None, \ - 'Access token given but no auth required' - return websocket + patching = None + + if access_token is not None: + patching = patch('homeassistant.auth.AuthManager.active', + return_value=True) + patching.start() + + try: + websocket = await client.ws_connect(wapi.URL) + auth_resp = await websocket.receive_json() + + if auth_resp['type'] == wapi.TYPE_AUTH_OK: + assert access_token is None, \ + 'Access token given but no auth required' + return websocket + + assert access_token is not None, \ + 'Access token required for fixture' - assert access_token is not None, 'Access token required for fixture' + await websocket.send_json({ + 'type': websocket_api.TYPE_AUTH, + 'access_token': access_token + }) - await websocket.send_json({ - 'type': websocket_api.TYPE_AUTH, - 'access_token': access_token - }) + auth_ok = await websocket.receive_json() + assert auth_ok['type'] == wapi.TYPE_AUTH_OK - auth_ok = await websocket.receive_json() - assert auth_ok['type'] == wapi.TYPE_AUTH_OK + finally: + if patching is not None: + patching.stop() # wrap in client websocket.client = client From 3f06b4eb9bf2e9e487f6afd680ba6a0023c96390 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Sep 2018 21:36:54 +0200 Subject: [PATCH 015/178] Update translations --- .../components/auth/.translations/sv.json | 4 ++++ .../components/hangouts/.translations/sv.json | 8 ++++++++ .../homematicip_cloud/.translations/sv.json | 1 + .../components/openuv/.translations/ar.json | 5 +++++ .../components/openuv/.translations/de.json | 19 ++++++++++++++++++ .../components/openuv/.translations/fa.json | 5 +++++ .../components/openuv/.translations/ko.json | 20 +++++++++++++++++++ .../components/openuv/.translations/nl.json | 19 ++++++++++++++++++ .../components/openuv/.translations/no.json | 20 +++++++++++++++++++ .../components/openuv/.translations/pl.json | 4 +++- .../components/openuv/.translations/sv.json | 4 +++- 11 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/openuv/.translations/ar.json create mode 100644 homeassistant/components/openuv/.translations/de.json create mode 100644 homeassistant/components/openuv/.translations/fa.json create mode 100644 homeassistant/components/openuv/.translations/ko.json create mode 100644 homeassistant/components/openuv/.translations/nl.json create mode 100644 homeassistant/components/openuv/.translations/no.json diff --git a/homeassistant/components/auth/.translations/sv.json b/homeassistant/components/auth/.translations/sv.json index e148766e2a1ce0..cf8227c09a37db 100644 --- a/homeassistant/components/auth/.translations/sv.json +++ b/homeassistant/components/auth/.translations/sv.json @@ -1,8 +1,12 @@ { "mfa_setup": { "totp": { + "error": { + "invalid_code": "Ogiltig kod, f\u00f6rs\u00f6k igen. Om du flera g\u00e5nger i rad f\u00e5r detta fel, se till att klockan i din Home Assistant \u00e4r korrekt inst\u00e4lld." + }, "step": { "init": { + "description": "F\u00f6r att aktivera tv\u00e5faktorsautentisering som anv\u00e4nder tidsbaserade eng\u00e5ngsl\u00f6senord, skanna QR-koden med din autentiseringsapp. Om du inte har en, rekommenderar vi antingen [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n{qr_code} \n\nN\u00e4r du har skannat koden anger du den sexsiffriga koden fr\u00e5n din app f\u00f6r att verifiera inst\u00e4llningen. Om du har problem med att skanna QR-koden, g\u00f6r en manuell inst\u00e4llning med kod ** ` {code} ` **.", "title": "St\u00e4ll in tv\u00e5faktorsautentisering med TOTP" } }, diff --git a/homeassistant/components/hangouts/.translations/sv.json b/homeassistant/components/hangouts/.translations/sv.json index d926097dfc2850..90bf4e97712b16 100644 --- a/homeassistant/components/hangouts/.translations/sv.json +++ b/homeassistant/components/hangouts/.translations/sv.json @@ -4,8 +4,16 @@ "already_configured": "Google Hangouts \u00e4r redan inst\u00e4llt", "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade" }, + "error": { + "invalid_2fa": "Ogiltig 2FA autentisering, f\u00f6rs\u00f6k igen.", + "invalid_2fa_method": "Ogiltig 2FA-metod (Verifiera med telefon).", + "invalid_login": "Ogiltig inloggning, f\u00f6rs\u00f6k igen." + }, "step": { "2fa": { + "data": { + "2fa": "2FA Pinkod" + }, "title": "Tv\u00e5faktorsautentisering" }, "user": { diff --git a/homeassistant/components/homematicip_cloud/.translations/sv.json b/homeassistant/components/homematicip_cloud/.translations/sv.json index 945dca8a277c79..4e8aac999de2dc 100644 --- a/homeassistant/components/homematicip_cloud/.translations/sv.json +++ b/homeassistant/components/homematicip_cloud/.translations/sv.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Accesspunkten \u00e4r redan konfigurerad", "conection_aborted": "Kunde inte ansluta till HMIP server", + "connection_aborted": "Det gick inte att ansluta till HMIP-servern", "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat" }, "error": { diff --git a/homeassistant/components/openuv/.translations/ar.json b/homeassistant/components/openuv/.translations/ar.json new file mode 100644 index 00000000000000..288fae919dc2fa --- /dev/null +++ b/homeassistant/components/openuv/.translations/ar.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/de.json b/homeassistant/components/openuv/.translations/de.json new file mode 100644 index 00000000000000..1f81ac30f5320b --- /dev/null +++ b/homeassistant/components/openuv/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Koordinaten existieren bereits", + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API-Schl\u00fcssel", + "elevation": "H\u00f6he", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + } + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/fa.json b/homeassistant/components/openuv/.translations/fa.json new file mode 100644 index 00000000000000..288fae919dc2fa --- /dev/null +++ b/homeassistant/components/openuv/.translations/fa.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/ko.json b/homeassistant/components/openuv/.translations/ko.json new file mode 100644 index 00000000000000..bb054f0b3a6580 --- /dev/null +++ b/homeassistant/components/openuv/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_api_key": "\uc798\ubabb\ub41c API \ud0a4" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API \ud0a4", + "elevation": "\uace0\ub3c4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/nl.json b/homeassistant/components/openuv/.translations/nl.json new file mode 100644 index 00000000000000..2c5086b3365278 --- /dev/null +++ b/homeassistant/components/openuv/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_api_key": "Ongeldige API-sleutel" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API-Sleutel", + "elevation": "Hoogte", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/no.json b/homeassistant/components/openuv/.translations/no.json new file mode 100644 index 00000000000000..2ffd5e7fb41c86 --- /dev/null +++ b/homeassistant/components/openuv/.translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Koordinatene er allerede registrert", + "invalid_api_key": "Ugyldig API-n\u00f8kkel" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API-n\u00f8kkel", + "elevation": "Elevasjon", + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + }, + "title": "Fyll ut informasjonen din" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/pl.json b/homeassistant/components/openuv/.translations/pl.json index fdb94b293b87b9..f6c52ffd04e8f4 100644 --- a/homeassistant/components/openuv/.translations/pl.json +++ b/homeassistant/components/openuv/.translations/pl.json @@ -8,9 +8,11 @@ "user": { "data": { "api_key": "Klucz API OpenUV", + "elevation": "Wysoko\u015b\u0107", "latitude": "Szeroko\u015b\u0107 geograficzna", "longitude": "D\u0142ugo\u015b\u0107 geograficzna" - } + }, + "title": "Wpisz swoje informacje" } }, "title": "OpenUV" diff --git a/homeassistant/components/openuv/.translations/sv.json b/homeassistant/components/openuv/.translations/sv.json index 0df9a0434ab8f5..d9de0f7c0a62c2 100644 --- a/homeassistant/components/openuv/.translations/sv.json +++ b/homeassistant/components/openuv/.translations/sv.json @@ -1,6 +1,7 @@ { "config": { "error": { + "identifier_exists": "Koordinater \u00e4r redan registrerade", "invalid_api_key": "Ogiltigt API-l\u00f6senord" }, "step": { @@ -13,6 +14,7 @@ }, "title": "Fyll i dina uppgifter" } - } + }, + "title": "OpenUV" } } \ No newline at end of file From 629c4a0bf57a753c651092f605c348f3d24d414a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Sep 2018 21:37:38 +0200 Subject: [PATCH 016/178] Update frontend to 20180911.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 add323c4c2887e..5fea70ce8461da 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180910.0'] +REQUIREMENTS = ['home-assistant-frontend==20180911.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 50cc56f3443b4f..14e9f753246db5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -452,7 +452,7 @@ hole==0.3.0 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180910.0 +home-assistant-frontend==20180911.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42f2c11242e9e7..9df1060e4acda4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.4 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180910.0 +home-assistant-frontend==20180911.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 06af76404ff28a3bf8531e952c33793e32bf621c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Sep 2018 21:40:35 +0200 Subject: [PATCH 017/178] Fix invalid state (#16558) * Fix invalid state * Make slightly more efficient in unsubscribing * Use uuid4" --- homeassistant/const.py | 3 +++ homeassistant/core.py | 20 +++++++++++++++----- tests/test_core.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d27b5e3a1b7967..b9b71734241b7c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -225,6 +225,9 @@ # Name ATTR_NAME = 'name' +# Data for a SERVICE_EXECUTED event +ATTR_SERVICE_CALL_ID = 'service_call_id' + # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' diff --git a/homeassistant/core.py b/homeassistant/core.py index fdbbe49ea05d69..39ee20cb1a8c82 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, EVENT_HOMEASSISTANT_CLOSE, @@ -1042,10 +1042,12 @@ async def async_call(self, domain: str, service: str, This method is a coroutine. """ context = context or Context() + call_id = uuid.uuid4().hex event_data = { ATTR_DOMAIN: domain.lower(), ATTR_SERVICE: service.lower(), ATTR_SERVICE_DATA: service_data, + ATTR_SERVICE_CALL_ID: call_id, } if not blocking: @@ -1058,8 +1060,9 @@ async def async_call(self, domain: str, service: str, @callback def service_executed(event: Event) -> None: """Handle an executed service.""" - if event.context == context: + if event.data[ATTR_SERVICE_CALL_ID] == call_id: fut.set_result(True) + unsub() unsub = self._hass.bus.async_listen( EVENT_SERVICE_EXECUTED, service_executed) @@ -1069,7 +1072,8 @@ def service_executed(event: Event) -> None: done, _ = await asyncio.wait([fut], timeout=SERVICE_CALL_LIMIT) success = bool(done) - unsub() + if not success: + unsub() return success async def _event_to_service_call(self, event: Event) -> None: @@ -1077,6 +1081,7 @@ async def _event_to_service_call(self, event: Event) -> None: service_data = event.data.get(ATTR_SERVICE_DATA) or {} domain = event.data.get(ATTR_DOMAIN).lower() # type: ignore service = event.data.get(ATTR_SERVICE).lower() # type: ignore + call_id = event.data.get(ATTR_SERVICE_CALL_ID) if not self.has_service(domain, service): if event.origin == EventOrigin.local: @@ -1088,12 +1093,17 @@ async def _event_to_service_call(self, event: Event) -> None: def fire_service_executed() -> None: """Fire service executed event.""" + if not call_id: + return + + data = {ATTR_SERVICE_CALL_ID: call_id} + if (service_handler.is_coroutinefunction or service_handler.is_callback): - self._hass.bus.async_fire(EVENT_SERVICE_EXECUTED, {}, + self._hass.bus.async_fire(EVENT_SERVICE_EXECUTED, data, EventOrigin.local, event.context) else: - self._hass.bus.fire(EVENT_SERVICE_EXECUTED, {}, + self._hass.bus.fire(EVENT_SERVICE_EXECUTED, data, EventOrigin.local, event.context) try: diff --git a/tests/test_core.py b/tests/test_core.py index ce066135709f1f..7e6d57136e45c6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,9 +20,10 @@ from homeassistant.const import ( __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM, ATTR_NOW, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED) + EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, + EVENT_SERVICE_EXECUTED) -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, async_mock_service PST = pytz.timezone('America/Los_Angeles') @@ -969,3 +970,27 @@ def test_track_task_functions(loop): assert hass._track_task finally: yield from hass.async_stop() + + +async def test_service_executed_with_subservices(hass): + """Test we block correctly till all services done.""" + calls = async_mock_service(hass, 'test', 'inner') + + async def handle_outer(call): + """Handle outer service call.""" + calls.append(call) + call1 = hass.services.async_call('test', 'inner', blocking=True, + context=call.context) + call2 = hass.services.async_call('test', 'inner', blocking=True, + context=call.context) + await asyncio.wait([call1, call2]) + calls.append(call) + + hass.services.async_register('test', 'outer', handle_outer) + + await hass.services.async_call('test', 'outer', blocking=True) + + assert len(calls) == 4 + assert [call.service for call in calls] == [ + 'outer', 'inner', 'inner', 'outer'] + assert len(hass.bus.async_listeners().get(EVENT_SERVICE_EXECUTED, [])) == 0 From 188f5de5ae8343f3a091347a89ec6504444d75f0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 11 Sep 2018 17:13:16 -0600 Subject: [PATCH 018/178] Fixes an OpenUV bug with the scan interval (#16570) --- homeassistant/components/openuv/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index bfd90b4a57419c..35ab16b4d1f5ad 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -179,7 +179,7 @@ async def refresh_sensors(event_time): hass.data[DOMAIN][DATA_OPENUV_LISTENER][ config_entry.entry_id] = async_track_time_interval( hass, refresh_sensors, - hass.data[DOMAIN][CONF_SCAN_INTERVAL]) + hass.data[DOMAIN].get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) return True From 6bd120ff1d5ba54336a3f19fa39bbdb1aaa9b468 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Tue, 11 Sep 2018 21:45:51 -0400 Subject: [PATCH 019/178] Bump pyeconet (#16571) * Updated pyeconet to the newest version to deal with econet API change --- homeassistant/components/climate/econet.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/econet.py b/homeassistant/components/climate/econet.py index 9350b8f853d0b2..8be640c37e117f 100644 --- a/homeassistant/components/climate/econet.py +++ b/homeassistant/components/climate/econet.py @@ -18,7 +18,7 @@ TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyeconet==0.0.5'] +REQUIREMENTS = ['pyeconet==0.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 14e9f753246db5..63861d2ab3df53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -839,7 +839,7 @@ pydukeenergy==0.0.6 pyebox==1.1.4 # homeassistant.components.climate.econet -pyeconet==0.0.5 +pyeconet==0.0.6 # homeassistant.components.switch.edimax pyedimax==0.1 From e2465da7c2bab8eceae8bfc63c4348736a36824d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 12 Sep 2018 04:52:01 +0200 Subject: [PATCH 020/178] yr: use async syntax (#16563) --- homeassistant/components/sensor/yr.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 16ae98f9141b1d..f081be1a2f13a9 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -66,9 +66,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Yr.no sensor.""" elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) forecast = config.get(CONF_FORECAST) @@ -93,7 +92,7 @@ def async_setup_platform(hass, config, async_add_entities, weather = YrData(hass, coordinates, forecast, dev) async_track_utc_time_change(hass, weather.updating_devices, minute=31) - yield from weather.fetching_data() + await weather.fetching_data() class YrSensor(Entity): @@ -156,8 +155,7 @@ def __init__(self, hass, coordinates, forecast, devices): self.data = {} self.hass = hass - @asyncio.coroutine - def fetching_data(self, *_): + async def fetching_data(self, *_): """Get the latest data from yr.no.""" import xmltodict @@ -169,12 +167,12 @@ def try_again(err: str): try: websession = async_get_clientsession(self.hass) with async_timeout.timeout(10, loop=self.hass.loop): - resp = yield from websession.get( + resp = await websession.get( self._url, params=self._urlparams) if resp.status != 200: try_again('{} returned {}'.format(resp.url, resp.status)) return - text = yield from resp.text() + text = await resp.text() except (asyncio.TimeoutError, aiohttp.ClientError) as err: try_again(err) @@ -186,11 +184,10 @@ def try_again(err: str): try_again(err) return - yield from self.updating_devices() + await self.updating_devices() async_call_later(self.hass, 60*60, self.fetching_data) - @asyncio.coroutine - def updating_devices(self, *_): + async def updating_devices(self, *_): """Find the current data from self.data.""" if not self.data: return @@ -256,4 +253,4 @@ def updating_devices(self, *_): tasks.append(dev.async_update_ha_state()) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) From 34d369ba26a2eff819bcd6f0215eca18397a5a82 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 12 Sep 2018 00:49:44 -0700 Subject: [PATCH 021/178] Return if refresh token is current used one in WS API (#16575) --- homeassistant/components/auth/__init__.py | 2 ++ homeassistant/components/websocket_api.py | 1 + tests/components/auth/test_init.py | 1 + 3 files changed, 4 insertions(+) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 5fac423f27a2cd..01cfe4724bfe6d 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -475,6 +475,7 @@ async def async_create_long_lived_access_token(user): def websocket_refresh_tokens( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): """Return metadata of users refresh tokens.""" + current_id = connection.request.get('refresh_token_id') connection.to_write.put_nowait(websocket_api.result_message(msg['id'], [{ 'id': refresh.id, 'client_id': refresh.client_id, @@ -482,6 +483,7 @@ def websocket_refresh_tokens( 'client_icon': refresh.client_icon, 'type': refresh.token_type, 'created_at': refresh.created_at, + 'is_current': refresh.id == current_id, } for refresh in connection.user.refresh_tokens.values()])) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 0c9ab366534f28..e9db666c032932 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -360,6 +360,7 @@ def handle_hass_stop(event): authenticated = refresh_token is not None if authenticated: request['hass_user'] = refresh_token.user + request['refresh_token_id'] = refresh_token.id elif ((not self.hass.auth.active or self.hass.auth.support_legacy) and diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a8e95c73a36fa2..ad2aa01737bb45 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -320,6 +320,7 @@ async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): assert token['client_name'] == refresh_token.client_name assert token['client_icon'] == refresh_token.client_icon assert token['created_at'] == refresh_token.created_at.isoformat() + assert token['is_current'] is True async def test_ws_delete_refresh_token(hass, hass_ws_client, From 601f2df5a7a2ace5c4f4f217aa7bdd4551e340c0 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Wed, 12 Sep 2018 11:22:21 +0200 Subject: [PATCH 022/178] Notifications for Android TV: Add fontsize and sending images (#16565) * Add fontsize and image functionality * woof --- .../components/notify/nfandroidtv.py | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/nfandroidtv.py b/homeassistant/components/notify/nfandroidtv.py index 044a037cc2978b..faf5e90e0166c7 100644 --- a/homeassistant/components/notify/nfandroidtv.py +++ b/homeassistant/components/notify/nfandroidtv.py @@ -9,6 +9,8 @@ import base64 import requests +from requests.auth import HTTPBasicAuth +from requests.auth import HTTPDigestAuth import voluptuous as vol from homeassistant.components.notify import ( @@ -21,12 +23,14 @@ CONF_IP = 'host' CONF_DURATION = 'duration' +CONF_FONTSIZE = 'fontsize' CONF_POSITION = 'position' CONF_TRANSPARENCY = 'transparency' CONF_COLOR = 'color' CONF_INTERRUPT = 'interrupt' DEFAULT_DURATION = 5 +DEFAULT_FONTSIZE = 'medium' DEFAULT_POSITION = 'bottom-right' DEFAULT_TRANSPARENCY = 'default' DEFAULT_COLOR = 'grey' @@ -37,11 +41,29 @@ 'cMXEAAAAASUVORK5CYII=') ATTR_DURATION = 'duration' +ATTR_FONTSIZE = 'fontsize' ATTR_POSITION = 'position' ATTR_TRANSPARENCY = 'transparency' ATTR_COLOR = 'color' ATTR_BKGCOLOR = 'bkgcolor' ATTR_INTERRUPT = 'interrupt' +ATTR_IMAGE = 'filename2' +ATTR_FILE = 'file' +# Attributes contained in file +ATTR_FILE_URL = 'url' +ATTR_FILE_PATH = 'path' +ATTR_FILE_USERNAME = 'username' +ATTR_FILE_PASSWORD = 'password' +ATTR_FILE_AUTH = 'auth' +# Any other value or absence of 'auth' lead to basic authentication being used +ATTR_FILE_AUTH_DIGEST = 'digest' + +FONTSIZES = { + 'small': 1, + 'medium': 0, + 'large': 2, + 'max': 3 +} POSITIONS = { 'bottom-right': 0, @@ -75,6 +97,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_IP): cv.string, vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Coerce(int), + vol.Optional(CONF_FONTSIZE, default=DEFAULT_FONTSIZE): + vol.In(FONTSIZES.keys()), vol.Optional(CONF_POSITION, default=DEFAULT_POSITION): vol.In(POSITIONS.keys()), vol.Optional(CONF_TRANSPARENCY, default=DEFAULT_TRANSPARENCY): @@ -90,6 +114,7 @@ def get_service(hass, config, discovery_info=None): """Get the Notifications for Android TV notification service.""" remoteip = config.get(CONF_IP) duration = config.get(CONF_DURATION) + fontsize = config.get(CONF_FONTSIZE) position = config.get(CONF_POSITION) transparency = config.get(CONF_TRANSPARENCY) color = config.get(CONF_COLOR) @@ -97,23 +122,26 @@ def get_service(hass, config, discovery_info=None): timeout = config.get(CONF_TIMEOUT) return NFAndroidTVNotificationService( - remoteip, duration, position, transparency, color, interrupt, timeout) + remoteip, duration, fontsize, position, + transparency, color, interrupt, timeout, hass.config.is_allowed_path) class NFAndroidTVNotificationService(BaseNotificationService): """Notification service for Notifications for Android TV.""" - def __init__(self, remoteip, duration, position, transparency, color, - interrupt, timeout): + def __init__(self, remoteip, duration, fontsize, position, transparency, + color, interrupt, timeout, is_allowed_path): """Initialize the service.""" self._target = 'http://{}:7676'.format(remoteip) self._default_duration = duration + self._default_fontsize = fontsize self._default_position = position self._default_transparency = transparency self._default_color = color self._default_interrupt = interrupt self._timeout = timeout self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON)) + self.is_allowed_path = is_allowed_path def send_message(self, message="", **kwargs): """Send a message to a Android TV device.""" @@ -123,7 +151,8 @@ def send_message(self, message="", **kwargs): 'application/octet-stream', {'Expires': '0'}), type='0', title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - msg=message, duration="%i" % self._default_duration, + msg=message, duration='%i' % self._default_duration, + fontsize='%i' % FONTSIZES.get(self._default_fontsize), position='%i' % POSITIONS.get(self._default_position), bkgcolor='%s' % COLORS.get(self._default_color), transparency='%i' % TRANSPARENCIES.get( @@ -140,6 +169,13 @@ def send_message(self, message="", **kwargs): except ValueError: _LOGGER.warning("Invalid duration-value: %s", str(duration)) + if ATTR_FONTSIZE in data: + fontsize = data.get(ATTR_FONTSIZE) + if fontsize in FONTSIZES: + payload[ATTR_FONTSIZE] = '%i' % FONTSIZES.get(fontsize) + else: + _LOGGER.warning("Invalid fontsize-value: %s", + str(fontsize)) if ATTR_POSITION in data: position = data.get(ATTR_POSITION) if position in POSITIONS: @@ -168,6 +204,19 @@ def send_message(self, message="", **kwargs): except vol.Invalid: _LOGGER.warning("Invalid interrupt-value: %s", str(interrupt)) + filedata = data.get(ATTR_FILE) if data else None + if filedata is not None: + # Load from file or URL + file_as_bytes = self.load_file( + url=filedata.get(ATTR_FILE_URL), + local_path=filedata.get(ATTR_FILE_PATH), + username=filedata.get(ATTR_FILE_USERNAME), + password=filedata.get(ATTR_FILE_PASSWORD), + auth=filedata.get(ATTR_FILE_AUTH)) + if file_as_bytes: + payload[ATTR_IMAGE] = ( + 'image', file_as_bytes, + 'application/octet-stream', {'Expires': '0'}) try: _LOGGER.debug("Payload: %s", str(payload)) @@ -178,3 +227,37 @@ def send_message(self, message="", **kwargs): except requests.exceptions.ConnectionError as err: _LOGGER.error("Error communicating with %s: %s", self._target, str(err)) + + def load_file(self, url=None, local_path=None, username=None, + password=None, auth=None): + """Load image/document/etc from a local path or URL.""" + try: + if url is not None: + # Check whether authentication parameters are provided + if username is not None and password is not None: + # Use digest or basic authentication + if ATTR_FILE_AUTH_DIGEST == auth: + auth_ = HTTPDigestAuth(username, password) + else: + auth_ = HTTPBasicAuth(username, password) + # Load file from URL with authentication + req = requests.get( + url, auth=auth_, timeout=DEFAULT_TIMEOUT) + else: + # Load file from URL without authentication + req = requests.get(url, timeout=DEFAULT_TIMEOUT) + return req.content + + elif local_path is not None: + # Check whether path is whitelisted in configuration.yaml + if self.is_allowed_path(local_path): + return open(local_path, "rb") + _LOGGER.warning("'%s' is not secure to load data from!", + local_path) + else: + _LOGGER.warning("Neither URL nor local path found in params!") + + except OSError as error: + _LOGGER.error("Can't load from url or local path: %s", error) + + return None From beed82ab12f9863c98eb31357d231fadf0045e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 12 Sep 2018 12:33:26 +0300 Subject: [PATCH 023/178] Upgrade pytest to 3.8.0 and pytest-timeout to 1.3.2 (#16489) --- requirements_test.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index e50ef699848b4d..4144c6866df5d6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,6 +12,6 @@ pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 -pytest-timeout==1.3.1 -pytest==3.7.2 +pytest-timeout==1.3.2 +pytest==3.8.0 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9df1060e4acda4..dbf8f27bd848d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -13,8 +13,8 @@ pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 -pytest-timeout==1.3.1 -pytest==3.7.2 +pytest-timeout==1.3.2 +pytest==3.8.0 requests_mock==1.5.2 From 117ea9e553afc2a94c2ba9c269e54ee7161d4575 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 12 Sep 2018 05:39:23 -0400 Subject: [PATCH 024/178] Refactor zha/async_device_initialized(). (#16485) Leverage endpoint.model and endpoint.manufacturer properties --- homeassistant/components/zha/__init__.py | 46 +++--------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 7aec4333ea8c6d..48c2ad20be05c8 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -179,8 +179,6 @@ async def async_device_initialized(self, device, join): if endpoint_id == 0: # ZDO continue - discovered_info = await _discover_endpoint_info(endpoint) - component = None profile_clusters = ([], []) device_key = "{}-{}".format(device.ieee, endpoint_id) @@ -212,10 +210,11 @@ async def async_device_initialized(self, device, join): 'endpoint': endpoint, 'in_clusters': {c.cluster_id: c for c in in_clusters}, 'out_clusters': {c.cluster_id: c for c in out_clusters}, + 'manufacturer': endpoint.manufacturer, + 'model': endpoint.model, 'new_join': join, 'unique_id': device_key, } - discovery_info.update(discovered_info) self._hass.data[DISCOVERY_KEY][device_key] = discovery_info await discovery.async_load_platform( @@ -234,7 +233,6 @@ async def async_device_initialized(self, device, join): device_key, zha_const.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, 'in_clusters', - discovered_info, join, ) @@ -246,7 +244,6 @@ async def async_device_initialized(self, device, join): device_key, zha_const.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, 'out_clusters', - discovered_info, join, ) @@ -257,7 +254,7 @@ def register_entity(self, ieee, entity_obj): async def _attempt_single_cluster_device(self, endpoint, cluster, profile_clusters, device_key, device_classes, discovery_attr, - entity_info, is_new_join): + is_new_join): """Try to set up an entity from a "bare" cluster.""" if cluster.cluster_id in profile_clusters: return @@ -277,12 +274,13 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, 'endpoint': endpoint, 'in_clusters': {}, 'out_clusters': {}, + 'manufacturer': endpoint.manufacturer, + 'model': endpoint.model, 'new_join': is_new_join, 'unique_id': cluster_key, 'entity_suffix': '_{}'.format(cluster.cluster_id), } discovery_info[discovery_attr] = {cluster.cluster_id: cluster} - discovery_info.update(entity_info) self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info await discovery.async_load_platform( @@ -369,40 +367,6 @@ def zdo_command(self, tsn, command_id, args): pass -async def _discover_endpoint_info(endpoint): - """Find some basic information about an endpoint.""" - extra_info = { - 'manufacturer': None, - 'model': None, - } - if 0 not in endpoint.in_clusters: - return extra_info - - async def read(attributes): - """Read attributes and update extra_info convenience function.""" - result, _ = await endpoint.in_clusters[0].read_attributes( - attributes, - allow_cache=True, - ) - extra_info.update(result) - - await read(['manufacturer', 'model']) - if extra_info['manufacturer'] is None or extra_info['model'] is None: - # Some devices fail at returning multiple results. Attempt separately. - await read(['manufacturer']) - await read(['model']) - - for key, value in extra_info.items(): - if isinstance(value, bytes): - try: - extra_info[key] = value.decode('ascii').strip() - except UnicodeDecodeError: - # Unsure what the best behaviour here is. Unset the key? - pass - - return extra_info - - def get_discovery_info(hass, discovery_info): """Get the full discovery info for a device. From 501f2b0a9321beed7a5c02a13e2634f8eac0ed0e Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 12 Sep 2018 06:43:06 -0400 Subject: [PATCH 025/178] Update fan.zha platform. (#16551) * Update fan.zha platform. switch from asyncio to async def() catch DeliveryError exceptions keep previous state, if reading 'fan_mode' attribute fails * fan.zha: Use None for unknown state. if we fail to read 'fan_mode' attribute, use None for state --- homeassistant/components/fan/zha.py | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index 2612c065393c20..d86e0c3c99b5fd 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -4,7 +4,6 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/fan.zha/ """ -import asyncio import logging from homeassistant.components import zha from homeassistant.components.fan import ( @@ -38,9 +37,8 @@ SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Zigbee Home Automation fans.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: @@ -76,32 +74,34 @@ def is_on(self) -> bool: return False return self._state != SPEED_OFF - @asyncio.coroutine - def async_turn_on(self, speed: str = None, **kwargs) -> None: + async def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn the entity on.""" if speed is None: speed = SPEED_MEDIUM - yield from self.async_set_speed(speed) + await self.async_set_speed(speed) - @asyncio.coroutine - def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" - yield from self.async_set_speed(SPEED_OFF) + await self.async_set_speed(SPEED_OFF) - @asyncio.coroutine - def async_set_speed(self, speed: str) -> None: + async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" - yield from self._endpoint.fan.write_attributes({ - 'fan_mode': SPEED_TO_VALUE[speed]}) + from zigpy.exceptions import DeliveryError + try: + await self._endpoint.fan.write_attributes( + {'fan_mode': SPEED_TO_VALUE[speed]} + ) + except DeliveryError as ex: + _LOGGER.error("%s: Could not set speed: %s", self.entity_id, ex) + return self._state = speed self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve latest state.""" - result = yield from zha.safe_read(self._endpoint.fan, ['fan_mode']) + result = await zha.safe_read(self._endpoint.fan, ['fan_mode']) new_value = result.get('fan_mode', None) self._state = VALUE_TO_SPEED.get(new_value, None) From 77026a224284c617a027a134141b5af2362f71e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9=20B=C5=91le?= Date: Wed, 12 Sep 2018 12:44:14 +0200 Subject: [PATCH 026/178] Increasing python-websockets' version number (#16578) * increasing python-websockets' version number so now it works with python 3.7 * required version for websockets increased to work with Python 3.7 * script/gen_requirements_all.py is done --- homeassistant/components/media_player/webostv.py | 2 +- homeassistant/components/spc.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index b5240bdb9f5f73..0a5b9fe509bf56 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==3.2'] +REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==6.0'] _CONFIGURING = {} # type: Dict[str, str] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index bf7db87f06b370..5b357efcabd779 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -20,7 +20,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['websockets==3.2'] +REQUIREMENTS = ['websockets==6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 63861d2ab3df53..c9bdccfb4a6c4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1480,7 +1480,7 @@ websocket-client==0.37.0 # homeassistant.components.spc # homeassistant.components.media_player.webostv -websockets==3.2 +websockets==6.0 # homeassistant.components.wirelesstag wirelesstagpy==0.3.0 From 453cbb7c606c413a343d1be31774e664ff05388a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Sep 2018 13:23:50 +0200 Subject: [PATCH 027/178] Update frontend to 20180912.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 5fea70ce8461da..a7a6187c939714 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180911.0'] +REQUIREMENTS = ['home-assistant-frontend==20180912.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index c9bdccfb4a6c4f..dfea5da4145392 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -452,7 +452,7 @@ hole==0.3.0 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180911.0 +home-assistant-frontend==20180912.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbf8f27bd848d7..e8bdd85a3e5817 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.4 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180911.0 +home-assistant-frontend==20180912.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From ff78a5b04b0831a5075b49508effc922392ca2c5 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 12 Sep 2018 04:24:16 -0700 Subject: [PATCH 028/178] Track refresh token last usage information (#16408) * Extend refresh_token to support last_used_at and last_used_by * Address code review comment * Remove unused code * Add it to websocket response * Fix typing --- homeassistant/auth/__init__.py | 5 ++++- homeassistant/auth/auth_store.py | 26 ++++++++++++++++++++++- homeassistant/auth/models.py | 5 ++++- homeassistant/components/auth/__init__.py | 19 +++++++++++------ tests/auth/test_init.py | 18 +++++++++++++++- tests/components/auth/test_init.py | 2 ++ 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index b0cebb5fd6c296..c6f978640f6a46 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -309,8 +309,11 @@ async def async_remove_refresh_token(self, @callback def async_create_access_token(self, - refresh_token: models.RefreshToken) -> str: + refresh_token: models.RefreshToken, + remote_ip: Optional[str] = None) -> str: """Create a new access token.""" + self._store.async_log_refresh_token_usage(refresh_token, remote_ip) + # pylint: disable=no-self-use now = dt_util.utcnow() return jwt.encode({ diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 8e8d03253e54d3..fb4700c806fc0e 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -195,6 +195,15 @@ async def async_get_refresh_token_by_token( return found + @callback + def async_log_refresh_token_usage( + self, refresh_token: models.RefreshToken, + remote_ip: Optional[str] = None) -> None: + """Update refresh token last used information.""" + refresh_token.last_used_at = dt_util.utcnow() + refresh_token.last_used_ip = remote_ip + self._async_schedule_save() + async def _async_load(self) -> None: """Load the users.""" data = await self._store.async_load() @@ -233,12 +242,21 @@ async def _async_load(self) -> None: 'Ignoring refresh token %(id)s with invalid created_at ' '%(created_at)s for user_id %(user_id)s', rt_dict) continue + token_type = rt_dict.get('token_type') if token_type is None: if rt_dict['client_id'] is None: token_type = models.TOKEN_TYPE_SYSTEM else: token_type = models.TOKEN_TYPE_NORMAL + + # old refresh_token don't have last_used_at (pre-0.78) + last_used_at_str = rt_dict.get('last_used_at') + if last_used_at_str: + last_used_at = dt_util.parse_datetime(last_used_at_str) + else: + last_used_at = None + token = models.RefreshToken( id=rt_dict['id'], user=users[rt_dict['user_id']], @@ -251,7 +269,9 @@ async def _async_load(self) -> None: access_token_expiration=timedelta( seconds=rt_dict['access_token_expiration']), token=rt_dict['token'], - jwt_key=rt_dict['jwt_key'] + jwt_key=rt_dict['jwt_key'], + last_used_at=last_used_at, + last_used_ip=rt_dict.get('last_used_ip'), ) users[rt_dict['user_id']].refresh_tokens[token.id] = token @@ -306,6 +326,10 @@ def _data_to_save(self) -> Dict: refresh_token.access_token_expiration.total_seconds(), 'token': refresh_token.token, 'jwt_key': refresh_token.jwt_key, + 'last_used_at': + refresh_token.last_used_at.isoformat() + if refresh_token.last_used_at else None, + 'last_used_ip': refresh_token.last_used_ip, } for user in self._users.values() for refresh_token in user.refresh_tokens.values() diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index c5273d7fa1dd3d..b0f4024c3ab76e 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -55,13 +55,16 @@ class RefreshToken: jwt_key = attr.ib(type=str, default=attr.Factory(lambda: generate_secret(64))) + last_used_at = attr.ib(type=Optional[datetime], default=None) + last_used_ip = attr.ib(type=Optional[str], default=None) + @attr.s(slots=True) class Credentials: """Credentials for a user on an auth provider.""" auth_provider_type = attr.ib(type=str) - auth_provider_id = attr.ib(type=str) # type: Optional[str] + auth_provider_id = attr.ib(type=Optional[str]) # Allow the auth provider to store data to represent their auth. data = attr.ib(type=dict) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 01cfe4724bfe6d..bee72d8e4fc3be 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -129,6 +129,7 @@ from homeassistant.auth.models import User, Credentials, \ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN from homeassistant.components import websocket_api +from homeassistant.components.http import KEY_REAL_IP from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView @@ -236,10 +237,12 @@ async def post(self, request): return await self._async_handle_revoke_token(hass, data) if grant_type == 'authorization_code': - return await self._async_handle_auth_code(hass, data) + return await self._async_handle_auth_code( + hass, data, str(request[KEY_REAL_IP])) if grant_type == 'refresh_token': - return await self._async_handle_refresh_token(hass, data) + return await self._async_handle_refresh_token( + hass, data, str(request[KEY_REAL_IP])) return self.json({ 'error': 'unsupported_grant_type', @@ -264,7 +267,7 @@ async def _async_handle_revoke_token(self, hass, data): await hass.auth.async_remove_refresh_token(refresh_token) return web.Response(status=200) - async def _async_handle_auth_code(self, hass, data): + async def _async_handle_auth_code(self, hass, data, remote_addr): """Handle authorization code request.""" client_id = data.get('client_id') if client_id is None or not indieauth.verify_client_id(client_id): @@ -300,7 +303,8 @@ async def _async_handle_auth_code(self, hass, data): refresh_token = await hass.auth.async_create_refresh_token(user, client_id) - access_token = hass.auth.async_create_access_token(refresh_token) + access_token = hass.auth.async_create_access_token( + refresh_token, remote_addr) return self.json({ 'access_token': access_token, @@ -310,7 +314,7 @@ async def _async_handle_auth_code(self, hass, data): int(refresh_token.access_token_expiration.total_seconds()), }) - async def _async_handle_refresh_token(self, hass, data): + async def _async_handle_refresh_token(self, hass, data, remote_addr): """Handle authorization code request.""" client_id = data.get('client_id') if client_id is not None and not indieauth.verify_client_id(client_id): @@ -338,7 +342,8 @@ async def _async_handle_refresh_token(self, hass, data): 'error': 'invalid_request', }, status_code=400) - access_token = hass.auth.async_create_access_token(refresh_token) + access_token = hass.auth.async_create_access_token( + refresh_token, remote_addr) return self.json({ 'access_token': access_token, @@ -484,6 +489,8 @@ def websocket_refresh_tokens( 'type': refresh.token_type, 'created_at': refresh.created_at, 'is_current': refresh.id == current_id, + 'last_used_at': refresh.last_used_at, + 'last_used_ip': refresh.last_used_ip, } for refresh in connection.user.refresh_tokens.values()])) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 765199b256c65e..8325bd2551aa30 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -278,7 +278,11 @@ async def test_saving_loading(hass, hass_storage): }) user = step['result'] await manager.async_activate_user(user) - await manager.async_create_refresh_token(user, CLIENT_ID) + # the first refresh token will be used to create access token + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + manager.async_create_access_token(refresh_token, '192.168.0.1') + # the second refresh token will not be used + await manager.async_create_refresh_token(user, 'dummy-client') await flush_store(manager._store._store) @@ -286,6 +290,18 @@ async def test_saving_loading(hass, hass_storage): users = await store2.async_get_users() assert len(users) == 1 assert users[0] == user + assert len(users[0].refresh_tokens) == 2 + for r_token in users[0].refresh_tokens.values(): + if r_token.client_id == CLIENT_ID: + # verify the first refresh token + assert r_token.last_used_at is not None + assert r_token.last_used_ip == '192.168.0.1' + elif r_token.client_id == 'dummy-client': + # verify the second refresh token + assert r_token.last_used_at is None + assert r_token.last_used_ip is None + else: + assert False, 'Unknown client_id: %s' % r_token.client_id async def test_cannot_retrieve_expired_access_token(hass): diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index ad2aa01737bb45..a3974553661646 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -321,6 +321,8 @@ async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): assert token['client_icon'] == refresh_token.client_icon assert token['created_at'] == refresh_token.created_at.isoformat() assert token['is_current'] is True + assert token['last_used_at'] == refresh_token.last_used_at.isoformat() + assert token['last_used_ip'] == refresh_token.last_used_ip async def test_ws_delete_refresh_token(hass, hass_ws_client, From 4efe86327d7c0948b3cb68128d163bcfb448c295 Mon Sep 17 00:00:00 2001 From: Marcel Hoppe Date: Wed, 12 Sep 2018 13:27:21 +0200 Subject: [PATCH 029/178] Hangouts help "page" and little bugfix (#16464) * add 'default_conversations' parameter * remove the empty message segment at the end of every message * add 'HangoutsHelp' intent * add hangouts/intents.py --- .coveragerc | 1 + homeassistant/components/hangouts/__init__.py | 26 ++++++++-- homeassistant/components/hangouts/const.py | 3 ++ .../components/hangouts/hangouts_bot.py | 51 ++++++++++++++----- homeassistant/components/hangouts/intents.py | 33 ++++++++++++ 5 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/hangouts/intents.py diff --git a/.coveragerc b/.coveragerc index ca36f4a8dbb712..bd08f5c38dfaa2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -123,6 +123,7 @@ omit = homeassistant/components/hangouts/const.py homeassistant/components/hangouts/hangouts_bot.py homeassistant/components/hangouts/hangups_utils.py + homeassistant/components/hangouts/intents.py homeassistant/components/*/hangouts.py homeassistant/components/hdmi_cec.py diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index ebadff57be3b14..8480ae09549de4 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -9,7 +9,9 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.hangouts.intents import HelpIntent from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import intent from homeassistant.helpers import dispatcher import homeassistant.helpers.config_validation as cv @@ -18,12 +20,13 @@ EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, MESSAGE_SCHEMA, SERVICE_SEND_MESSAGE, SERVICE_UPDATE, CONF_SENTENCES, CONF_MATCHERS, - CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA) + CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA, + CONF_DEFAULT_CONVERSATIONS, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, + INTENT_HELP) # We need an import from .config_flow, without it .config_flow is never loaded. from .config_flow import HangoutsFlowHandler # noqa: F401 - REQUIREMENTS = ['hangups==0.4.5'] _LOGGER = logging.getLogger(__name__) @@ -33,6 +36,8 @@ vol.Optional(CONF_INTENTS, default={}): vol.Schema({ cv.string: INTENT_SCHEMA }), + vol.Optional(CONF_DEFAULT_CONVERSATIONS, default=[]): + [TARGETS_SCHEMA], vol.Optional(CONF_ERROR_SUPPRESSED_CONVERSATIONS, default=[]): [TARGETS_SCHEMA] }) @@ -47,16 +52,23 @@ async def async_setup(hass, config): if config is None: hass.data[DOMAIN] = { CONF_INTENTS: {}, + CONF_DEFAULT_CONVERSATIONS: [], CONF_ERROR_SUPPRESSED_CONVERSATIONS: [], } return True hass.data[DOMAIN] = { CONF_INTENTS: config[CONF_INTENTS], + CONF_DEFAULT_CONVERSATIONS: config[CONF_DEFAULT_CONVERSATIONS], CONF_ERROR_SUPPRESSED_CONVERSATIONS: config[CONF_ERROR_SUPPRESSED_CONVERSATIONS], } + if (hass.data[DOMAIN][CONF_INTENTS] and + INTENT_HELP not in hass.data[DOMAIN][CONF_INTENTS]): + hass.data[DOMAIN][CONF_INTENTS][INTENT_HELP] = { + CONF_SENTENCES: ['HELP']} + for data in hass.data[DOMAIN][CONF_INTENTS].values(): matchers = [] for sentence in data[CONF_SENTENCES]: @@ -82,6 +94,7 @@ async def async_setup_entry(hass, config): hass, config.data.get(CONF_REFRESH_TOKEN), hass.data[DOMAIN][CONF_INTENTS], + hass.data[DOMAIN][CONF_DEFAULT_CONVERSATIONS], hass.data[DOMAIN][CONF_ERROR_SUPPRESSED_CONVERSATIONS]) hass.data[DOMAIN][CONF_BOT] = bot except GoogleAuthError as exception: @@ -96,11 +109,12 @@ async def async_setup_entry(hass, config): dispatcher.async_dispatcher_connect( hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - bot.async_update_conversation_commands) + bot.async_resolve_conversations) + dispatcher.async_dispatcher_connect( hass, - EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - bot.async_handle_update_error_suppressed_conversations) + EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, + bot.async_update_conversation_commands) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, bot.async_handle_hass_stop) @@ -116,6 +130,8 @@ async def async_setup_entry(hass, config): async_handle_update_users_and_conversations, schema=vol.Schema({})) + intent.async_register(hass, HelpIntent(hass)) + return True diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index 3b96edf93a29a6..caae0de169b0cf 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -24,10 +24,13 @@ CONF_SENTENCES = 'sentences' CONF_MATCHERS = 'matchers' +INTENT_HELP = 'HangoutsHelp' + EVENT_HANGOUTS_CONNECTED = 'hangouts_connected' EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected' EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed' EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed' +EVENT_HANGOUTS_CONVERSATIONS_RESOLVED = 'hangouts_conversations_resolved' EVENT_HANGOUTS_MESSAGE_RECEIVED = 'hangouts_message_received' CONF_CONVERSATION_ID = 'id' diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index 15f4156d374486..7edc8898c8cf81 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -8,7 +8,7 @@ EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, EVENT_HANGOUTS_DISCONNECTED, EVENT_HANGOUTS_MESSAGE_RECEIVED, CONF_MATCHERS, CONF_CONVERSATION_ID, - CONF_CONVERSATION_NAME) + CONF_CONVERSATION_NAME, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, INTENT_HELP) _LOGGER = logging.getLogger(__name__) @@ -16,7 +16,8 @@ class HangoutsBot: """The Hangouts Bot.""" - def __init__(self, hass, refresh_token, intents, error_suppressed_convs): + def __init__(self, hass, refresh_token, intents, + default_convs, error_suppressed_convs): """Set up the client.""" self.hass = hass self._connected = False @@ -29,6 +30,8 @@ def __init__(self, hass, refresh_token, intents, error_suppressed_convs): self._client = None self._user_list = None self._conversation_list = None + self._default_convs = default_convs + self._default_conv_ids = None self._error_suppressed_convs = error_suppressed_convs self._error_suppressed_conv_ids = None @@ -51,7 +54,7 @@ def _resolve_conversation_name(self, name): return conv return None - def async_update_conversation_commands(self, _): + def async_update_conversation_commands(self): """Refresh the commands for every conversation.""" self._conversation_intents = {} @@ -63,6 +66,8 @@ def async_update_conversation_commands(self, _): if conv_id is not None: conversations.append(conv_id) data['_' + CONF_CONVERSATIONS] = conversations + elif self._default_conv_ids: + data['_' + CONF_CONVERSATIONS] = self._default_conv_ids else: data['_' + CONF_CONVERSATIONS] = \ [conv.id_ for conv in self._conversation_list.get_all()] @@ -81,13 +86,22 @@ def async_update_conversation_commands(self, _): self._conversation_list.on_event.add_observer( self._async_handle_conversation_event) - def async_handle_update_error_suppressed_conversations(self, _): - """Resolve the list of error suppressed conversations.""" + def async_resolve_conversations(self, _): + """Resolve the list of default and error suppressed conversations.""" + self._default_conv_ids = [] self._error_suppressed_conv_ids = [] + + for conversation in self._default_convs: + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + self._default_conv_ids.append(conv_id) + for conversation in self._error_suppressed_convs: conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: self._error_suppressed_conv_ids.append(conv_id) + dispatcher.async_dispatcher_send(self.hass, + EVENT_HANGOUTS_CONVERSATIONS_RESOLVED) async def _async_handle_conversation_event(self, event): from hangups import ChatMessageEvent @@ -112,7 +126,8 @@ async def _async_handle_conversation_message(self, if intents is not None: is_error = False try: - intent_result = await self._async_process(intents, message) + intent_result = await self._async_process(intents, message, + conv_id) except (intent.UnknownIntent, intent.IntentHandleError) as err: is_error = True intent_result = intent.IntentResponse() @@ -133,7 +148,7 @@ async def _async_handle_conversation_message(self, [{'text': message, 'parse_str': True}], [{CONF_CONVERSATION_ID: conv_id}]) - async def _async_process(self, intents, text): + async def _async_process(self, intents, text, conv_id): """Detect a matching intent.""" for intent_type, data in intents.items(): for matcher in data.get(CONF_MATCHERS, []): @@ -141,12 +156,15 @@ async def _async_process(self, intents, text): if not match: continue + if intent_type == INTENT_HELP: + return await self.hass.helpers.intent.async_handle( + DOMAIN, intent_type, + {'conv_id': {'value': conv_id}}, text) - response = await self.hass.helpers.intent.async_handle( + return await self.hass.helpers.intent.async_handle( DOMAIN, intent_type, - {key: {'value': value} for key, value - in match.groupdict().items()}, text) - return response + {key: {'value': value} + for key, value in match.groupdict().items()}, text) async def async_connect(self): """Login to the Google Hangouts.""" @@ -204,15 +222,16 @@ async def _async_send_message(self, message, targets): from hangups import ChatMessageSegment, hangouts_pb2 messages = [] for segment in message: + if messages: + messages.append(ChatMessageSegment('', + segment_type=hangouts_pb2. + SEGMENT_TYPE_LINE_BREAK)) if 'parse_str' in segment and segment['parse_str']: messages.extend(ChatMessageSegment.from_str(segment['text'])) else: if 'parse_str' in segment: del segment['parse_str'] messages.append(ChatMessageSegment(**segment)) - messages.append(ChatMessageSegment('', - segment_type=hangouts_pb2. - SEGMENT_TYPE_LINE_BREAK)) if not messages: return False @@ -247,3 +266,7 @@ async def async_handle_send_message(self, service): async def async_handle_update_users_and_conversations(self, _=None): """Handle the update_users_and_conversations service.""" await self._async_list_conversations() + + def get_intents(self, conv_id): + """Return the intents for a specific conversation.""" + return self._conversation_intents.get(conv_id) diff --git a/homeassistant/components/hangouts/intents.py b/homeassistant/components/hangouts/intents.py new file mode 100644 index 00000000000000..be52f059139f5a --- /dev/null +++ b/homeassistant/components/hangouts/intents.py @@ -0,0 +1,33 @@ +"""Intents for the hangouts component.""" +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv + +from .const import INTENT_HELP, DOMAIN, CONF_BOT + + +class HelpIntent(intent.IntentHandler): + """Handle Help intents.""" + + intent_type = INTENT_HELP + slot_schema = { + 'conv_id': cv.string + } + + def __init__(self, hass): + """Set up the intent.""" + self.hass = hass + + async def async_handle(self, intent_obj): + """Handle the intent.""" + slots = self.async_validate_slots(intent_obj.slots) + conv_id = slots['conv_id']['value'] + + intents = self.hass.data[DOMAIN][CONF_BOT].get_intents(conv_id) + response = intent_obj.create_response() + help_text = "I understand the following sentences:" + for intent_data in intents.values(): + for sentence in intent_data['sentences']: + help_text += "\n'{}'".format(sentence) + response.async_set_speech(help_text) + + return response From 308b7fb3854b05f716ee7e71cb757ed273b5bd9b Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 12 Sep 2018 04:42:54 -0700 Subject: [PATCH 030/178] Add retry limit for chromecast connection (#16471) --- homeassistant/components/media_player/cast.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 83c3595ebd9c1e..beec1e5609cd4f 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -61,6 +61,10 @@ vol.All(cv.ensure_list, [cv.string]), }) +CONNECTION_RETRY = 3 +CONNECTION_RETRY_WAIT = 2 +CONNECTION_TIMEOUT = 10 + @attr.s(slots=True, frozen=True) class ChromecastInfo: @@ -368,15 +372,13 @@ async def async_set_cast_info(self, cast_info): return await self._async_disconnect() - # Failed connection will unfortunately never raise an exception, it - # will instead just try connecting indefinitely. # pylint: disable=protected-access _LOGGER.debug("Connecting to cast device %s", cast_info) chromecast = await self.hass.async_add_job( pychromecast._get_chromecast_from_host, ( cast_info.host, cast_info.port, cast_info.uuid, cast_info.model_name, cast_info.friendly_name - )) + ), CONNECTION_RETRY, CONNECTION_RETRY_WAIT, CONNECTION_TIMEOUT) self._chromecast = chromecast self._status_listener = CastStatusListener(self, chromecast) # Initialise connection status as connected because we can only From 2682d26f79c383fd623d82adb2a26afa041d7605 Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Wed, 12 Sep 2018 07:54:38 -0400 Subject: [PATCH 031/178] Konnected component feature updates (#16479) * add config option to invert state of binary_sensor * Add API endpoint to get state of a Konnected switch --- homeassistant/components/konnected.py | 60 +++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py index 3df285863139ab..388fa41f36f890 100644 --- a/homeassistant/components/konnected.py +++ b/homeassistant/components/konnected.py @@ -19,7 +19,7 @@ HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, - ATTR_ENTITY_ID, ATTR_STATE) + ATTR_ENTITY_ID, ATTR_STATE, STATE_ON) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv @@ -35,6 +35,7 @@ CONF_MOMENTARY = 'momentary' CONF_PAUSE = 'pause' CONF_REPEAT = 'repeat' +CONF_INVERSE = 'inverse' STATE_LOW = 'low' STATE_HIGH = 'high' @@ -48,6 +49,7 @@ vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN), vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INVERSE): cv.boolean, }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) ) @@ -156,6 +158,7 @@ def save_data(self): CONF_TYPE: entity[CONF_TYPE], CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( self.device_id[6:], PIN_TO_ZONE[pin])), + CONF_INVERSE: entity.get(CONF_INVERSE), ATTR_STATE: None } _LOGGER.debug('Set up sensor %s (initial state: %s)', @@ -259,15 +262,19 @@ def actuator_configuration(self): def update_initial_states(self): """Update the initial state of each sensor from status poll.""" - for sensor in self.status.get('sensors'): - entity_id = self.stored_configuration[CONF_BINARY_SENSORS]. \ - get(sensor.get(CONF_PIN), {}). \ - get(ATTR_ENTITY_ID) + for sensor_data in self.status.get('sensors'): + sensor_config = self.stored_configuration[CONF_BINARY_SENSORS]. \ + get(sensor_data.get(CONF_PIN), {}) + entity_id = sensor_config.get(ATTR_ENTITY_ID) + + state = bool(sensor_data.get(ATTR_STATE)) + if sensor_config.get(CONF_INVERSE): + state = not state async_dispatcher_send( self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), - bool(sensor.get(ATTR_STATE))) + state) def sync_device_config(self): """Sync the new pin configuration to the Konnected device.""" @@ -321,6 +328,43 @@ def __init__(self, auth_token): """Initialize the view.""" self.auth_token = auth_token + @staticmethod + def binary_value(state, activation): + """Return binary value for GPIO based on state and activation.""" + if activation == STATE_HIGH: + return 1 if state == STATE_ON else 0 + return 0 if state == STATE_ON else 1 + + async def get(self, request: Request, device_id) -> Response: + """Return the current binary state of a switch.""" + hass = request.app['hass'] + pin_num = int(request.query.get('pin')) + data = hass.data[DOMAIN] + + device = data[CONF_DEVICES][device_id] + if not device: + return self.json_message( + 'Device ' + device_id + ' not configured', + status_code=HTTP_NOT_FOUND) + + try: + pin = next(filter( + lambda switch: switch[CONF_PIN] == pin_num, + device[CONF_SWITCHES])) + except StopIteration: + pin = None + + if not pin: + return self.json_message( + 'Switch on pin ' + pin_num + ' not configured', + status_code=HTTP_NOT_FOUND) + + return self.json( + {'pin': pin_num, + 'state': self.binary_value( + hass.states.get(pin[ATTR_ENTITY_ID]).state, + pin[CONF_ACTIVATION])}) + async def put(self, request: Request, device_id, pin_num=None, state=None) -> Response: """Receive a sensor update via PUT request and async set state.""" @@ -341,7 +385,6 @@ async def put(self, request: Request, device_id, return self.json_message( "unauthorized", status_code=HTTP_UNAUTHORIZED) pin_num = int(pin_num) - state = bool(int(state)) device = data[CONF_DEVICES].get(device_id) if device is None: return self.json_message('unregistered device', @@ -356,6 +399,9 @@ async def put(self, request: Request, device_id, if entity_id is None: return self.json_message('uninitialized sensor/actuator', status_code=HTTP_NOT_FOUND) + state = bool(int(state)) + if pin_data.get(CONF_INVERSE): + state = not state async_dispatcher_send( hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) From 3824582e681b8eb25d0cbcc74eb3b26da1d03962 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Sep 2018 20:17:52 +0200 Subject: [PATCH 032/178] Add config entry to iOS (#16580) * Add config entry to iOS * Add translation --- .../components/ios/.translations/en.json | 14 ++++ .../components/{ios.py => ios/__init__.py} | 73 ++++++++++++------- homeassistant/components/ios/strings.json | 14 ++++ homeassistant/components/notify/ios.py | 16 ++-- homeassistant/components/sensor/ios.py | 13 ++-- homeassistant/config_entries.py | 1 + tests/components/ios/__init__.py | 1 + tests/components/ios/test_init.py | 61 ++++++++++++++++ 8 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/ios/.translations/en.json rename homeassistant/components/{ios.py => ios/__init__.py} (81%) create mode 100644 homeassistant/components/ios/strings.json create mode 100644 tests/components/ios/__init__.py create mode 100644 tests/components/ios/test_init.py diff --git a/homeassistant/components/ios/.translations/en.json b/homeassistant/components/ios/.translations/en.json new file mode 100644 index 00000000000000..ae2e4e03f74a24 --- /dev/null +++ b/homeassistant/components/ios/.translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of Home Assistant iOS is necessary." + }, + "step": { + "confirm": { + "description": "Do you want to set up the Home Assistant iOS component?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios/__init__.py similarity index 81% rename from homeassistant/components/ios.py rename to homeassistant/components/ios/__init__.py index 7f7377469fd3cb..3766e00879d715 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios/__init__.py @@ -11,13 +11,14 @@ import voluptuous as vol # from voluptuous.humanize import humanize_error +from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import (HTTP_INTERNAL_SERVER_ERROR, HTTP_BAD_REQUEST) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import discovery +from homeassistant.helpers import ( + config_validation as cv, discovery, config_entry_flow) from homeassistant.util.json import load_json, save_json @@ -164,62 +165,70 @@ CONFIGURATION_FILE = '.ios.conf' -CONFIG_FILE = {ATTR_DEVICES: {}} -CONFIG_FILE_PATH = "" - - -def devices_with_push(): +def devices_with_push(hass): """Return a dictionary of push enabled targets.""" targets = {} - for device_name, device in CONFIG_FILE[ATTR_DEVICES].items(): + for device_name, device in hass.data[DOMAIN][ATTR_DEVICES].items(): if device.get(ATTR_PUSH_ID) is not None: targets[device_name] = device.get(ATTR_PUSH_ID) return targets -def enabled_push_ids(): +def enabled_push_ids(hass): """Return a list of push enabled target push IDs.""" push_ids = list() - for device in CONFIG_FILE[ATTR_DEVICES].values(): + for device in hass.data[DOMAIN][ATTR_DEVICES].values(): if device.get(ATTR_PUSH_ID) is not None: push_ids.append(device.get(ATTR_PUSH_ID)) return push_ids -def devices(): +def devices(hass): """Return a dictionary of all identified devices.""" - return CONFIG_FILE[ATTR_DEVICES] + return hass.data[DOMAIN][ATTR_DEVICES] -def device_name_for_push_id(push_id): +def device_name_for_push_id(hass, push_id): """Return the device name for the push ID.""" - for device_name, device in CONFIG_FILE[ATTR_DEVICES].items(): + for device_name, device in hass.data[DOMAIN][ATTR_DEVICES].items(): if device.get(ATTR_PUSH_ID) is push_id: return device_name return None -def setup(hass, config): +async def async_setup(hass, config): """Set up the iOS component.""" - global CONFIG_FILE - global CONFIG_FILE_PATH + conf = config.get(DOMAIN) + + ios_config = await hass.async_add_executor_job( + load_json, hass.config.path(CONFIGURATION_FILE)) - CONFIG_FILE_PATH = hass.config.path(CONFIGURATION_FILE) + if ios_config == {}: + ios_config[ATTR_DEVICES] = {} - CONFIG_FILE = load_json(CONFIG_FILE_PATH) + ios_config[CONF_PUSH] = (conf or {}).get(CONF_PUSH, {}) - if CONFIG_FILE == {}: - CONFIG_FILE[ATTR_DEVICES] = {} + hass.data[DOMAIN] = ios_config + # No entry support for notify component yet discovery.load_platform(hass, 'notify', DOMAIN, {}, config) - discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + if conf is not None: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + - hass.http.register_view(iOSIdentifyDeviceView) +async def async_setup_entry(hass, entry): + """Set up an iOS entry.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, 'sensor')) - app_config = config.get(DOMAIN, {}) - hass.http.register_view(iOSPushConfigView(app_config.get(CONF_PUSH, {}))) + hass.http.register_view( + iOSIdentifyDeviceView(hass.config.path(CONFIGURATION_FILE))) + hass.http.register_view(iOSPushConfigView(hass.data[DOMAIN][CONF_PUSH])) return True @@ -247,6 +256,10 @@ class iOSIdentifyDeviceView(HomeAssistantView): url = '/api/ios/identify' name = 'api:ios:identify' + def __init__(self, config_path): + """Initiliaze the view.""" + self._config_path = config_path + @asyncio.coroutine def post(self, request): """Handle the POST request for device identification.""" @@ -255,6 +268,8 @@ def post(self, request): except ValueError: return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) + hass = request.app['hass'] + # Commented for now while iOS app is getting frequent updates # try: # data = IDENTIFY_SCHEMA(req_data) @@ -266,12 +281,16 @@ def post(self, request): name = data.get(ATTR_DEVICE_ID) - CONFIG_FILE[ATTR_DEVICES][name] = data + hass.data[DOMAIN][ATTR_DEVICES][name] = data try: - save_json(CONFIG_FILE_PATH, CONFIG_FILE) + save_json(self._config_path, hass.data[DOMAIN]) except HomeAssistantError: return self.json_message("Error saving device.", HTTP_INTERNAL_SERVER_ERROR) return self.json({"status": "registered"}) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Home Assistant iOS', lambda *_: True) diff --git a/homeassistant/components/ios/strings.json b/homeassistant/components/ios/strings.json new file mode 100644 index 00000000000000..cbb63cf822995d --- /dev/null +++ b/homeassistant/components/ios/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "Home Assistant iOS", + "step": { + "confirm": { + "title": "Home Assistant iOS", + "description": "Do you want to set up the Home Assistant iOS component?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Home Assistant iOS is necessary." + } + } +} diff --git a/homeassistant/components/notify/ios.py b/homeassistant/components/notify/ios.py index 8609e1dabee928..e6a37d707ad2a3 100644 --- a/homeassistant/components/notify/ios.py +++ b/homeassistant/components/notify/ios.py @@ -24,7 +24,7 @@ # pylint: disable=invalid-name -def log_rate_limits(target, resp, level=20): +def log_rate_limits(hass, target, resp, level=20): """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) @@ -33,7 +33,7 @@ def log_rate_limits(target, resp, level=20): "%d sent, %d allowed, %d errors, " "resets in %s") _LOGGER.log(level, rate_limit_msg, - ios.device_name_for_push_id(target), + ios.device_name_for_push_id(hass, target), rate_limits["successful"], rate_limits["maximum"], rate_limits["errors"], str(resetsAtTime).split(".")[0]) @@ -45,7 +45,7 @@ def get_service(hass, config, discovery_info=None): # Need this to enable requirements checking in the app. hass.config.components.add("notify.ios") - if not ios.devices_with_push(): + if not ios.devices_with_push(hass): _LOGGER.error("The notify.ios platform was loaded but no " "devices exist! Please check the documentation at " "https://home-assistant.io/ecosystem/ios/notifications" @@ -64,7 +64,7 @@ def __init__(self): @property def targets(self): """Return a dictionary of registered targets.""" - return ios.devices_with_push() + return ios.devices_with_push(self.hass) def send_message(self, message="", **kwargs): """Send a message to the Lambda APNS gateway.""" @@ -78,13 +78,13 @@ def send_message(self, message="", **kwargs): targets = kwargs.get(ATTR_TARGET) if not targets: - targets = ios.enabled_push_ids() + targets = ios.enabled_push_ids(self.hass) if kwargs.get(ATTR_DATA) is not None: data[ATTR_DATA] = kwargs.get(ATTR_DATA) for target in targets: - if target not in ios.enabled_push_ids(): + if target not in ios.enabled_push_ids(self.hass): _LOGGER.error("The target (%s) does not exist in .ios.conf.", targets) return @@ -102,8 +102,8 @@ def send_message(self, message="", **kwargs): message = req.json().get("message", fallback_message) if req.status_code == 429: _LOGGER.warning(message) - log_rate_limits(target, req.json(), 30) + log_rate_limits(self.hass, target, req.json(), 30) else: _LOGGER.error(message) else: - log_rate_limits(target, req.json()) + log_rate_limits(self.hass, target, req.json()) diff --git a/homeassistant/components/sensor/ios.py b/homeassistant/components/sensor/ios.py index f775381c4ec6b2..a50d1161676dc0 100644 --- a/homeassistant/components/sensor/ios.py +++ b/homeassistant/components/sensor/ios.py @@ -21,14 +21,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the iOS sensor.""" - if discovery_info is None: - return + # Leave here for if someone accidentally adds platform: ios to config + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up iOS from a config entry.""" dev = list() - for device_name, device in ios.devices().items(): + for device_name, device in ios.devices(hass).items(): for sensor_type in ('level', 'state'): dev.append(IOSSensor(sensor_type, device_name, device)) - add_entities(dev, True) + async_add_entities(dev, True) class IOSSensor(Entity): @@ -102,5 +105,5 @@ def icon(self): def update(self): """Get the latest state of the sensor.""" - self._device = ios.devices().get(self._device_name) + self._device = ios.devices(self.hass).get(self._device_name) self._state = self._device[ios.ATTR_BATTERY][self.type] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 15932f2c3f8ec1..b06d6e7df5522f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -140,6 +140,7 @@ async def async_step_discovery(info): 'deconz', 'homematicip_cloud', 'hue', + 'ios', 'nest', 'openuv', 'sonos', diff --git a/tests/components/ios/__init__.py b/tests/components/ios/__init__.py new file mode 100644 index 00000000000000..a028090473e769 --- /dev/null +++ b/tests/components/ios/__init__.py @@ -0,0 +1 @@ +"""Tests for the iOS component.""" diff --git a/tests/components/ios/test_init.py b/tests/components/ios/test_init.py new file mode 100644 index 00000000000000..9a45fac3ce15d8 --- /dev/null +++ b/tests/components/ios/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the iOS init file.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.setup import async_setup_component +from homeassistant.components import ios + +from tests.common import mock_component, mock_coro + + +@pytest.fixture(autouse=True) +def mock_load_json(): + """Mock load_json.""" + with patch('homeassistant.components.ios.load_json', return_value={}): + yield + + +@pytest.fixture(autouse=True) +def mock_dependencies(hass): + """Mock dependencies loaded.""" + mock_component(hass, 'zeroconf') + mock_component(hass, 'device_tracker') + + +async def test_creating_entry_sets_up_sensor(hass): + """Test setting up iOS loads the sensor component.""" + with patch('homeassistant.components.sensor.ios.async_setup_entry', + return_value=mock_coro(True)) as mock_setup: + result = await hass.config_entries.flow.async_init( + ios.DOMAIN, context={'source': config_entries.SOURCE_USER}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + + +async def test_configuring_ios_creates_entry(hass): + """Test that specifying config will create an entry.""" + with patch('homeassistant.components.ios.async_setup_entry', + return_value=mock_coro(True)) as mock_setup: + await async_setup_component(hass, ios.DOMAIN, { + 'ios': { + 'push': {} + } + }) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + + +async def test_not_configuring_ios_not_creates_entry(hass): + """Test that no config will not create an entry.""" + with patch('homeassistant.components.ios.async_setup_entry', + return_value=mock_coro(True)) as mock_setup: + await async_setup_component(hass, ios.DOMAIN, {}) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 0 From cb542a90ebd766d5de5badb27e974498707cd2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 12 Sep 2018 21:10:04 +0200 Subject: [PATCH 033/178] Switchmate (#16395) * Switchmate * switchmate * swithcmate * switchmate * switchmate * switchmate * Make switchmate a bit more robust * style * style * style * Use external lib for switchmate * add_entities * Update requirements_all.txt * unnecessary string format --- homeassistant/components/switch/switchmate.py | 27 ++++++------------- requirements_all.txt | 4 ++- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index 7ccd3bee4b621a..fddb878b0ba22d 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -12,16 +12,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['bluepy==1.1.4'] +REQUIREMENTS = ['pySwitchmate==0.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Switchmate' -HANDLE = 0x2e -ON_KEY = b'\x00' -OFF_KEY = b'\x01' SCAN_INTERVAL = timedelta(minutes=30) @@ -34,7 +30,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None) -> None: """Perform the setup for Switchmate devices.""" name = config.get(CONF_NAME) - mac_addr = config.get(CONF_MAC) + mac_addr = config[CONF_MAC] add_entities([Switchmate(mac_addr, name)], True) @@ -43,17 +39,10 @@ class Switchmate(SwitchDevice): def __init__(self, mac, name) -> None: """Initialize the Switchmate.""" - # pylint: disable=import-error - import bluepy - self._state = False + import switchmate self._name = name self._mac = mac - try: - self._device = bluepy.btle.Peripheral(self._mac, - bluepy.btle.ADDR_TYPE_RANDOM) - except bluepy.btle.BTLEException: - _LOGGER.error("Failed to set up switchmate") - raise PlatformNotReady() + self._device = switchmate.Switchmate(mac=mac) @property def unique_id(self) -> str: @@ -67,17 +56,17 @@ def name(self) -> str: def update(self) -> None: """Synchronize state with switch.""" - self._state = self._device.readCharacteristic(HANDLE) == ON_KEY + self._device.update() @property def is_on(self) -> bool: """Return true if it is on.""" - return self._state + return self._device.state def turn_on(self, **kwargs) -> None: """Turn the switch on.""" - self._device.writeCharacteristic(HANDLE, ON_KEY, True) + self._device.turn_on() def turn_off(self, **kwargs) -> None: """Turn the switch off.""" - self._device.writeCharacteristic(HANDLE, OFF_KEY, True) + self._device.turn_off() diff --git a/requirements_all.txt b/requirements_all.txt index dfea5da4145392..db753e959e89c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,6 @@ blinkstick==1.1.8 blockchain==1.4.4 # homeassistant.components.light.decora -# homeassistant.components.switch.switchmate # bluepy==1.1.4 # homeassistant.components.sensor.bme680 @@ -749,6 +748,9 @@ pyHS100==0.3.3 # homeassistant.components.rfxtrx pyRFXtrx==0.23 +# homeassistant.components.switch.switchmate +pySwitchmate==0.3 + # homeassistant.components.sensor.tibber pyTibber==0.4.1 From d0aeb90c22b251b8dbb0c5b7ff92a4d10e9ed841 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Thu, 13 Sep 2018 01:04:15 +0200 Subject: [PATCH 034/178] Update pyhomematic to 0.1.48 (#16588) --- homeassistant/components/homematic/__init__.py | 7 ++++--- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 2b517652ad7e2c..b9e4294f6e10d7 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.47'] +REQUIREMENTS = ['pyhomematic==0.1.48'] _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,8 @@ 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', - 'IPKeySwitchPowermeter', 'IPThermostatWall230V'], + 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', + 'IPWeatherSensorBasic'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -87,7 +88,7 @@ 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor', - 'SmartwareMotion'], + 'SmartwareMotion', 'IPWeatherSensorPlus'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } diff --git a/requirements_all.txt b/requirements_all.txt index db753e959e89c6..8bf267ae83c093 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -895,7 +895,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.47 +pyhomematic==0.1.48 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 From 1586d3000c9bd378a8372d919ded05745f11425f Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 12 Sep 2018 22:52:31 -0700 Subject: [PATCH 035/178] Fix broken bluetooth tracker (#16589) --- homeassistant/components/device_tracker/bluetooth_tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 217df0aacd47b0..d22a1ba7c1f3e7 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -80,7 +80,7 @@ def discover_devices(): request_rssi = config.get(CONF_REQUEST_RSSI, False) - def update_bluetooth(): + def update_bluetooth(_): """Update Bluetooth and set timer for the next update.""" update_bluetooth_once() track_point_in_utc_time( @@ -111,7 +111,7 @@ def handle_update_bluetooth(call): """Update bluetooth devices on demand.""" update_bluetooth_once() - update_bluetooth() + update_bluetooth(dt_util.utcnow()) hass.services.register( DOMAIN, "bluetooth_tracker_update", handle_update_bluetooth) From f2203e52efd03ab957ad6488ba97364b45d4dca1 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 13 Sep 2018 03:11:47 -0400 Subject: [PATCH 036/178] Add configure_reporting() method to zha component (#16487) * Add zha.configure_reporting() method. Binds a cluster and configures reporting for the specified attribute. * git add homeassistant/components/binary_sensor/zha.py * Refactor sensor.zha to use new 'configure_reporting() method. * Zha configure reporting - switch (#1) * use configure_reporting for zha switch * lint fixes * Rename variables/classes to properly reflect the content --- homeassistant/components/binary_sensor/zha.py | 27 ++++++------- homeassistant/components/sensor/zha.py | 6 +-- homeassistant/components/switch/zha.py | 18 ++++++--- homeassistant/components/zha/__init__.py | 39 +++++++++++++++++++ 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index cabbbd704a0014..0842ab7db6ea8f 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -65,28 +65,25 @@ async def _async_setup_iaszone(hass, config, async_add_entities, async def _async_setup_remote(hass, config, async_add_entities, discovery_info): - async def safe(coro): - """Run coro, catching ZigBee delivery errors, and ignoring them.""" - import zigpy.exceptions - try: - await coro - except zigpy.exceptions.DeliveryError as exc: - _LOGGER.warning("Ignoring error during setup: %s", exc) + remote = Remote(**discovery_info) if discovery_info['new_join']: from zigpy.zcl.clusters.general import OnOff, LevelControl out_clusters = discovery_info['out_clusters'] if OnOff.cluster_id in out_clusters: cluster = out_clusters[OnOff.cluster_id] - await safe(cluster.bind()) - await safe(cluster.configure_reporting(0, 0, 600, 1)) + await zha.configure_reporting( + remote.entity_id, cluster, 0, min_report=0, max_report=600, + reportable_change=1 + ) if LevelControl.cluster_id in out_clusters: cluster = out_clusters[LevelControl.cluster_id] - await safe(cluster.bind()) - await safe(cluster.configure_reporting(0, 1, 600, 1)) + await zha.configure_reporting( + remote.entity_id, cluster, 0, min_report=1, max_report=600, + reportable_change=1 + ) - sensor = Switch(**discovery_info) - async_add_entities([sensor], update_before_add=True) + async_add_entities([remote], update_before_add=True) class BinarySensor(zha.Entity, BinarySensorDevice): @@ -131,7 +128,7 @@ def cluster_command(self, tsn, command_id, args): async def async_update(self): """Retrieve latest state.""" - from bellows.types.basic import uint16_t + from zigpy.types.basic import uint16_t result = await zha.safe_read(self._endpoint.ias_zone, ['zone_status'], @@ -141,7 +138,7 @@ async def async_update(self): self._state = result.get('zone_status', self._state) & 3 -class Switch(zha.Entity, BinarySensorDevice): +class Remote(zha.Entity, BinarySensorDevice): """ZHA switch/remote controller/button.""" _domain = DOMAIN diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 6202f8cb7efd29..0a6710f8f6cec9 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -57,9 +57,9 @@ def make_sensor(discovery_info): if discovery_info['new_join']: cluster = list(in_clusters.values())[0] - yield from cluster.bind() - yield from cluster.configure_reporting( - sensor.value_attribute, 300, 600, sensor.min_reportable_change, + yield from zha.configure_reporting( + sensor.entity_id, cluster, sensor.value_attribute, + reportable_change=sensor.min_reportable_change ) return sensor diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 9f780b631b655f..7ac93180cd7703 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -17,17 +17,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation switches.""" + from zigpy.zcl.clusters.general import OnOff + discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return - 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,) + switch = Switch(**discovery_info) + + if discovery_info['new_join']: + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await zha.configure_reporting( + switch.entity_id, cluster, switch.value_attribute, + min_report=0, max_report=600, reportable_change=1 + ) - async_add_entities([Switch(**discovery_info)], update_before_add=True) + async_add_entities([switch], update_before_add=True) class Switch(zha.Entity, SwitchDevice): diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 48c2ad20be05c8..4c1109f685da80 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -399,3 +399,42 @@ async def safe_read(cluster, attributes, allow_cache=True): return result except Exception: # pylint: disable=broad-except return {} + + +async def configure_reporting(entity_id, cluster, attr, skip_bind=False, + min_report=300, max_report=900, + reportable_change=1): + """Configure attribute reporting for a cluster. + + while swallowing the DeliverError exceptions in case of unreachable + devices. + """ + from zigpy.exceptions import DeliveryError + + attr_name = cluster.attributes.get(attr, [attr])[0] + cluster_name = cluster.ep_attribute + if not skip_bind: + try: + res = await cluster.bind() + _LOGGER.debug( + "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to bind '%s' cluster: %s", + entity_id, cluster_name, str(ex) + ) + + try: + res = await cluster.configure_reporting(attr, min_report, + max_report, reportable_change) + _LOGGER.debug( + "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Status: %s", + entity_id, attr_name, cluster_name, min_report, max_report, + reportable_change, res[0][0].status + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", + entity_id, attr_name, cluster_name, str(ex) + ) From 7a52bbdf241ce5f79f49633a17b8bb404abd9f13 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 13 Sep 2018 03:22:50 -0400 Subject: [PATCH 037/178] Allow only_cache parameter in zha.safe_read() (#16553) * Allow only_cache parameter in zha.safe_read() * Use cache_only for binary_sensor.zha initial update. * Use cache_only for fan.zha initial update. * Use cache_only for sensor.zha initial update. * Use cache_only for switch.zha initial update. * Use cache_only for light.zha initial update. * Refactor cached only read in zha platform. --- homeassistant/components/binary_sensor/zha.py | 3 ++- homeassistant/components/fan/zha.py | 4 +++- homeassistant/components/light/zha.py | 16 ++++++++++++---- homeassistant/components/sensor/zha.py | 9 +++++---- homeassistant/components/switch/zha.py | 4 +++- homeassistant/components/zha/__init__.py | 6 +++++- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 0842ab7db6ea8f..aa07a673c973d2 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -132,7 +132,8 @@ async def async_update(self): result = await zha.safe_read(self._endpoint.ias_zone, ['zone_status'], - allow_cache=False) + allow_cache=False, + only_cache=(not self._initialized)) 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/fan/zha.py b/homeassistant/components/fan/zha.py index d86e0c3c99b5fd..b5615f18d730ed 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -101,7 +101,9 @@ async def async_set_speed(self, speed: str) -> None: async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.fan, ['fan_mode']) + result = await zha.safe_read(self._endpoint.fan, ['fan_mode'], + allow_cache=False, + only_cache=(not self._initialized)) new_value = result.get('fan_mode', None) self._state = VALUE_TO_SPEED.get(new_value, None) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index dc5c4977944d0d..dd675ee674deae 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -154,23 +154,31 @@ def supported_features(self): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, ['on_off']) + result = await zha.safe_read(self._endpoint.on_off, ['on_off'], + allow_cache=False, + only_cache=(not self._initialized)) self._state = result.get('on_off', self._state) if self._supported_features & light.SUPPORT_BRIGHTNESS: result = await zha.safe_read(self._endpoint.level, - ['current_level']) + ['current_level'], + allow_cache=False, + only_cache=(not self._initialized)) self._brightness = result.get('current_level', self._brightness) if self._supported_features & light.SUPPORT_COLOR_TEMP: result = await zha.safe_read(self._endpoint.light_color, - ['color_temperature']) + ['color_temperature'], + allow_cache=False, + only_cache=(not self._initialized)) self._color_temp = result.get('color_temperature', self._color_temp) if self._supported_features & light.SUPPORT_COLOR: result = await zha.safe_read(self._endpoint.light_color, - ['current_x', 'current_y']) + ['current_x', 'current_y'], + allow_cache=False, + only_cache=(not self._initialized)) if 'current_x' in result and 'current_y' in result: xy_color = (round(result['current_x']/65535, 3), round(result['current_y']/65535, 3)) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 0a6710f8f6cec9..9962270dfd76af 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -95,7 +95,9 @@ async def async_update(self): """Retrieve latest state.""" result = await zha.safe_read( list(self._in_clusters.values())[0], - [self.value_attribute] + [self.value_attribute], + allow_cache=False, + only_cache=(not self._initialized) ) self._state = result.get(self.value_attribute, self._state) @@ -224,7 +226,6 @@ async def async_update(self): _LOGGER.debug("%s async_update", self.entity_id) result = await zha.safe_read( - self._endpoint.electrical_measurement, - ['active_power'], - allow_cache=False) + self._endpoint.electrical_measurement, ['active_power'], + allow_cache=False, only_cache=(not self._initialized)) self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 7ac93180cd7703..4fde35653482cb 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -86,5 +86,7 @@ async def async_turn_off(self, **kwargs): async def async_update(self): """Retrieve latest state.""" result = await zha.safe_read(self._endpoint.on_off, - ['on_off']) + ['on_off'], + allow_cache=False, + only_cache=(not self._initialized)) self._state = result.get('on_off', self._state) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 4c1109f685da80..f6b5868ff81989 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -336,6 +336,7 @@ def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, self._in_listeners = {} self._out_listeners = {} + self._initialized = False application_listener.register_entity(ieee, self) async def async_added_to_hass(self): @@ -348,6 +349,8 @@ async def async_added_to_hass(self): for cluster_id, cluster in self._out_clusters.items(): cluster.add_listener(self._out_listeners.get(cluster_id, self)) + self._initialized = True + @property def unique_id(self) -> str: """Return a unique ID.""" @@ -384,7 +387,7 @@ def get_discovery_info(hass, discovery_info): return all_discovery_info.get(discovery_key, None) -async def safe_read(cluster, attributes, allow_cache=True): +async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): """Swallow all exceptions from network read. If we throw during initialization, setup fails. Rather have an entity that @@ -395,6 +398,7 @@ async def safe_read(cluster, attributes, allow_cache=True): result, _ = await cluster.read_attributes( attributes, allow_cache=allow_cache, + only_cache=only_cache ) return result except Exception: # pylint: disable=broad-except From 901dd9acca50daec63ef97138823eaed35514a6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 13 Sep 2018 09:23:33 +0200 Subject: [PATCH 038/178] Update tibber lib version (#16590) --- homeassistant/components/sensor/tibber.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index ebc38fcb739e62..2dee84982da71d 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -21,7 +21,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util import Throttle -REQUIREMENTS = ['pyTibber==0.4.1'] +REQUIREMENTS = ['pyTibber==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8bf267ae83c093..1f708b9d1a8a05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,7 +752,7 @@ pyRFXtrx==0.23 pySwitchmate==0.3 # homeassistant.components.sensor.tibber -pyTibber==0.4.1 +pyTibber==0.5.0 # homeassistant.components.switch.dlink pyW215==0.6.0 From aec134c47abde714c41ce221d1de2b23fb0ba69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 13 Sep 2018 09:26:46 +0200 Subject: [PATCH 039/178] xiaomi lib 0.10.0 (#16591) --- homeassistant/components/xiaomi_aqara.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 2090f5227093dc..f2d51d2fc2e291 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -23,7 +23,7 @@ from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.9.5'] +REQUIREMENTS = ['PyXiaomiGateway==0.10.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1f708b9d1a8a05..933245e02e820c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -58,7 +58,7 @@ PyRMVtransport==0.1 PySwitchbot==0.3 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.9.5 +PyXiaomiGateway==0.10.0 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From f43d9ba6805b0b11f078dd255b13699a3d793a00 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Thu, 13 Sep 2018 03:35:40 -0400 Subject: [PATCH 040/178] Support for the Quirky Nimbus (#16520) * Support for quriky nimbu * Fixed lint * fixed some typos --- homeassistant/components/climate/wink.py | 10 +- homeassistant/components/wink/__init__.py | 163 ++++++++++++++++++-- homeassistant/components/wink/services.yaml | 41 +++++ requirements_all.txt | 2 +- 4 files changed, 199 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index d8e6843bec84e3..3013a155380a8d 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -118,7 +118,7 @@ def device_state_attributes(self): self.hass, self.target_temperature_low, self.temperature_unit, PRECISION_TENTHS) - if self.external_temperature: + if self.external_temperature is not None: data[ATTR_EXTERNAL_TEMPERATURE] = show_temp( self.hass, self.external_temperature, self.temperature_unit, PRECISION_TENTHS) @@ -126,16 +126,16 @@ def device_state_attributes(self): if self.smart_temperature: data[ATTR_SMART_TEMPERATURE] = self.smart_temperature - if self.occupied: + if self.occupied is not None: data[ATTR_OCCUPIED] = self.occupied - if self.eco_target: + if self.eco_target is not None: data[ATTR_ECO_TARGET] = self.eco_target - if self.heat_on: + if self.heat_on is not None: data[ATTR_HEAT_ON] = self.heat_on - if self.cool_on: + if self.cool_on is not None: data[ATTR_COOL_ON] = self.cool_on current_humidity = self.current_humidity diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index c996572bf510e5..0399b25b278ea3 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['python-wink==1.9.1', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.10.1', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -73,11 +73,25 @@ SERVICE_SIREN_STROBE_ENABLED = "set_siren_strobe_enabled" SERVICE_CHIME_STROBE_ENABLED = "set_chime_strobe_enabled" SERVICE_ENABLE_SIREN = "enable_siren" +SERVICE_SET_DIAL_CONFIG = "set_nimbus_dial_configuration" +SERVICE_SET_DIAL_STATE = "set_nimbus_dial_state" ATTR_VOLUME = "volume" ATTR_TONE = "tone" ATTR_ENABLED = "enabled" ATTR_AUTO_SHUTOFF = "auto_shutoff" +ATTR_MIN_VALUE = "min_value" +ATTR_MAX_VALUE = "max_value" +ATTR_ROTATION = "rotation" +ATTR_SCALE = "scale" +ATTR_TICKS = "ticks" +ATTR_MIN_POSITION = "min_position" +ATTR_MAX_POSITION = "max_position" +ATTR_VALUE = "value" +ATTR_LABELS = "labels" + +SCALES = ["linear", "log"] +ROTATIONS = ["cw", "ccw"] VOLUMES = ["low", "medium", "high"] TONES = ["doorbell", "fur_elise", "doorbell_extended", "alert", @@ -145,6 +159,23 @@ vol.Required(ATTR_ENABLED): cv.boolean }) +DIAL_CONFIG_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_MIN_VALUE): vol.Coerce(int), + vol.Optional(ATTR_MAX_VALUE): vol.Coerce(int), + vol.Optional(ATTR_MIN_POSITION): cv.positive_int, + vol.Optional(ATTR_MAX_POSITION): cv.positive_int, + vol.Optional(ATTR_ROTATION): vol.In(ROTATIONS), + vol.Optional(ATTR_SCALE): vol.In(SCALES), + vol.Optional(ATTR_TICKS): cv.positive_int +}) + +DIAL_STATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): vol.Coerce(int), + vol.Optional(ATTR_LABELS): cv.ensure_list(cv.string) +}) + WINK_COMPONENTS = [ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate', 'fan', 'alarm_control_panel', 'scene' @@ -432,8 +463,23 @@ def delete_device(call): DOMAIN, SERVICE_SET_PAIRING_MODE, set_pairing_mode, schema=SET_PAIRING_MODE_SCHEMA) - def service_handle(service): - """Handle services.""" + def nimbus_service_handle(service): + """Handle nimbus services.""" + entity_id = service.data.get('entity_id')[0] + _all_dials = [] + for sensor in hass.data[DOMAIN]['entities']['sensor']: + if isinstance(sensor, WinkNimbusDialDevice): + _all_dials.append(sensor) + for _dial in _all_dials: + if _dial.entity_id == entity_id: + if service.service == SERVICE_SET_DIAL_CONFIG: + _dial.set_configuration(**service.data) + if service.service == SERVICE_SET_DIAL_STATE: + _dial.wink.set_state(service.data.get("value"), + service.data.get("labels")) + + def siren_service_handle(service): + """Handle siren services.""" entity_ids = service.data.get('entity_id') all_sirens = [] for switch in hass.data[DOMAIN]['entities']['switch']: @@ -495,41 +541,68 @@ def service_handle(service): if sirens: hass.services.register(DOMAIN, SERVICE_SET_AUTO_SHUTOFF, - service_handle, + siren_service_handle, schema=SET_AUTO_SHUTOFF_SCHEMA) hass.services.register(DOMAIN, SERVICE_ENABLE_SIREN, - service_handle, + siren_service_handle, schema=ENABLED_SIREN_SCHEMA) if has_dome_or_wink_siren: hass.services.register(DOMAIN, SERVICE_SET_SIREN_TONE, - service_handle, + siren_service_handle, schema=SET_SIREN_TONE_SCHEMA) hass.services.register(DOMAIN, SERVICE_ENABLE_CHIME, - service_handle, + siren_service_handle, schema=SET_CHIME_MODE_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_SIREN_VOLUME, - service_handle, + siren_service_handle, schema=SET_VOLUME_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_CHIME_VOLUME, - service_handle, + siren_service_handle, schema=SET_VOLUME_SCHEMA) hass.services.register(DOMAIN, SERVICE_SIREN_STROBE_ENABLED, - service_handle, + siren_service_handle, schema=SET_STROBE_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_CHIME_STROBE_ENABLED, - service_handle, + siren_service_handle, schema=SET_STROBE_ENABLED_SCHEMA) component.add_entities(sirens) + nimbi = [] + dials = {} + all_nimbi = pywink.get_cloud_clocks() + all_dials = [] + for nimbus in all_nimbi: + if nimbus.object_type() == "cloud_clock": + nimbi.append(nimbus) + dials[nimbus.object_id()] = [] + for nimbus in all_nimbi: + if nimbus.object_type() == "dial": + dials[nimbus.parent_id()].append(nimbus) + + for nimbus in nimbi: + for dial in dials[nimbus.object_id()]: + all_dials.append(WinkNimbusDialDevice(nimbus, dial, hass)) + + if nimbi: + hass.services.register(DOMAIN, SERVICE_SET_DIAL_CONFIG, + nimbus_service_handle, + schema=DIAL_CONFIG_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_DIAL_STATE, + nimbus_service_handle, + schema=DIAL_STATE_SCHEMA) + + component.add_entities(all_dials) + return True @@ -596,6 +669,7 @@ def __init__(self, wink, hass): self.wink.name()) def _pubnub_update(self, message): + _LOGGER.debug(message) try: if message is None: _LOGGER.error("Error on pubnub update for %s " @@ -740,3 +814,70 @@ def device_state_attributes(self): attributes["chime_mode"] = chime_mode return attributes + + +class WinkNimbusDialDevice(WinkDevice): + """Representation of the Quirky Nimbus device.""" + + def __init__(self, nimbus, dial, hass): + """Initialize the Nimbus dial.""" + super().__init__(dial, hass) + self.parent = nimbus + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['sensor'].append(self) + + @property + def state(self): + """Return dials current value.""" + return self.wink.state() + + @property + def name(self): + """Return the name of the device.""" + return self.parent.name() + " dial " + str(self.wink.index() + 1) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = super(WinkNimbusDialDevice, self).device_state_attributes + dial_attributes = self.dial_attributes() + + return {**attributes, **dial_attributes} + + def dial_attributes(self): + """Return the dial only attributes.""" + return { + "labels": self.wink.labels(), + "position": self.wink.position(), + "rotation": self.wink.rotation(), + "max_value": self.wink.max_value(), + "min_value": self.wink.min_value(), + "num_ticks": self.wink.ticks(), + "scale_type": self.wink.scale(), + "max_position": self.wink.max_position(), + "min_position": self.wink.min_position() + } + + def set_configuration(self, **kwargs): + """ + Set the dial config. + + Anything not sent will default to current setting. + """ + attributes = {**self.dial_attributes(), **kwargs} + + min_value = attributes["min_value"] + max_value = attributes["max_value"] + rotation = attributes["rotation"] + ticks = attributes["num_ticks"] + scale = attributes["scale_type"] + min_position = attributes["min_position"] + max_position = attributes["max_position"] + + self.wink.set_configuration(min_value, max_value, rotation, + scale=scale, ticks=ticks, + min_position=min_position, + max_position=max_position) diff --git a/homeassistant/components/wink/services.yaml b/homeassistant/components/wink/services.yaml index 1dc4ecf959bf11..a3b489f9cf54ba 100644 --- a/homeassistant/components/wink/services.yaml +++ b/homeassistant/components/wink/services.yaml @@ -111,3 +111,44 @@ set_chime_volume: volume: description: Volume level. One of ["low", "medium", "high"] example: "low" + +set_nimbus_dial_configuration: + description: Set the configuration of an individual nimbus dial + fields: + entity_id: + description: Name of the entity to set. + example: 'wink.nimbus_dial_3' + rotation: + description: Direction dial hand should spin ["cw" or "ccw"] + example: 'cw' + ticks: + description: Number of times the hand should move + example: 12 + scale: + description: How the dial should move in response to higher values ["log" or "linear"] + example: "linear" + min_value: + description: The minimum value allowed to be set + example: 0 + max_value: + description: The maximum value allowd to be set + example: 500 + min_position: + description: The minimum position the dial hand can rotate to generally [0-360] + example: 0 + max_position: + description: The maximum position the dial hand can rotate to generally [0-360] + example: 360 + +set_nimbus_dial_state: + description: Set the value and lables of an individual nimbus dial + fields: + entity_id: + description: Name fo the entity to set. + example: 'wink.nimbus_dial_3' + value: + description: The value that should be set (Should be between min_value and max_value) + example: 250 + labels: + description: The values shown on the dial labels ["Dial 1", "test"] the first value is what is shown by default the second value is shown when the nimbus is pressed + example: ["example", "test"] \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 933245e02e820c..a5936d14169e51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1160,7 +1160,7 @@ python-velbus==2.0.19 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.9.1 +python-wink==1.10.1 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.3 From f63dba552142e90b42c192be4c62cbbfafa02266 Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 13 Sep 2018 10:48:17 +0300 Subject: [PATCH 041/178] Multiple tag managers for Wireless Sensor Tags. (#16353) * Added support for multiple tag managers. Fixed typo for signal strength. * Corrected broken merge. * Fixed flake8/pylint error. * Improved docstring. --- .../components/binary_sensor/wirelesstag.py | 114 ++++------------ .../components/sensor/wirelesstag.py | 83 +++--------- .../components/switch/wirelesstag.py | 29 +--- homeassistant/components/wirelesstag.py | 128 ++++++++++-------- requirements_all.txt | 2 +- 5 files changed, 117 insertions(+), 239 deletions(-) diff --git a/homeassistant/components/binary_sensor/wirelesstag.py b/homeassistant/components/binary_sensor/wirelesstag.py index 4bec3a824c395a..190b408abf38df 100644 --- a/homeassistant/components/binary_sensor/wirelesstag.py +++ b/homeassistant/components/binary_sensor/wirelesstag.py @@ -14,9 +14,6 @@ BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.wirelesstag import ( DOMAIN as WIRELESSTAG_DOMAIN, - WIRELESSTAG_TYPE_13BIT, WIRELESSTAG_TYPE_WATER, - WIRELESSTAG_TYPE_ALSPRO, - WIRELESSTAG_TYPE_WEMO_DEVICE, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor) from homeassistant.const import ( @@ -30,7 +27,7 @@ # On means in range, Off means out of range SENSOR_PRESENCE = 'presence' -# On means motion detected, Off means cear +# On means motion detected, Off means clear SENSOR_MOTION = 'motion' # On means open, Off means closed @@ -55,49 +52,21 @@ SENSOR_MOISTURE = 'moisture' # On means tag battery is low, Off means normal -SENSOR_BATTERY = 'low_battery' +SENSOR_BATTERY = 'battery' # Sensor types: Name, device_class, push notification type representing 'on', # attr to check SENSOR_TYPES = { - SENSOR_PRESENCE: ['Presence', 'presence', 'is_in_range', { - "on": "oor", - "off": "back_in_range" - }, 2], - SENSOR_MOTION: ['Motion', 'motion', 'is_moved', { - "on": "motion_detected", - }, 5], - SENSOR_DOOR: ['Door', 'door', 'is_door_open', { - "on": "door_opened", - "off": "door_closed" - }, 5], - SENSOR_COLD: ['Cold', 'cold', 'is_cold', { - "on": "temp_toolow", - "off": "temp_normal" - }, 4], - SENSOR_HEAT: ['Heat', 'heat', 'is_heat', { - "on": "temp_toohigh", - "off": "temp_normal" - }, 4], - SENSOR_DRY: ['Too dry', 'dry', 'is_too_dry', { - "on": "too_dry", - "off": "cap_normal" - }, 2], - SENSOR_WET: ['Too wet', 'wet', 'is_too_humid', { - "on": "too_humid", - "off": "cap_normal" - }, 2], - SENSOR_LIGHT: ['Light', 'light', 'is_light_on', { - "on": "too_bright", - "off": "light_normal" - }, 1], - SENSOR_MOISTURE: ['Leak', 'moisture', 'is_leaking', { - "on": "water_detected", - "off": "water_dried", - }, 1], - SENSOR_BATTERY: ['Low Battery', 'battery', 'is_battery_low', { - "on": "low_battery" - }, 3] + SENSOR_PRESENCE: 'Presence', + SENSOR_MOTION: 'Motion', + SENSOR_DOOR: 'Door', + SENSOR_COLD: 'Cold', + SENSOR_HEAT: 'Heat', + SENSOR_DRY: 'Too dry', + SENSOR_WET: 'Too wet', + SENSOR_LIGHT: 'Light', + SENSOR_MOISTURE: 'Leak', + SENSOR_BATTERY: 'Low Battery' } @@ -114,7 +83,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [] tags = platform.tags for tag in tags.values(): - allowed_sensor_types = WirelessTagBinarySensor.allowed_sensors(tag) + allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config.get(CONF_MONITORED_CONDITIONS): if sensor_type in allowed_sensor_types: sensors.append(WirelessTagBinarySensor(platform, tag, @@ -127,59 +96,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorDevice): """A binary sensor implementation for WirelessTags.""" - @classmethod - def allowed_sensors(cls, tag): - """Return list of allowed sensor types for specific tag type.""" - sensors_map = { - # 13-bit tag - allows everything but not light and moisture - WIRELESSTAG_TYPE_13BIT: [ - SENSOR_PRESENCE, SENSOR_BATTERY, - SENSOR_MOTION, SENSOR_DOOR, - SENSOR_COLD, SENSOR_HEAT, - SENSOR_DRY, SENSOR_WET], - - # Moister/water sensor - temperature and moisture only - WIRELESSTAG_TYPE_WATER: [ - SENSOR_PRESENCE, SENSOR_BATTERY, - SENSOR_COLD, SENSOR_HEAT, - SENSOR_MOISTURE], - - # ALS Pro: allows everything, but not moisture - WIRELESSTAG_TYPE_ALSPRO: [ - SENSOR_PRESENCE, SENSOR_BATTERY, - SENSOR_MOTION, SENSOR_DOOR, - SENSOR_COLD, SENSOR_HEAT, - SENSOR_DRY, SENSOR_WET, - SENSOR_LIGHT], - - # Wemo are power switches. - WIRELESSTAG_TYPE_WEMO_DEVICE: [SENSOR_PRESENCE] - } - - # allow everything if tag type is unknown - # (i just dont have full catalog of them :)) - tag_type = tag.tag_type - fullset = SENSOR_TYPES.keys() - return sensors_map[tag_type] if tag_type in sensors_map else fullset - def __init__(self, api, tag, sensor_type): """Initialize a binary sensor for a Wireless Sensor Tags.""" super().__init__(api, tag) self._sensor_type = sensor_type self._name = '{0} {1}'.format(self._tag.name, - SENSOR_TYPES[self._sensor_type][0]) - self._device_class = SENSOR_TYPES[self._sensor_type][1] - self._tag_attr = SENSOR_TYPES[self._sensor_type][2] - self.binary_spec = SENSOR_TYPES[self._sensor_type][3] - self.tag_id_index_template = SENSOR_TYPES[self._sensor_type][4] + self.event.human_readable_name) async def async_added_to_hass(self): """Register callbacks.""" tag_id = self.tag_id event_type = self.device_class + mac = self.tag_manager_mac async_dispatcher_connect( self.hass, - SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type), + SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), self._on_binary_event_callback) @property @@ -190,7 +121,12 @@ def is_on(self): @property def device_class(self): """Return the class of the binary sensor.""" - return self._device_class + return self._sensor_type + + @property + def event(self): + """Binary event of tag.""" + return self._tag.event[self._sensor_type] @property def principal_value(self): @@ -198,9 +134,7 @@ def principal_value(self): Subclasses need override based on type of sensor. """ - return ( - STATE_ON if getattr(self._tag, self._tag_attr, False) - else STATE_OFF) + return STATE_ON if self.event.is_state_on else STATE_OFF def updated_state_value(self): """Use raw princial value.""" @@ -208,7 +142,7 @@ def updated_state_value(self): @callback def _on_binary_event_callback(self, event): - """Update state from arrive push notification.""" + """Update state from arrived push notification.""" # state should be 'on' or 'off' self._state = event.data.get('state') self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/wirelesstag.py b/homeassistant/components/sensor/wirelesstag.py index a68fb5d0caff39..eb9ce2970653b7 100644 --- a/homeassistant/components/sensor/wirelesstag.py +++ b/homeassistant/components/sensor/wirelesstag.py @@ -15,13 +15,9 @@ CONF_MONITORED_CONDITIONS) from homeassistant.components.wirelesstag import ( DOMAIN as WIRELESSTAG_DOMAIN, - WIRELESSTAG_TYPE_13BIT, WIRELESSTAG_TYPE_WATER, - WIRELESSTAG_TYPE_ALSPRO, - WIRELESSTAG_TYPE_WEMO_DEVICE, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor) import homeassistant.helpers.config_validation as cv -from homeassistant.const import TEMP_CELSIUS DEPENDENCIES = ['wirelesstag'] @@ -32,24 +28,12 @@ SENSOR_MOISTURE = 'moisture' SENSOR_LIGHT = 'light' -SENSOR_TYPES = { - SENSOR_TEMPERATURE: { - 'unit': TEMP_CELSIUS, - 'attr': 'temperature' - }, - SENSOR_HUMIDITY: { - 'unit': '%', - 'attr': 'humidity' - }, - SENSOR_MOISTURE: { - 'unit': '%', - 'attr': 'moisture' - }, - SENSOR_LIGHT: { - 'unit': 'lux', - 'attr': 'light' - } -} +SENSOR_TYPES = [ + SENSOR_TEMPERATURE, + SENSOR_HUMIDITY, + SENSOR_MOISTURE, + SENSOR_LIGHT +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS, default=[]): @@ -64,7 +48,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): tags = platform.tags for tag in tags.values(): for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - if sensor_type in WirelessTagSensor.allowed_sensors(tag): + if sensor_type in tag.allowed_sensor_types: sensors.append(WirelessTagSensor( platform, tag, sensor_type, hass.config)) @@ -74,36 +58,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class WirelessTagSensor(WirelessTagBaseSensor): """Representation of a Sensor.""" - @classmethod - def allowed_sensors(cls, tag): - """Return array of allowed sensor types for tag.""" - all_sensors = SENSOR_TYPES.keys() - sensors_per_tag_type = { - WIRELESSTAG_TYPE_13BIT: [ - SENSOR_TEMPERATURE, - SENSOR_HUMIDITY], - WIRELESSTAG_TYPE_WATER: [ - SENSOR_TEMPERATURE, - SENSOR_MOISTURE], - WIRELESSTAG_TYPE_ALSPRO: [ - SENSOR_TEMPERATURE, - SENSOR_HUMIDITY, - SENSOR_LIGHT], - WIRELESSTAG_TYPE_WEMO_DEVICE: [] - } - - tag_type = tag.tag_type - return ( - sensors_per_tag_type[tag_type] if tag_type in sensors_per_tag_type - else all_sensors) - def __init__(self, api, tag, sensor_type, config): """Initialize a WirelessTag sensor.""" super().__init__(api, tag) self._sensor_type = sensor_type - self._tag_attr = SENSOR_TYPES[self._sensor_type]['attr'] - self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]['unit'] self._name = self._tag.name # I want to see entity_id as: @@ -118,7 +77,7 @@ async def async_added_to_hass(self): """Register callbacks.""" async_dispatcher_connect( self.hass, - SIGNAL_TAG_UPDATE.format(self.tag_id), + SIGNAL_TAG_UPDATE.format(self.tag_id, self.tag_manager_mac), self._update_tag_info_callback) @property @@ -144,33 +103,23 @@ def device_class(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return self._unit_of_measurement + return self._sensor.unit @property def principal_value(self): """Return sensor current value.""" - return getattr(self._tag, self._tag_attr, False) + return self._sensor.value + + @property + def _sensor(self): + """Return tag sensor entity.""" + return self._tag.sensor[self._sensor_type] @callback def _update_tag_info_callback(self, event): """Handle push notification sent by tag manager.""" - if event.data.get('id') != self.tag_id: - return - _LOGGER.info("Entity to update state: %s event data: %s", self, event.data) - new_value = self.principal_value - try: - if self._sensor_type == SENSOR_TEMPERATURE: - new_value = event.data.get('temp') - elif (self._sensor_type == SENSOR_HUMIDITY or - self._sensor_type == SENSOR_MOISTURE): - new_value = event.data.get('cap') - elif self._sensor_type == SENSOR_LIGHT: - new_value = event.data.get('lux') - except Exception as error: # pylint: disable=broad-except - _LOGGER.info("Unable to update value of entity: \ - %s error: %s event: %s", self, error, event) - + new_value = self._sensor.value_from_update_event(event.data) self._state = self.decorate_value(new_value) self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/wirelesstag.py b/homeassistant/components/switch/wirelesstag.py index 5796216d50f30a..cbe62d107da543 100644 --- a/homeassistant/components/switch/wirelesstag.py +++ b/homeassistant/components/switch/wirelesstag.py @@ -11,9 +11,6 @@ from homeassistant.components.wirelesstag import ( DOMAIN as WIRELESSTAG_DOMAIN, - WIRELESSTAG_TYPE_13BIT, WIRELESSTAG_TYPE_WATER, - WIRELESSTAG_TYPE_ALSPRO, - WIRELESSTAG_TYPE_WEMO_DEVICE, WirelessTagBaseSensor) from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import ( @@ -53,7 +50,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): tags = platform.load_tags() for switch_type in config.get(CONF_MONITORED_CONDITIONS): for _, tag in tags.items(): - if switch_type in WirelessTagSwitch.allowed_switches(tag): + if switch_type in tag.allowed_monitoring_types: switches.append(WirelessTagSwitch(platform, tag, switch_type)) add_entities(switches, True) @@ -62,30 +59,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class WirelessTagSwitch(WirelessTagBaseSensor, SwitchDevice): """A switch implementation for Wireless Sensor Tags.""" - @classmethod - def allowed_switches(cls, tag): - """Return allowed switch types for wireless tag.""" - all_sensors = SWITCH_TYPES.keys() - sensors_per_tag_spec = { - WIRELESSTAG_TYPE_13BIT: [ - ARM_TEMPERATURE, ARM_HUMIDITY, ARM_MOTION], - WIRELESSTAG_TYPE_WATER: [ - ARM_TEMPERATURE, ARM_MOISTURE], - WIRELESSTAG_TYPE_ALSPRO: [ - ARM_TEMPERATURE, ARM_HUMIDITY, ARM_MOTION, ARM_LIGHT], - WIRELESSTAG_TYPE_WEMO_DEVICE: [] - } - - tag_type = tag.tag_type - - result = ( - sensors_per_tag_spec[tag_type] - if tag_type in sensors_per_tag_spec else all_sensors) - _LOGGER.info("Allowed switches: %s tag_type: %s", - str(result), tag_type) - - return result - def __init__(self, api, tag, switch_type): """Initialize a switch for Wireless Sensor Tag.""" super().__init__(api, tag) diff --git a/homeassistant/components/wirelesstag.py b/homeassistant/components/wirelesstag.py index 19fb2d40b5d8e8..f2832100066152 100644 --- a/homeassistant/components/wirelesstag.py +++ b/homeassistant/components/wirelesstag.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/wirelesstag/ """ + import logging from requests.exceptions import HTTPError, ConnectTimeout @@ -11,17 +12,18 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_USERNAME, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv +from homeassistant import util from homeassistant.helpers.entity import Entity from homeassistant.helpers.dispatcher import ( dispatcher_send) -REQUIREMENTS = ['wirelesstagpy==0.3.0'] +REQUIREMENTS = ['wirelesstagpy==0.4.0'] _LOGGER = logging.getLogger(__name__) -# straight of signal in dBm -ATTR_TAG_SIGNAL_STRAIGHT = 'signal_straight' +# strength of signal in dBm +ATTR_TAG_SIGNAL_STRENGTH = 'signal_strength' # indicates if tag is out of range or not ATTR_TAG_OUT_OF_RANGE = 'out_of_range' # number in percents from max power of tag receiver @@ -34,13 +36,13 @@ DOMAIN = 'wirelesstag' DEFAULT_ENTITY_NAMESPACE = 'wirelesstag' -WIRELESSTAG_TYPE_13BIT = 13 -WIRELESSTAG_TYPE_ALSPRO = 26 -WIRELESSTAG_TYPE_WATER = 32 -WIRELESSTAG_TYPE_WEMO_DEVICE = 82 +# template for signal - first parameter is tag_id, +# second, tag manager mac address +SIGNAL_TAG_UPDATE = 'wirelesstag.tag_info_updated_{}_{}' -SIGNAL_TAG_UPDATE = 'wirelesstag.tag_info_updated_{}' -SIGNAL_BINARY_EVENT_UPDATE = 'wirelesstag.binary_event_updated_{}_{}' +# template for signal - tag_id, sensor type and +# tag manager mac address +SIGNAL_BINARY_EVENT_UPDATE = 'wirelesstag.binary_event_updated_{}_{}_{}' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -58,6 +60,12 @@ def __init__(self, hass, api): self.hass = hass self.api = api self.tags = {} + self._local_base_url = None + + @property + def tag_manager_macs(self): + """Return list of tag managers mac addresses in user account.""" + return self.api.mac_addresses def load_tags(self): """Load tags from remote server.""" @@ -69,72 +77,84 @@ def arm(self, switch): func_name = 'arm_{}'.format(switch.sensor_type) arm_func = getattr(self.api, func_name) if arm_func is not None: - arm_func(switch.tag_id) + arm_func(switch.tag_id, switch.tag_manager_mac) def disarm(self, switch): """Disarm entity sensor monitoring.""" func_name = 'disarm_{}'.format(switch.sensor_type) disarm_func = getattr(self.api, func_name) if disarm_func is not None: - disarm_func(switch.tag_id) + disarm_func(switch.tag_id, switch.tag_manager_mac) - # pylint: disable=no-self-use - def make_push_notitication(self, name, url, content): - """Create notification config.""" - from wirelesstagpy import NotificationConfig - return NotificationConfig(name, { - 'url': url, 'verb': 'POST', - 'content': content, 'disabled': False, 'nat': True}) - - def install_push_notifications(self, binary_sensors): - """Set up local push notification from tag manager.""" - _LOGGER.info("Registering local push notifications.") + def make_notifications(self, binary_sensors, mac): + """Create configurations for push notifications.""" + _LOGGER.info("Creating configurations for push notifications.") configs = [] - binary_url = self.binary_event_callback_url - for event in binary_sensors: - for state, name in event.binary_spec.items(): - content = ('{"type": "' + event.device_class + - '", "id":{' + str(event.tag_id_index_template) + - '}, "state": \"' + state + '\"}') - config = self.make_push_notitication(name, binary_url, content) - configs.append(config) - - content = ("{\"name\":\"{0}\",\"id\":{1},\"temp\":{2}," + - "\"cap\":{3},\"lux\":{4}}") + bi_url = self.binary_event_callback_url + for bi_sensor in binary_sensors: + configs.extend(bi_sensor.event.build_notifications(bi_url, mac)) + update_url = self.update_callback_url - update_config = self.make_push_notitication( - 'update', update_url, content) + from wirelesstagpy import NotificationConfig as NC + update_config = NC.make_config_for_update_event(update_url, mac) + configs.append(update_config) + return configs - result = self.api.install_push_notification(0, configs, True) - if not result: - self.hass.components.persistent_notification.create( - "Error: failed to install local push notifications
", - title="Wireless Sensor Tag Setup Local Push Notifications", - notification_id="wirelesstag_failed_push_notification") - else: - _LOGGER.info("Installed push notifications for all tags.") + def install_push_notifications(self, binary_sensors): + """Register local push notification from tag manager.""" + _LOGGER.info("Registering local push notifications.") + for mac in self.tag_manager_macs: + configs = self.make_notifications(binary_sensors, mac) + # install notifications for all tags in tag manager + # specified by mac + result = self.api.install_push_notification(0, configs, True, mac) + if not result: + self.hass.components.persistent_notification.create( + "Error: failed to install local push notifications
", + title="Wireless Sensor Tag Setup Local Push Notifications", + notification_id="wirelesstag_failed_push_notification") + else: + _LOGGER.info("Installed push notifications for all\ + tags in %s.", mac) + + @property + def local_base_url(self): + """Define base url of hass in local network.""" + if self._local_base_url is None: + self._local_base_url = "http://{}".format(util.get_local_ip()) + + port = self.hass.config.api.port + if port is not None: + self._local_base_url += ':{}'.format(port) + return self._local_base_url @property def update_callback_url(self): """Return url for local push notifications(update event).""" return '{}/api/events/wirelesstag_update_tags'.format( - self.hass.config.api.base_url) + self.local_base_url) @property def binary_event_callback_url(self): """Return url for local push notifications(binary event).""" return '{}/api/events/wirelesstag_binary_event'.format( - self.hass.config.api.base_url) + self.local_base_url) def handle_update_tags_event(self, event): """Handle push event from wireless tag manager.""" _LOGGER.info("push notification for update arrived: %s", event) - dispatcher_send( - self.hass, - SIGNAL_TAG_UPDATE.format(event.data.get('id')), - event) + try: + tag_id = event.data.get('id') + mac = event.data.get('mac') + dispatcher_send( + self.hass, + SIGNAL_TAG_UPDATE.format(tag_id, mac), + event) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error("Unable to handle tag update event:\ + %s error: %s", str(event), str(ex)) def handle_binary_event(self, event): """Handle push notifications for binary (on/off) events.""" @@ -142,12 +162,13 @@ def handle_binary_event(self, event): try: tag_id = event.data.get('id') event_type = event.data.get('type') + mac = event.data.get('mac') dispatcher_send( self.hass, - SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type), + SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), event) except Exception as ex: # pylint: disable=broad-except - _LOGGER.error("Unable to handle binary event:\ + _LOGGER.error("Unable to handle tag binary event:\ %s error: %s", str(event), str(ex)) @@ -193,6 +214,7 @@ def __init__(self, api, tag): self._tag = tag self._uuid = self._tag.uuid self.tag_id = self._tag.tag_id + self.tag_manager_mac = self._tag.tag_manager_mac self._name = self._tag.name self._state = None @@ -251,8 +273,8 @@ def device_state_attributes(self): return { ATTR_BATTERY_LEVEL: self._tag.battery_remaining, ATTR_VOLTAGE: '{:.2f}V'.format(self._tag.battery_volts), - ATTR_TAG_SIGNAL_STRAIGHT: '{}dBm'.format( - self._tag.signal_straight), + ATTR_TAG_SIGNAL_STRENGTH: '{}dBm'.format( + self._tag.signal_strength), ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range, ATTR_TAG_POWER_CONSUMPTION: '{:.2f}%'.format( self._tag.power_consumption) diff --git a/requirements_all.txt b/requirements_all.txt index a5936d14169e51..88ce55d0515596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1485,7 +1485,7 @@ websocket-client==0.37.0 websockets==6.0 # homeassistant.components.wirelesstag -wirelesstagpy==0.3.0 +wirelesstagpy==0.4.0 # homeassistant.components.zigbee xbee-helper==0.0.7 From e59ba28fe6184034d4297dcd230765a770a0f0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 13 Sep 2018 11:01:28 +0300 Subject: [PATCH 042/178] Add Huawei LTE router platform, device tracker, and sensor (#16498) * Add Huawei LTE router platform, device tracker, and sensor * Add myself to CODEOWNERS for huawei_lte --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/device_tracker/huawei_lte.py | 65 +++++++ homeassistant/components/huawei_lte.py | 123 +++++++++++++ homeassistant/components/sensor/huawei_lte.py | 169 ++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 365 insertions(+) create mode 100644 homeassistant/components/device_tracker/huawei_lte.py create mode 100644 homeassistant/components/huawei_lte.py create mode 100644 homeassistant/components/sensor/huawei_lte.py diff --git a/.coveragerc b/.coveragerc index bd08f5c38dfaa2..336edbff7360f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -141,6 +141,9 @@ omit = homeassistant/components/homematicip_cloud.py homeassistant/components/*/homematicip_cloud.py + homeassistant/components/huawei_lte.py + homeassistant/components/*/huawei_lte.py + homeassistant/components/hydrawise.py homeassistant/components/*/hydrawise.py diff --git a/CODEOWNERS b/CODEOWNERS index b86e09a6b7284b..82262c8fa83d40 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -97,6 +97,8 @@ homeassistant/components/*/eight_sleep.py @mezz64 homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline homeassistant/components/homekit/* @cdce8p +homeassistant/components/huawei_lte.py @scop +homeassistant/components/*/huawei_lte.py @scop homeassistant/components/knx.py @Julius2342 homeassistant/components/*/knx.py @Julius2342 homeassistant/components/konnected.py @heythisisnate diff --git a/homeassistant/components/device_tracker/huawei_lte.py b/homeassistant/components/device_tracker/huawei_lte.py new file mode 100644 index 00000000000000..4b4eb3f001a8a1 --- /dev/null +++ b/homeassistant/components/device_tracker/huawei_lte.py @@ -0,0 +1,65 @@ +""" +Support for Huawei LTE routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.huawei_lte/ +""" +from typing import Any, Dict, List, Optional + +import attr +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, DeviceScanner, +) +from homeassistant.const import CONF_URL +from ..huawei_lte import DATA_KEY, RouterData + + +DEPENDENCIES = ['huawei_lte'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_URL): cv.url, +}) + + +def get_scanner(hass, config): + """Get a Huawei LTE router scanner.""" + data = hass.data[DATA_KEY].get_data(config) + return HuaweiLteScanner(data) + + +@attr.s +class HuaweiLteScanner(DeviceScanner): + """Huawei LTE router scanner.""" + + data = attr.ib(type=RouterData) + + _hosts = attr.ib(init=False, factory=dict) + + def scan_devices(self) -> List[str]: + """Scan for devices.""" + self.data.update() + self._hosts = { + x["MacAddress"]: x + for x in self.data["wlan_host_list.Hosts.Host"] + if x.get("MacAddress") + } + return list(self._hosts) + + def get_device_name(self, device: str) -> Optional[str]: + """Get name for a device.""" + host = self._hosts.get(device) + return host.get("HostName") or None if host else None + + def get_extra_attributes(self, device: str) -> Dict[str, Any]: + """ + Get extra attributes of a device. + + Some known extra attributes that may be returned in the dict + include MacAddress (MAC address), ID (client ID), IpAddress + (IP address), AssociatedSsid (associated SSID), AssociatedTime + (associated time in seconds), and HostName (host name). + """ + return self._hosts.get(device) or {} diff --git a/homeassistant/components/huawei_lte.py b/homeassistant/components/huawei_lte.py new file mode 100644 index 00000000000000..268289d2bf5146 --- /dev/null +++ b/homeassistant/components/huawei_lte.py @@ -0,0 +1,123 @@ +""" +Support for Huawei LTE routers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/huawei_lte/ +""" +from datetime import timedelta +from functools import reduce +import logging +import operator + +import voluptuous as vol +import attr + +from homeassistant.const import ( + CONF_URL, CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.util import Throttle + + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['huawei-lte-api==1.0.12'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +DOMAIN = 'huawei_lte' +DATA_KEY = 'huawei_lte' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + })]) +}, extra=vol.ALLOW_EXTRA) + + +@attr.s +class RouterData: + """Class for router state.""" + + client = attr.ib() + device_information = attr.ib(init=False, factory=dict) + device_signal = attr.ib(init=False, factory=dict) + traffic_statistics = attr.ib(init=False, factory=dict) + wlan_host_list = attr.ib(init=False, factory=dict) + + def __getitem__(self, path: str): + """ + Get value corresponding to a dotted path. + + The first path component designates a member of this class + such as device_information, device_signal etc, and the remaining + path points to a value in the member's data structure. + """ + cat, *path_ = path.split(".") + return reduce(operator.getitem, path_, getattr(self, cat)) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self) -> None: + """Call API to update data.""" + self.device_information = self.client.device.information() + _LOGGER.debug("device_information=%s", self.device_information) + self.device_signal = self.client.device.signal() + _LOGGER.debug("device_signal=%s", self.device_signal) + self.traffic_statistics = self.client.monitoring.traffic_statistics() + _LOGGER.debug("traffic_statistics=%s", self.traffic_statistics) + self.wlan_host_list = self.client.wlan.host_list() + _LOGGER.debug("wlan_host_list=%s", self.wlan_host_list) + + +@attr.s +class HuaweiLteData: + """Shared state.""" + + data = attr.ib(init=False, factory=dict) + + def get_data(self, config): + """Get the requested or the only data value.""" + if CONF_URL in config: + return self.data.get(config[CONF_URL]) + if len(self.data) == 1: + return next(iter(self.data.values())) + + return None + + +def setup(hass, config) -> bool: + """Set up Huawei LTE component.""" + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = HuaweiLteData() + for conf in config.get(DOMAIN, []): + _setup_lte(hass, conf) + return True + + +def _setup_lte(hass, lte_config) -> None: + """Set up Huawei LTE router.""" + from huawei_lte_api.AuthorizedConnection import AuthorizedConnection + from huawei_lte_api.Client import Client + + url = lte_config[CONF_URL] + username = lte_config[CONF_USERNAME] + password = lte_config[CONF_PASSWORD] + + connection = AuthorizedConnection( + url, + username=username, + password=password, + ) + client = Client(connection) + + data = RouterData(client) + data.update() + hass.data[DATA_KEY].data[url] = data + + def cleanup(event): + """Clean up resources.""" + client.user.logout() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) diff --git a/homeassistant/components/sensor/huawei_lte.py b/homeassistant/components/sensor/huawei_lte.py new file mode 100644 index 00000000000000..d73472a705b7a1 --- /dev/null +++ b/homeassistant/components/sensor/huawei_lte.py @@ -0,0 +1,169 @@ +"""Huawei LTE sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.huawei_lte/ +""" + +import logging +import re + +import attr +import voluptuous as vol + +from homeassistant.const import ( + CONF_URL, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN, +) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +from ..huawei_lte import DATA_KEY, RouterData + + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['huawei_lte'] + +DEFAULT_NAME_TEMPLATE = 'Huawei {}: {}' + +DEFAULT_SENSORS = [ + "device_information.WanIPAddress", + "device_signal.rssi", +] + +SENSOR_META = { + "device_information.SoftwareVersion": dict( + name="Software version", + ), + "device_information.WanIPAddress": dict( + name="WAN IP address", + icon="mdi:ip", + ), + "device_information.WanIPv6Address": dict( + name="WAN IPv6 address", + icon="mdi:ip", + ), + "device_signal.rsrq": dict( + name="RSRQ", + # http://www.lte-anbieter.info/technik/rsrq.php + icon=lambda x: + x >= -5 and "mdi:signal-cellular-3" + or x >= -8 and "mdi:signal-cellular-2" + or x >= -11 and "mdi:signal-cellular-1" + or "mdi:signal-cellular-outline" + ), + "device_signal.rsrp": dict( + name="RSRP", + # http://www.lte-anbieter.info/technik/rsrp.php + icon=lambda x: + x >= -80 and "mdi:signal-cellular-3" + or x >= -95 and "mdi:signal-cellular-2" + or x >= -110 and "mdi:signal-cellular-1" + or "mdi:signal-cellular-outline" + ), + "device_signal.rssi": dict( + name="RSSI", + # https://eyesaas.com/wi-fi-signal-strength/ + icon=lambda x: + x >= -60 and "mdi:signal-cellular-3" + or x >= -70 and "mdi:signal-cellular-2" + or x >= -80 and "mdi:signal-cellular-1" + or "mdi:signal-cellular-outline" + ), + "device_signal.sinr": dict( + name="SINR", + # http://www.lte-anbieter.info/technik/sinr.php + icon=lambda x: + x >= 10 and "mdi:signal-cellular-3" + or x >= 5 and "mdi:signal-cellular-2" + or x >= 0 and "mdi:signal-cellular-1" + or "mdi:signal-cellular-outline" + ), +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_URL): cv.url, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=DEFAULT_SENSORS): cv.ensure_list, +}) + + +def setup_platform( + hass, config, add_entities, discovery_info): + """Set up Huawei LTE sensor devices.""" + data = hass.data[DATA_KEY].get_data(config) + sensors = [] + for path in config.get(CONF_MONITORED_CONDITIONS): + sensors.append(HuaweiLteSensor( + data, path, SENSOR_META.get(path, {}))) + add_entities(sensors, True) + + +@attr.s +class HuaweiLteSensor(Entity): + """Huawei LTE sensor entity.""" + + data = attr.ib(type=RouterData) + path = attr.ib(type=list) + meta = attr.ib(type=dict) + + _state = attr.ib(init=False, default=STATE_UNKNOWN) + _unit = attr.ib(init=False, type=str) + + @property + def unique_id(self) -> str: + """Return unique ID for sensor.""" + return "%s_%s" % ( + self.path, + self.data["device_information.SerialNumber"], + ) + + @property + def name(self) -> str: + """Return sensor name.""" + dname = self.data["device_information.DeviceName"] + vname = self.meta.get("name", self.path) + return DEFAULT_NAME_TEMPLATE.format(dname, vname) + + @property + def state(self): + """Return sensor state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return sensor's unit of measurement.""" + return self.meta.get("unit", self._unit) + + @property + def icon(self): + """Return icon for sensor.""" + icon = self.meta.get("icon") + if callable(icon): + return icon(self.state) + return icon + + def update(self): + """Update state.""" + self.data.update() + + unit = None + try: + value = self.data[self.path] + except KeyError: + _LOGGER.warning("%s not in data", self.path) + value = None + + if value is not None: + # Clean up value and infer unit, e.g. -71dBm, 15 dB + match = re.match( + r"(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value)) + if match: + try: + value = float(match.group("value")) + unit = match.group("unit") + except ValueError: + pass + + self._state = value + self._unit = unit diff --git a/requirements_all.txt b/requirements_all.txt index 88ce55d0515596..d740e915cbcd3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -463,6 +463,9 @@ homematicip==0.9.8 # homeassistant.components.remember_the_milk httplib2==0.10.3 +# homeassistant.components.huawei_lte +huawei-lte-api==1.0.12 + # homeassistant.components.hydrawise hydrawiser==0.1.1 From d076251b189c0a11f2bd528fdfdd0fb3d5dc8f5d Mon Sep 17 00:00:00 2001 From: Harvtronix Date: Thu, 13 Sep 2018 10:38:07 -0400 Subject: [PATCH 043/178] Changing z-wave brightness calculation to respect 0x01 and 0x02 byte values (#16420) * Changing z-wave brightness calculation to respect 0x01 and 0x02 byte values * adding additional line breaks to satisfy houndci * - Update comment style for linter - Add additional unit test to increase code coverage * Update zwave.py --- homeassistant/components/light/zwave.py | 12 +++++++++++- tests/components/light/test_zwave.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 55feef496f8bf4..1e768eb127a945 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -63,6 +63,16 @@ def brightness_state(value): return 0, STATE_OFF +def byte_to_zwave_brightness(value): + """Convert brightness in 0-255 scale to 0-99 scale. + + `value` -- (int) Brightness byte value from 0-255. + """ + if value > 0: + return max(1, int((value / 255) * 99)) + return 0 + + def ct_to_hs(temp): """Convert color temperature (mireds) to hs.""" colorlist = list( @@ -187,7 +197,7 @@ def turn_on(self, **kwargs): # brightness. Level 255 means to set it to previous value. if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - brightness = int((self._brightness / 255) * 99) + brightness = byte_to_zwave_brightness(self._brightness) else: brightness = 255 diff --git a/tests/components/light/test_zwave.py b/tests/components/light/test_zwave.py index 62bcf834b98f6a..5805c8eb2fbbe3 100644 --- a/tests/components/light/test_zwave.py +++ b/tests/components/light/test_zwave.py @@ -105,6 +105,26 @@ def test_dimmer_turn_on(mock_openzwave): assert entity_id == device.entity_id +def test_dimmer_min_brightness(mock_openzwave): + """Test turning on a dimmable Z-Wave light to its minimum brightness.""" + node = MockNode() + value = MockValue(data=0, node=node) + values = MockLightValues(primary=value) + device = zwave.get_device(node=node, values=values, node_config={}) + + assert not device.is_on + + device.turn_on(**{ATTR_BRIGHTNESS: 1}) + + assert device.is_on + assert device.brightness == 1 + + device.turn_on(**{ATTR_BRIGHTNESS: 0}) + + assert device.is_on + assert device.brightness == 0 + + def test_dimmer_transitions(mock_openzwave): """Test dimming transition on a dimmable Z-Wave light.""" node = MockNode() From 67b5b5bc85c76303e2ccc88f70251631d93141c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 14 Sep 2018 09:42:23 +0300 Subject: [PATCH 044/178] Add myself to CODEOWNERS for upcloud (#16599) --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 82262c8fa83d40..24c19c8b694406 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -119,6 +119,8 @@ homeassistant/components/*/tesla.py @zabuldon homeassistant/components/tellduslive.py @molobrakos @fredrike homeassistant/components/*/tellduslive.py @molobrakos @fredrike homeassistant/components/*/tradfri.py @ggravlingen +homeassistant/components/upcloud.py @scop +homeassistant/components/*/upcloud.py @scop homeassistant/components/velux.py @Julius2342 homeassistant/components/*/velux.py @Julius2342 homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi From 05c07171674ee9f40c8ae29709d04d6b3d24db3c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 14 Sep 2018 11:57:18 +0200 Subject: [PATCH 045/178] Add websocket list APIs for the registries (#16597) * Add websocket list APIs for the registries * Remove identifiers * Fix tests * Use ordered dict --- homeassistant/components/config/__init__.py | 13 +++- .../components/config/device_registry.py | 46 ++++++++++++++ .../components/config/entity_registry.py | 32 ++++++++++ tests/common.py | 15 ++++- .../components/config/test_device_registry.py | 60 +++++++++++++++++++ .../components/config/test_entity_registry.py | 45 ++++++++++++++ tests/helpers/test_device_registry.py | 18 +----- 7 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/config/device_registry.py create mode 100644 tests/components/config/test_device_registry.py diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 581d8fc3f7b9f1..df0e2f13ac1ddf 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -13,8 +13,17 @@ DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script', - 'entity_registry', 'config_entries') +SECTIONS = ( + 'automation', + 'config_entries', + 'core', + 'customize', + 'device_registry', + 'entity_registry', + 'group', + 'hassbian', + 'script', +) ON_DEMAND = ('zwave',) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py new file mode 100644 index 00000000000000..8383e0cdc7daac --- /dev/null +++ b/homeassistant/components/config/device_registry.py @@ -0,0 +1,46 @@ +"""HTTP views to interact with the device registry.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import async_get_registry +from homeassistant.components import websocket_api + +DEPENDENCIES = ['websocket_api'] + +WS_TYPE_LIST = 'config/device_registry/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + + +async def async_setup(hass): + """Enable the Entity Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_devices, + SCHEMA_WS_LIST + ) + return True + + +@callback +def websocket_list_devices(hass, connection, msg): + """Handle list devices command. + + Async friendly. + """ + async def retrieve_entities(): + """Get devices from registry.""" + registry = await async_get_registry(hass) + connection.send_message_outside(websocket_api.result_message( + msg['id'], [{ + 'config_entries': list(entry.config_entries), + 'connections': list(entry.connections), + 'manufacturer': entry.manufacturer, + 'model': entry.model, + 'name': entry.name, + 'sw_version': entry.sw_version, + 'id': entry.id, + } for entry in registry.devices.values()] + )) + + hass.async_add_job(retrieve_entities()) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 2fac420c39c92c..0f9abf167e5175 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -8,6 +8,11 @@ DEPENDENCIES = ['websocket_api'] +WS_TYPE_LIST = 'config/entity_registry/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + WS_TYPE_GET = 'config/entity_registry/get' SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_GET, @@ -26,6 +31,10 @@ async def async_setup(hass): """Enable the Entity Registry views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list_entities, + SCHEMA_WS_LIST + ) hass.components.websocket_api.async_register_command( WS_TYPE_GET, websocket_get_entity, SCHEMA_WS_GET @@ -37,6 +46,29 @@ async def async_setup(hass): return True +@callback +def websocket_list_entities(hass, connection, msg): + """Handle list registry entries command. + + Async friendly. + """ + async def retrieve_entities(): + """Get entities from registry.""" + registry = await async_get_registry(hass) + connection.send_message_outside(websocket_api.result_message( + msg['id'], [{ + 'config_entry_id': entry.config_entry_id, + 'device_id': entry.device_id, + 'disabled_by': entry.disabled_by, + 'entity_id': entry.entity_id, + 'name': entry.name, + 'platform': entry.platform, + } for entry in registry.entities.values()] + )) + + hass.async_add_job(retrieve_entities()) + + @callback def websocket_get_entity(hass, connection, msg): """Handle get entity registry entry command. diff --git a/tests/common.py b/tests/common.py index 738c51fb3f0982..26cd7743accb96 100644 --- a/tests/common.py +++ b/tests/common.py @@ -19,7 +19,7 @@ from homeassistant.config import async_process_component_config from homeassistant.helpers import ( intent, entity, restore_state, entity_registry, - entity_platform, storage) + entity_platform, storage, device_registry) from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util import homeassistant.util.yaml as yaml @@ -332,6 +332,19 @@ async def _get_reg(): return registry +def mock_device_registry(hass, mock_entries=None): + """Mock the Device Registry.""" + registry = device_registry.DeviceRegistry(hass) + registry.devices = mock_entries or OrderedDict() + + async def _get_reg(): + return registry + + hass.data[device_registry.DATA_REGISTRY] = \ + hass.loop.create_task(_get_reg()) + return registry + + class MockUser(auth_models.User): """Mock a user in Home Assistant.""" diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py new file mode 100644 index 00000000000000..491319bf92791b --- /dev/null +++ b/tests/components/config/test_device_registry.py @@ -0,0 +1,60 @@ +"""Test entity_registry API.""" +import pytest + +from homeassistant.components.config import device_registry +from tests.common import mock_device_registry + + +@pytest.fixture +def client(hass, hass_ws_client): + """Fixture that can interact with the config manager API.""" + hass.loop.run_until_complete(device_registry.async_setup(hass)) + yield hass.loop.run_until_complete(hass_ws_client(hass)) + + +@pytest.fixture +def registry(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +async def test_list_devices(hass, client, registry): + """Test list entries.""" + registry.async_get_or_create( + config_entry='1234', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + registry.async_get_or_create( + config_entry='1234', + connections={}, + identifiers={('bridgeid', '1234')}, + manufacturer='manufacturer', model='model') + + await client.send_json({ + 'id': 5, + 'type': 'config/device_registry/list', + }) + msg = await client.receive_json() + + for entry in msg['result']: + entry.pop('id') + + assert msg['result'] == [ + { + 'config_entries': ['1234'], + 'connections': [['ethernet', '12:34:56:78:90:AB:CD:EF']], + 'manufacturer': 'manufacturer', + 'model': 'model', + 'name': None, + 'sw_version': None + }, + { + 'config_entries': ['1234'], + 'connections': [], + 'manufacturer': 'manufacturer', + 'model': 'model', + 'name': None, + 'sw_version': None + } + ] diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 559f29372de4a7..cd74faf18434ac 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -1,4 +1,6 @@ """Test entity_registry API.""" +from collections import OrderedDict + import pytest from homeassistant.helpers.entity_registry import RegistryEntry @@ -13,6 +15,49 @@ def client(hass, hass_ws_client): yield hass.loop.run_until_complete(hass_ws_client(hass)) +async def test_list_entities(hass, client): + """Test list entries.""" + entities = OrderedDict() + entities['test_domain.name'] = RegistryEntry( + entity_id='test_domain.name', + unique_id='1234', + platform='test_platform', + name='Hello World' + ) + entities['test_domain.no_name'] = RegistryEntry( + entity_id='test_domain.no_name', + unique_id='6789', + platform='test_platform', + ) + + mock_registry(hass, entities) + + await client.send_json({ + 'id': 5, + 'type': 'config/entity_registry/list', + }) + msg = await client.receive_json() + + assert msg['result'] == [ + { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'entity_id': 'test_domain.name', + 'name': 'Hello World', + 'platform': 'test_platform', + }, + { + 'config_entry_id': None, + 'device_id': None, + 'disabled_by': None, + 'entity_id': 'test_domain.no_name', + 'name': None, + 'platform': 'test_platform', + } + ] + + async def test_get_entity(hass, client): """Test get entry.""" mock_registry(hass, { diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index a9132529bc3b07..5ae6b4df651f0b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,28 +1,14 @@ """Tests for the Device Registry.""" import pytest -from collections import OrderedDict - from homeassistant.helpers import device_registry - - -def mock_registry(hass, mock_entries=None): - """Mock the Device Registry.""" - registry = device_registry.DeviceRegistry(hass) - registry.devices = mock_entries or OrderedDict() - - async def _get_reg(): - return registry - - hass.data[device_registry.DATA_REGISTRY] = \ - hass.loop.create_task(_get_reg()) - return registry +from tests.common import mock_device_registry @pytest.fixture def registry(hass): """Return an empty, loaded, registry.""" - return mock_registry(hass) + return mock_device_registry(hass) async def test_get_or_create_returns_same_entry(registry): From 72e746d240c42ed552cb3c10f5c44d9d210c58ad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 14 Sep 2018 11:57:31 +0200 Subject: [PATCH 046/178] MQTT config entry (#16594) * MQTT config entry * Solely rely on config entry * Improve wawrning * Lint * Lint2 --- .../components/hangouts/.translations/en.json | 2 + .../homematicip_cloud/.translations/en.json | 1 - .../components/mqtt/.translations/en.json | 23 +++ homeassistant/components/mqtt/__init__.py | 182 ++++++++++++------ homeassistant/components/mqtt/config_flow.py | 98 ++++++++++ homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/strings.json | 23 +++ homeassistant/config_entries.py | 17 ++ tests/components/mqtt/test_config_flow.py | 85 ++++++++ tests/components/mqtt/test_init.py | 111 ++++++----- tests/components/mqtt/test_server.py | 24 +-- 11 files changed, 435 insertions(+), 133 deletions(-) create mode 100644 homeassistant/components/mqtt/.translations/en.json create mode 100644 homeassistant/components/mqtt/config_flow.py create mode 100644 homeassistant/components/mqtt/const.py create mode 100644 homeassistant/components/mqtt/strings.json create mode 100644 tests/components/mqtt/test_config_flow.py diff --git a/homeassistant/components/hangouts/.translations/en.json b/homeassistant/components/hangouts/.translations/en.json index f526bec4f34679..08d491e7009a8c 100644 --- a/homeassistant/components/hangouts/.translations/en.json +++ b/homeassistant/components/hangouts/.translations/en.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "", "title": "2-Factor-Authentication" }, "user": { @@ -21,6 +22,7 @@ "email": "E-Mail Address", "password": "Password" }, + "description": "", "title": "Google Hangouts Login" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json index 6fcfcddd75d6e6..605bb0d250bba7 100644 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Access point is already configured", - "conection_aborted": "Could not connect to HMIP server", "connection_aborted": "Could not connect to HMIP server", "unknown": "Unknown error occurred." }, diff --git a/homeassistant/components/mqtt/.translations/en.json b/homeassistant/components/mqtt/.translations/en.json new file mode 100644 index 00000000000000..1f0ed341bb6065 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of MQTT is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to the broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "Password", + "port": "Port", + "username": "Username" + }, + "description": "Please enter the connection information of your MQTT broker.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6bb08d7e8e5429..ac29671667f040 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -18,6 +18,7 @@ import voluptuous as vol +from homeassistant import config_entries from homeassistant.helpers.typing import HomeAssistantType, ConfigType, \ ServiceDataType from homeassistant.core import callback, Event, ServiceCall @@ -30,8 +31,12 @@ run_coroutine_threadsafe, run_callback_threadsafe) from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_VALUE_TEMPLATE, CONF_USERNAME, - CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD) + CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD, + EVENT_HOMEASSISTANT_START) +# Loading the config flow file will register the flow +from . import config_flow # noqa # pylint: disable=unused-import +from .const import CONF_BROKER from .server import HBMQTT_CONFIG_SCHEMA REQUIREMENTS = ['paho-mqtt==1.3.1'] @@ -41,11 +46,12 @@ DOMAIN = 'mqtt' DATA_MQTT = 'mqtt' +DATA_MQTT_CONFIG = 'mqtt_config' SERVICE_PUBLISH = 'publish' CONF_EMBEDDED = 'embedded' -CONF_BROKER = 'broker' + CONF_CLIENT_ID = 'client_id' CONF_DISCOVERY = 'discovery' CONF_DISCOVERY_PREFIX = 'discovery_prefix' @@ -311,6 +317,7 @@ async def _async_setup_server(hass: HomeAssistantType, if not success: return None + return broker_config @@ -340,19 +347,15 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: conf = config.get(DOMAIN) # type: Optional[ConfigType] if conf is None: - conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - conf = cast(ConfigType, conf) + # If we have a config entry, setup is done by that config entry. + # If there is no config entry, this should fail. + return bool(hass.config_entries.async_entries(DOMAIN)) - client_id = conf.get(CONF_CLIENT_ID) # type: Optional[str] - keepalive = conf.get(CONF_KEEPALIVE) # type: int + conf = dict(conf) - # Only setup if embedded config passed in or no broker specified - if CONF_EMBEDDED not in conf and CONF_BROKER in conf: - broker_config = None - else: + if CONF_EMBEDDED in conf or CONF_BROKER not in conf: if (conf.get(CONF_PASSWORD) is None and - config.get('http') is not None and - config['http'].get('api_password') is not None): + config.get('http', {}).get('api_password') is not None): _LOGGER.error( "Starting from release 0.76, the embedded MQTT broker does not" " use api_password as default password anymore. Please set" @@ -362,48 +365,98 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: broker_config = await _async_setup_server(hass, config) - if CONF_BROKER in conf: - broker = conf[CONF_BROKER] # type: str - port = conf[CONF_PORT] # type: int - username = conf.get(CONF_USERNAME) # type: Optional[str] - password = conf.get(CONF_PASSWORD) # type: Optional[str] - certificate = conf.get(CONF_CERTIFICATE) # type: Optional[str] - client_key = conf.get(CONF_CLIENT_KEY) # type: Optional[str] - client_cert = conf.get(CONF_CLIENT_CERT) # type: Optional[str] - tls_insecure = conf.get(CONF_TLS_INSECURE) # type: Optional[bool] - protocol = conf[CONF_PROTOCOL] # type: str - elif broker_config is not None: - # If no broker passed in, auto config to internal server - broker, port, username, password, certificate, protocol = broker_config - # Embedded broker doesn't have some ssl variables - client_key, client_cert, tls_insecure = None, None, None - # hbmqtt requires a client id to be set. - if client_id is None: - client_id = 'home-assistant' - else: - err = "Unable to start MQTT broker." - if conf.get(CONF_EMBEDDED) is not None: - # Explicit embedded config, requires explicit broker config - err += " (Broker configuration required.)" - _LOGGER.error(err) + if broker_config is None: + _LOGGER.error('Unable to start embedded MQTT broker') + return False + + conf.update({ + CONF_BROKER: broker_config[0], + CONF_PORT: broker_config[1], + CONF_USERNAME: broker_config[2], + CONF_PASSWORD: broker_config[3], + CONF_CERTIFICATE: broker_config[4], + CONF_PROTOCOL: broker_config[5], + CONF_CLIENT_KEY: None, + CONF_CLIENT_CERT: None, + CONF_TLS_INSECURE: None, + }) + + hass.data[DATA_MQTT_CONFIG] = conf + + # Only import if we haven't before. + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={} + )) + + if conf.get(CONF_DISCOVERY): + async def async_setup_discovery(event): + await _async_setup_discovery(hass, config) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_setup_discovery) + + return True + + +async def async_setup_entry(hass, entry): + """Load a config entry.""" + conf = hass.data.get(DATA_MQTT_CONFIG) + + # Config entry was created because user had configuration.yaml entry + # They removed that, so remove entry. + if conf is None and entry.source == config_entries.SOURCE_IMPORT: + hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id)) return False + # If user didn't have configuration.yaml config, generate defaults + if conf is None: + conf = CONFIG_SCHEMA({ + DOMAIN: entry.data + })[DOMAIN] + elif any(key in conf for key in entry.data): + _LOGGER.warning( + 'Data in your config entry is going to override your ' + 'configuration.yaml: %s', entry.data) + + conf.update(entry.data) + + broker = conf[CONF_BROKER] + port = conf[CONF_PORT] + client_id = conf.get(CONF_CLIENT_ID) + keepalive = conf[CONF_KEEPALIVE] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + client_key = conf.get(CONF_CLIENT_KEY) + client_cert = conf.get(CONF_CLIENT_CERT) + tls_insecure = conf.get(CONF_TLS_INSECURE) + protocol = conf[CONF_PROTOCOL] + # For cloudmqtt.com, secured connection, auto fill in certificate - if (certificate is None and 19999 < port < 30000 and - broker.endswith('.cloudmqtt.com')): + if (conf.get(CONF_CERTIFICATE) is None and + 19999 < conf[CONF_PORT] < 30000 and + conf[CONF_BROKER].endswith('.cloudmqtt.com')): certificate = os.path.join(os.path.dirname(__file__), 'addtrustexternalcaroot.crt') # When the certificate is set to auto, use bundled certs from requests - if certificate == 'auto': + elif conf.get(CONF_CERTIFICATE) == 'auto': certificate = requests.certs.where() - will_message = None # type: Optional[Message] - if conf.get(CONF_WILL_MESSAGE) is not None: - will_message = Message(**conf.get(CONF_WILL_MESSAGE)) - birth_message = None # type: Optional[Message] - if conf.get(CONF_BIRTH_MESSAGE) is not None: - birth_message = Message(**conf.get(CONF_BIRTH_MESSAGE)) + else: + certificate = None + + if CONF_WILL_MESSAGE in conf: + will_message = Message(**conf[CONF_WILL_MESSAGE]) + else: + will_message = None + + if CONF_BIRTH_MESSAGE in conf: + birth_message = Message(**conf[CONF_BIRTH_MESSAGE]) + else: + birth_message = None # Be able to override versions other than TLSv1.0 under Python3.6 conf_tls_version = conf.get(CONF_TLS_VERSION) # type: str @@ -421,14 +474,27 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: else: tls_version = ssl.PROTOCOL_TLSv1 - try: - hass.data[DATA_MQTT] = MQTT( - hass, broker, port, client_id, keepalive, username, password, - certificate, client_key, client_cert, tls_insecure, protocol, - will_message, birth_message, tls_version) - except socket.error: - _LOGGER.exception("Can't connect to the broker. " - "Please check your settings and the broker itself") + hass.data[DATA_MQTT] = MQTT( + hass, + broker=broker, + port=port, + client_id=client_id, + keepalive=keepalive, + username=username, + password=password, + certificate=certificate, + client_key=client_key, + client_cert=client_cert, + tls_insecure=tls_insecure, + protocol=protocol, + will_message=will_message, + birth_message=birth_message, + tls_version=tls_version, + ) + + success = await hass.data[DATA_MQTT].async_connect() # type: bool + + if not success: return False async def async_stop_mqtt(event: Event): @@ -437,10 +503,6 @@ async def async_stop_mqtt(event: Event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) - success = await hass.data[DATA_MQTT].async_connect() # type: bool - if not success: - return False - async def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" msg_topic = call.data[ATTR_TOPIC] # type: str @@ -466,9 +528,6 @@ async def async_publish_service(call: ServiceCall): DOMAIN, SERVICE_PUBLISH, async_publish_service, schema=MQTT_PUBLISH_SCHEMA) - if conf.get(CONF_DISCOVERY): - await _async_setup_discovery(hass, config) - return True @@ -501,7 +560,8 @@ def __init__(self, hass: HomeAssistantType, broker: str, port: int, certificate: Optional[str], client_key: Optional[str], client_cert: Optional[str], tls_insecure: Optional[bool], protocol: Optional[str], will_message: Optional[Message], - birth_message: Optional[Message], tls_version) -> None: + birth_message: Optional[Message], + tls_version: Optional[int]) -> None: """Initialize Home Assistant MQTT client.""" import paho.mqtt.client as mqtt diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py new file mode 100644 index 00000000000000..42bc324f8fc72f --- /dev/null +++ b/homeassistant/components/mqtt/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for MQTT.""" +from collections import OrderedDict +import queue + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_PORT + +from .const import CONF_BROKER + + +@config_entries.HANDLERS.register('mqtt') +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort( + reason='single_instance_allowed' + ) + + return await self.async_step_broker() + + async def async_step_broker(self, user_input=None): + """Confirm setup.""" + errors = {} + + if user_input is not None: + can_connect = await self.hass.async_add_executor_job( + try_connection, user_input[CONF_BROKER], user_input[CONF_PORT], + user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD)) + + if can_connect: + return self.async_create_entry( + title=user_input[CONF_BROKER], + data=user_input + ) + + errors['base'] = 'cannot_connect' + + fields = OrderedDict() + fields[vol.Required(CONF_BROKER)] = str + fields[vol.Required(CONF_PORT, default=1883)] = vol.Coerce(int) + fields[vol.Optional(CONF_USERNAME)] = str + fields[vol.Optional(CONF_PASSWORD)] = str + + return self.async_show_form( + step_id='broker', + data_schema=vol.Schema(fields), + errors=errors, + ) + + async def async_step_import(self, user_input): + """Import a config entry. + + Special type of import, we're not actually going to store any data. + Instead, we're going to rely on the values that are in config file. + """ + if self._async_current_entries(): + return self.async_abort( + reason='single_instance_allowed' + ) + + return self.async_create_entry( + title='configuration.yaml', + data={} + ) + + +def try_connection(broker, port, username, password): + """Test if we can connect to an MQTT broker.""" + import paho.mqtt.client as mqtt + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + result = queue.Queue(maxsize=1) + + def on_connect(client_, userdata, flags, result_code): + """Handle connection result.""" + result.put(result_code == mqtt.CONNACK_ACCEPTED) + + client.on_connect = on_connect + + client.connect_async(broker, port) + client.loop_start() + + try: + return result.get(timeout=5) + except queue.Empty: + return False + finally: + client.disconnect() + client.loop_stop() diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py new file mode 100644 index 00000000000000..8f9d938cf881e5 --- /dev/null +++ b/homeassistant/components/mqtt/const.py @@ -0,0 +1,2 @@ +"""Constants used by multiple MQTT modules.""" +CONF_BROKER = 'broker' diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json new file mode 100644 index 00000000000000..a38983125aed55 --- /dev/null +++ b/homeassistant/components/mqtt/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "MQTT", + "step": { + "broker": { + "title": "MQTT", + "description": "Please enter the connection information of your MQTT broker.", + "data": { + "broker": "Broker", + "port": "Port", + "username": "Username", + "password": "Password" + } + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of MQTT is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to the broker." + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b06d6e7df5522f..b1e98d310487af 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -141,6 +141,7 @@ async def async_step_discovery(info): 'homematicip_cloud', 'hue', 'ios', + 'mqtt', 'nest', 'openuv', 'sonos', @@ -463,3 +464,19 @@ def _data_to_save(self): async def _old_conf_migrator(old_config): """Migrate the pre-0.73 config format to the latest version.""" return {'entries': old_config} + + +class ConfigFlow(data_entry_flow.FlowHandler): + """Base class for config flows with some helpers.""" + + @callback + def _async_current_entries(self): + """Return current entries.""" + return self.hass.config_entries.async_entries(self.handler) + + @callback + def _async_in_progress(self): + """Return other in progress flows for current domain.""" + return [flw for flw in self.hass.config_entries.flow.async_progress() + if flw['handler'] == self.handler and + flw['flow_id'] != self.flow_id] diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py new file mode 100644 index 00000000000000..4a4d783940f48f --- /dev/null +++ b/tests/components/mqtt/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro + + +@pytest.fixture(autouse=True) +def mock_finish_setup(): + """Mock out the finish setup method.""" + with patch('homeassistant.components.mqtt.MQTT.async_connect', + return_value=mock_coro(True)) as mock_finish: + yield mock_finish + + +@pytest.fixture +def mock_try_connection(): + """Mock the try connection method.""" + with patch( + 'homeassistant.components.mqtt.config_flow.try_connection' + ) as mock_try: + yield mock_try + + +async def test_user_connection_works(hass, mock_try_connection, + mock_finish_setup): + """Test we can finish a config flow.""" + mock_try_connection.return_value = True + + result = await hass.config_entries.flow.async_init( + 'mqtt', context={'source': 'user'}) + assert result['type'] == 'form' + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], { + 'broker': '127.0.0.1', + } + ) + + assert result['type'] == 'create_entry' + # Check we tried the connection + assert len(mock_try_connection.mock_calls) == 1 + # Check config entry got setup + assert len(mock_finish_setup.mock_calls) == 1 + + +async def test_user_connection_fails(hass, mock_try_connection, + mock_finish_setup): + """Test if connnection cannot be made.""" + mock_try_connection.return_value = False + + result = await hass.config_entries.flow.async_init( + 'mqtt', context={'source': 'user'}) + assert result['type'] == 'form' + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], { + 'broker': '127.0.0.1', + } + ) + + assert result['type'] == 'form' + assert result['errors']['base'] == 'cannot_connect' + + # Check we tried the connection + assert len(mock_try_connection.mock_calls) == 1 + # Check config entry did not setup + assert len(mock_finish_setup.mock_calls) == 0 + + +async def test_manual_config_set(hass, mock_try_connection, + mock_finish_setup): + """Test we ignore entry if manual config available.""" + assert await async_setup_component( + hass, 'mqtt', {'mqtt': {'broker': 'bla'}}) + assert len(mock_finish_setup.mock_calls) == 1 + + mock_try_connection.return_value = True + + result = await hass.config_entries.flow.async_init( + 'mqtt', context={'source': 'user'}) + assert result['type'] == 'abort' diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 51bd75f66e38cc..831bcaa1d24f1f 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2,21 +2,29 @@ import asyncio import unittest from unittest import mock -import socket import ssl +import pytest import voluptuous as vol from homeassistant.core import callback from homeassistant.setup import async_setup_component -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.const import (EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_STOP) from tests.common import (get_test_home_assistant, mock_coro, mock_mqtt_component, threadsafe_coroutine_factory, fire_mqtt_message, - async_fire_mqtt_message) + async_fire_mqtt_message, MockConfigEntry) + + +@pytest.fixture +def mock_MQTT(): + """Make sure connection is established.""" + with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: + mock_MQTT.return_value.async_connect.return_value = mock_coro(True) + yield mock_MQTT @asyncio.coroutine @@ -533,64 +541,59 @@ def test_setup_embedded_with_embedded(hass): assert _start.call_count == 1 -@asyncio.coroutine -def test_setup_fails_if_no_connect_broker(hass): +async def test_setup_fails_if_no_connect_broker(hass): """Test for setup failure if connection to broker is missing.""" - test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker'}} - - with mock.patch('homeassistant.components.mqtt.MQTT', - side_effect=socket.error()): - result = yield from async_setup_component(hass, mqtt.DOMAIN, - test_broker_cfg) - assert not result + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={ + mqtt.CONF_BROKER: 'test-broker' + }) with mock.patch('paho.mqtt.client.Client') as mock_client: mock_client().connect = lambda *args: 1 - result = yield from async_setup_component(hass, mqtt.DOMAIN, - test_broker_cfg) - assert not result + assert not await mqtt.async_setup_entry(hass, entry) -@asyncio.coroutine -def test_setup_uses_certificate_on_certificate_set_to_auto(hass): +async def test_setup_uses_certificate_on_certificate_set_to_auto( + hass, mock_MQTT): """Test setup uses bundled certs when certificate is set to auto.""" - test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker', - 'certificate': 'auto'}} + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={ + mqtt.CONF_BROKER: 'test-broker', + 'certificate': 'auto' + }) - with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: - yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + assert await mqtt.async_setup_entry(hass, entry) assert mock_MQTT.called import requests.certs expectedCertificate = requests.certs.where() - assert mock_MQTT.mock_calls[0][1][7] == expectedCertificate + assert mock_MQTT.mock_calls[0][2]['certificate'] == expectedCertificate -@asyncio.coroutine -def test_setup_does_not_use_certificate_on_mqtts_port(hass): - """Test setup doesn't use bundled certs when certificate is not set.""" - test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker', - 'port': 8883}} +async def test_setup_does_not_use_certificate_on_mqtts_port(hass, mock_MQTT): + """Test setup doesn't use bundled certs when ssl set.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={ + mqtt.CONF_BROKER: 'test-broker', + 'port': 8883 + }) - with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: - yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + assert await mqtt.async_setup_entry(hass, entry) assert mock_MQTT.called - assert mock_MQTT.mock_calls[0][1][2] == 8883 + assert mock_MQTT.mock_calls[0][2]['port'] == 8883 import requests.certs mqttsCertificateBundle = requests.certs.where() - assert mock_MQTT.mock_calls[0][1][7] != mqttsCertificateBundle + assert mock_MQTT.mock_calls[0][2]['port'] != mqttsCertificateBundle -@asyncio.coroutine -def test_setup_without_tls_config_uses_tlsv1_under_python36(hass): +async def test_setup_without_tls_config_uses_tlsv1_under_python36( + hass, mock_MQTT): """Test setup defaults to TLSv1 under python3.6.""" - test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker'}} + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={ + mqtt.CONF_BROKER: 'test-broker', + }) - with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: - yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + assert await mqtt.async_setup_entry(hass, entry) assert mock_MQTT.called @@ -600,34 +603,35 @@ def test_setup_without_tls_config_uses_tlsv1_under_python36(hass): else: expectedTlsVersion = ssl.PROTOCOL_TLSv1 - assert mock_MQTT.mock_calls[0][1][14] == expectedTlsVersion + assert mock_MQTT.mock_calls[0][2]['tls_version'] == expectedTlsVersion -@asyncio.coroutine -def test_setup_with_tls_config_uses_tls_version1_2(hass): +async def test_setup_with_tls_config_uses_tls_version1_2(hass, mock_MQTT): """Test setup uses specified TLS version.""" - test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker', - 'tls_version': '1.2'}} + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={ + mqtt.CONF_BROKER: 'test-broker', + 'tls_version': '1.2' + }) - with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: - yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + assert await mqtt.async_setup_entry(hass, entry) assert mock_MQTT.called - assert mock_MQTT.mock_calls[0][1][14] == ssl.PROTOCOL_TLSv1_2 + assert mock_MQTT.mock_calls[0][2]['tls_version'] == ssl.PROTOCOL_TLSv1_2 -@asyncio.coroutine -def test_setup_with_tls_config_of_v1_under_python36_only_uses_v1(hass): +async def test_setup_with_tls_config_of_v1_under_python36_only_uses_v1( + hass, mock_MQTT): """Test setup uses TLSv1.0 if explicitly chosen.""" - test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker', - 'tls_version': '1.0'}} + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={ + mqtt.CONF_BROKER: 'test-broker', + 'tls_version': '1.0' + }) - with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: - yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + assert await mqtt.async_setup_entry(hass, entry) assert mock_MQTT.called - assert mock_MQTT.mock_calls[0][1][14] == ssl.PROTOCOL_TLSv1 + assert mock_MQTT.mock_calls[0][2]['tls_version'] == ssl.PROTOCOL_TLSv1 @asyncio.coroutine @@ -671,3 +675,8 @@ def test_mqtt_subscribes_topics_on_connect(hass): } calls = {call[1][1]: call[1][2] for call in hass.add_job.mock_calls} assert calls == expected + + +async def test_setup_fails_without_config(hass): + """Test if the MQTT component fails to load with no config.""" + assert not await async_setup_component(hass, mqtt.DOMAIN, {}) diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 976fdd3d15cd98..9f80f75369071e 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -57,8 +57,8 @@ def test_creating_config_with_pass_and_no_http_pass(self, mock_mqtt): assert mock_mqtt.called from pprint import pprint pprint(mock_mqtt.mock_calls) - assert mock_mqtt.mock_calls[1][1][5] == 'homeassistant' - assert mock_mqtt.mock_calls[1][1][6] == password + assert mock_mqtt.mock_calls[1][2]['username'] == 'homeassistant' + assert mock_mqtt.mock_calls[1][2]['password'] == password @patch('passlib.apps.custom_app_context', Mock(return_value='')) @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) @@ -82,24 +82,8 @@ def test_creating_config_with_pass_and_http_pass(self, mock_mqtt): assert mock_mqtt.called from pprint import pprint pprint(mock_mqtt.mock_calls) - assert mock_mqtt.mock_calls[1][1][5] == 'homeassistant' - assert mock_mqtt.mock_calls[1][1][6] == password - - @patch('passlib.apps.custom_app_context', Mock(return_value='')) - @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) - @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) - @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) - @patch('homeassistant.components.mqtt.MQTT') - def test_creating_config_without_pass(self, mock_mqtt): - """Test if the MQTT server gets started without password.""" - mock_mqtt().async_connect.return_value = mock_coro(True) - self.hass.bus.listen_once = MagicMock() - - self.hass.config.api = MagicMock(api_password=None) - assert setup_component(self.hass, mqtt.DOMAIN, {}) - assert mock_mqtt.called - assert mock_mqtt.mock_calls[1][1][5] is None - assert mock_mqtt.mock_calls[1][1][6] is None + assert mock_mqtt.mock_calls[1][2]['username'] == 'homeassistant' + assert mock_mqtt.mock_calls[1][2]['password'] == password @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker.start', return_value=mock_coro()) From 481f6e09face53c4c422700f8d774447fe475f22 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 14 Sep 2018 12:08:33 +0200 Subject: [PATCH 047/178] Upgrade python-twitch-client to 0.6.0 (#16602) --- homeassistant/components/sensor/twitch.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/twitch.py b/homeassistant/components/sensor/twitch.py index 57e98bc273de54..3e00f799dcfa9c 100644 --- a/homeassistant/components/sensor/twitch.py +++ b/homeassistant/components/sensor/twitch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-twitch-client==0.5.1'] +REQUIREMENTS = ['python-twitch-client==0.6.0'] _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): client.ingests.get_server_list() except HTTPError: _LOGGER.error("Client ID is not valid") - return False + return users = client.users.translate_usernames_to_ids(channels) diff --git a/requirements_all.txt b/requirements_all.txt index d740e915cbcd3d..05e8c1d5ae1d2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1154,7 +1154,7 @@ python-tado==0.2.3 python-telegram-bot==11.0.0 # homeassistant.components.sensor.twitch -python-twitch-client==0.5.1 +python-twitch-client==0.6.0 # homeassistant.components.velbus python-velbus==2.0.19 From e82e75baf3456ad16786562a6f0efb88e3ba4ad9 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 14 Sep 2018 12:28:09 +0200 Subject: [PATCH 048/178] Improve precision of timer ticks (#16598) --- homeassistant/core.py | 3 ++- tests/test_core.py | 26 ++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 39ee20cb1a8c82..18520ed5d0c9e2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1256,4 +1256,5 @@ def stop_timer(_: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_timer) _LOGGER.info("Timer:starting") - fire_time_event(monotonic()) + slp_seconds = 1 - (dt_util.utcnow().microsecond / 10**6) + hass.loop.call_later(slp_seconds, lambda: fire_time_event(monotonic())) diff --git a/tests/test_core.py b/tests/test_core.py index 7e6d57136e45c6..4eecf995811fb8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -862,21 +862,29 @@ def mock_callback(func): with patch.object(ha, 'callback', mock_callback), \ patch('homeassistant.core.dt_util.utcnow', - return_value=sentinel.mock_date): + return_value=datetime(2018, 12, 31, 3, 4, 5, 333333)): ha._async_create_timer(hass) + assert len(hass.loop.call_later.mock_calls) == 1 + slp_seconds, action = hass.loop.call_later.mock_calls[0][1] + assert abs(slp_seconds - 0.666667) < 0.001 + + with patch('homeassistant.core.dt_util.utcnow', + return_value=sentinel.mock_date): + action() + assert len(funcs) == 2 fire_time_event, stop_timer = funcs assert len(hass.bus.async_listen_once.mock_calls) == 1 assert len(hass.bus.async_fire.mock_calls) == 1 - assert len(hass.loop.call_later.mock_calls) == 1 + assert len(hass.loop.call_later.mock_calls) == 2 event_type, callback = hass.bus.async_listen_once.mock_calls[0][1] assert event_type == EVENT_HOMEASSISTANT_STOP assert callback is stop_timer - slp_seconds, callback, nxt = hass.loop.call_later.mock_calls[0][1] + slp_seconds, callback, nxt = hass.loop.call_later.mock_calls[1][1] assert abs(slp_seconds - 0.9) < 0.001 assert callback is fire_time_event assert abs(nxt - 11.2) < 0.001 @@ -901,15 +909,21 @@ def mock_callback(func): with patch.object(ha, 'callback', mock_callback), \ patch('homeassistant.core.dt_util.utcnow', - return_value=sentinel.mock_date): + return_value=datetime(2018, 12, 31, 3, 4, 5, 333333)): ha._async_create_timer(hass) + _, action = hass.loop.call_later.mock_calls[0][1] + + with patch('homeassistant.core.dt_util.utcnow', + return_value=sentinel.mock_date): + action() + assert len(funcs) == 2 fire_time_event, stop_timer = funcs - assert len(hass.loop.call_later.mock_calls) == 1 + assert len(hass.loop.call_later.mock_calls) == 2 - slp_seconds, callback, nxt = hass.loop.call_later.mock_calls[0][1] + slp_seconds, callback, nxt = hass.loop.call_later.mock_calls[1][1] assert slp_seconds == 1 assert callback is fire_time_event assert abs(nxt - 12.3) < 0.001 From 7705666061ce7afe1660780d97534805c89b14e4 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 14 Sep 2018 13:49:20 +0200 Subject: [PATCH 049/178] Rewrite bluetooth le (#16592) * Rewrite bluetooth le * Update requirements_all.txt * Update gen_requirements_all.py * Update bluetooth_le_tracker.py * Update bluetooth_le_tracker.py * Update bluetooth_le_tracker.py * Update bluetooth_le_tracker.py * Update bluetooth_le_tracker.py * Update bluetooth_le_tracker.py --- .../device_tracker/bluetooth_le_tracker.py | 35 +++++++------------ requirements_all.txt | 4 +-- script/gen_requirements_all.py | 1 - 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index d9cda24b69930b..47b86ab9ab2091 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -6,35 +6,25 @@ """ import logging -import voluptuous as vol from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - PLATFORM_SCHEMA, load_config, SOURCE_TYPE_BLUETOOTH_LE + load_config, SOURCE_TYPE_BLUETOOTH_LE ) import homeassistant.util.dt as dt_util -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['gattlib==0.20150805'] +REQUIREMENTS = ['pygatt==3.2.0'] BLE_PREFIX = 'BLE_' MIN_SEEN_NEW = 5 -CONF_SCAN_DURATION = 'scan_duration' -CONF_BLUETOOTH_DEVICE = 'device_id' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SCAN_DURATION, default=10): cv.positive_int, - vol.Optional(CONF_BLUETOOTH_DEVICE, default='hci0'): cv.string -}) def setup_scanner(hass, config, see, discovery_info=None): """Set up the Bluetooth LE Scanner.""" # pylint: disable=import-error - from gattlib import DiscoveryService - + import pygatt new_devices = {} def see_device(address, name, new_device=False): @@ -61,17 +51,17 @@ def discover_ble_devices(): """Discover Bluetooth LE devices.""" _LOGGER.debug("Discovering Bluetooth LE devices") try: - service = DiscoveryService(ble_dev_id) - devices = service.discover(duration) + adapter = pygatt.GATTToolBackend() + devs = adapter.scan() + + devices = {x['address']: x['name'] for x in devs} _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) except RuntimeError as error: _LOGGER.error("Error during Bluetooth LE scan: %s", error) - devices = [] + return {} return devices yaml_path = hass.config.path(YAML_DEVICES) - duration = config.get(CONF_SCAN_DURATION) - ble_dev_id = config.get(CONF_BLUETOOTH_DEVICE) devs_to_track = [] devs_donot_track = [] @@ -102,11 +92,11 @@ def update_ble(now): """Lookup Bluetooth LE devices and update status.""" devs = discover_ble_devices() for mac in devs_to_track: - _LOGGER.debug("Checking %s", mac) - result = mac in devs - if not result: - # Could not lookup device name + if mac not in devs: continue + + if devs[mac] is None: + devs[mac] = mac see_device(mac, devs[mac]) if track_new: @@ -119,5 +109,4 @@ def update_ble(now): track_point_in_utc_time(hass, update_ble, dt_util.utcnow() + interval) update_ble(dt_util.utcnow()) - return True diff --git a/requirements_all.txt b/requirements_all.txt index 05e8c1d5ae1d2d..4aa1161ddcd1b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,9 +384,6 @@ fritzhome==1.0.4 # homeassistant.components.tts.google gTTS-token==1.1.1 -# homeassistant.components.device_tracker.bluetooth_le_tracker -# gattlib==0.20150805 - # homeassistant.components.sensor.gearbest gearbest_parser==1.0.7 @@ -879,6 +876,7 @@ pyfritzhome==0.3.7 # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.device_tracker.bluetooth_le_tracker # homeassistant.components.sensor.skybeacon pygatt==3.2.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a307ec9ee1571e..ec024bef614b41 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -19,7 +19,6 @@ 'bluepy', 'opencv-python', 'python-lirc', - 'gattlib', 'pyuserinput', 'evdev', 'pycups', From 0d0bda9658616d955bc410bf3d3eee30189b9395 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 14 Sep 2018 15:29:10 -0400 Subject: [PATCH 050/178] Switch components.sensor.zha to await syntax. (#16619) --- homeassistant/components/sensor/zha.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 9962270dfd76af..0d5b40d1d98a6d 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -4,7 +4,6 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/sensor.zha/ """ -import asyncio import logging from homeassistant.components.sensor import DOMAIN @@ -17,20 +16,18 @@ DEPENDENCIES = ['zha'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up Zigbee Home Automation sensors.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return - sensor = yield from make_sensor(discovery_info) + sensor = await make_sensor(discovery_info) async_add_entities([sensor], update_before_add=True) -@asyncio.coroutine -def make_sensor(discovery_info): +async def make_sensor(discovery_info): """Create ZHA sensors factory.""" from zigpy.zcl.clusters.measurement import ( RelativeHumidity, TemperatureMeasurement, PressureMeasurement, @@ -57,7 +54,7 @@ def make_sensor(discovery_info): if discovery_info['new_join']: cluster = list(in_clusters.values())[0] - yield from zha.configure_reporting( + await zha.configure_reporting( sensor.entity_id, cluster, sensor.value_attribute, reportable_change=sensor.min_reportable_change ) From 9c1a539f904ad81d815dc633739342af14214566 Mon Sep 17 00:00:00 2001 From: christopheBfr <43133099+christopheBfr@users.noreply.github.com> Date: Fri, 14 Sep 2018 21:31:08 +0200 Subject: [PATCH 051/178] =?UTF-8?q?Adding=20support=20for=20RTDSContactSen?= =?UTF-8?q?sor=20and=20RTDSMotionSensor=20in=20Tahoma=20=E2=80=A6=20(RTS?= =?UTF-8?q?=20Alarms=20sensors=20and=20contacts=20for=20Somfy=20Protexiom?= =?UTF-8?q?=20alarms)=20(#16609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding support for RTDSContactSensor and RTDSContactSensor in Tahoma component * Removing extra blank lines --- homeassistant/components/sensor/tahoma.py | 19 ++++++++++++++++--- homeassistant/components/tahoma.py | 2 ++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/tahoma.py b/homeassistant/components/sensor/tahoma.py index a59eb705498803..5918bd7c9f8b6e 100644 --- a/homeassistant/components/sensor/tahoma.py +++ b/homeassistant/components/sensor/tahoma.py @@ -56,6 +56,10 @@ def unit_of_measurement(self): return 'lx' if self.tahoma_device.type == 'Humidity Sensor': return '%' + if self.tahoma_device.type == 'rtds:RTDSContactSensor': + return None + if self.tahoma_device.type == 'rtds:RTDSMotionSensor': + return None def update(self): """Update the state.""" @@ -63,12 +67,21 @@ def update(self): if self.tahoma_device.type == 'io:LightIOSystemSensor': self.current_value = self.tahoma_device.active_states[ 'core:LuminanceState'] + self._available = bool(self.tahoma_device.active_states.get( + 'core:StatusState') == 'available') if self.tahoma_device.type == 'io:SomfyContactIOSystemSensor': self.current_value = self.tahoma_device.active_states[ 'core:ContactState'] - - self._available = bool(self.tahoma_device.active_states.get( - 'core:StatusState') == 'available') + self._available = bool(self.tahoma_device.active_states.get( + 'core:StatusState') == 'available') + if self.tahoma_device.type == 'rtds:RTDSContactSensor': + self.current_value = self.tahoma_device.active_states[ + 'core:ContactState'] + self._available = True + if self.tahoma_device.type == 'rtds:RTDSMotionSensor': + self.current_value = self.tahoma_device.active_states[ + 'core:OccupancyState'] + self._available = True _LOGGER.debug("Update %s, value: %d", self._name, self.current_value) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 64071ddb037561..366799b872c947 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -54,6 +54,8 @@ 'io:HorizontalAwningIOComponent': 'cover', 'io:OnOffLightIOComponent': 'switch', 'rtds:RTDSSmokeSensor': 'smoke', + 'rtds:RTDSContactSensor': 'sensor', + 'rtds:RTDSMotionSensor': 'sensor' } From 8ce2d701c294b7310b00648591fc6ebfae6f5243 Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Fri, 14 Sep 2018 15:31:41 -0400 Subject: [PATCH 052/178] fix bug where momentary switch with activation low does not reset (#16603) --- homeassistant/components/switch/konnected.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/konnected.py b/homeassistant/components/switch/konnected.py index 20774accbd579e..84016dac28d299 100644 --- a/homeassistant/components/switch/konnected.py +++ b/homeassistant/components/switch/konnected.py @@ -83,7 +83,7 @@ def turn_on(self, **kwargs): if self._momentary and resp.get(ATTR_STATE) != -1: # Immediately set the state back off for momentary switches - self._set_state(self._boolean_state(False)) + self._set_state(False) def turn_off(self, **kwargs): """Send a command to turn off the switch.""" From 1ca09ea36f80458cd9819d85b9aae29c1ed343a4 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Fri, 14 Sep 2018 23:44:48 -0700 Subject: [PATCH 053/178] Extracting zoneminder to a new library (#16527) * Migrating out the zoneminder platform (and camera.zoneminder) to a new library * Clean up the global variable ZM usage * Modify camera.zoneminder to use the new Monitor class implementation * Refactor camera.zoneminder after latest refactor in zm-py * Implementing changes to switch.zoneminder to use zm-py native methods * Complete migrating over sensor.zoneminder to the zm-py library * Tweaking ZoneMinder components from code review * Linting fixes for the zoneminder components * Directly assign value when turning on/off in switch.zoneminder --- homeassistant/components/camera/zoneminder.py | 91 ++++------------- homeassistant/components/sensor/zoneminder.py | 76 +++++--------- homeassistant/components/switch/zoneminder.py | 49 ++++----- homeassistant/components/zoneminder.py | 99 +++---------------- requirements_all.txt | 3 + 5 files changed, 76 insertions(+), 242 deletions(-) diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py index e48caa42a34069..bda50a6f75ce2e 100644 --- a/homeassistant/components/camera/zoneminder.py +++ b/homeassistant/components/camera/zoneminder.py @@ -4,91 +4,47 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.zoneminder/ """ -import asyncio import logging -from urllib.parse import urljoin, urlencode from homeassistant.const import CONF_NAME from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) - -from homeassistant.components import zoneminder +from homeassistant.components.zoneminder import DOMAIN as ZONEMINDER_DOMAIN _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['zoneminder'] -DOMAIN = 'zoneminder' - -# From ZoneMinder's web/includes/config.php.in -ZM_STATE_ALARM = "2" - - -def _get_image_url(hass, monitor, mode): - zm_data = hass.data[DOMAIN] - query = urlencode({ - 'mode': mode, - 'buffer': monitor['StreamReplayBuffer'], - 'monitor': monitor['Id'], - }) - url = '{zms_url}?{query}'.format( - zms_url=urljoin(zm_data['server_origin'], zm_data['path_zms']), - query=query, - ) - _LOGGER.debug('Monitor %s %s URL (without auth): %s', - monitor['Id'], mode, url) - - if not zm_data['username']: - return url - - url += '&user={:s}'.format(zm_data['username']) - if not zm_data['password']: - return url - return url + '&pass={:s}'.format(zm_data['password']) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder cameras.""" - cameras = [] - monitors = zoneminder.get_state('api/monitors.json') + zm_client = hass.data[ZONEMINDER_DOMAIN] + + monitors = zm_client.get_monitors() if not monitors: _LOGGER.warning("Could not fetch monitors from ZoneMinder") return - for i in monitors['monitors']: - monitor = i['Monitor'] - - if monitor['Function'] == 'None': - _LOGGER.info("Skipping camera %s", monitor['Id']) - continue - - _LOGGER.info("Initializing camera %s", monitor['Id']) - - device_info = { - CONF_NAME: monitor['Name'], - CONF_MJPEG_URL: _get_image_url(hass, monitor, 'jpeg'), - CONF_STILL_IMAGE_URL: _get_image_url(hass, monitor, 'single') - } - cameras.append(ZoneMinderCamera(hass, device_info, monitor)) - - if not cameras: - _LOGGER.warning("No active cameras found") - return - - async_add_entities(cameras) + cameras = [] + for monitor in monitors: + _LOGGER.info("Initializing camera %s", monitor.id) + cameras.append(ZoneMinderCamera(hass, monitor)) + add_entities(cameras) class ZoneMinderCamera(MjpegCamera): """Representation of a ZoneMinder Monitor Stream.""" - def __init__(self, hass, device_info, monitor): + def __init__(self, hass, monitor): """Initialize as a subclass of MjpegCamera.""" + device_info = { + CONF_NAME: monitor.name, + CONF_MJPEG_URL: monitor.mjpeg_image_url, + CONF_STILL_IMAGE_URL: monitor.still_image_url + } super().__init__(hass, device_info) - self._monitor_id = int(monitor['Id']) self._is_recording = None + self._monitor = monitor @property def should_poll(self): @@ -97,17 +53,8 @@ def should_poll(self): def update(self): """Update our recording state from the ZM API.""" - _LOGGER.debug("Updating camera state for monitor %i", self._monitor_id) - status_response = zoneminder.get_state( - 'api/monitors/alarm/id:%i/command:status.json' % self._monitor_id - ) - - if not status_response: - _LOGGER.warning("Could not get status for monitor %i", - self._monitor_id) - return - - self._is_recording = status_response.get('status') == ZM_STATE_ALARM + _LOGGER.debug("Updating camera state for monitor %i", self._monitor.id) + self._is_recording = self._monitor.is_recording @property def is_recording(self): diff --git a/homeassistant/components/sensor/zoneminder.py b/homeassistant/components/sensor/zoneminder.py index 80f8529d847d5d..d4164bbf7210fa 100644 --- a/homeassistant/components/sensor/zoneminder.py +++ b/homeassistant/components/sensor/zoneminder.py @@ -8,12 +8,11 @@ import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.zoneminder import DOMAIN as ZONEMINDER_DOMAIN from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity -from homeassistant.components import zoneminder -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -43,20 +42,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder sensor platform.""" include_archived = config.get(CONF_INCLUDE_ARCHIVED) - sensors = [] + zm_client = hass.data[ZONEMINDER_DOMAIN] + monitors = zm_client.get_monitors() + if not monitors: + _LOGGER.warning('Could not fetch any monitors from ZoneMinder') - monitors = zoneminder.get_state('api/monitors.json') - for i in monitors['monitors']: - sensors.append( - ZMSensorMonitors(int(i['Monitor']['Id']), i['Monitor']['Name']) - ) + sensors = [] + for monitor in monitors: + sensors.append(ZMSensorMonitors(monitor)) for sensor in config[CONF_MONITORED_CONDITIONS]: - sensors.append( - ZMSensorEvents(int(i['Monitor']['Id']), - i['Monitor']['Name'], - include_archived, sensor) - ) + sensors.append(ZMSensorEvents(monitor, include_archived, sensor)) add_entities(sensors) @@ -64,16 +60,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZMSensorMonitors(Entity): """Get the status of each ZoneMinder monitor.""" - def __init__(self, monitor_id, monitor_name): + def __init__(self, monitor): """Initialize monitor sensor.""" - self._monitor_id = monitor_id - self._monitor_name = monitor_name - self._state = None + self._monitor = monitor + self._state = monitor.function.value @property def name(self): """Return the name of the sensor.""" - return '{} Status'.format(self._monitor_name) + return '{} Status'.format(self._monitor.name) @property def state(self): @@ -82,32 +77,28 @@ def state(self): def update(self): """Update the sensor.""" - monitor = zoneminder.get_state( - 'api/monitors/{}.json'.format(self._monitor_id) - ) - if monitor['monitor']['Monitor']['Function'] is None: - self._state = STATE_UNKNOWN + state = self._monitor.function + if not state: + self._state = None else: - self._state = monitor['monitor']['Monitor']['Function'] + self._state = state.value class ZMSensorEvents(Entity): """Get the number of events for each monitor.""" - def __init__(self, monitor_id, monitor_name, include_archived, - sensor_type): + def __init__(self, monitor, include_archived, sensor_type): """Initialize event sensor.""" - self._monitor_id = monitor_id - self._monitor_name = monitor_name + from zoneminder.monitor import TimePeriod + self._monitor = monitor self._include_archived = include_archived - self._type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] + self.time_period = TimePeriod.get_time_period(sensor_type) self._state = None @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self._monitor_name, self._name) + return '{} {}'.format(self._monitor.name, self.time_period.title) @property def unit_of_measurement(self): @@ -121,22 +112,5 @@ def state(self): def update(self): """Update the sensor.""" - date_filter = '1%20{}'.format(self._type) - if self._type == 'all': - # The consoleEvents API uses DATE_SUB, so give it - # something large - date_filter = '100%20year' - - archived_filter = '/Archived=:0' - if self._include_archived: - archived_filter = '' - - event = zoneminder.get_state( - 'api/events/consoleEvents/{}{}.json'.format(date_filter, - archived_filter) - ) - - try: - self._state = event['results'][str(self._monitor_id)] - except (TypeError, KeyError): - self._state = '0' + self._state = self._monitor.get_events( + self.time_period, self._include_archived) diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/switch/zoneminder.py index 496e7549aaa28b..265f94fbbb12d5 100644 --- a/homeassistant/components/switch/zoneminder.py +++ b/homeassistant/components/switch/zoneminder.py @@ -9,8 +9,8 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.zoneminder import DOMAIN as ZONEMINDER_DOMAIN from homeassistant.const import (CONF_COMMAND_ON, CONF_COMMAND_OFF) -from homeassistant.components import zoneminder import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -25,22 +25,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZoneMinder switch platform.""" - on_state = config.get(CONF_COMMAND_ON) - off_state = config.get(CONF_COMMAND_OFF) + from zoneminder.monitor import MonitorState + on_state = MonitorState(config.get(CONF_COMMAND_ON)) + off_state = MonitorState(config.get(CONF_COMMAND_OFF)) - switches = [] + zm_client = hass.data[ZONEMINDER_DOMAIN] - monitors = zoneminder.get_state('api/monitors.json') - for i in monitors['monitors']: - switches.append( - ZMSwitchMonitors( - int(i['Monitor']['Id']), - i['Monitor']['Name'], - on_state, - off_state - ) - ) + monitors = zm_client.get_monitors() + if not monitors: + _LOGGER.warning('Could not fetch monitors from ZoneMinder') + return + switches = [] + for monitor in monitors: + switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) add_entities(switches) @@ -49,10 +47,9 @@ class ZMSwitchMonitors(SwitchDevice): icon = 'mdi:record-rec' - def __init__(self, monitor_id, monitor_name, on_state, off_state): + def __init__(self, monitor, on_state, off_state): """Initialize the switch.""" - self._monitor_id = monitor_id - self._monitor_name = monitor_name + self._monitor = monitor self._on_state = on_state self._off_state = off_state self._state = None @@ -60,15 +57,11 @@ def __init__(self, monitor_id, monitor_name, on_state, off_state): @property def name(self): """Return the name of the switch.""" - return "%s State" % self._monitor_name + return '{}\'s State'.format(self._monitor.name) def update(self): """Update the switch value.""" - monitor = zoneminder.get_state( - 'api/monitors/%i.json' % self._monitor_id - ) - current_state = monitor['monitor']['Monitor']['Function'] - self._state = True if current_state == self._on_state else False + self._state = self._monitor.function == self._on_state @property def is_on(self): @@ -77,14 +70,8 @@ def is_on(self): def turn_on(self, **kwargs): """Turn the entity on.""" - zoneminder.change_state( - 'api/monitors/%i.json' % self._monitor_id, - {'Monitor[Function]': self._on_state} - ) + self._monitor.function = self._on_state def turn_off(self, **kwargs): """Turn the entity off.""" - zoneminder.change_state( - 'api/monitors/%i.json' % self._monitor_id, - {'Monitor[Function]': self._off_state} - ) + self._monitor.function = self._off_state diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py index 5c045544456760..0778d9d7ef703b 100644 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder.py @@ -5,9 +5,7 @@ https://home-assistant.io/components/zoneminder/ """ import logging -from urllib.parse import urljoin -import requests import voluptuous as vol from homeassistant.const import ( @@ -17,6 +15,8 @@ _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['zm-py==0.0.1'] + CONF_PATH_ZMS = 'path_zms' DEFAULT_PATH = '/zm/' @@ -26,10 +26,6 @@ DEFAULT_VERIFY_SSL = True DOMAIN = 'zoneminder' -LOGIN_RETRIES = 2 - -ZM = {} - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, @@ -45,8 +41,7 @@ def setup(hass, config): """Set up the ZoneMinder component.""" - global ZM - ZM = {} + from zoneminder.zm import ZoneMinder conf = config[DOMAIN] if conf[CONF_SSL]: @@ -55,83 +50,11 @@ def setup(hass, config): schema = 'http' server_origin = '{}://{}'.format(schema, conf[CONF_HOST]) - url = urljoin(server_origin, conf[CONF_PATH]) - username = conf.get(CONF_USERNAME, None) - password = conf.get(CONF_PASSWORD, None) - - ssl_verification = conf.get(CONF_VERIFY_SSL) - - ZM['server_origin'] = server_origin - ZM['url'] = url - ZM['username'] = username - ZM['password'] = password - ZM['path_zms'] = conf.get(CONF_PATH_ZMS) - ZM['ssl_verification'] = ssl_verification - - hass.data[DOMAIN] = ZM - - return login() - - -def login(): - """Login to the ZoneMinder API.""" - _LOGGER.debug("Attempting to login to ZoneMinder") - - login_post = {'view': 'console', 'action': 'login'} - if ZM['username']: - login_post['username'] = ZM['username'] - if ZM['password']: - login_post['password'] = ZM['password'] - - req = requests.post(ZM['url'] + '/index.php', data=login_post, - verify=ZM['ssl_verification'], timeout=DEFAULT_TIMEOUT) - - ZM['cookies'] = req.cookies - - # Login calls returns a 200 response on both failure and success. - # The only way to tell if you logged in correctly is to issue an api call. - req = requests.get( - ZM['url'] + 'api/host/getVersion.json', cookies=ZM['cookies'], - timeout=DEFAULT_TIMEOUT, verify=ZM['ssl_verification']) - - if not req.ok: - _LOGGER.error("Connection error logging into ZoneMinder") - return False - - return True - - -def _zm_request(method, api_url, data=None): - """Perform a Zoneminder request.""" - # Since the API uses sessions that expire, sometimes we need to re-auth - # if the call fails. - for _ in range(LOGIN_RETRIES): - req = requests.request( - method, urljoin(ZM['url'], api_url), data=data, - cookies=ZM['cookies'], timeout=DEFAULT_TIMEOUT, - verify=ZM['ssl_verification']) - - if not req.ok: - login() - else: - break - - else: - _LOGGER.error("Unable to get API response from ZoneMinder") - - try: - return req.json() - except ValueError: - _LOGGER.exception( - "JSON decode exception caught while attempting to decode: %s", - req.text) - - -def get_state(api_url): - """Get a state from the ZoneMinder API service.""" - return _zm_request('get', api_url) - - -def change_state(api_url, post_data): - """Update a state using the Zoneminder API.""" - return _zm_request('post', api_url, data=post_data) + hass.data[DOMAIN] = ZoneMinder(server_origin, + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + conf.get(CONF_PATH), + conf.get(CONF_PATH_ZMS), + conf.get(CONF_VERIFY_SSL)) + + return hass.data[DOMAIN].login() diff --git a/requirements_all.txt b/requirements_all.txt index 4aa1161ddcd1b9..ec5bf752393062 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,3 +1541,6 @@ zigpy-xbee==0.1.1 # homeassistant.components.zha zigpy==0.2.0 + +# homeassistant.components.zoneminder +zm-py==0.0.1 From 0c945d81c83f37adeade33217e224cc3990c7b67 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 15 Sep 2018 00:25:21 -0700 Subject: [PATCH 054/178] Add @rohankapoorcom to CODEOWNERS for the zoneminder platform (#16627) --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 24c19c8b694406..68d0d4a833dac4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -125,5 +125,7 @@ homeassistant/components/velux.py @Julius2342 homeassistant/components/*/velux.py @Julius2342 homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi +homeassistant/components/zoneminder.py @rohankapoorcom +homeassistant/components/*/zoneminder.py @rohankapoorcom homeassistant/scripts/check_config.py @kellerza From e054e4da1bc89e505d066c8f749bc599e50d1c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 15 Sep 2018 10:42:36 +0300 Subject: [PATCH 055/178] Small huawei_lte improvements (#16626) * Add bunch of RouterData tests * Avoid raising AttributeError from RouterData.__getitem__ * Use new style string formatting * Use {key: value} instead of dict(key=value) --- homeassistant/components/huawei_lte.py | 8 +++- homeassistant/components/sensor/huawei_lte.py | 2 +- tests/components/huawei_lte.py | 48 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tests/components/huawei_lte.py diff --git a/homeassistant/components/huawei_lte.py b/homeassistant/components/huawei_lte.py index 268289d2bf5146..33da6be56db4e2 100644 --- a/homeassistant/components/huawei_lte.py +++ b/homeassistant/components/huawei_lte.py @@ -55,8 +55,12 @@ def __getitem__(self, path: str): such as device_information, device_signal etc, and the remaining path points to a value in the member's data structure. """ - cat, *path_ = path.split(".") - return reduce(operator.getitem, path_, getattr(self, cat)) + root, *rest = path.split(".") + try: + data = getattr(self, root) + except AttributeError as err: + raise KeyError from err + return reduce(operator.getitem, rest, data) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self) -> None: diff --git a/homeassistant/components/sensor/huawei_lte.py b/homeassistant/components/sensor/huawei_lte.py index d73472a705b7a1..f5a21999ab80bc 100644 --- a/homeassistant/components/sensor/huawei_lte.py +++ b/homeassistant/components/sensor/huawei_lte.py @@ -113,7 +113,7 @@ class HuaweiLteSensor(Entity): @property def unique_id(self) -> str: """Return unique ID for sensor.""" - return "%s_%s" % ( + return "{}_{}".format( self.path, self.data["device_information.SerialNumber"], ) diff --git a/tests/components/huawei_lte.py b/tests/components/huawei_lte.py new file mode 100644 index 00000000000000..0fe803208ffb38 --- /dev/null +++ b/tests/components/huawei_lte.py @@ -0,0 +1,48 @@ +"""Huawei LTE component tests.""" +import pytest + +from homeassistant.components import huawei_lte + + +@pytest.fixture(autouse=True) +def routerdata(): + """Set up a router data for testing.""" + rd = huawei_lte.RouterData(None) + rd.device_information = { + 'SoftwareVersion': '1.0', + 'nested': {'foo': 'bar'}, + } + return rd + + +async def test_routerdata_get_nonexistent_root(routerdata): + """Test that accessing a nonexistent root element raises KeyError.""" + with pytest.raises(KeyError): # NOT AttributeError + routerdata["nonexistent_root.foo"] + + +async def test_routerdata_get_nonexistent_leaf(routerdata): + """Test that accessing a nonexistent leaf element raises KeyError.""" + with pytest.raises(KeyError): + routerdata["device_information.foo"] + + +async def test_routerdata_get_nonexistent_leaf_path(routerdata): + """Test that accessing a nonexistent long path raises KeyError.""" + with pytest.raises(KeyError): + routerdata["device_information.long.path.foo"] + + +async def test_routerdata_get_simple(routerdata): + """Test that accessing a short, simple path works.""" + assert routerdata["device_information.SoftwareVersion"] == "1.0" + + +async def test_routerdata_get_longer(routerdata): + """Test that accessing a longer path works.""" + assert routerdata["device_information.nested.foo"] == "bar" + + +async def test_routerdata_get_dict(routerdata): + """Test that returning an intermediate dict works.""" + assert routerdata["device_information.nested"] == {'foo': 'bar'} From 00918af94df0ed9be4e7243ebecbb54b4e85533c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Oldag?= Date: Sat, 15 Sep 2018 10:08:52 +0200 Subject: [PATCH 056/178] Upgrade pwmled to 1.3.0 (#16624) --- homeassistant/components/light/rpi_gpio_pwm.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/rpi_gpio_pwm.py b/homeassistant/components/light/rpi_gpio_pwm.py index 5a0e0546b1f253..25c72c247eef42 100644 --- a/homeassistant/components/light/rpi_gpio_pwm.py +++ b/homeassistant/components/light/rpi_gpio_pwm.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['pwmled==1.2.1'] +REQUIREMENTS = ['pwmled==1.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ec5bf752393062..d9cf55041cca68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -721,7 +721,7 @@ pushbullet.py==0.11.0 pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm -pwmled==1.2.1 +pwmled==1.3.0 # homeassistant.components.august py-august==0.6.0 From 19514ea500c444d11ed05879c7da734b16143d2a Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 15 Sep 2018 02:26:29 -0700 Subject: [PATCH 057/178] Clean up MjpegCamera by removing unnused hass object in __init__ (#16628) --- homeassistant/components/camera/axis.py | 2 +- homeassistant/components/camera/mjpeg.py | 4 ++-- homeassistant/components/camera/zoneminder.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index 5630759a7f862e..4227cca7e2f111 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -50,7 +50,7 @@ class AxisCamera(MjpegCamera): def __init__(self, hass, config, port): """Initialize Axis Communications camera component.""" - super().__init__(hass, config) + super().__init__(config) self.port = port dispatcher_connect( hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index ed7d58658ed318..f1917aaf23e306 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -47,7 +47,7 @@ def async_setup_platform(hass, config, async_add_entities, """Set up a MJPEG IP Camera.""" if discovery_info: config = PLATFORM_SCHEMA(discovery_info) - async_add_entities([MjpegCamera(hass, config)]) + async_add_entities([MjpegCamera(config)]) def extract_image_from_mjpeg(stream): @@ -65,7 +65,7 @@ def extract_image_from_mjpeg(stream): class MjpegCamera(Camera): """An implementation of an IP camera that is reachable over a URL.""" - def __init__(self, hass, device_info): + def __init__(self, device_info): """Initialize a MJPEG camera.""" super().__init__() self._name = device_info.get(CONF_NAME) diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py index bda50a6f75ce2e..55d8d91d3ee663 100644 --- a/homeassistant/components/camera/zoneminder.py +++ b/homeassistant/components/camera/zoneminder.py @@ -28,21 +28,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): cameras = [] for monitor in monitors: _LOGGER.info("Initializing camera %s", monitor.id) - cameras.append(ZoneMinderCamera(hass, monitor)) + cameras.append(ZoneMinderCamera(monitor)) add_entities(cameras) class ZoneMinderCamera(MjpegCamera): """Representation of a ZoneMinder Monitor Stream.""" - def __init__(self, hass, monitor): + def __init__(self, monitor): """Initialize as a subclass of MjpegCamera.""" device_info = { CONF_NAME: monitor.name, CONF_MJPEG_URL: monitor.mjpeg_image_url, CONF_STILL_IMAGE_URL: monitor.still_image_url } - super().__init__(hass, device_info) + super().__init__(device_info) self._is_recording = None self._monitor = monitor From cc38981a38ed2c97ab45df5b536ad34ce742f39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 15 Sep 2018 14:27:37 +0300 Subject: [PATCH 058/178] Update developer doc links to developers.home-assistant.io (#16622) --- CONTRIBUTING.md | 2 +- README.rst | 4 ++-- docs/source/index.rst | 2 +- homeassistant/components/websocket_api.py | 2 +- script/gen_requirements_all.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86e212bb11d696..fbe77c7756fd89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,5 +10,5 @@ The process is straight-forward. - Ensure tests work. - Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant. -Still interested? Then you should take a peek at the [developer documentation](https://home-assistant.io/developers/) to get more details. +Still interested? Then you should take a peek at the [developer documentation](https://developers.home-assistant.io/) to get more details. diff --git a/README.rst b/README.rst index 6cf19d89c3c7c9..4f459162a7e271 100644 --- a/README.rst +++ b/README.rst @@ -21,8 +21,8 @@ Featured integrations |screenshot-components| -The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture `__ and the `section on creating your own -components `__. +The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture `__ and the `section on creating your own +components `__. If you run into issues while using Home Assistant or during development of a component, check the `Home Assistant help section `__ of our website for further help and information. diff --git a/docs/source/index.rst b/docs/source/index.rst index a6157dc7aac478..c592f66c070c0e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -19,4 +19,4 @@ Indices and tables * :ref:`modindex` * :ref:`search` -.. _Home Assistant developers: https://home-assistant.io/developers/ +.. _Home Assistant developers: https://developers.home-assistant.io/ diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index e9db666c032932..6f4c4d47fe9560 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -2,7 +2,7 @@ Websocket based API for Home Assistant. For more details about this component, please refer to the documentation at -https://home-assistant.io/developers/websocket_api/ +https://developers.home-assistant.io/docs/external_api_websocket.html """ import asyncio from concurrent import futures diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ec024bef614b41..d1d29affeff44c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -115,8 +115,8 @@ 'colorama<=1', # Windows only requirement in check_config ) -URL_PIN = ('https://home-assistant.io/developers/code_review_platform/' - '#1-requirements') +URL_PIN = ('https://developers.home-assistant.io/docs/' + 'creating_platform_code_review.html#1-requirements') CONSTRAINT_PATH = os.path.join(os.path.dirname(__file__), From 34deaf88496b2c7f8db88c918ae1f8be2570462c Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sat, 15 Sep 2018 04:28:25 -0700 Subject: [PATCH 059/178] Add valid_window=1 to TOTP verify (#16625) --- homeassistant/auth/mfa_modules/totp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 0914658a6557e6..50cd9d334660b0 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -149,10 +149,10 @@ def _validate_2fa(self, user_id: str, code: str) -> bool: if ota_secret is None: # even we cannot find user, we still do verify # to make timing the same as if user was found. - pyotp.TOTP(DUMMY_SECRET).verify(code) + pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1) return False - return bool(pyotp.TOTP(ota_secret).verify(code)) + return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1)) class TotpSetupFlow(SetupFlow): From 05922ac56a6d03b51250691e38a28a79d5110652 Mon Sep 17 00:00:00 2001 From: Mattias Welponer Date: Sat, 15 Sep 2018 21:28:50 +0200 Subject: [PATCH 060/178] Add new devices to HomematicIP Cloud (#16636) * Add support for outdoor temperature sensor and cleanup * Add support for rotary handle and water sensor * Fix comment --- .../binary_sensor/homematicip_cloud.py | 21 +++++++++++++++++-- .../components/sensor/homematicip_cloud.py | 16 +++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py index dd22a835504f0d..6c8b7ff191e754 100644 --- a/homeassistant/components/binary_sensor/homematicip_cloud.py +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -27,17 +27,20 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the HomematicIP Cloud binary sensor from a config entry.""" from homematicip.aio.device import ( - AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector) + AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector, + AsyncWaterSensor, AsyncRotaryHandleSensor) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] for device in home.devices: - if isinstance(device, AsyncShutterContact): + if isinstance(device, (AsyncShutterContact, AsyncRotaryHandleSensor)): devices.append(HomematicipShutterContact(home, device)) elif isinstance(device, AsyncMotionDetectorIndoor): devices.append(HomematicipMotionDetector(home, device)) elif isinstance(device, AsyncSmokeDetector): devices.append(HomematicipSmokeDetector(home, device)) + elif isinstance(device, AsyncWaterSensor): + devices.append(HomematicipWaterDetector(home, device)) if devices: async_add_entities(devices) @@ -91,3 +94,17 @@ def device_class(self): def is_on(self): """Return true if smoke is detected.""" return self._device.smokeDetectorAlarmType != STATE_SMOKE_OFF + + +class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud water detector.""" + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'moisture' + + @property + def is_on(self): + """Return true if moisture or waterlevel is detected.""" + return self._device.moistureDetected or self._device.waterlevelDetected diff --git a/homeassistant/components/sensor/homematicip_cloud.py b/homeassistant/components/sensor/homematicip_cloud.py index 2b8365b8f64cf7..73fef98fb76de2 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -32,20 +32,22 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the HomematicIP Cloud sensors from a config entry.""" - from homematicip.device import ( - HeatingThermostat, TemperatureHumiditySensorWithoutDisplay, - TemperatureHumiditySensorDisplay, MotionDetectorIndoor) + from homematicip.aio.device import ( + AsyncHeatingThermostat, AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncTemperatureHumiditySensorDisplay, AsyncMotionDetectorIndoor, + AsyncTemperatureHumiditySensorOutdoor) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] for device in home.devices: - if isinstance(device, HeatingThermostat): + if isinstance(device, AsyncHeatingThermostat): devices.append(HomematicipHeatingThermostat(home, device)) - if isinstance(device, (TemperatureHumiditySensorDisplay, - TemperatureHumiditySensorWithoutDisplay)): + if isinstance(device, (AsyncTemperatureHumiditySensorDisplay, + AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncTemperatureHumiditySensorOutdoor)): devices.append(HomematicipTemperatureSensor(home, device)) devices.append(HomematicipHumiditySensor(home, device)) - if isinstance(device, MotionDetectorIndoor): + if isinstance(device, AsyncMotionDetectorIndoor): devices.append(HomematicipIlluminanceSensor(home, device)) if devices: From 9c9df793b4548ccce52b1dff6cc15a0a460e7713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 16 Sep 2018 00:17:47 +0100 Subject: [PATCH 061/178] New EDP re:dy component (#16426) * add new EDP re:dy platform * lint * move api code to pypi module; fix lint * fix lint; remove unused import * pass aiohttp client session and hass loop to platform * update requirements_all.txt * fix docstring lint * normalize quotes * use async setup_platform * improve entities update mechanism * doc lint * send update topic only after loading platforms * lint whitespaces * mute used-before-assignment pylint false error --- .coveragerc | 3 + homeassistant/components/edp_redy.py | 135 ++++++++++++++++++++ homeassistant/components/sensor/edp_redy.py | 115 +++++++++++++++++ homeassistant/components/switch/edp_redy.py | 94 ++++++++++++++ requirements_all.txt | 3 + 5 files changed, 350 insertions(+) create mode 100644 homeassistant/components/edp_redy.py create mode 100644 homeassistant/components/sensor/edp_redy.py create mode 100644 homeassistant/components/switch/edp_redy.py diff --git a/.coveragerc b/.coveragerc index 336edbff7360f1..9b7111b21e13f3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -92,6 +92,9 @@ omit = homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py + homeassistant/components/edp_redy.py + homeassistant/components/*/edp_redy.py + homeassistant/components/egardia.py homeassistant/components/*/egardia.py diff --git a/homeassistant/components/edp_redy.py b/homeassistant/components/edp_redy.py new file mode 100644 index 00000000000000..caf4ad41d99e54 --- /dev/null +++ b/homeassistant/components/edp_redy.py @@ -0,0 +1,135 @@ +""" +Support for EDP re:dy. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/edp_redy/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_START) +from homeassistant.core import callback +from homeassistant.helpers import discovery, dispatcher, aiohttp_client +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'edp_redy' +EDP_REDY = 'edp_redy' +DATA_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) +UPDATE_INTERVAL = 30 + +REQUIREMENTS = ['edp_redy==0.0.2'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the EDP re:dy component.""" + from edp_redy import EdpRedySession + + session = EdpRedySession(config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + aiohttp_client.async_get_clientsession(hass), + hass.loop) + hass.data[EDP_REDY] = session + platform_loaded = False + + async def async_update_and_sched(time): + update_success = await session.async_update() + + if update_success: + nonlocal platform_loaded + # pylint: disable=used-before-assignment + if not platform_loaded: + for component in ['sensor', 'switch']: + await discovery.async_load_platform(hass, component, + DOMAIN, {}, config) + platform_loaded = True + + dispatcher.async_dispatcher_send(hass, DATA_UPDATE_TOPIC) + + # schedule next update + async_track_point_in_time(hass, async_update_and_sched, + time + timedelta(seconds=UPDATE_INTERVAL)) + + async def start_component(event): + _LOGGER.debug("Starting updates") + await async_update_and_sched(dt_util.utcnow()) + + # only start fetching data after HA boots to prevent delaying the boot + # process + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_component) + + return True + + +class EdpRedyDevice(Entity): + """Representation a base re:dy device.""" + + def __init__(self, session, device_id, name): + """Initialize the device.""" + self._session = session + self._state = None + self._is_available = True + self._device_state_attributes = {} + self._id = device_id + self._unique_id = device_id + self._name = name if name else device_id + + async def async_added_to_hass(self): + """Subscribe to the data updates topic.""" + dispatcher.async_dispatcher_connect( + self.hass, DATA_UPDATE_TOPIC, self._data_updated) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def available(self): + """Return True if entity is available.""" + return self._is_available + + @property + def should_poll(self): + """Return the polling state. No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device_state_attributes + + @callback + def _data_updated(self): + """Update state, trigger updates.""" + self.async_schedule_update_ha_state(True) + + def _parse_data(self, data): + """Parse data received from the server.""" + if "OutOfOrder" in data: + try: + self._is_available = not data['OutOfOrder'] + except ValueError: + _LOGGER.error( + "Could not parse OutOfOrder for %s", self._id) + self._is_available = False diff --git a/homeassistant/components/sensor/edp_redy.py b/homeassistant/components/sensor/edp_redy.py new file mode 100644 index 00000000000000..0f259ec673a558 --- /dev/null +++ b/homeassistant/components/sensor/edp_redy.py @@ -0,0 +1,115 @@ +"""Support for EDP re:dy sensors.""" +import logging + +from homeassistant.helpers.entity import Entity + +from homeassistant.components.edp_redy import EdpRedyDevice, EDP_REDY + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['edp_redy'] + +# Load power in watts (W) +ATTR_ACTIVE_POWER = 'active_power' + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Perform the setup for re:dy devices.""" + from edp_redy.session import ACTIVE_POWER_ID + + session = hass.data[EDP_REDY] + devices = [] + + # Create sensors for modules + for device_json in session.modules_dict.values(): + if 'HA_POWER_METER' not in device_json['Capabilities']: + continue + devices.append(EdpRedyModuleSensor(session, device_json)) + + # Create a sensor for global active power + devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home", + 'mdi:flash', 'W')) + + async_add_entities(devices, True) + + +class EdpRedySensor(EdpRedyDevice, Entity): + """Representation of a EDP re:dy generic sensor.""" + + def __init__(self, session, sensor_id, name, icon, unit): + """Initialize the sensor.""" + super().__init__(session, sensor_id, name) + + self._icon = icon + self._unit = unit + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this sensor.""" + return self._unit + + async def async_update(self): + """Parse the data for this sensor.""" + if self._id in self._session.values_dict: + self._state = self._session.values_dict[self._id] + self._is_available = True + else: + self._is_available = False + + +class EdpRedyModuleSensor(EdpRedyDevice, Entity): + """Representation of a EDP re:dy module sensor.""" + + def __init__(self, session, device_json): + """Initialize the sensor.""" + super().__init__(session, device_json['PKID'], + "Power {0}".format(device_json['Name'])) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return 'mdi:flash' + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this sensor.""" + return 'W' + + async def async_update(self): + """Parse the data for this sensor.""" + if self._id in self._session.modules_dict: + device_json = self._session.modules_dict[self._id] + self._parse_data(device_json) + else: + self._is_available = False + + def _parse_data(self, data): + """Parse data received from the server.""" + super()._parse_data(data) + + _LOGGER.debug("Sensor data: %s", str(data)) + + for state_var in data['StateVars']: + if state_var['Name'] == 'ActivePower': + try: + self._state = float(state_var['Value']) * 1000 + except ValueError: + _LOGGER.error("Could not parse power for %s", self._id) + self._state = 0 + self._is_available = False diff --git a/homeassistant/components/switch/edp_redy.py b/homeassistant/components/switch/edp_redy.py new file mode 100644 index 00000000000000..1576361da332ec --- /dev/null +++ b/homeassistant/components/switch/edp_redy.py @@ -0,0 +1,94 @@ +"""Support for EDP re:dy plugs/switches.""" +import logging + +from homeassistant.components.edp_redy import EdpRedyDevice, EDP_REDY +from homeassistant.components.switch import SwitchDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['edp_redy'] + +# Load power in watts (W) +ATTR_ACTIVE_POWER = 'active_power' + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Perform the setup for re:dy devices.""" + session = hass.data[EDP_REDY] + devices = [] + for device_json in session.modules_dict.values(): + if 'HA_SWITCH' not in device_json['Capabilities']: + continue + devices.append(EdpRedySwitch(session, device_json)) + + async_add_entities(devices, True) + + +class EdpRedySwitch(EdpRedyDevice, SwitchDevice): + """Representation of a Edp re:dy switch (plugs, switches, etc).""" + + def __init__(self, session, device_json): + """Initialize the switch.""" + super().__init__(session, device_json['PKID'], device_json['Name']) + + self._active_power = None + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return 'mdi:power-plug' + + @property + def is_on(self): + """Return true if it is on.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._active_power is not None: + attrs = {ATTR_ACTIVE_POWER: self._active_power} + else: + attrs = {} + attrs.update(super().device_state_attributes) + return attrs + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + if await self._async_send_state_cmd(True): + self._state = True + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + if await self._async_send_state_cmd(False): + self._state = False + self.async_schedule_update_ha_state() + + async def _async_send_state_cmd(self, state): + state_json = {'devModuleId': self._id, 'key': 'RelayState', + 'value': state} + return await self._session.async_set_state_var(state_json) + + async def async_update(self): + """Parse the data for this switch.""" + if self._id in self._session.modules_dict: + device_json = self._session.modules_dict[self._id] + self._parse_data(device_json) + else: + self._is_available = False + + def _parse_data(self, data): + """Parse data received from the server.""" + super()._parse_data(data) + + for state_var in data['StateVars']: + if state_var['Name'] == 'RelayState': + self._state = state_var['Value'] == 'true' + elif state_var['Name'] == 'ActivePower': + try: + self._active_power = float(state_var['Value']) * 1000 + except ValueError: + _LOGGER.error("Could not parse power for %s", self._id) + self._active_power = None diff --git a/requirements_all.txt b/requirements_all.txt index d9cf55041cca68..8bcb6faa285155 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -311,6 +311,9 @@ dsmr_parser==0.11 # homeassistant.components.sensor.dweet dweepy==0.3.0 +# homeassistant.components.edp_redy +edp_redy==0.0.2 + # homeassistant.components.media_player.horizon einder==0.3.1 From baeb791d84035cd9bc354514ac1f5095eeb6f71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 16 Sep 2018 13:58:26 +0200 Subject: [PATCH 062/178] Upgrade Switchmate lib (#16637) * new version switchmate lib, fix bug, and enable more logging in lib * new version switchmate lib, fix bug, and enable more logging in lib --- homeassistant/components/switch/switchmate.py | 13 ++++++++----- requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index fddb878b0ba22d..4955d72c5e344d 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,10 +13,11 @@ from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.3'] +REQUIREMENTS = ['pySwitchmate==0.4.1'] _LOGGER = logging.getLogger(__name__) +CONF_FLIP_ON_OFF = 'flip_on_off' DEFAULT_NAME = 'Switchmate' SCAN_INTERVAL = timedelta(minutes=30) @@ -24,6 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_FLIP_ON_OFF, default=False): cv.boolean, }) @@ -31,18 +33,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None) -> None: """Perform the setup for Switchmate devices.""" name = config.get(CONF_NAME) mac_addr = config[CONF_MAC] - add_entities([Switchmate(mac_addr, name)], True) + flip_on_off = config[CONF_FLIP_ON_OFF] + add_entities([Switchmate(mac_addr, name, flip_on_off)], True) class Switchmate(SwitchDevice): """Representation of a Switchmate.""" - def __init__(self, mac, name) -> None: + def __init__(self, mac, name, flip_on_off) -> None: """Initialize the Switchmate.""" import switchmate - self._name = name self._mac = mac - self._device = switchmate.Switchmate(mac=mac) + self._name = name + self._device = switchmate.Switchmate(mac=mac, flip_on_off=flip_on_off) @property def unique_id(self) -> str: diff --git a/requirements_all.txt b/requirements_all.txt index 8bcb6faa285155..74f5e2605e64ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,7 +752,7 @@ pyHS100==0.3.3 pyRFXtrx==0.23 # homeassistant.components.switch.switchmate -pySwitchmate==0.3 +pySwitchmate==0.4.1 # homeassistant.components.sensor.tibber pyTibber==0.5.0 From 2002497d090336a0de95308afd2d807064564f8b Mon Sep 17 00:00:00 2001 From: tadly Date: Sun, 16 Sep 2018 22:53:25 +0200 Subject: [PATCH 063/178] Upgrade zeroconf to 0.21.0 (#16647) --- homeassistant/components/zeroconf.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf.py b/homeassistant/components/zeroconf.py index 675c3a65e5f4e1..1f20ebad0860ce 100644 --- a/homeassistant/components/zeroconf.py +++ b/homeassistant/components/zeroconf.py @@ -12,7 +12,7 @@ from homeassistant import util from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -REQUIREMENTS = ['zeroconf==0.20.0'] +REQUIREMENTS = ['zeroconf==0.21.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 74f5e2605e64ba..2d0d784e6dd9f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1531,7 +1531,7 @@ youtube_dl==2018.09.10 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.20.0 +zeroconf==0.21.0 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 From a1e6e04a5ebbdcfd5c14db80eeeaeed04cf51ca4 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sun, 16 Sep 2018 22:56:01 +0200 Subject: [PATCH 064/178] Update pyhomematic to 0.1.49 (#16649) --- homeassistant/components/homematic/__init__.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index b9e4294f6e10d7..4e6b3f04ee144a 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.48'] +REQUIREMENTS = ['pyhomematic==0.1.49'] _LOGGER = logging.getLogger(__name__) @@ -108,6 +108,7 @@ 'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}], 'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}], 'RSSI_PEER': ['rssi', {}], + 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], 'CONTROL_MODE': ['mode', { diff --git a/requirements_all.txt b/requirements_all.txt index 2d0d784e6dd9f3..a13068532baaf9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.48 +pyhomematic==0.1.49 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 From 5e3e441aa0e11b8acc1f180c4ca5db1497e1e430 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 17 Sep 2018 07:44:23 +0200 Subject: [PATCH 065/178] Upgrade holidays to 0.9.7 (#16651) --- .../components/binary_sensor/workday.py | 39 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 3fb2d7f7f86f59..1d85d9c9a47cc3 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -15,35 +15,38 @@ from homeassistant.components.binary_sensor import BinarySensorDevice import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['holidays==0.9.7'] -REQUIREMENTS = ['holidays==0.9.6'] +_LOGGER = logging.getLogger(__name__) # List of all countries currently supported by holidays # There seems to be no way to get the list out at runtime -ALL_COUNTRIES = ['Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', - 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', - 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', - 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', - 'DE', 'Hungary', 'HU', 'India', 'IND', 'Ireland', - 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', 'Mexico', 'MX', - 'Netherlands', 'NL', 'NewZealand', 'NZ', 'Northern Ireland', - 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', - 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', - 'Slovakia', 'SK', 'South Africa', 'ZA', 'Spain', 'ES', - 'Sweden', 'SE', 'Switzerland', 'CH', 'UnitedKingdom', 'UK', - 'UnitedStates', 'US', 'Wales'] +ALL_COUNTRIES = [ + 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', 'Belarus', 'BY' + 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', + 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', + 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', + 'India', 'IND', 'Ireland', 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', + 'Mexico', 'MX', 'Netherlands', 'NL', 'NewZealand', 'NZ', + 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', + 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK', + 'South Africa', 'ZA', 'Spain', 'ES', 'Sweden', 'SE', 'Switzerland', 'CH', + 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', +] + +ALLOWED_DAYS = WEEKDAYS + ['holiday'] + CONF_COUNTRY = 'country' CONF_PROVINCE = 'province' CONF_WORKDAYS = 'workdays' +CONF_EXCLUDES = 'excludes' +CONF_OFFSET = 'days_offset' + # By default, Monday - Friday are workdays DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] -CONF_EXCLUDES = 'excludes' # By default, public holidays, Saturdays and Sundays are excluded from workdays DEFAULT_EXCLUDES = ['sat', 'sun', 'holiday'] DEFAULT_NAME = 'Workday Sensor' -ALLOWED_DAYS = WEEKDAYS + ['holiday'] -CONF_OFFSET = 'days_offset' DEFAULT_OFFSET = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -86,7 +89,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: _LOGGER.error("There is no province/state %s in country %s", province, country) - return False + return _LOGGER.debug("Found the following holidays for your configuration:") for date, name in sorted(obj_holidays.items()): diff --git a/requirements_all.txt b/requirements_all.txt index a13068532baaf9..4f0ea37f32d29c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -448,7 +448,7 @@ hipnotify==1.0.8 hole==0.3.0 # homeassistant.components.binary_sensor.workday -holidays==0.9.6 +holidays==0.9.7 # homeassistant.components.frontend home-assistant-frontend==20180912.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8bdd85a3e5817..6268efc664b2a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ haversine==0.4.5 hbmqtt==0.9.4 # homeassistant.components.binary_sensor.workday -holidays==0.9.6 +holidays==0.9.7 # homeassistant.components.frontend home-assistant-frontend==20180912.0 From fea1c921fc7665e1a6ed6527a5088841ec8c3ad2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 17 Sep 2018 07:45:01 +0200 Subject: [PATCH 066/178] Fix link to docs (#16652) --- homeassistant/components/vacuum/ecovacs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/ecovacs.py b/homeassistant/components/vacuum/ecovacs.py index ac01d8e7a203ce..b92ae598f805e6 100644 --- a/homeassistant/components/vacuum/ecovacs.py +++ b/homeassistant/components/vacuum/ecovacs.py @@ -2,7 +2,7 @@ Support for Ecovacs Ecovacs Vaccums. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/vacuum.neato/ +https://home-assistant.io/components/vacuum.ecovacs/ """ import logging From 44fdfdf695d1562a8e2e49076295e72448c5ce48 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 17 Sep 2018 01:45:44 -0400 Subject: [PATCH 067/178] Log raw result of configure_reporting() command. (#16655) --- homeassistant/components/zha/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index f6b5868ff81989..573f432f4c04db 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -433,9 +433,9 @@ async def configure_reporting(entity_id, cluster, attr, skip_bind=False, res = await cluster.configure_reporting(attr, min_report, max_report, reportable_change) _LOGGER.debug( - "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Status: %s", + "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", entity_id, attr_name, cluster_name, min_report, max_report, - reportable_change, res[0][0].status + reportable_change, res ) except DeliveryError as ex: _LOGGER.debug( From 3e0c6c176a739f4b4507a309f59384f59a3b8e96 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 17 Sep 2018 10:10:50 +0200 Subject: [PATCH 068/178] Rework timer delays (#16650) * Calibrate timer for each tick * Return of timer out of sync detection --- homeassistant/core.py | 25 +++++++++++++---------- tests/test_core.py | 47 ++++++++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 18520ed5d0c9e2..e8d50e003d9df2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1230,22 +1230,26 @@ def _async_create_timer(hass: HomeAssistant) -> None: """Create a timer that will start on HOMEASSISTANT_START.""" handle = None + def schedule_tick(now: datetime.datetime) -> None: + """Schedule a timer tick when the next second rolls around.""" + nonlocal handle + + slp_seconds = 1 - (now.microsecond / 10**6) + target = monotonic() + slp_seconds + handle = hass.loop.call_later(slp_seconds, fire_time_event, target) + @callback - def fire_time_event(nxt: float) -> None: + def fire_time_event(target: float) -> None: """Fire next time event.""" - nonlocal handle + now = dt_util.utcnow() hass.bus.async_fire(EVENT_TIME_CHANGED, - {ATTR_NOW: dt_util.utcnow()}) - nxt += 1 - slp_seconds = nxt - monotonic() + {ATTR_NOW: now}) - if slp_seconds < 0: + if monotonic() > target + 1: _LOGGER.error('Timer got out of sync. Resetting') - nxt = monotonic() + 1 - slp_seconds = 1 - handle = hass.loop.call_later(slp_seconds, fire_time_event, nxt) + schedule_tick(now) @callback def stop_timer(_: Event) -> None: @@ -1256,5 +1260,4 @@ def stop_timer(_: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_timer) _LOGGER.info("Timer:starting") - slp_seconds = 1 - (dt_util.utcnow().microsecond / 10**6) - hass.loop.call_later(slp_seconds, lambda: fire_time_event(monotonic())) + schedule_tick(dt_util.utcnow()) diff --git a/tests/test_core.py b/tests/test_core.py index 4eecf995811fb8..d88257abfb4b03 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,7 +4,7 @@ import logging import os import unittest -from unittest.mock import patch, MagicMock, sentinel +from unittest.mock import patch, MagicMock from datetime import datetime, timedelta from tempfile import TemporaryDirectory @@ -858,23 +858,25 @@ def mock_callback(func): funcs.append(func) return orig_callback(func) - mock_monotonic.side_effect = 10.2, 10.3 + mock_monotonic.side_effect = 10.2, 10.8, 11.3 with patch.object(ha, 'callback', mock_callback), \ patch('homeassistant.core.dt_util.utcnow', return_value=datetime(2018, 12, 31, 3, 4, 5, 333333)): ha._async_create_timer(hass) + assert len(funcs) == 2 + fire_time_event, stop_timer = funcs + assert len(hass.loop.call_later.mock_calls) == 1 - slp_seconds, action = hass.loop.call_later.mock_calls[0][1] - assert abs(slp_seconds - 0.666667) < 0.001 + delay, callback, target = hass.loop.call_later.mock_calls[0][1] + assert abs(delay - 0.666667) < 0.001 + assert callback is fire_time_event + assert abs(target - 10.866667) < 0.001 with patch('homeassistant.core.dt_util.utcnow', - return_value=sentinel.mock_date): - action() - - assert len(funcs) == 2 - fire_time_event, stop_timer = funcs + return_value=datetime(2018, 12, 31, 3, 4, 6, 100000)): + callback(target) assert len(hass.bus.async_listen_once.mock_calls) == 1 assert len(hass.bus.async_fire.mock_calls) == 1 @@ -884,14 +886,14 @@ def mock_callback(func): assert event_type == EVENT_HOMEASSISTANT_STOP assert callback is stop_timer - slp_seconds, callback, nxt = hass.loop.call_later.mock_calls[1][1] - assert abs(slp_seconds - 0.9) < 0.001 + delay, callback, target = hass.loop.call_later.mock_calls[1][1] + assert abs(delay - 0.9) < 0.001 assert callback is fire_time_event - assert abs(nxt - 11.2) < 0.001 + assert abs(target - 12.2) < 0.001 event_type, event_data = hass.bus.async_fire.mock_calls[0][1] assert event_type == EVENT_TIME_CHANGED - assert event_data[ATTR_NOW] is sentinel.mock_date + assert event_data[ATTR_NOW] == datetime(2018, 12, 31, 3, 4, 6, 100000) @patch('homeassistant.core.monotonic') @@ -905,28 +907,31 @@ def mock_callback(func): funcs.append(func) return orig_callback(func) - mock_monotonic.side_effect = 10.2, 11.3, 11.3 + mock_monotonic.side_effect = 10.2, 13.3, 13.4 with patch.object(ha, 'callback', mock_callback), \ patch('homeassistant.core.dt_util.utcnow', return_value=datetime(2018, 12, 31, 3, 4, 5, 333333)): ha._async_create_timer(hass) - _, action = hass.loop.call_later.mock_calls[0][1] + delay, callback, target = hass.loop.call_later.mock_calls[0][1] - with patch('homeassistant.core.dt_util.utcnow', - return_value=sentinel.mock_date): - action() + with patch.object(ha, '_LOGGER', MagicMock()) as mock_logger, \ + patch('homeassistant.core.dt_util.utcnow', + return_value=datetime(2018, 12, 31, 3, 4, 8, 200000)): + callback(target) + + assert len(mock_logger.error.mock_calls) == 1 assert len(funcs) == 2 fire_time_event, stop_timer = funcs assert len(hass.loop.call_later.mock_calls) == 2 - slp_seconds, callback, nxt = hass.loop.call_later.mock_calls[1][1] - assert slp_seconds == 1 + delay, callback, target = hass.loop.call_later.mock_calls[1][1] + assert abs(delay - 0.8) < 0.001 assert callback is fire_time_event - assert abs(nxt - 12.3) < 0.001 + assert abs(target - 14.2) < 0.001 @asyncio.coroutine From 201fd4afeec56f031dab027f0b84debea5bced77 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Sep 2018 10:12:46 +0200 Subject: [PATCH 069/178] Add config entries to connection class (#16618) --- homeassistant/components/cast/__init__.py | 3 +- .../components/config/config_entries.py | 1 + .../components/deconz/config_flow.py | 5 ++- .../components/hangouts/config_flow.py | 5 ++- .../homematicip_cloud/config_flow.py | 5 ++- homeassistant/components/hue/config_flow.py | 5 ++- homeassistant/components/ios/__init__.py | 3 +- homeassistant/components/mqtt/config_flow.py | 1 + homeassistant/components/nest/config_flow.py | 5 ++- .../components/openuv/config_flow.py | 5 ++- homeassistant/components/sonos/__init__.py | 3 +- homeassistant/components/zone/config_flow.py | 4 +- homeassistant/config_entries.py | 37 ++++++++++++++++--- homeassistant/helpers/config_entry_flow.py | 26 ++++--------- tests/common.py | 6 ++- tests/components/binary_sensor/test_deconz.py | 3 +- .../components/config/test_config_entries.py | 21 ++++++----- tests/components/light/test_deconz.py | 2 +- tests/components/light/test_hue.py | 2 +- tests/components/scene/test_deconz.py | 3 +- tests/components/sensor/test_deconz.py | 3 +- tests/components/switch/test_deconz.py | 3 +- tests/helpers/test_config_entry_flow.py | 3 +- tests/test_config_entries.py | 13 ++++--- 24 files changed, 104 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 6885f24269a07e..53f5e704019004 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -35,4 +35,5 @@ async def _async_has_devices(hass): config_entry_flow.register_discovery_flow( - DOMAIN, 'Google Cast', _async_has_devices) + DOMAIN, 'Google Cast', _async_has_devices, + config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index e0c0e7daaf4f37..73b2767be4b569 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -54,6 +54,7 @@ def get(self, request): 'title': entry.title, 'source': entry.source, 'state': entry.state, + 'connection_class': entry.connection_class, } for entry in hass.config_entries.async_entries()]) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index fb2eb54232a1c7..65fcf51b930a7d 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.helpers import aiohttp_client @@ -23,10 +23,11 @@ def configured_hosts(hass): @config_entries.HANDLERS.register(DOMAIN) -class DeconzFlowHandler(data_entry_flow.FlowHandler): +class DeconzFlowHandler(config_entries.ConfigFlow): """Handle a deCONZ config flow.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH def __init__(self): """Initialize the deCONZ config flow.""" diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py index 74eb14b050da34..9d66338dff0cb3 100644 --- a/homeassistant/components/hangouts/config_flow.py +++ b/homeassistant/components/hangouts/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure Google Hangouts.""" import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import callback @@ -19,10 +19,11 @@ def configured_hangouts(hass): @config_entries.HANDLERS.register(HANGOUTS_DOMAIN) -class HangoutsFlowHandler(data_entry_flow.FlowHandler): +class HangoutsFlowHandler(config_entries.ConfigFlow): """Config flow Google Hangouts.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH def __init__(self): """Initialize Google Hangouts config flow.""" diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index d5045cf151b1e8..ea251a3bf87983 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure the HomematicIP Cloud component.""" import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from .const import DOMAIN as HMIPC_DOMAIN @@ -18,10 +18,11 @@ def configured_haps(hass): @config_entries.HANDLERS.register(HMIPC_DOMAIN) -class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler): +class HomematicipCloudFlowHandler(config_entries.ConfigFlow): """Config flow for the HomematicIP Cloud component.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH def __init__(self): """Initialize HomematicIP Cloud config flow.""" diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 49ebbdaabf5dbb..24ad65e1feba35 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -6,7 +6,7 @@ import async_timeout import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -41,10 +41,11 @@ def _find_username_from_config(hass, filename): @config_entries.HANDLERS.register(DOMAIN) -class HueFlowHandler(data_entry_flow.FlowHandler): +class HueFlowHandler(config_entries.ConfigFlow): """Handle a Hue config flow.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL def __init__(self): """Initialize the Hue flow.""" diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 3766e00879d715..a4884739f96331 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -293,4 +293,5 @@ def post(self, request): config_entry_flow.register_discovery_flow( - DOMAIN, 'Home Assistant iOS', lambda *_: True) + DOMAIN, 'Home Assistant iOS', lambda *_: True, + config_entries.CONN_CLASS_CLOUD_PUSH) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 42bc324f8fc72f..82b6f35056b5fa 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -15,6 +15,7 @@ class FlowHandler(config_entries.ConfigFlow): """Handle a config flow.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index c9987693b1af75..3385fd4f850b57 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -7,7 +7,7 @@ import async_timeout import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import load_json @@ -49,10 +49,11 @@ class CodeInvalid(NestAuthError): @config_entries.HANDLERS.register(DOMAIN) -class NestFlowHandler(data_entry_flow.FlowHandler): +class NestFlowHandler(config_entries.ConfigFlow): """Handle a Nest config flow.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH def __init__(self): """Initialize the Nest config flow.""" diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 55ee566268e6e6..6d7ae0f65bd157 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -4,7 +4,7 @@ import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE) @@ -23,10 +23,11 @@ def configured_instances(hass): @config_entries.HANDLERS.register(DOMAIN) -class OpenUvFlowHandler(data_entry_flow.FlowHandler): +class OpenUvFlowHandler(config_entries.ConfigFlow): """Handle an OpenUV config flow.""" VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL def __init__(self): """Initialize the config flow.""" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 6c9280195ccdce..60f3a8858b2e11 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,4 +34,5 @@ async def _async_has_devices(hass): return await hass.async_add_executor_job(soco.discover) -config_entry_flow.register_discovery_flow(DOMAIN, 'Sonos', _async_has_devices) +config_entry_flow.register_discovery_flow( + DOMAIN, 'Sonos', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/zone/config_flow.py b/homeassistant/components/zone/config_flow.py index 01577de4c8f0a1..bf221a828adc33 100644 --- a/homeassistant/components/zone/config_flow.py +++ b/homeassistant/components/zone/config_flow.py @@ -3,7 +3,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.const import ( CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) from homeassistant.core import callback @@ -20,7 +20,7 @@ def configured_zones(hass): @config_entries.HANDLERS.register(DOMAIN) -class ZoneFlowHandler(data_entry_flow.FlowHandler): +class ZoneFlowHandler(config_entries.ConfigFlow): """Zone config flow.""" VERSION = 1 diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b1e98d310487af..e4c4b5c03276b1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -27,9 +27,10 @@ 'user' step. @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(data_entry_flow.FlowHandler): + class ExampleConfigFlow(config_entries.ConfigFlow): VERSION = 1 + CONNETION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH async def async_step_user(self, user_input=None): … @@ -117,7 +118,7 @@ async def async_step_discovery(info): import logging import uuid -from typing import Set, Optional, List # noqa pylint: disable=unused-import +from typing import Set, Optional, List, Dict # noqa pylint: disable=unused-import from homeassistant import data_entry_flow from homeassistant.core import callback, HomeAssistant @@ -170,15 +171,23 @@ async def async_step_discovery(info): EVENT_FLOW_DISCOVERED = 'config_entry_discovered' +CONN_CLASS_CLOUD_PUSH = 'cloud_push' +CONN_CLASS_CLOUD_POLL = 'cloud_poll' +CONN_CLASS_LOCAL_PUSH = 'local_push' +CONN_CLASS_LOCAL_POLL = 'local_poll' +CONN_CLASS_ASSUMED = 'assumed' +CONN_CLASS_UNKNOWN = 'unknown' + class ConfigEntry: """Hold a configuration entry.""" __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'source', - 'state') + 'connection_class', 'state') def __init__(self, version: str, domain: str, title: str, data: dict, - source: str, entry_id: Optional[str] = None, + source: str, connection_class: str, + entry_id: Optional[str] = None, state: str = ENTRY_STATE_NOT_LOADED) -> None: """Initialize a config entry.""" # Unique id of the config entry @@ -199,6 +208,9 @@ def __init__(self, version: str, domain: str, title: str, data: dict, # Source of the configuration (user, discovery, cloud) self.source = source + # Connection class + self.connection_class = connection_class + # State of the entry (LOADED, NOT_LOADED) self.state = state @@ -266,6 +278,7 @@ def as_dict(self): 'title': self.title, 'data': self.data, 'source': self.source, + 'connection_class': self.connection_class, } @@ -352,7 +365,18 @@ async def async_load(self) -> None: self._entries = [] return - self._entries = [ConfigEntry(**entry) for entry in config['entries']] + self._entries = [ + ConfigEntry( + version=entry['version'], + domain=entry['domain'], + entry_id=entry['entry_id'], + data=entry['data'], + source=entry['source'], + title=entry['title'], + # New in 0.79 + connection_class=entry.get('connection_class', + CONN_CLASS_UNKNOWN)) + for entry in config['entries']] async def async_forward_entry_setup(self, entry, component): """Forward the setup of an entry to a different component. @@ -402,6 +426,7 @@ async def _async_finish_flow(self, flow, result): title=result['title'], data=result['data'], source=flow.context['source'], + connection_class=flow.CONNECTION_CLASS, ) self._entries.append(entry) self._async_schedule_save() @@ -469,6 +494,8 @@ async def _old_conf_migrator(old_config): class ConfigFlow(data_entry_flow.FlowHandler): """Base class for config flows with some helpers.""" + CONNECTION_CLASS = CONN_CLASS_UNKNOWN + @callback def _async_current_entries(self): """Return current entries.""" diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index e17d5071c6a502..d940afc1152329 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,26 +1,28 @@ """Helpers for data entry flows for config entries.""" from functools import partial -from homeassistant.core import callback -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries -def register_discovery_flow(domain, title, discovery_function): +def register_discovery_flow(domain, title, discovery_function, + connection_class): """Register flow for discovered integrations that not require auth.""" config_entries.HANDLERS.register(domain)( - partial(DiscoveryFlowHandler, domain, title, discovery_function)) + partial(DiscoveryFlowHandler, domain, title, discovery_function, + connection_class)) -class DiscoveryFlowHandler(data_entry_flow.FlowHandler): +class DiscoveryFlowHandler(config_entries.ConfigFlow): """Handle a discovery config flow.""" VERSION = 1 - def __init__(self, domain, title, discovery_function): + def __init__(self, domain, title, discovery_function, connection_class): """Initialize the discovery config flow.""" self._domain = domain self._title = title self._discovery_function = discovery_function + self.CONNECTION_CLASS = connection_class # pylint: disable=C0103 async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" @@ -83,15 +85,3 @@ async def async_step_import(self, _): title=self._title, data={}, ) - - @callback - def _async_current_entries(self): - """Return current entries.""" - return self.hass.config_entries.async_entries(self._domain) - - @callback - def _async_in_progress(self): - """Return other in progress flows for current domain.""" - return [flw for flw in self.hass.config_entries.flow.async_progress() - if flw['handler'] == self._domain and - flw['flow_id'] != self.flow_id] diff --git a/tests/common.py b/tests/common.py index 26cd7743accb96..6629207b28850f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -550,14 +550,16 @@ class MockConfigEntry(config_entries.ConfigEntry): def __init__(self, *, domain='test', data=None, version=0, entry_id=None, source=config_entries.SOURCE_USER, title='Mock Title', - state=None): + state=None, + connection_class=config_entries.CONN_CLASS_UNKNOWN): """Initialize a mock config entry.""" kwargs = { 'entry_id': entry_id or 'mock-id', 'domain': domain, 'data': data or {}, 'version': version, - 'title': title + 'title': title, + 'connection_class': connection_class, } if source is not None: kwargs['source'] = source diff --git a/tests/components/binary_sensor/test_deconz.py b/tests/components/binary_sensor/test_deconz.py index 2e33e28fa578cc..c2d20bd47c8535 100644 --- a/tests/components/binary_sensor/test_deconz.py +++ b/tests/components/binary_sensor/test_deconz.py @@ -42,7 +42,8 @@ async def setup_bridge(hass, data, allow_clip_sensor=True): hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( 1, deconz.DOMAIN, 'Mock Title', - {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test') + {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH) await hass.config_entries.async_forward_entry_setup( config_entry, 'binary_sensor') # To flush out the service call to update the group diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ba053050f997fb..66d29aac757343 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -9,7 +9,6 @@ from homeassistant import config_entries as core_ce from homeassistant.config_entries import HANDLERS -from homeassistant.data_entry_flow import FlowHandler from homeassistant.setup import async_setup_component from homeassistant.components.config import config_entries from homeassistant.loader import set_component @@ -37,13 +36,15 @@ def test_get_entries(hass, client): MockConfigEntry( domain='comp', title='Test 1', - source='bla' + source='bla', + connection_class=core_ce.CONN_CLASS_LOCAL_POLL, ).add_to_hass(hass) MockConfigEntry( domain='comp2', title='Test 2', source='bla2', state=core_ce.ENTRY_STATE_LOADED, + connection_class=core_ce.CONN_CLASS_ASSUMED, ).add_to_hass(hass) resp = yield from client.get('/api/config/config_entries/entry') assert resp.status == 200 @@ -55,13 +56,15 @@ def test_get_entries(hass, client): 'domain': 'comp', 'title': 'Test 1', 'source': 'bla', - 'state': 'not_loaded' + 'state': 'not_loaded', + 'connection_class': 'local_poll', }, { 'domain': 'comp2', 'title': 'Test 2', 'source': 'bla2', 'state': 'loaded', + 'connection_class': 'assumed', }, ] @@ -100,7 +103,7 @@ def test_available_flows(hass, client): @asyncio.coroutine def test_initialize_flow(hass, client): """Test we can initialize a flow.""" - class TestFlow(FlowHandler): + class TestFlow(core_ce.ConfigFlow): @asyncio.coroutine def async_step_user(self, user_input=None): schema = OrderedDict() @@ -155,7 +158,7 @@ def async_step_user(self, user_input=None): @asyncio.coroutine def test_abort(hass, client): """Test a flow that aborts.""" - class TestFlow(FlowHandler): + class TestFlow(core_ce.ConfigFlow): @asyncio.coroutine def async_step_user(self, user_input=None): return self.async_abort(reason='bla') @@ -181,7 +184,7 @@ def test_create_account(hass, client): hass, 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(FlowHandler): + class TestFlow(core_ce.ConfigFlow): VERSION = 1 @asyncio.coroutine @@ -213,7 +216,7 @@ def test_two_step_flow(hass, client): hass, 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(FlowHandler): + class TestFlow(core_ce.ConfigFlow): VERSION = 1 @asyncio.coroutine @@ -269,7 +272,7 @@ def async_step_account(self, user_input=None): @asyncio.coroutine def test_get_progress_index(hass, client): """Test querying for the flows that are in progress.""" - class TestFlow(FlowHandler): + class TestFlow(core_ce.ConfigFlow): VERSION = 5 @asyncio.coroutine @@ -301,7 +304,7 @@ def async_step_account(self, user_input=None): @asyncio.coroutine def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" - class TestFlow(FlowHandler): + class TestFlow(core_ce.ConfigFlow): @asyncio.coroutine def async_step_user(self, user_input=None): schema = OrderedDict() diff --git a/tests/components/light/test_deconz.py b/tests/components/light/test_deconz.py index df088d7a1b5dde..f52219beaa833f 100644 --- a/tests/components/light/test_deconz.py +++ b/tests/components/light/test_deconz.py @@ -64,7 +64,7 @@ async def setup_bridge(hass, data, allow_deconz_groups=True): config_entry = config_entries.ConfigEntry( 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host', 'allow_deconz_groups': allow_deconz_groups}, - 'test') + 'test', config_entries.CONN_CLASS_LOCAL_PUSH) await hass.config_entries.async_forward_entry_setup(config_entry, 'light') # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index db8d7e5f1e19e6..ad4026e7f31a14 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -199,7 +199,7 @@ async def setup_bridge(hass, mock_bridge): hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { 'host': 'mock-host' - }, 'test') + }, 'test', config_entries.CONN_CLASS_LOCAL_POLL) await hass.config_entries.async_forward_entry_setup(config_entry, 'light') # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/scene/test_deconz.py b/tests/components/scene/test_deconz.py index 8c22f718fa0c40..89bb5297e78a3c 100644 --- a/tests/components/scene/test_deconz.py +++ b/tests/components/scene/test_deconz.py @@ -36,7 +36,8 @@ async def setup_bridge(hass, data): hass.data[deconz.DATA_DECONZ_UNSUB] = [] hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( - 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH) await hass.config_entries.async_forward_entry_setup(config_entry, 'scene') # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/sensor/test_deconz.py b/tests/components/sensor/test_deconz.py index d7cdb458646154..f40ba631d94232 100644 --- a/tests/components/sensor/test_deconz.py +++ b/tests/components/sensor/test_deconz.py @@ -58,7 +58,8 @@ async def setup_bridge(hass, data, allow_clip_sensor=True): hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( 1, deconz.DOMAIN, 'Mock Title', - {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test') + {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH) await hass.config_entries.async_forward_entry_setup(config_entry, 'sensor') # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/switch/test_deconz.py b/tests/components/switch/test_deconz.py index 57fc8b3bcd9c23..f4aac63166f29e 100644 --- a/tests/components/switch/test_deconz.py +++ b/tests/components/switch/test_deconz.py @@ -54,7 +54,8 @@ async def setup_bridge(hass, data): hass.data[deconz.DATA_DECONZ_UNSUB] = [] hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( - 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH) await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index a9b4dc158e0ecd..a578afae1d2ed7 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -21,7 +21,8 @@ async def has_discovered_devices(hass): with patch.dict(config_entries.HANDLERS): config_entry_flow.register_discovery_flow( - 'test', 'Test', has_discovered_devices) + 'test', 'Test', has_discovered_devices, + config_entries.CONN_CLASS_LOCAL_POLL) yield handler_conf diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d8756d87a19f98..57d63eb8271fbc 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -103,7 +103,7 @@ def test_add_entry_calls_setup_entry(hass, manager): hass, 'comp', MockModule('comp', async_setup_entry=mock_setup_entry)) - class TestFlow(data_entry_flow.FlowHandler): + class TestFlow(config_entries.ConfigFlow): VERSION = 1 @@ -159,8 +159,9 @@ async def test_saving_and_loading(hass): hass, 'test', MockModule('test', async_setup_entry=lambda *args: mock_coro(True))) - class TestFlow(data_entry_flow.FlowHandler): + class TestFlow(config_entries.ConfigFlow): VERSION = 5 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL @asyncio.coroutine def async_step_user(self, user_input=None): @@ -175,8 +176,9 @@ def async_step_user(self, user_input=None): await hass.config_entries.flow.async_init( 'test', context={'source': config_entries.SOURCE_USER}) - class Test2Flow(data_entry_flow.FlowHandler): + class Test2Flow(config_entries.ConfigFlow): VERSION = 3 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH @asyncio.coroutine def async_step_user(self, user_input=None): @@ -209,6 +211,7 @@ def async_step_user(self, user_input=None): assert orig.title == loaded.title assert orig.data == loaded.data assert orig.source == loaded.source + assert orig.connection_class == loaded.connection_class async def test_forward_entry_sets_up_component(hass): @@ -252,7 +255,7 @@ async def test_discovery_notification(hass): loader.set_component(hass, 'test', MockModule('test')) await async_setup_component(hass, 'persistent_notification', {}) - class TestFlow(data_entry_flow.FlowHandler): + class TestFlow(config_entries.ConfigFlow): VERSION = 5 async def async_step_discovery(self, user_input=None): @@ -289,7 +292,7 @@ async def test_discovery_notification_not_created(hass): loader.set_component(hass, 'test', MockModule('test')) await async_setup_component(hass, 'persistent_notification', {}) - class TestFlow(data_entry_flow.FlowHandler): + class TestFlow(config_entries.ConfigFlow): VERSION = 5 async def async_step_discovery(self, user_input=None): From 1c251009fe7cef1834186887b08d8e60861a2647 Mon Sep 17 00:00:00 2001 From: damarco Date: Mon, 17 Sep 2018 10:42:21 +0200 Subject: [PATCH 070/178] Add zha device entity (#14579) * Add endpoint entity * Fix lint error * Add nwk address as device state attribute * Change to ZhaDeviceEntity * Show last_seen only if offline * Remove obsolete _discover_endpoint_info() --- homeassistant/components/zha/__init__.py | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 573f432f4c04db..7c48a577850415 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -7,6 +7,7 @@ import collections import enum import logging +import time import voluptuous as vol @@ -14,6 +15,7 @@ from homeassistant import const as ha_const from homeassistant.helpers import discovery, entity from homeassistant.util import slugify +from homeassistant.helpers.entity_component import EntityComponent REQUIREMENTS = [ 'bellows==0.7.0', @@ -139,6 +141,7 @@ def __init__(self, hass, config): """Initialize the listener.""" self._hass = hass self._config = config + self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._device_registry = collections.defaultdict(list) hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) @@ -175,10 +178,17 @@ async def async_device_initialized(self, device, join): import homeassistant.components.zha.const as zha_const zha_const.populate_data() + device_manufacturer = device_model = None + for endpoint_id, endpoint in device.endpoints.items(): if endpoint_id == 0: # ZDO continue + if endpoint.manufacturer is not None: + device_manufacturer = endpoint.manufacturer + if endpoint.model is not None: + device_model = endpoint.model + component = None profile_clusters = ([], []) device_key = "{}-{}".format(device.ieee, endpoint_id) @@ -247,6 +257,14 @@ async def async_device_initialized(self, device, join): join, ) + endpoint_entity = ZhaDeviceEntity( + device, + device_manufacturer, + device_model, + self, + ) + await self._component.async_add_entities([endpoint_entity]) + def register_entity(self, ieee, entity_obj): """Record the creation of a hass entity associated with ieee.""" self._device_registry[ieee].append(entity_obj) @@ -370,6 +388,77 @@ def zdo_command(self, tsn, command_id, args): pass +class ZhaDeviceEntity(entity.Entity): + """A base class for ZHA devices.""" + + def __init__(self, device, manufacturer, model, application_listener, + keepalive_interval=7200, **kwargs): + """Init ZHA endpoint entity.""" + self._device_state_attributes = { + 'nwk': '0x{0:04x}'.format(device.nwk), + 'ieee': str(device.ieee), + 'lqi': device.lqi, + 'rssi': device.rssi, + } + + ieee = device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) + if manufacturer is not None and model is not None: + self._unique_id = "{}_{}_{}".format( + slugify(manufacturer), + slugify(model), + ieeetail, + ) + self._device_state_attributes['friendly_name'] = "{} {}".format( + manufacturer, + model, + ) + else: + self._unique_id = str(ieeetail) + + self._device = device + self._state = 'offline' + self._keepalive_interval = keepalive_interval + + application_listener.register_entity(ieee, self) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def state(self) -> str: + """Return the state of the entity.""" + return self._state + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + update_time = None + if self._device.last_seen is not None and self._state == 'offline': + time_struct = time.localtime(self._device.last_seen) + update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) + self._device_state_attributes['last_seen'] = update_time + if ('last_seen' in self._device_state_attributes and + self._state != 'offline'): + del self._device_state_attributes['last_seen'] + self._device_state_attributes['lqi'] = self._device.lqi + self._device_state_attributes['rssi'] = self._device.rssi + return self._device_state_attributes + + async def async_update(self): + """Handle polling.""" + if self._device.last_seen is None: + self._state = 'offline' + else: + difference = time.time() - self._device.last_seen + if difference > self._keepalive_interval: + self._state = 'offline' + else: + self._state = 'online' + + def get_discovery_info(hass, discovery_info): """Get the full discovery info for a device. From 849a93e0a641336de99721542e41631769656244 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 15 Sep 2018 12:45:45 +0200 Subject: [PATCH 071/178] Update translations --- .../components/auth/.translations/fr.json | 2 +- .../components/cast/.translations/ca.json | 2 +- .../components/cast/.translations/fr.json | 2 +- .../components/hangouts/.translations/en.json | 2 -- .../components/hangouts/.translations/fr.json | 5 ++++ .../homematicip_cloud/.translations/en.json | 1 + .../homematicip_cloud/.translations/fr.json | 15 ++++++++---- .../components/hue/.translations/fr.json | 2 +- .../components/ios/.translations/ca.json | 14 +++++++++++ .../components/ios/.translations/cs.json | 10 ++++++++ .../components/ios/.translations/fr.json | 14 +++++++++++ .../components/ios/.translations/ko.json | 14 +++++++++++ .../components/ios/.translations/nl.json | 14 +++++++++++ .../components/ios/.translations/pl.json | 14 +++++++++++ .../components/ios/.translations/zh-Hans.json | 14 +++++++++++ .../components/mqtt/.translations/ca.json | 23 +++++++++++++++++++ .../components/mqtt/.translations/fr.json | 23 +++++++++++++++++++ .../components/mqtt/.translations/ko.json | 23 +++++++++++++++++++ .../components/nest/.translations/fr.json | 6 ++++- .../components/openuv/.translations/nl.json | 1 + .../components/sonos/.translations/ca.json | 2 +- .../components/sonos/.translations/fr.json | 2 +- 22 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/ios/.translations/ca.json create mode 100644 homeassistant/components/ios/.translations/cs.json create mode 100644 homeassistant/components/ios/.translations/fr.json create mode 100644 homeassistant/components/ios/.translations/ko.json create mode 100644 homeassistant/components/ios/.translations/nl.json create mode 100644 homeassistant/components/ios/.translations/pl.json create mode 100644 homeassistant/components/ios/.translations/zh-Hans.json create mode 100644 homeassistant/components/mqtt/.translations/ca.json create mode 100644 homeassistant/components/mqtt/.translations/fr.json create mode 100644 homeassistant/components/mqtt/.translations/ko.json diff --git a/homeassistant/components/auth/.translations/fr.json b/homeassistant/components/auth/.translations/fr.json index e8a8037c39a7af..b8d10dc89d07a6 100644 --- a/homeassistant/components/auth/.translations/fr.json +++ b/homeassistant/components/auth/.translations/fr.json @@ -2,7 +2,7 @@ "mfa_setup": { "totp": { "error": { - "invalid_code": "Code invalide. S'il vous pla\u00eet essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte." + "invalid_code": "Code invalide. Veuillez essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte." }, "step": { "init": { diff --git a/homeassistant/components/cast/.translations/ca.json b/homeassistant/components/cast/.translations/ca.json index e65e00f8624b69..570cc7fdc00739 100644 --- a/homeassistant/components/cast/.translations/ca.json +++ b/homeassistant/components/cast/.translations/ca.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "No s'han trobat dispositius de Google Cast a la xarxa.", - "single_instance_allowed": "Nom\u00e9s cal una \u00fanica configuraci\u00f3 de Google Cast." + "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Google Cast." }, "step": { "confirm": { diff --git a/homeassistant/components/cast/.translations/fr.json b/homeassistant/components/cast/.translations/fr.json index d3b95121de6f35..99feeb3c89837d 100644 --- a/homeassistant/components/cast/.translations/fr.json +++ b/homeassistant/components/cast/.translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "Aucun appareil Google Cast trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Seulement une seule configuration de Google Cast est n\u00e9cessaire." + "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire." }, "step": { "confirm": { diff --git a/homeassistant/components/hangouts/.translations/en.json b/homeassistant/components/hangouts/.translations/en.json index 08d491e7009a8c..f526bec4f34679 100644 --- a/homeassistant/components/hangouts/.translations/en.json +++ b/homeassistant/components/hangouts/.translations/en.json @@ -14,7 +14,6 @@ "data": { "2fa": "2FA Pin" }, - "description": "", "title": "2-Factor-Authentication" }, "user": { @@ -22,7 +21,6 @@ "email": "E-Mail Address", "password": "Password" }, - "description": "", "title": "Google Hangouts Login" } }, diff --git a/homeassistant/components/hangouts/.translations/fr.json b/homeassistant/components/hangouts/.translations/fr.json index 53759f9b5342fb..00a7d5fd80d2f9 100644 --- a/homeassistant/components/hangouts/.translations/fr.json +++ b/homeassistant/components/hangouts/.translations/fr.json @@ -5,10 +5,15 @@ "unknown": "Une erreur inconnue s'est produite" }, "error": { + "invalid_2fa": "Authentification \u00e0 2 facteurs invalide, veuillez r\u00e9essayer.", + "invalid_2fa_method": "M\u00e9thode 2FA non valide (v\u00e9rifiez sur le t\u00e9l\u00e9phone).", "invalid_login": "Login invalide, veuillez r\u00e9essayer." }, "step": { "2fa": { + "data": { + "2fa": "Code PIN d'authentification \u00e0 2 facteurs" + }, "title": "Authentification \u00e0 2 facteurs" }, "user": { diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json index 605bb0d250bba7..6fcfcddd75d6e6 100644 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Access point is already configured", + "conection_aborted": "Could not connect to HMIP server", "connection_aborted": "Could not connect to HMIP server", "unknown": "Unknown error occurred." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/fr.json b/homeassistant/components/homematicip_cloud/.translations/fr.json index 8405946daa2163..6cab0993c0156f 100644 --- a/homeassistant/components/homematicip_cloud/.translations/fr.json +++ b/homeassistant/components/homematicip_cloud/.translations/fr.json @@ -4,12 +4,13 @@ "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", "conection_aborted": "Impossible de se connecter au serveur HMIP", "connection_aborted": "Impossible de se connecter au serveur HMIP", - "unknown": "Une erreur inconnue s'est produite" + "unknown": "Une erreur inconnue s'est produite." }, "error": { "invalid_pin": "Code PIN invalide, veuillez r\u00e9essayer.", "press_the_button": "Veuillez appuyer sur le bouton bleu.", - "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer." + "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer.", + "timeout_button": "D\u00e9lai d'attente expir\u00e9, veuillez r\u00e9\u00e9ssayer." }, "step": { "init": { @@ -17,8 +18,14 @@ "hapid": "ID du point d'acc\u00e8s (SGTIN)", "name": "Nom (facultatif, utilis\u00e9 comme pr\u00e9fixe de nom pour tous les p\u00e9riph\u00e9riques)", "pin": "Code PIN (facultatif)" - } + }, + "title": "Choisissez le point d'acc\u00e8s HomematicIP" + }, + "link": { + "description": "Appuyez sur le bouton bleu du point d'acc\u00e8s et sur le bouton Envoyer pour enregistrer HomematicIP avec Home Assistant. \n\n ![Emplacement du bouton sur le pont](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Lier le point d'acc\u00e8s" } - } + }, + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/fr.json b/homeassistant/components/hue/.translations/fr.json index 73613f237dac3b..5414bf01ea7738 100644 --- a/homeassistant/components/hue/.translations/fr.json +++ b/homeassistant/components/hue/.translations/fr.json @@ -24,6 +24,6 @@ "title": "Hub de liaison" } }, - "title": "Pont Philips Hue" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/ca.json b/homeassistant/components/ios/.translations/ca.json new file mode 100644 index 00000000000000..1b1ed732ab3fba --- /dev/null +++ b/homeassistant/components/ios/.translations/ca.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "Voleu configurar el component Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/cs.json b/homeassistant/components/ios/.translations/cs.json new file mode 100644 index 00000000000000..95d675076daa48 --- /dev/null +++ b/homeassistant/components/ios/.translations/cs.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/fr.json b/homeassistant/components/ios/.translations/fr.json new file mode 100644 index 00000000000000..934849549e7d4a --- /dev/null +++ b/homeassistant/components/ios/.translations/fr.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Seule une configuration de Home Assistant iOS est n\u00e9cessaire." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer le composant Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/ko.json b/homeassistant/components/ios/.translations/ko.json new file mode 100644 index 00000000000000..6d69ea3126c794 --- /dev/null +++ b/homeassistant/components/ios/.translations/ko.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 Home Assistant iOS \uad6c\uc131\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Home Assistant iOS \ucef4\ud3ec\ub10c\ud2b8\uc758 \uc124\uc815\uc744 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/nl.json b/homeassistant/components/ios/.translations/nl.json new file mode 100644 index 00000000000000..8e5c46692a03f6 --- /dev/null +++ b/homeassistant/components/ios/.translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Home Assistant iOS nodig." + }, + "step": { + "confirm": { + "description": "Wilt u het Home Assistant iOS component instellen?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/pl.json b/homeassistant/components/ios/.translations/pl.json new file mode 100644 index 00000000000000..6240f074cfc3b6 --- /dev/null +++ b/homeassistant/components/ios/.translations/pl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/zh-Hans.json b/homeassistant/components/ios/.translations/zh-Hans.json new file mode 100644 index 00000000000000..0de30f0f3da0d5 --- /dev/null +++ b/homeassistant/components/ios/.translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Home Assistant iOS \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8bbe\u7f6e Home Assistant iOS \u7ec4\u4ef6\uff1f", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json new file mode 100644 index 00000000000000..57e9a83d201e46 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de MQTT." + }, + "error": { + "cannot_connect": "No es pot connectar amb el broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari" + }, + "description": "Introdu\u00efu la informaci\u00f3 de connexi\u00f3 del vostre broker MQTT.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/fr.json b/homeassistant/components/mqtt/.translations/fr.json new file mode 100644 index 00000000000000..1870c598e3b7a0 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Une seule configuration de MQTT est autoris\u00e9e." + }, + "error": { + "cannot_connect": "Impossible de se connecter au broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "description": "Veuillez entrer les informations de connexion de votre broker MQTT.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json new file mode 100644 index 00000000000000..a38b00fd68dc51 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 MQTT \ube0c\ub85c\ucee4\ub9cc \uad6c\uc131\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc74c" + }, + "step": { + "broker": { + "data": { + "broker": "\ube0c\ub85c\ucee4", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "MQTT \ube0c\ub85c\ucee4\uc640\uc758 \uc5f0\uacb0 \uc815\ubcf4\ub97c \uc785\ub825\ud558\uc138\uc694.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/fr.json b/homeassistant/components/nest/.translations/fr.json index fd2cb435eb25d8..822ec6ae836848 100644 --- a/homeassistant/components/nest/.translations/fr.json +++ b/homeassistant/components/nest/.translations/fr.json @@ -2,18 +2,22 @@ "config": { "abort": { "already_setup": "Vous ne pouvez configurer qu'un seul compte Nest.", + "authorize_url_fail": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation.", "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", "no_flows": "Vous devez configurer Nest avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Erreur interne lors de la validation du code", - "invalid_code": "Code invalide" + "invalid_code": "Code invalide", + "timeout": "D\u00e9lai de la validation du code expir\u00e9", + "unknown": "Erreur inconnue lors de la validation du code" }, "step": { "init": { "data": { "flow_impl": "Fournisseur" }, + "description": "S\u00e9lectionnez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Nest.", "title": "Fournisseur d'authentification" }, "link": { diff --git a/homeassistant/components/openuv/.translations/nl.json b/homeassistant/components/openuv/.translations/nl.json index 2c5086b3365278..e2b264182d0742 100644 --- a/homeassistant/components/openuv/.translations/nl.json +++ b/homeassistant/components/openuv/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "error": { + "identifier_exists": "Co\u00f6rdinaten al geregistreerd", "invalid_api_key": "Ongeldige API-sleutel" }, "step": { diff --git a/homeassistant/components/sonos/.translations/ca.json b/homeassistant/components/sonos/.translations/ca.json index 9a745784b25fd2..a6f1f99a3790f3 100644 --- a/homeassistant/components/sonos/.translations/ca.json +++ b/homeassistant/components/sonos/.translations/ca.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "No s'han trobat dispositius Sonos a la xarxa.", - "single_instance_allowed": "Nom\u00e9s cal una \u00fanica configuraci\u00f3 de Sonos." + "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Sonos." }, "step": { "confirm": { diff --git a/homeassistant/components/sonos/.translations/fr.json b/homeassistant/components/sonos/.translations/fr.json index 809591dee7cda6..fd2a77bd12968d 100644 --- a/homeassistant/components/sonos/.translations/fr.json +++ b/homeassistant/components/sonos/.translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "Aucun p\u00e9riph\u00e9rique Sonos trouv\u00e9 sur le r\u00e9seau.", - "single_instance_allowed": "Seulement une seule configuration de Sonos est n\u00e9cessaire." + "single_instance_allowed": "Une seule configuration de Sonos est n\u00e9cessaire." }, "step": { "confirm": { From b8257866f5d7aaa56f5ed4491967c3bb8f395d59 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Sep 2018 13:39:30 +0200 Subject: [PATCH 072/178] Clean up device update, add via-hub (#16659) * Clean up device update, add via-hub * Test loading/saving data * Lint * Add to Hue" * Lint + tests --- .../components/config/device_registry.py | 1 + homeassistant/components/deconz/__init__.py | 2 +- homeassistant/components/hue/__init__.py | 2 +- homeassistant/components/light/hue.py | 1 + homeassistant/helpers/device_registry.py | 109 +++++++++++---- homeassistant/helpers/entity_platform.py | 13 +- homeassistant/helpers/entity_registry.py | 14 +- tests/common.py | 5 + .../components/config/test_device_registry.py | 16 ++- tests/components/hue/test_init.py | 4 +- tests/helpers/test_device_registry.py | 125 +++++++++++++++--- tests/helpers/test_entity_platform.py | 52 ++++++++ tests/helpers/test_entity_registry.py | 7 +- 13 files changed, 271 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 8383e0cdc7daac..88aa5727a972c2 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -40,6 +40,7 @@ async def retrieve_entities(): 'name': entry.name, 'sw_version': entry.sw_version, 'id': entry.id, + 'hub_device_id': entry.hub_device_id, } for entry in registry.devices.values()] )) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 6ed0a6e2c11acf..82f4233a7da799 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -127,7 +127,7 @@ def async_add_remote(sensors): device_registry = await \ hass.helpers.device_registry.async_get_registry() device_registry.async_get_or_create( - config_entry=config_entry.entry_id, + config_entry_id=config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, deconz.config.mac)}, identifiers={(DOMAIN, deconz.config.bridgeid)}, manufacturer='Dresden Elektronik', model=deconz.config.modelid, diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 38b521078f4222..7a781c99f538d7 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -140,7 +140,7 @@ async def async_setup_entry(hass, entry): config = bridge.api.config device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( - config_entry=entry.entry_id, + config_entry_id=entry.entry_id, connections={ (dr.CONNECTION_NETWORK_MAC, config.mac) }, diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 6f6e0ed617e01f..958abaca0331d7 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -302,6 +302,7 @@ def device_info(self): 'model': self.light.productname or self.light.modelid, # Not yet exposed as properties in aiohue 'sw_version': self.light.raw['swversion'], + 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), } async def async_turn_on(self, **kwargs): diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index e6ff45af2fe05f..8d4cd0a5bbf6ab 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -10,6 +10,7 @@ from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) +_UNDEF = object() DATA_REGISTRY = 'device_registry' @@ -32,6 +33,7 @@ class DeviceEntry: model = attr.ib(type=str) name = attr.ib(type=str, default=None) sw_version = attr.ib(type=str, default=None) + hub_device_id = attr.ib(type=str, default=None) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) @@ -54,28 +56,36 @@ def async_get_device(self, identifiers: set, connections: set): return None @callback - def async_get_or_create(self, *, config_entry, connections, identifiers, - manufacturer, model, name=None, sw_version=None): + def async_get_or_create(self, *, config_entry_id, connections, identifiers, + manufacturer, model, name=None, sw_version=None, + via_hub=None): """Get device. Create if it doesn't exist.""" if not identifiers and not connections: return None device = self.async_get_device(identifiers, connections) + if via_hub is not None: + hub_device = self.async_get_device({via_hub}, set()) + hub_device_id = hub_device.id if hub_device else None + else: + hub_device_id = None + if device is not None: - if config_entry not in device.config_entries: - device.config_entries.add(config_entry) - self.async_schedule_save() - return device + return self._async_update_device( + device.id, config_entry_id=config_entry_id, + hub_device_id=hub_device_id + ) device = DeviceEntry( - config_entries=[config_entry], + config_entries={config_entry_id}, connections=connections, identifiers=identifiers, manufacturer=manufacturer, model=model, name=name, - sw_version=sw_version + sw_version=sw_version, + hub_device_id=hub_device_id ) self.devices[device.id] = device @@ -83,24 +93,64 @@ def async_get_or_create(self, *, config_entry, connections, identifiers, return device + @callback + def _async_update_device(self, device_id, *, config_entry_id=_UNDEF, + remove_config_entry_id=_UNDEF, + hub_device_id=_UNDEF): + """Update device attributes.""" + old = self.devices[device_id] + + changes = {} + + config_entries = old.config_entries + + if (config_entry_id is not _UNDEF and + config_entry_id not in old.config_entries): + config_entries = old.config_entries | {config_entry_id} + + if (remove_config_entry_id is not _UNDEF and + remove_config_entry_id in config_entries): + config_entries = set(config_entries) + config_entries.remove(remove_config_entry_id) + + if config_entries is not old.config_entries: + changes['config_entries'] = config_entries + + if (hub_device_id is not _UNDEF and + hub_device_id != old.hub_device_id): + changes['hub_device_id'] = hub_device_id + + if not changes: + return old + + new = self.devices[device_id] = attr.evolve(old, **changes) + self.async_schedule_save() + return new + async def async_load(self): """Load the device registry.""" - devices = await self._store.async_load() - - if devices is None: - self.devices = OrderedDict() - return - - self.devices = {device['id']: DeviceEntry( - config_entries=device['config_entries'], - connections={tuple(conn) for conn in device['connections']}, - identifiers={tuple(iden) for iden in device['identifiers']}, - manufacturer=device['manufacturer'], - model=device['model'], - name=device['name'], - sw_version=device['sw_version'], - id=device['id'], - ) for device in devices['devices']} + data = await self._store.async_load() + + devices = OrderedDict() + + if data is not None: + for device in data['devices']: + devices[device['id']] = DeviceEntry( + config_entries=set(device['config_entries']), + connections={tuple(conn) for conn + in device['connections']}, + identifiers={tuple(iden) for iden + in device['identifiers']}, + manufacturer=device['manufacturer'], + model=device['model'], + name=device['name'], + sw_version=device['sw_version'], + id=device['id'], + # Introduced in 0.79 + hub_device_id=device.get('hub_device_id'), + ) + + self.devices = devices @callback def async_schedule_save(self): @@ -122,18 +172,19 @@ def _data_to_save(self): 'name': entry.name, 'sw_version': entry.sw_version, 'id': entry.id, + 'hub_device_id': entry.hub_device_id, } for entry in self.devices.values() ] return data @callback - def async_clear_config_entry(self, config_entry): + def async_clear_config_entry(self, config_entry_id): """Clear config entry from registry entries.""" - for device in self.devices.values(): - if config_entry in device.config_entries: - device.config_entries.remove(config_entry) - self.async_schedule_save() + for dev_id, device in self.devices.items(): + if config_entry_id in device.config_entries: + self._async_update_device( + dev_id, remove_config_entry_id=config_entry_id) @bind_hass diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 083a2946122fc8..f2913e37339109 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -273,16 +273,19 @@ async def _async_add_entity(self, entity, update_before_add, config_entry_id = None device_info = entity.device_info + if config_entry_id is not None and device_info is not None: device = device_registry.async_get_or_create( - config_entry=config_entry_id, - connections=device_info.get('connections', []), - identifiers=device_info.get('identifiers', []), + config_entry_id=config_entry_id, + connections=device_info.get('connections') or set(), + identifiers=device_info.get('identifiers') or set(), manufacturer=device_info.get('manufacturer'), model=device_info.get('model'), name=device_info.get('name'), - sw_version=device_info.get('sw_version')) - device_id = device.id + sw_version=device_info.get('sw_version'), + via_hub=device_info.get('via_hub')) + if device: + device_id = device.id else: device_id = None diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index da3645a96fe864..01c8419dc040c8 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -31,7 +31,7 @@ STORAGE_KEY = 'core.entity_registry' -@attr.s(slots=True) +@attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -113,14 +113,9 @@ def async_get_or_create(self, domain, platform, unique_id, *, """Get entity. Create if it doesn't exist.""" entity_id = self.async_get_entity_id(domain, platform, unique_id) if entity_id: - entry = self.entities[entity_id] - if entry.config_entry_id == config_entry_id: - return entry - - self._async_update_entity( + return self._async_update_entity( entity_id, config_entry_id=config_entry_id, device_id=device_id) - return self.entities[entity_id] entity_id = self.async_generate_entity_id( domain, suggested_object_id or '{}_{}'.format(platform, unique_id)) @@ -253,10 +248,9 @@ def _data_to_save(self): @callback def async_clear_config_entry(self, config_entry): """Clear config entry from registry entries.""" - for entry in self.entities.values(): + for entity_id, entry in self.entities.items(): if config_entry == entry.config_entry_id: - entry.config_entry_id = None - self.async_schedule_save() + self._async_update_entity(entity_id, config_entry_id=None) @bind_hass diff --git a/tests/common.py b/tests/common.py index 6629207b28850f..56e86a4cd5c166 100644 --- a/tests/common.py +++ b/tests/common.py @@ -763,6 +763,11 @@ def available(self): """Return True if entity is available.""" return self._handle('available') + @property + def device_info(self): + """Info how it links to a device.""" + return self._handle('device_info') + def _handle(self, attr): """Return attribute value.""" if attr in self._values: diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 491319bf92791b..f8ea51cfdc821a 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -21,15 +21,16 @@ def registry(hass): async def test_list_devices(hass, client, registry): """Test list entries.""" registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={}, identifiers={('bridgeid', '1234')}, - manufacturer='manufacturer', model='model') + manufacturer='manufacturer', model='model', + via_hub=('bridgeid', '0123')) await client.send_json({ 'id': 5, @@ -37,8 +38,7 @@ async def test_list_devices(hass, client, registry): }) msg = await client.receive_json() - for entry in msg['result']: - entry.pop('id') + dev1, dev2 = [entry.pop('id') for entry in msg['result']] assert msg['result'] == [ { @@ -47,7 +47,8 @@ async def test_list_devices(hass, client, registry): 'manufacturer': 'manufacturer', 'model': 'model', 'name': None, - 'sw_version': None + 'sw_version': None, + 'hub_device_id': None, }, { 'config_entries': ['1234'], @@ -55,6 +56,7 @@ async def test_list_devices(hass, client, registry): 'manufacturer': 'manufacturer', 'model': 'model', 'name': None, - 'sw_version': None + 'sw_version': None, + 'hub_device_id': dev1, } ] diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 1c4768746d5b6f..5da6d5b709aa1b 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -182,7 +182,7 @@ async def test_config_passed_to_config_entry(hass): assert len(mock_registry.mock_calls) == 1 assert mock_registry.mock_calls[0][2] == { - 'config_entry': entry.entry_id, + 'config_entry_id': entry.entry_id, 'connections': { ('mac', 'mock-mac') }, @@ -192,7 +192,7 @@ async def test_config_passed_to_config_entry(hass): 'manufacturer': 'Signify', 'name': 'mock-name', 'model': 'mock-modelid', - 'sw_version': 'mock-swversion' + 'sw_version': 'mock-swversion', } diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 5ae6b4df651f0b..b251846c491271 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2,7 +2,7 @@ import pytest from homeassistant.helpers import device_registry -from tests.common import mock_device_registry +from tests.common import mock_device_registry, flush_store @pytest.fixture @@ -14,41 +14,41 @@ def registry(hass): async def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={('ethernet', '11:22:33:44:55:66:77:88')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '1234')}, manufacturer='manufacturer', model='model') assert len(registry.devices) == 1 - assert entry is entry2 - assert entry is entry3 + assert entry.id == entry2.id + assert entry.id == entry3.id assert entry.identifiers == {('bridgeid', '0123')} async def test_requirement_for_identifier_or_connection(registry): """Make sure we do require some descriptor of device.""" entry = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers=set(), manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections=set(), identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections=set(), identifiers=set(), manufacturer='manufacturer', model='model') @@ -62,25 +62,25 @@ async def test_requirement_for_identifier_or_connection(registry): async def test_multiple_config_entries(registry): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( - config_entry='123', + config_entry_id='123', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( - config_entry='456', + config_entry_id='456', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( - config_entry='123', + config_entry_id='123', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') assert len(registry.devices) == 1 - assert entry is entry2 - assert entry is entry3 - assert entry.config_entries == {'123', '456'} + assert entry.id == entry2.id + assert entry.id == entry3.id + assert entry2.config_entries == {'123', '456'} async def test_loading_from_storage(hass, hass_storage): @@ -118,7 +118,7 @@ async def test_loading_from_storage(hass, hass_storage): registry = await device_registry.async_get_registry(hass) entry = registry.async_get_or_create( - config_entry='1234', + config_entry_id='1234', connections={('Zigbee', '01.23.45.67.89')}, identifiers={('serial', '12:34:56:78:90:AB:CD:EF')}, manufacturer='manufacturer', model='model') @@ -129,25 +129,106 @@ async def test_loading_from_storage(hass, hass_storage): async def test_removing_config_entries(registry): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( - config_entry='123', + config_entry_id='123', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( - config_entry='456', + config_entry_id='456', connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( - config_entry='123', + config_entry_id='123', connections={('ethernet', '34:56:78:90:AB:CD:EF:12')}, identifiers={('bridgeid', '4567')}, manufacturer='manufacturer', model='model') assert len(registry.devices) == 2 - assert entry is entry2 - assert entry is not entry3 - assert entry.config_entries == {'123', '456'} + assert entry.id == entry2.id + assert entry.id != entry3.id + assert entry2.config_entries == {'123', '456'} + registry.async_clear_config_entry('123') + entry = registry.async_get_device({('bridgeid', '0123')}, set()) + entry3 = registry.async_get_device({('bridgeid', '4567')}, set()) + assert entry.config_entries == {'456'} assert entry3.config_entries == set() + + +async def test_specifying_hub_device_create(registry): + """Test specifying a hub and updating.""" + hub = registry.async_get_or_create( + config_entry_id='123', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('hue', '0123')}, + manufacturer='manufacturer', model='hub') + + light = registry.async_get_or_create( + config_entry_id='456', + connections=set(), + identifiers={('hue', '456')}, + manufacturer='manufacturer', model='light', + via_hub=('hue', '0123')) + + assert light.hub_device_id == hub.id + + +async def test_specifying_hub_device_update(registry): + """Test specifying a hub and updating.""" + light = registry.async_get_or_create( + config_entry_id='456', + connections=set(), + identifiers={('hue', '456')}, + manufacturer='manufacturer', model='light', + via_hub=('hue', '0123')) + + assert light.hub_device_id is None + + hub = registry.async_get_or_create( + config_entry_id='123', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('hue', '0123')}, + manufacturer='manufacturer', model='hub') + + light = registry.async_get_or_create( + config_entry_id='456', + connections=set(), + identifiers={('hue', '456')}, + manufacturer='manufacturer', model='light', + via_hub=('hue', '0123')) + + assert light.hub_device_id == hub.id + + +async def test_loading_saving_data(hass, registry): + """Test that we load/save data correctly.""" + orig_hub = registry.async_get_or_create( + config_entry_id='123', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('hue', '0123')}, + manufacturer='manufacturer', model='hub') + + orig_light = registry.async_get_or_create( + config_entry_id='456', + connections=set(), + identifiers={('hue', '456')}, + manufacturer='manufacturer', model='light', + via_hub=('hue', '0123')) + + assert len(registry.devices) == 2 + + # Now load written data in new registry + registry2 = device_registry.DeviceRegistry(hass) + await flush_store(registry._store) + await registry2.async_load() + + # Ensure same order + assert list(registry.devices) == list(registry2.devices) + + new_hub = registry2.async_get_device({('hue', '0123')}, set()) + new_light = registry2.async_get_device({('hue', '456')}, set()) + + assert orig_hub == new_hub + assert orig_light == new_light diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index b51219ddbed089..631d446d186196 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -676,3 +676,55 @@ async def test_entity_registry_updates_invalid_entity_id(hass): assert hass.states.get('test_domain.world') is not None assert hass.states.get('invalid_entity_id') is None assert hass.states.get('diff_domain.world') is None + + +async def test_device_info_called(hass): + """Test device info is forwarded correctly.""" + registry = await hass.helpers.device_registry.async_get_registry() + hub = registry.async_get_or_create( + config_entry_id='123', + connections=set(), + identifiers={('hue', 'hub-id')}, + manufacturer='manufacturer', model='hub' + ) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([ + # Invalid device info + MockEntity(unique_id='abcd', device_info={}), + # Valid device info + MockEntity(unique_id='qwer', device_info={ + 'identifiers': {('hue', '1234')}, + 'connections': {('mac', 'abcd')}, + 'manufacturer': 'test-manuf', + 'model': 'test-model', + 'name': 'test-name', + 'sw_version': 'test-sw', + 'via_hub': ('hue', 'hub-id'), + }), + ]) + return True + + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry(entry_id='super-mock-id') + entity_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + device = registry.async_get_device({('hue', '1234')}, set()) + assert device is not None + assert device.identifiers == {('hue', '1234')} + assert device.connections == {('mac', 'abcd')} + assert device.manufacturer == 'test-manuf' + assert device.model == 'test-model' + assert device.name == 'test-name' + assert device.sw_version == 'test-sw' + assert device.hub_device_id == hub.id diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index bb28287ddd81ec..a8c9086b2d2d1d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -6,7 +6,7 @@ from homeassistant.helpers import entity_registry -from tests.common import mock_registry +from tests.common import mock_registry, flush_store YAML__OPEN_PATH = 'homeassistant.util.yaml.open' @@ -77,8 +77,7 @@ async def test_loading_saving_data(hass, registry): # Now load written data in new registry registry2 = entity_registry.EntityRegistry(hass) - registry2._store = registry._store - + await flush_store(registry._store) await registry2.async_load() # Ensure same order @@ -192,6 +191,8 @@ async def test_removing_config_entry_id(registry): 'light', 'hue', '5678', config_entry_id='mock-id-1') assert entry.config_entry_id == 'mock-id-1' registry.async_clear_config_entry('mock-id-1') + + entry = registry.entities[entry.entity_id] assert entry.config_entry_id is None From 41ac2a3c7373a6edb6b4701179fba82c574acb01 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Sep 2018 14:18:04 +0200 Subject: [PATCH 073/178] Bump frontend to 20180917.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 a7a6187c939714..529d7d5fb3d165 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180912.0'] +REQUIREMENTS = ['home-assistant-frontend==20180917.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 4f0ea37f32d29c..13cf9082e6c710 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -451,7 +451,7 @@ hole==0.3.0 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180912.0 +home-assistant-frontend==20180917.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6268efc664b2a8..8693627ba9510c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.4 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180912.0 +home-assistant-frontend==20180917.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 4b30cbbf3b69eb6e4fb93e706aaa011b385fbce9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Sep 2018 14:18:20 +0200 Subject: [PATCH 074/178] Update translations --- .../components/ios/.translations/hu.json | 14 +++++++++++ .../components/ios/.translations/ru.json | 14 +++++++++++ .../components/ios/.translations/zh-Hant.json | 14 +++++++++++ .../components/mqtt/.translations/hu.json | 23 +++++++++++++++++++ .../components/mqtt/.translations/ko.json | 2 +- .../components/mqtt/.translations/pl.json | 23 +++++++++++++++++++ .../components/mqtt/.translations/ru.json | 23 +++++++++++++++++++ .../mqtt/.translations/zh-Hans.json | 23 +++++++++++++++++++ .../mqtt/.translations/zh-Hant.json | 23 +++++++++++++++++++ 9 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ios/.translations/hu.json create mode 100644 homeassistant/components/ios/.translations/ru.json create mode 100644 homeassistant/components/ios/.translations/zh-Hant.json create mode 100644 homeassistant/components/mqtt/.translations/hu.json create mode 100644 homeassistant/components/mqtt/.translations/pl.json create mode 100644 homeassistant/components/mqtt/.translations/ru.json create mode 100644 homeassistant/components/mqtt/.translations/zh-Hans.json create mode 100644 homeassistant/components/mqtt/.translations/zh-Hant.json diff --git a/homeassistant/components/ios/.translations/hu.json b/homeassistant/components/ios/.translations/hu.json new file mode 100644 index 00000000000000..5ee001db3c59e1 --- /dev/null +++ b/homeassistant/components/ios/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Csak egyetlen Home Assistant iOS konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Home Assistant iOS komponenst?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/ru.json b/homeassistant/components/ios/.translations/ru.json new file mode 100644 index 00000000000000..7030f18b72999d --- /dev/null +++ b/homeassistant/components/ios/.translations/ru.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0414\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/zh-Hant.json b/homeassistant/components/ios/.translations/zh-Hant.json new file mode 100644 index 00000000000000..8cfedf31673d25 --- /dev/null +++ b/homeassistant/components/ios/.translations/zh-Hant.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Home Assistant iOS \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant iOS \u5143\u4ef6\uff1f", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/hu.json b/homeassistant/components/mqtt/.translations/hu.json new file mode 100644 index 00000000000000..d85814e917c69d --- /dev/null +++ b/homeassistant/components/mqtt/.translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Csak egyetlen MQTT konfigur\u00e1ci\u00f3 megengedett." + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni a br\u00f3kerhez." + }, + "step": { + "broker": { + "data": { + "broker": "Br\u00f3ker", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index a38b00fd68dc51..a1e130c59cfa11 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -4,7 +4,7 @@ "single_instance_allowed": "\ud558\ub098\uc758 MQTT \ube0c\ub85c\ucee4\ub9cc \uad6c\uc131\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4." }, "error": { - "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc74c" + "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." }, "step": { "broker": { diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json new file mode 100644 index 00000000000000..f3ccaf8f37e223 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja MQTT." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z po\u015brednikiem." + }, + "step": { + "broker": { + "data": { + "broker": "Po\u015brednik", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a informacje o po\u0142\u0105czeniu po\u015brednika MQTT.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json new file mode 100644 index 00000000000000..c9dc3c2fd600c6 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0414\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f MQTT." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443." + }, + "step": { + "broker": { + "data": { + "broker": "\u0411\u0440\u043e\u043a\u0435\u0440", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0432\u0430\u0448\u0435\u043c\u0443 \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/zh-Hans.json b/homeassistant/components/mqtt/.translations/zh-Hans.json new file mode 100644 index 00000000000000..f539bd2a630a49 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/zh-Hans.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u53ea\u5141\u8bb8\u4e00\u4e2a MQTT \u914d\u7f6e\u3002" + }, + "error": { + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u670d\u52a1\u5668\u3002" + }, + "step": { + "broker": { + "data": { + "broker": "\u670d\u52a1\u5668", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u7684 MQTT \u670d\u52a1\u5668\u7684\u8fde\u63a5\u4fe1\u606f\u3002", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/zh-Hant.json b/homeassistant/components/mqtt/.translations/zh-Hant.json new file mode 100644 index 00000000000000..e6f27a439d5aea --- /dev/null +++ b/homeassistant/components/mqtt/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 MQTT\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Broker\u3002" + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "\u4f7f\u7528\u8005\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165 MQTT Broker \u9023\u7dda\u8cc7\u8a0a\u3002", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file From 25712f16b3b60aa5067ff29425190ff5552afb9b Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Mon, 17 Sep 2018 23:43:31 +0300 Subject: [PATCH 075/178] Jewish calendar sensor (#16393) * Initial commit for jewish calendar sensor * Make check for logging errors into it's own function * Can't use f-strings as we need to support python3.5 * Implement basic functionality: printing of date * Update requirements_all.txt * Allow user to specify date for sensor * Add hdate to test requirements * Update to match pull request * Support date output in hebrew * Limit languages to english and hebrew * Add name back to sensor * Change icon to be calendar-today * Add multiple sensors * Fix tests * Make Hound happy, remove unused imported class * hdate expects datetime.date not datetime.datetime * Return sensor name * Times should be returned as time object, not datetime * Add myself to codeowners for jewish calendar component * Return actual reading, not index * Add more tests. Currently failing. Will need to update hdate API and version before continuing. * Fix weekly portion test * Make all tests pass * Make travis happy and add a test so it doesnt happen again * Remove defaults in __init__ method * Change sensor state variable to local variable in update() method * Minor changes --- CODEOWNERS | 1 + .../components/sensor/jewish_calendar.py | 134 ++++++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../components/sensor/test_jewish_calendar.py | 134 ++++++++++++++++++ 6 files changed, 276 insertions(+) create mode 100644 homeassistant/components/sensor/jewish_calendar.py create mode 100644 tests/components/sensor/test_jewish_calendar.py diff --git a/CODEOWNERS b/CODEOWNERS index 68d0d4a833dac4..b6ce8c04909786 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,6 +72,7 @@ homeassistant/components/sensor/airvisual.py @bachya homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 +homeassistant/components/sensor/jewish_calendar.py @tsvi homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/nsw_fuel_station.py @nickw444 homeassistant/components/sensor/pollen.py @bachya diff --git a/homeassistant/components/sensor/jewish_calendar.py b/homeassistant/components/sensor/jewish_calendar.py new file mode 100644 index 00000000000000..544b7be0c5d232 --- /dev/null +++ b/homeassistant/components/sensor/jewish_calendar.py @@ -0,0 +1,134 @@ +""" +Platform to retrieve Jewish calendar information for Home Assistant. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.jewish_calendar/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['hdate==0.6.3'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + 'date': ['Date', 'mdi:judaism'], + 'weekly_portion': ['Parshat Hashavua', 'mdi:book-open-variant'], + 'holiday_name': ['Holiday', 'mdi:calendar-star'], + 'holyness': ['Holyness', 'mdi:counter'], + 'first_light': ['Alot Hashachar', 'mdi:weather-sunset-up'], + 'gra_end_shma': ['Latest time for Shm"a GR"A', 'mdi:calendar-clock'], + 'mga_end_shma': ['Latest time for Shm"a MG"A', 'mdi:calendar-clock'], + 'plag_mincha': ['Plag Hamincha', 'mdi:weather-sunset-down'], + 'first_stars': ['T\'set Hakochavim', 'mdi:weather-night'], +} + +CONF_DIASPORA = 'diaspora' +CONF_LANGUAGE = 'language' +CONF_SENSORS = 'sensors' + +DEFAULT_NAME = 'Jewish Calendar' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DIASPORA, default=False): cv.boolean, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_LANGUAGE, default='english'): + vol.In(['hebrew', 'english']), + vol.Optional(CONF_SENSORS, default=['date']): + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]), +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Jewish calendar sensor platform.""" + language = config.get(CONF_LANGUAGE) + name = config.get(CONF_NAME) + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + diaspora = config.get(CONF_DIASPORA) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return + + dev = [] + for sensor_type in config[CONF_SENSORS]: + dev.append(JewishCalSensor( + name, language, sensor_type, latitude, longitude, diaspora)) + async_add_entities(dev, True) + + +class JewishCalSensor(Entity): + """Representation of an Jewish calendar sensor.""" + + def __init__( + self, name, language, sensor_type, latitude, longitude, diaspora): + """Initialize the Jewish calendar sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self._hebrew = (language == 'hebrew') + self._state = None + self.latitude = latitude + self.longitude = longitude + self.diaspora = diaspora + _LOGGER.debug("Sensor %s initialized", self.type) + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def icon(self): + """Icon to display in the front end.""" + return SENSOR_TYPES[self.type][1] + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Update the state of the sensor.""" + import hdate + + today = dt_util.now().date() + + date = hdate.HDate( + today, diaspora=self.diaspora, hebrew=self._hebrew) + + if self.type == 'date': + self._state = hdate.date.get_hebrew_date( + date.h_day, date.h_month, date.h_year, hebrew=self._hebrew) + elif self.type == 'weekly_portion': + self._state = hdate.date.get_parashe( + date.get_reading(self.diaspora), hebrew=self._hebrew) + elif self.type == 'holiday_name': + try: + self._state = next( + x.description[self._hebrew].long + for x in hdate.htables.HOLIDAYS + if x.index == date.get_holyday()) + except StopIteration: + self._state = None + elif self.type == 'holyness': + self._state = hdate.date.get_holyday_type(date.get_holyday()) + else: + times = hdate.Zmanim( + date=today, + latitude=self.latitude, longitude=self.longitude, + hebrew=self._hebrew).zmanim + self._state = times[self.type].time() + + _LOGGER.debug("New value: %s", self._state) diff --git a/requirements_all.txt b/requirements_all.txt index 13cf9082e6c710..96e5ed0b1e3555 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -435,6 +435,9 @@ haversine==0.4.5 # homeassistant.components.mqtt.server hbmqtt==0.9.4 +# homeassistant.components.sensor.jewish_calendar +hdate==0.6.3 + # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8693627ba9510c..42561336ff95ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,6 +80,9 @@ haversine==0.4.5 # homeassistant.components.mqtt.server hbmqtt==0.9.4 +# homeassistant.components.sensor.jewish_calendar +hdate==0.6.3 + # homeassistant.components.binary_sensor.workday holidays==0.9.7 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d1d29affeff44c..3cde13a2a9755e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -55,6 +55,7 @@ 'ha-ffmpeg', 'haversine', 'hbmqtt', + 'hdate', 'holidays', 'home-assistant-frontend', 'homematicip', diff --git a/tests/components/sensor/test_jewish_calendar.py b/tests/components/sensor/test_jewish_calendar.py new file mode 100644 index 00000000000000..3260512495fd77 --- /dev/null +++ b/tests/components/sensor/test_jewish_calendar.py @@ -0,0 +1,134 @@ +"""The tests for the Jewish calendar sensor platform.""" +import unittest +from datetime import datetime as dt +from unittest.mock import patch + +from homeassistant.util.async_ import run_coroutine_threadsafe +from homeassistant.setup import setup_component +from homeassistant.components.sensor.jewish_calendar import JewishCalSensor +from tests.common import get_test_home_assistant + + +class TestJewishCalenderSensor(unittest.TestCase): + """Test the Jewish Calendar sensor.""" + + TEST_LATITUDE = 31.778 + TEST_LONGITUDE = 35.235 + + def setUp(self): + """Set up things to run when tests begin.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def checkForLoggingErrors(self): + """Check whether logger spitted out errors.""" + errors = [rec for rec in self.cm.records if rec.levelname == "ERROR"] + self.assertFalse(errors, ("Logger reported error(s): ", + [err.getMessage() for err in errors])) + + def test_jewish_calendar_min_config(self): + """Test minimum jewish calendar configuration.""" + config = { + 'sensor': { + 'platform': 'jewish_calendar' + } + } + with self.assertLogs() as self.cm: + assert setup_component(self.hass, 'sensor', config) + self.checkForLoggingErrors() + + def test_jewish_calendar_hebrew(self): + """Test jewish calendar sensor with language set to hebrew.""" + config = { + 'sensor': { + 'platform': 'jewish_calendar', + 'language': 'hebrew', + } + } + with self.assertLogs() as self.cm: + assert setup_component(self.hass, 'sensor', config) + self.checkForLoggingErrors() + + def test_jewish_calendar_multiple_sensors(self): + """Test jewish calendar sensor with multiple sensors setup.""" + config = { + 'sensor': { + 'platform': 'jewish_calendar', + 'sensors': [ + 'date', 'weekly_portion', 'holiday_name', + 'holyness', 'first_light', 'gra_end_shma', + 'mga_end_shma', 'plag_mincha', 'first_stars' + ] + } + } + with self.assertLogs() as self.cm: + assert setup_component(self.hass, 'sensor', config) + self.checkForLoggingErrors() + + def test_jewish_calendar_sensor_date_output(self): + """Test Jewish calendar sensor date output.""" + test_time = dt(2018, 9, 3) + sensor = JewishCalSensor( + name='test', language='english', sensor_type='date', + latitude=self.TEST_LATITUDE, longitude=self.TEST_LONGITUDE, + diaspora=False) + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + self.assertEqual(sensor.state, '23 Elul 5778') + + def test_jewish_calendar_sensor_date_output_hebrew(self): + """Test Jewish calendar sensor date output in hebrew.""" + test_time = dt(2018, 9, 3) + sensor = JewishCalSensor( + name='test', language='hebrew', sensor_type='date', + latitude=self.TEST_LATITUDE, longitude=self.TEST_LONGITUDE, + diaspora=False) + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + self.assertEqual(sensor.state, "כ\"ג באלול ה\' תשע\"ח") + + def test_jewish_calendar_sensor_holiday_name(self): + """Test Jewish calendar sensor date output in hebrew.""" + test_time = dt(2018, 9, 10) + sensor = JewishCalSensor( + name='test', language='hebrew', sensor_type='holiday_name', + latitude=self.TEST_LATITUDE, longitude=self.TEST_LONGITUDE, + diaspora=False) + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + self.assertEqual(sensor.state, "א\' ראש השנה") + + def test_jewish_calendar_sensor_holyness(self): + """Test Jewish calendar sensor date output in hebrew.""" + test_time = dt(2018, 9, 10) + sensor = JewishCalSensor( + name='test', language='hebrew', sensor_type='holyness', + latitude=self.TEST_LATITUDE, longitude=self.TEST_LONGITUDE, + diaspora=False) + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + self.assertEqual(sensor.state, 1) + + def test_jewish_calendar_sensor_torah_reading(self): + """Test Jewish calendar sensor date output in hebrew.""" + test_time = dt(2018, 9, 8) + sensor = JewishCalSensor( + name='test', language='hebrew', sensor_type='weekly_portion', + latitude=self.TEST_LATITUDE, longitude=self.TEST_LONGITUDE, + diaspora=False) + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop).result() + self.assertEqual(sensor.state, "פרשת נצבים") From 27d50d388fe61e38f5623d0e63c4bdc28764ebe2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 17 Sep 2018 14:44:18 -0600 Subject: [PATCH 076/178] Fixes an AirVisual bug where response data is missing (#16673) --- homeassistant/components/sensor/airvisual.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index dcd89ccb78a72f..6837b9e1b2fab8 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -281,7 +281,7 @@ async def _async_update(self): _LOGGER.debug("New data retrieved: %s", resp) self.pollution_info = resp['current']['pollution'] - except AirVisualError as err: + except (KeyError, AirVisualError) as err: if self.city and self.state and self.country: location = (self.city, self.state, self.country) else: From a7325ebe1fa440f2f6dbde815f19417547de929f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 18 Sep 2018 07:55:13 +0200 Subject: [PATCH 077/178] Suppress traceback and log error (#16669) --- homeassistant/components/sensor/scrape.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index e702c52e06a4e8..9a43c3ff295236 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -114,11 +114,16 @@ def update(self): raw_data = BeautifulSoup(self.rest.data, 'html.parser') _LOGGER.debug(raw_data) - if self._attr is not None: - value = raw_data.select(self._select)[0][self._attr] - else: - value = raw_data.select(self._select)[0].text - _LOGGER.debug(value) + + try: + if self._attr is not None: + value = raw_data.select(self._select)[0][self._attr] + else: + value = raw_data.select(self._select)[0].text + _LOGGER.debug(value) + except IndexError: + _LOGGER.error("Unable to extract data from HTML") + return if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( From 72419a1afe5aa09c8e5e5d79c32d9c09a7af4241 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Mon, 17 Sep 2018 23:30:20 -0700 Subject: [PATCH 078/178] Fix Ecovacs vacuums showing "None" for name (#16654) Was previously checking for a missing key, when should have been checking for the value being None --- homeassistant/components/ecovacs.py | 7 ++++--- homeassistant/components/vacuum/ecovacs.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ecovacs.py b/homeassistant/components/ecovacs.py index 2e51b048d15e43..53761a1013fa51 100644 --- a/homeassistant/components/ecovacs.py +++ b/homeassistant/components/ecovacs.py @@ -59,8 +59,9 @@ def setup(hass, config): _LOGGER.debug("Ecobot devices: %s", devices) for device in devices: - _LOGGER.info("Discovered Ecovacs device on account: %s", - device['nick']) + _LOGGER.info( + "Discovered Ecovacs device on account: %s with nickname %s", + device['did'], device['nick']) vacbot = VacBot(ecovacs_api.uid, ecovacs_api.REALM, ecovacs_api.resource, @@ -74,7 +75,7 @@ def stop(event: object) -> None: """Shut down open connections to Ecovacs XMPP server.""" for device in hass.data[ECOVACS_DEVICES]: _LOGGER.info("Shutting down connection to Ecovacs device %s", - device.vacuum['nick']) + device.vacuum['did']) device.disconnect() # Listen for HA stop to disconnect. diff --git a/homeassistant/components/vacuum/ecovacs.py b/homeassistant/components/vacuum/ecovacs.py index b92ae598f805e6..927362ac9db268 100644 --- a/homeassistant/components/vacuum/ecovacs.py +++ b/homeassistant/components/vacuum/ecovacs.py @@ -43,9 +43,9 @@ def __init__(self, device): """Initialize the Ecovacs Vacuum.""" self.device = device self.device.connect_and_wait_until_ready() - try: + if self.device.vacuum.get('nick', None) is not None: self._name = '{}'.format(self.device.vacuum['nick']) - except KeyError: + else: # In case there is no nickname defined, use the device id self._name = '{}'.format(self.device.vacuum['did']) From d2246d5a4fd84fcd85d249c7654c8654484f03c8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 18 Sep 2018 12:15:55 +0200 Subject: [PATCH 079/178] Fix test --- .../components/sensor/jewish_calendar.py | 3 +- .../components/sensor/test_jewish_calendar.py | 32 ++++++------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/sensor/jewish_calendar.py b/homeassistant/components/sensor/jewish_calendar.py index 544b7be0c5d232..e5838fa854362a 100644 --- a/homeassistant/components/sensor/jewish_calendar.py +++ b/homeassistant/components/sensor/jewish_calendar.py @@ -126,8 +126,7 @@ async def async_update(self): self._state = hdate.date.get_holyday_type(date.get_holyday()) else: times = hdate.Zmanim( - date=today, - latitude=self.latitude, longitude=self.longitude, + date=today, latitude=self.latitude, longitude=self.longitude, hebrew=self._hebrew).zmanim self._state = times[self.type].time() diff --git a/tests/components/sensor/test_jewish_calendar.py b/tests/components/sensor/test_jewish_calendar.py index 3260512495fd77..990f26d6ea7605 100644 --- a/tests/components/sensor/test_jewish_calendar.py +++ b/tests/components/sensor/test_jewish_calendar.py @@ -23,12 +23,6 @@ def tearDown(self): """Stop everything that was started.""" self.hass.stop() - def checkForLoggingErrors(self): - """Check whether logger spitted out errors.""" - errors = [rec for rec in self.cm.records if rec.levelname == "ERROR"] - self.assertFalse(errors, ("Logger reported error(s): ", - [err.getMessage() for err in errors])) - def test_jewish_calendar_min_config(self): """Test minimum jewish calendar configuration.""" config = { @@ -36,9 +30,7 @@ def test_jewish_calendar_min_config(self): 'platform': 'jewish_calendar' } } - with self.assertLogs() as self.cm: - assert setup_component(self.hass, 'sensor', config) - self.checkForLoggingErrors() + assert setup_component(self.hass, 'sensor', config) def test_jewish_calendar_hebrew(self): """Test jewish calendar sensor with language set to hebrew.""" @@ -48,9 +40,8 @@ def test_jewish_calendar_hebrew(self): 'language': 'hebrew', } } - with self.assertLogs() as self.cm: - assert setup_component(self.hass, 'sensor', config) - self.checkForLoggingErrors() + + assert setup_component(self.hass, 'sensor', config) def test_jewish_calendar_multiple_sensors(self): """Test jewish calendar sensor with multiple sensors setup.""" @@ -64,9 +55,8 @@ def test_jewish_calendar_multiple_sensors(self): ] } } - with self.assertLogs() as self.cm: - assert setup_component(self.hass, 'sensor', config) - self.checkForLoggingErrors() + + assert setup_component(self.hass, 'sensor', config) def test_jewish_calendar_sensor_date_output(self): """Test Jewish calendar sensor date output.""" @@ -90,8 +80,7 @@ def test_jewish_calendar_sensor_date_output_hebrew(self): diaspora=False) with patch('homeassistant.util.dt.now', return_value=test_time): run_coroutine_threadsafe( - sensor.async_update(), - self.hass.loop).result() + sensor.async_update(), self.hass.loop).result() self.assertEqual(sensor.state, "כ\"ג באלול ה\' תשע\"ח") def test_jewish_calendar_sensor_holiday_name(self): @@ -103,8 +92,7 @@ def test_jewish_calendar_sensor_holiday_name(self): diaspora=False) with patch('homeassistant.util.dt.now', return_value=test_time): run_coroutine_threadsafe( - sensor.async_update(), - self.hass.loop).result() + sensor.async_update(), self.hass.loop).result() self.assertEqual(sensor.state, "א\' ראש השנה") def test_jewish_calendar_sensor_holyness(self): @@ -116,8 +104,7 @@ def test_jewish_calendar_sensor_holyness(self): diaspora=False) with patch('homeassistant.util.dt.now', return_value=test_time): run_coroutine_threadsafe( - sensor.async_update(), - self.hass.loop).result() + sensor.async_update(), self.hass.loop).result() self.assertEqual(sensor.state, 1) def test_jewish_calendar_sensor_torah_reading(self): @@ -129,6 +116,5 @@ def test_jewish_calendar_sensor_torah_reading(self): diaspora=False) with patch('homeassistant.util.dt.now', return_value=test_time): run_coroutine_threadsafe( - sensor.async_update(), - self.hass.loop).result() + sensor.async_update(), self.hass.loop).result() self.assertEqual(sensor.state, "פרשת נצבים") From cba3a5b055912ced76045a44f9f346728733b98e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 18 Sep 2018 14:59:39 +0200 Subject: [PATCH 080/178] Upgrade paho-mqtt to 1.4.0 (#16688) --- homeassistant/components/mqtt/__init__.py | 72 ++++++++++---------- homeassistant/components/mqtt/config_flow.py | 28 +++----- homeassistant/components/mqtt/discovery.py | 4 +- homeassistant/components/mqtt/server.py | 5 +- homeassistant/components/shiftr.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 51 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ac29671667f040..bcb0d60902bd8e 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -6,40 +6,40 @@ """ import asyncio from itertools import groupby -from typing import Optional, Any, Union, Callable, List, cast # noqa: F401 -from operator import attrgetter import logging +from operator import attrgetter import os import socket -import time import ssl -import requests.certs -import attr +import time +from typing import Any, Callable, List, Optional, Union, cast # noqa: F401 +import attr +import requests.certs import voluptuous as vol from homeassistant import config_entries -from homeassistant.helpers.typing import HomeAssistantType, ConfigType, \ - ServiceDataType -from homeassistant.core import callback, Event, ServiceCall -from homeassistant.setup import async_prepare_setup_platform +from homeassistant.const import ( + CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, + CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import Event, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import bind_hass -from homeassistant.helpers import template, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import template from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ( + ConfigType, HomeAssistantType, ServiceDataType) +from homeassistant.loader import bind_hass +from homeassistant.setup import async_prepare_setup_platform from homeassistant.util.async_ import ( - run_coroutine_threadsafe, run_callback_threadsafe) -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_VALUE_TEMPLATE, CONF_USERNAME, - CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD, - EVENT_HOMEASSISTANT_START) + run_callback_threadsafe, run_coroutine_threadsafe) # Loading the config flow file will register the flow from . import config_flow # noqa # pylint: disable=unused-import from .const import CONF_BROKER from .server import HBMQTT_CONFIG_SCHEMA -REQUIREMENTS = ['paho-mqtt==1.3.1'] +REQUIREMENTS = ['paho-mqtt==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -296,8 +296,7 @@ def remove(): return remove -async def _async_setup_server(hass: HomeAssistantType, - config: ConfigType): +async def _async_setup_server(hass: HomeAssistantType, config: ConfigType): """Try to start embedded MQTT broker. This method is a coroutine. @@ -366,7 +365,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: broker_config = await _async_setup_server(hass, config) if broker_config is None: - _LOGGER.error('Unable to start embedded MQTT broker') + _LOGGER.error("Unable to start embedded MQTT broker") return False conf.update({ @@ -418,8 +417,8 @@ async def async_setup_entry(hass, entry): })[DOMAIN] elif any(key in conf for key in entry.data): _LOGGER.warning( - 'Data in your config entry is going to override your ' - 'configuration.yaml: %s', entry.data) + "Data in your config entry is going to override your " + "configuration.yaml: %s", entry.data) conf.update(entry.data) @@ -438,8 +437,8 @@ async def async_setup_entry(hass, entry): if (conf.get(CONF_CERTIFICATE) is None and 19999 < conf[CONF_PORT] < 30000 and conf[CONF_BROKER].endswith('.cloudmqtt.com')): - certificate = os.path.join(os.path.dirname(__file__), - 'addtrustexternalcaroot.crt') + certificate = os.path.join( + os.path.dirname(__file__), 'addtrustexternalcaroot.crt') # When the certificate is set to auto, use bundled certs from requests elif conf.get(CONF_CERTIFICATE) == 'auto': @@ -623,12 +622,12 @@ async def async_connect(self) -> bool: result = await self.hass.async_add_job( self._mqttc.connect, self.broker, self.port, self.keepalive) except OSError as err: - _LOGGER.error('Failed to connect due to exception: %s', err) + _LOGGER.error("Failed to connect due to exception: %s", err) return False if result != 0: import paho.mqtt.client as mqtt - _LOGGER.error('Failed to connect: %s', mqtt.error_string(result)) + _LOGGER.error("Failed to connect: %s", mqtt.error_string(result)) return False self._mqttc.loop_start() @@ -655,7 +654,7 @@ async def async_subscribe(self, topic: str, This method is a coroutine. """ if not isinstance(topic, str): - raise HomeAssistantError("topic needs to be a string!") + raise HomeAssistantError("Topic needs to be a string!") subscription = Subscription(topic, msg_callback, qos, encoding) self.subscriptions.append(subscription) @@ -697,8 +696,8 @@ async def _async_perform_subscription(self, topic: str, qos: int) -> None: self._mqttc.subscribe, topic, qos) _raise_on_error(result) - def _mqtt_on_connect(self, _mqttc, _userdata, _flags, - result_code: int) -> None: + def _mqtt_on_connect( + self, _mqttc, _userdata, _flags, result_code: int) -> None: """On connect callback. Resubscribe to all topics we were subscribed to and publish birth @@ -707,7 +706,7 @@ def _mqtt_on_connect(self, _mqttc, _userdata, _flags, import paho.mqtt.client as mqtt if result_code != mqtt.CONNACK_ACCEPTED: - _LOGGER.error('Unable to connect to the MQTT broker: %s', + _LOGGER.error("Unable to connect to the MQTT broker: %s", mqtt.connack_string(result_code)) self._mqttc.disconnect() return @@ -741,14 +740,13 @@ def _mqtt_handle_message(self, msg) -> None: try: payload = msg.payload.decode(subscription.encoding) except (AttributeError, UnicodeDecodeError): - _LOGGER.warning("Can't decode payload %s on %s " - "with encoding %s", - msg.payload, msg.topic, - subscription.encoding) + _LOGGER.warning( + "Can't decode payload %s on %s with encoding %s", + msg.payload, msg.topic, subscription.encoding) continue - self.hass.async_run_job(subscription.callback, - msg.topic, payload, msg.qos) + self.hass.async_run_job( + subscription.callback, msg.topic, payload, msg.qos) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" @@ -810,7 +808,7 @@ def __init__(self, availability_topic: Optional[str], qos: Optional[int], self._payload_not_available = payload_not_available async def async_added_to_hass(self) -> None: - """Subscribe mqtt events. + """Subscribe MQTT events. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 82b6f35056b5fa..a8987a19742e9a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_PORT +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from .const import CONF_BROKER @@ -20,14 +20,12 @@ class FlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if self._async_current_entries(): - return self.async_abort( - reason='single_instance_allowed' - ) + return self.async_abort(reason='single_instance_allowed') return await self.async_step_broker() async def async_step_broker(self, user_input=None): - """Confirm setup.""" + """Confirm the setup.""" errors = {} if user_input is not None: @@ -37,9 +35,7 @@ async def async_step_broker(self, user_input=None): if can_connect: return self.async_create_entry( - title=user_input[CONF_BROKER], - data=user_input - ) + title=user_input[CONF_BROKER], data=user_input) errors['base'] = 'cannot_connect' @@ -50,10 +46,7 @@ async def async_step_broker(self, user_input=None): fields[vol.Optional(CONF_PASSWORD)] = str return self.async_show_form( - step_id='broker', - data_schema=vol.Schema(fields), - errors=errors, - ) + step_id='broker', data_schema=vol.Schema(fields), errors=errors) async def async_step_import(self, user_input): """Import a config entry. @@ -62,14 +55,9 @@ async def async_step_import(self, user_input): Instead, we're going to rely on the values that are in config file. """ if self._async_current_entries(): - return self.async_abort( - reason='single_instance_allowed' - ) - - return self.async_create_entry( - title='configuration.yaml', - data={} - ) + return self.async_abort(reason='single_instance_allowed') + + return self.async_create_entry(title='configuration.yaml', data={}) def try_connection(broker, port, username, password): diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 128c45f1311ade..689515f64c81e9 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -9,9 +9,9 @@ import re from homeassistant.components import mqtt -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.const import CONF_PLATFORM from homeassistant.components.mqtt import CONF_STATE_TOPIC +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.discovery import async_load_platform _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 45529411ed52e6..dda2214ce46d10 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -14,6 +14,9 @@ import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['hbmqtt==0.9.4'] + +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['http'] # None allows custom config to be created through generate_config @@ -27,8 +30,6 @@ }) }, extra=vol.ALLOW_EXTRA)) -_LOGGER = logging.getLogger(__name__) - @asyncio.coroutine def async_start(hass, password, server_config): diff --git a/homeassistant/components/shiftr.py b/homeassistant/components/shiftr.py index 67baa045b182cf..17a46be47344cc 100644 --- a/homeassistant/components/shiftr.py +++ b/homeassistant/components/shiftr.py @@ -14,7 +14,7 @@ EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import state as state_helper -REQUIREMENTS = ['paho-mqtt==1.3.1'] +REQUIREMENTS = ['paho-mqtt==1.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 96e5ed0b1e3555..ff438baecd1a36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -660,7 +660,7 @@ orvibo==1.1.1 # homeassistant.components.mqtt # homeassistant.components.shiftr -paho-mqtt==1.3.1 +paho-mqtt==1.4.0 # homeassistant.components.media_player.panasonic_viera panasonic_viera==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42561336ff95ed..6990c4da5438f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ numpy==1.15.1 # homeassistant.components.mqtt # homeassistant.components.shiftr -paho-mqtt==1.3.1 +paho-mqtt==1.4.0 # homeassistant.components.device_tracker.aruba # homeassistant.components.device_tracker.asuswrt From 2a85ed723697b792dfaeca9f2bf3a038fc184246 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 18 Sep 2018 09:06:52 -0400 Subject: [PATCH 081/178] Streamline log messages (#16243) --- homeassistant/core.py | 2 +- tests/components/zwave/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index e8d50e003d9df2..6b1ea3f426393f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -476,7 +476,7 @@ def async_fire(self, event_type: str, event_data: Optional[Dict] = None, event = Event(event_type, event_data, origin, None, context) if event_type != EVENT_TIME_CHANGED: - _LOGGER.info("Bus:Handling %s", event) + _LOGGER.debug("Bus:Handling %s", event) if not listeners: return diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index c9224885bbcab8..1857d14ad84b3f 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -1180,7 +1180,7 @@ def test_print_node(self): self.zwave_network.nodes = {14: node} - with self.assertLogs(level='INFO') as mock_logger: + with self.assertLogs(level='DEBUG') as mock_logger: self.hass.services.call('zwave', 'print_node', { const.ATTR_NODE_ID: 14 }) From 1913d07c394ad29e446c7f87b6fba83ead580dd0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 18 Sep 2018 15:49:54 +0200 Subject: [PATCH 082/178] Upgrade zeroconf to 0.21.1 (#16687) --- homeassistant/components/zeroconf.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf.py b/homeassistant/components/zeroconf.py index 1f20ebad0860ce..d63b532ccbbfa1 100644 --- a/homeassistant/components/zeroconf.py +++ b/homeassistant/components/zeroconf.py @@ -12,7 +12,7 @@ from homeassistant import util from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -REQUIREMENTS = ['zeroconf==0.21.0'] +REQUIREMENTS = ['zeroconf==0.21.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ff438baecd1a36..9f3fa57ddba945 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1534,7 +1534,7 @@ youtube_dl==2018.09.10 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.21.0 +zeroconf==0.21.1 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 From 8e7f783da80afa5a91760f1275b3deb63014102f Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 18 Sep 2018 15:51:22 +0200 Subject: [PATCH 083/178] Added velbus counter sensors, updated to py-velbus 2.0.20 (#16683) --- homeassistant/components/sensor/velbus.py | 14 ++++++-------- homeassistant/components/velbus.py | 6 +++--- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/velbus.py b/homeassistant/components/sensor/velbus.py index ea4af320addd5d..8e9aafd3605284 100644 --- a/homeassistant/components/sensor/velbus.py +++ b/homeassistant/components/sensor/velbus.py @@ -6,8 +6,6 @@ """ import logging -from homeassistant.const import ( - TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE) from homeassistant.components.velbus import ( DOMAIN as VELBUS_DOMAIN, VelbusEntity) @@ -25,24 +23,24 @@ async def async_setup_platform(hass, config, async_add_entities, for sensor in discovery_info: module = hass.data[VELBUS_DOMAIN].get_module(sensor[0]) channel = sensor[1] - sensors.append(VelbusTempSensor(module, channel)) + sensors.append(VelbusSensor(module, channel)) async_add_entities(sensors) -class VelbusTempSensor(VelbusEntity): - """Representation of a temperature sensor.""" +class VelbusSensor(VelbusEntity): + """Representation of a sensor.""" @property def device_class(self): """Return the device class of the sensor.""" - return DEVICE_CLASS_TEMPERATURE + return self._module.get_class(self._channel) @property def state(self): """Return the state of the sensor.""" - return self._module.getCurTemp() + return self._module.get_state(self._channel) @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return TEMP_CELSIUS + return self._module.get_unit(self._channel) diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py index d2def6f96bc7ae..2304054c40421c 100644 --- a/homeassistant/components/velbus.py +++ b/homeassistant/components/velbus.py @@ -12,7 +12,7 @@ from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-velbus==2.0.19'] +REQUIREMENTS = ['python-velbus==2.0.20'] _LOGGER = logging.getLogger(__name__) @@ -48,7 +48,7 @@ def callback(): discovery_info = { 'switch': [], 'binary_sensor': [], - 'temp_sensor': [] + 'sensor': [] } for module in modules: for channel in range(1, module.number_of_channels() + 1): @@ -63,7 +63,7 @@ def callback(): load_platform(hass, 'binary_sensor', DOMAIN, discovery_info['binary_sensor'], config) load_platform(hass, 'sensor', DOMAIN, - discovery_info['temp_sensor'], config) + discovery_info['sensor'], config) controller.scan(callback) diff --git a/requirements_all.txt b/requirements_all.txt index 9f3fa57ddba945..ca00fad60c61d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ python-telegram-bot==11.0.0 python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.19 +python-velbus==2.0.20 # homeassistant.components.media_player.vlc python-vlc==1.1.2 From 56b0d2e99fb748f25344f11ec21e3163a71729cd Mon Sep 17 00:00:00 2001 From: Adam Dullage Date: Tue, 18 Sep 2018 14:55:10 +0100 Subject: [PATCH 084/178] Added support for Starling Bank (#16522) * Added support for Starling Bank * Fixing Lint Issues * Resolving Change Requests * HTTP Error Handling & Sandbox URL Option * Update starlingbank Requirement to v1.2 * Minor changes --- .coveragerc | 1 + .../components/sensor/starlingbank.py | 103 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 107 insertions(+) create mode 100644 homeassistant/components/sensor/starlingbank.py diff --git a/.coveragerc b/.coveragerc index 9b7111b21e13f3..25765254c4db39 100644 --- a/.coveragerc +++ b/.coveragerc @@ -747,6 +747,7 @@ omit = homeassistant/components/sensor/sonarr.py homeassistant/components/sensor/speedtest.py homeassistant/components/sensor/spotcrime.py + homeassistant/components/sensor/starlingbank.py homeassistant/components/sensor/steam_online.py homeassistant/components/sensor/supervisord.py homeassistant/components/sensor/swiss_hydrological_data.py diff --git a/homeassistant/components/sensor/starlingbank.py b/homeassistant/components/sensor/starlingbank.py new file mode 100644 index 00000000000000..a0c6f23e496d50 --- /dev/null +++ b/homeassistant/components/sensor/starlingbank.py @@ -0,0 +1,103 @@ +""" +Support for balance data via the Starling Bank API. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.starlingbank/ +""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['starlingbank==1.2'] + +_LOGGER = logging.getLogger(__name__) + +BALANCE_TYPES = ['cleared_balance', 'effective_balance'] + +CONF_ACCOUNTS = 'accounts' +CONF_BALANCE_TYPES = 'balance_types' +CONF_SANDBOX = 'sandbox' + +DEFAULT_SANDBOX = False +DEFAULT_ACCOUNT_NAME = 'Starling' + +ICON = 'mdi:currency-gbp' + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_BALANCE_TYPES, default=BALANCE_TYPES): + vol.All(cv.ensure_list, [vol.In(BALANCE_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_ACCOUNT_NAME): cv.string, + vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCOUNTS): vol.Schema([ACCOUNT_SCHEMA]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Sterling Bank sensor platform.""" + from starlingbank import StarlingAccount + + sensors = [] + for account in config[CONF_ACCOUNTS]: + try: + starling_account = StarlingAccount( + account[CONF_ACCESS_TOKEN], sandbox=account[CONF_SANDBOX]) + for balance_type in account[CONF_BALANCE_TYPES]: + sensors.append(StarlingBalanceSensor( + starling_account, account[CONF_NAME], balance_type)) + except requests.exceptions.HTTPError as error: + _LOGGER.error( + "Unable to set up Starling account '%s': %s", + account[CONF_NAME], error) + + add_devices(sensors, True) + + +class StarlingBalanceSensor(Entity): + """Representation of a Starling balance sensor.""" + + def __init__(self, starling_account, account_name, balance_data_type): + """Initialize the sensor.""" + self._starling_account = starling_account + self._balance_data_type = balance_data_type + self._state = None + self._account_name = account_name + + @property + def name(self): + """Return the name of the sensor.""" + return "{0} {1}".format( + self._account_name, + self._balance_data_type.replace('_', ' ').capitalize()) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._starling_account.currency + + @property + def icon(self): + """Return the entity icon.""" + return ICON + + def update(self): + """Fetch new state data for the sensor.""" + self._starling_account.balance.update() + if self._balance_data_type == 'cleared_balance': + self._state = self._starling_account.balance.cleared_balance + elif self._balance_data_type == 'effective_balance': + self._state = self._starling_account.balance.effective_balance diff --git a/requirements_all.txt b/requirements_all.txt index ca00fad60c61d7..203a61041aa4d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1366,6 +1366,9 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.sensor.sql sqlalchemy==1.2.11 +# homeassistant.components.sensor.starlingbank +starlingbank==1.2 + # homeassistant.components.statsd statsd==3.2.1 From b7ef4dddb418273d7c2e7df13bd641ba857eb42f Mon Sep 17 00:00:00 2001 From: Fabien Piuzzi Date: Wed, 19 Sep 2018 00:42:09 +0200 Subject: [PATCH 085/178] Netdata configuration change: Allows multiple elements per group (#16656) Allows multiple Netdata elements per group --- homeassistant/components/sensor/netdata.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index 79fb59b4f7b7bf..dc517a0c50db0d 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -24,6 +24,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +CONF_DATA_GROUP = 'data_group' CONF_ELEMENT = 'element' DEFAULT_HOST = 'localhost' @@ -33,9 +34,9 @@ DEFAULT_ICON = 'mdi:desktop-classic' RESOURCE_SCHEMA = vol.Any({ + vol.Required(CONF_DATA_GROUP): cv.string, vol.Required(CONF_ELEMENT): cv.string, vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, - vol.Optional(CONF_NAME): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -65,16 +66,14 @@ async def async_setup_platform( dev = [] for entry, data in resources.items(): - sensor = entry + icon = data[CONF_ICON] + sensor = data[CONF_DATA_GROUP] element = data[CONF_ELEMENT] - sensor_name = icon = None + sensor_name = entry try: resource_data = netdata.api.metrics[sensor] unit = '%' if resource_data['units'] == 'percentage' else \ resource_data['units'] - if data is not None: - sensor_name = data.get(CONF_NAME) - icon = data.get(CONF_ICON) except KeyError: _LOGGER.error("Sensor is not available: %s", sensor) continue From 7f462ba0ecc6dd043ce16e1c30954bae5cb71bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 19 Sep 2018 09:58:58 +0300 Subject: [PATCH 086/178] Upgrade mypy to 0.630 (#16674) --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4144c6866df5d6..15e06c4e53d54d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ coveralls==1.2.0 flake8-docstrings==1.3.0 flake8==3.5 mock-open==1.3.1 -mypy==0.620 +mypy==0.630 pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6990c4da5438f6..d1853d20879b7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ coveralls==1.2.0 flake8-docstrings==1.3.0 flake8==3.5 mock-open==1.3.1 -mypy==0.620 +mypy==0.630 pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 From d376049a3f9369e5d24951a96268d0995e70a580 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 19 Sep 2018 12:57:55 +0200 Subject: [PATCH 087/178] Use one regex for Hass.io URL check (#16710) --- homeassistant/components/hassio/http.py | 45 ++++++++++++------------- tests/components/hassio/test_http.py | 8 +++-- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index c51d45cc3396eb..55cc7f547876da 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -22,23 +22,24 @@ X_HASSIO = 'X-HASSIO-KEY' -NO_TIMEOUT = { - re.compile(r'^homeassistant/update$'), - re.compile(r'^host/update$'), - re.compile(r'^supervisor/update$'), - re.compile(r'^addons/[^/]*/update$'), - re.compile(r'^addons/[^/]*/install$'), - re.compile(r'^addons/[^/]*/rebuild$'), - re.compile(r'^snapshots/.*/full$'), - re.compile(r'^snapshots/.*/partial$'), - re.compile(r'^snapshots/[^/]*/upload$'), - re.compile(r'^snapshots/[^/]*/download$'), -} - -NO_AUTH = { - re.compile(r'^app/.*$'), - re.compile(r'^addons/[^/]*/logo$') -} +NO_TIMEOUT = re.compile( + r'^(?:' + r'|homeassistant/update' + r'|host/update' + r'|supervisor/update' + r'|addons/[^/]+/(?:update|install|rebuild)' + r'|snapshots/.+/full' + r'|snapshots/.+/partial' + r'|snapshots/[^/]+/(?:upload|download)' + r')$' +) + +NO_AUTH = re.compile( + r'^(?:' + r'|app/.*' + r'|addons/[^/]+/logo' + r')$' +) class HassIOView(HomeAssistantView): @@ -128,15 +129,13 @@ def _create_response_log(client, data): def _get_timeout(path): """Return timeout for a URL path.""" - for re_path in NO_TIMEOUT: - if re_path.match(path): - return 0 + if NO_TIMEOUT.match(path): + return 0 return 300 def _need_auth(path): """Return if a path need authentication.""" - for re_path in NO_AUTH: - if re_path.match(path): - return False + if NO_AUTH.match(path): + return False return True diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index ce260225097a12..4370c011891b8e 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -36,9 +36,13 @@ def test_forward_request(hassio_client): @asyncio.coroutine -def test_auth_required_forward_request(hassio_client): +@pytest.mark.parametrize( + 'build_type', [ + 'supervisor/info', 'homeassistant/update', 'host/info' + ]) +def test_auth_required_forward_request(hassio_client, build_type): """Test auth required for normal request.""" - resp = yield from hassio_client.post('/api/hassio/beer') + resp = yield from hassio_client.post("/api/hassio/{}".format(build_type)) # Check we got right response assert resp.status == 401 From da108f1999e9d232852c589fddce552e3eecd1a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Sep 2018 15:17:05 +0200 Subject: [PATCH 088/178] bump frontend to 20180919.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 529d7d5fb3d165..861c4a95d69776 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180917.0'] +REQUIREMENTS = ['home-assistant-frontend==20180919.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 203a61041aa4d0..ca477091dc56a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,7 +454,7 @@ hole==0.3.0 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180917.0 +home-assistant-frontend==20180919.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1853d20879b7f..a2d556511b9608 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ hdate==0.6.3 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180917.0 +home-assistant-frontend==20180919.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 0121e3cb041c5aedd6d809275e494a945c06cb8b Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Wed, 19 Sep 2018 15:40:02 +0200 Subject: [PATCH 089/178] Remove usage of "run_until_complete" (#16617) * De-run_forever()-ization * Use asyncio.run (or our own implementation on Python <3.7) * hass.start is only used by tests * setup_and_run_hass() is now async * Add "main" async hass.run method * move SIGINT handling to helpers/signal.py * add flag to .run to disable hass's signal handlers * Teach async_start and async_stop to not step on each other (more than necessary) * shorten over-long lines * restore missing "import asyncio" * move run_asyncio to homeassistant.util.async_ * LOGGER: warn => warning * Add "force" flag to async_stop only useful for testing * Add 'attrs==18.2.0' to requirements_all.txt Required for keeping requirements_test_all.txt in sync, where it is in turn required to prevent auto-downgrading "attrs" during "pip install" * Fixes for mypy * Fix "mock_signal" fixture * Revert mistaken edit * Flake8 fixes * mypy fixes * pylint fix * Revert adding attrs== to requirements_test*.txt solved by using "pip -c" * Rename "run" to "async_run", as per calling conventions --- homeassistant/__main__.py | 34 ++++----- homeassistant/bootstrap.py | 2 - homeassistant/core.py | 70 ++++++++++++++++--- homeassistant/helpers/signal.py | 14 +++- homeassistant/util/async_.py | 22 +++++- .../alarm_control_panel/test_spc.py | 2 +- tests/components/binary_sensor/test_spc.py | 2 +- tests/components/sensor/test_dsmr.py | 11 ++- tests/conftest.py | 2 +- tests/helpers/test_discovery.py | 2 +- tests/test_bootstrap.py | 2 - 11 files changed, 126 insertions(+), 37 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 80bc1dccff443f..af89564f102d9d 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -22,12 +22,12 @@ def attempt_use_uvloop() -> None: """Attempt to use uvloop.""" import asyncio - try: import uvloop - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) except ImportError: pass + else: + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) def validate_python() -> None: @@ -239,10 +239,10 @@ def cmdline() -> List[str]: return [arg for arg in sys.argv if arg != '--daemon'] -def setup_and_run_hass(config_dir: str, - args: argparse.Namespace) -> int: +async def setup_and_run_hass(config_dir: str, + args: argparse.Namespace) -> int: """Set up HASS and run.""" - from homeassistant import bootstrap + from homeassistant import bootstrap, core # Run a simple daemon runner process on Windows to handle restarts if os.name == 'nt' and '--runner' not in sys.argv: @@ -255,35 +255,34 @@ def setup_and_run_hass(config_dir: str, if exc.returncode != RESTART_EXIT_CODE: sys.exit(exc.returncode) + hass = core.HomeAssistant() + if args.demo_mode: config = { 'frontend': {}, 'demo': {} } # type: Dict[str, Any] - hass = bootstrap.from_config_dict( - config, config_dir=config_dir, verbose=args.verbose, + bootstrap.async_from_config_dict( + config, hass, config_dir=config_dir, verbose=args.verbose, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, log_file=args.log_file, log_no_color=args.log_no_color) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) - hass = bootstrap.from_config_file( - config_file, verbose=args.verbose, skip_pip=args.skip_pip, + await bootstrap.async_from_config_file( + config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, log_file=args.log_file, log_no_color=args.log_no_color) - if hass is None: - return -1 - if args.open_ui: # Imported here to avoid importing asyncio before monkey patch from homeassistant.util.async_ import run_callback_threadsafe def open_browser(_: Any) -> None: """Open the web interface in a browser.""" - if hass.config.api is not None: # type: ignore + if hass.config.api is not None: import webbrowser - webbrowser.open(hass.config.api.base_url) # type: ignore + webbrowser.open(hass.config.api.base_url) run_callback_threadsafe( hass.loop, @@ -291,7 +290,7 @@ def open_browser(_: Any) -> None: EVENT_HOMEASSISTANT_START, open_browser ) - return hass.start() + return await hass.async_run() def try_to_restart() -> None: @@ -365,11 +364,12 @@ def main() -> int: if args.pid_file: write_pid(args.pid_file) - exit_code = setup_and_run_hass(config_dir, args) + from homeassistant.util.async_ import asyncio_run + exit_code = asyncio_run(setup_and_run_hass(config_dir, args)) if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() - return exit_code + return exit_code # type: ignore # mypy cannot yet infer it if __name__ == "__main__": diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2125ab46a8c531..0676cec7fad5d4 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -18,7 +18,6 @@ from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.signal import async_register_signal_handling _LOGGER = logging.getLogger(__name__) @@ -159,7 +158,6 @@ async def async_from_config_dict(config: Dict[str, Any], stop = time() _LOGGER.info("Home Assistant initialized in %.2fs", stop-start) - async_register_signal_handling(hass) return hass diff --git a/homeassistant/core.py b/homeassistant/core.py index 6b1ea3f426393f..653f95cceceb08 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -154,6 +154,8 @@ def __init__( self.state = CoreState.not_running self.exit_code = 0 # type: int self.config_entries = None # type: Optional[ConfigEntries] + # If not None, use to signal end-of-loop + self._stopped = None # type: Optional[asyncio.Event] @property def is_running(self) -> bool: @@ -161,23 +163,45 @@ def is_running(self) -> bool: return self.state in (CoreState.starting, CoreState.running) def start(self) -> int: - """Start home assistant.""" + """Start home assistant. + + Note: This function is only used for testing. + For regular use, use "await hass.run()". + """ # Register the async start fire_coroutine_threadsafe(self.async_start(), self.loop) - # Run forever and catch keyboard interrupt + # Run forever try: # Block until stopped _LOGGER.info("Starting Home Assistant core loop") self.loop.run_forever() - except KeyboardInterrupt: - self.loop.call_soon_threadsafe( - self.loop.create_task, self.async_stop()) - self.loop.run_forever() finally: self.loop.close() return self.exit_code + async def async_run(self, *, attach_signals: bool = True) -> int: + """Home Assistant main entry point. + + Start Home Assistant and block until stopped. + + This method is a coroutine. + """ + if self.state != CoreState.not_running: + raise RuntimeError("HASS is already running") + + # _async_stop will set this instead of stopping the loop + self._stopped = asyncio.Event() + + await self.async_start() + if attach_signals: + from homeassistant.helpers.signal \ + import async_register_signal_handling + async_register_signal_handling(self) + + await self._stopped.wait() + return self.exit_code + async def async_start(self) -> None: """Finalize startup from inside the event loop. @@ -203,6 +227,13 @@ async def async_start(self) -> None: # Allow automations to set up the start triggers before changing state await asyncio.sleep(0) + + if self.state != CoreState.starting: + _LOGGER.warning( + 'Home Assistant startup has been interrupted. ' + 'Its state may be inconsistent.') + return + self.state = CoreState.running _async_create_timer(self) @@ -321,13 +352,32 @@ async def async_block_till_done(self) -> None: def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" + if self.state == CoreState.not_running: # just ignore + return fire_coroutine_threadsafe(self.async_stop(), self.loop) - async def async_stop(self, exit_code: int = 0) -> None: + async def async_stop(self, exit_code: int = 0, *, + force: bool = False) -> None: """Stop Home Assistant and shuts down all threads. + The "force" flag commands async_stop to proceed regardless of + Home Assistan't current state. You should not set this flag + unless you're testing. + This method is a coroutine. """ + if not force: + # Some tests require async_stop to run, + # regardless of the state of the loop. + if self.state == CoreState.not_running: # just ignore + return + if self.state == CoreState.stopping: + _LOGGER.info("async_stop called twice: ignored") + return + if self.state == CoreState.starting: + # This may not work + _LOGGER.warning("async_stop called before startup is complete") + # stage 1 self.state = CoreState.stopping self.async_track_tasks() @@ -341,7 +391,11 @@ async def async_stop(self, exit_code: int = 0) -> None: self.executor.shutdown() self.exit_code = exit_code - self.loop.stop() + + if self._stopped is not None: + self._stopped.set() + else: + self.loop.stop() @attr.s(slots=True, frozen=True) diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 824b32177cdb54..6068cad33af34c 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -17,7 +17,13 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: if sys.platform != 'win32': @callback def async_signal_handle(exit_code): - """Wrap signal handling.""" + """Wrap signal handling. + + * queue call to shutdown task + * re-instate default handler + """ + hass.loop.remove_signal_handler(signal.SIGTERM) + hass.loop.remove_signal_handler(signal.SIGINT) hass.async_create_task(hass.async_stop(exit_code)) try: @@ -26,6 +32,12 @@ def async_signal_handle(exit_code): except ValueError: _LOGGER.warning("Could not bind to SIGTERM") + try: + hass.loop.add_signal_handler( + signal.SIGINT, async_signal_handle, 0) + except ValueError: + _LOGGER.warning("Could not bind to SIGINT") + try: hass.loop.add_signal_handler( signal.SIGHUP, async_signal_handle, RESTART_EXIT_CODE) diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index aa030bf13c7286..04456b8cb2fad7 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -6,12 +6,32 @@ from asyncio.events import AbstractEventLoop from asyncio.futures import Future +import asyncio from asyncio import ensure_future -from typing import Any, Union, Coroutine, Callable, Generator +from typing import Any, Union, Coroutine, Callable, Generator, TypeVar, \ + Awaitable _LOGGER = logging.getLogger(__name__) +try: + # pylint: disable=invalid-name + asyncio_run = asyncio.run # type: ignore +except AttributeError: + _T = TypeVar('_T') + + def asyncio_run(main: Awaitable[_T], *, debug: bool = False) -> _T: + """Minimal re-implementation of asyncio.run (since 3.7).""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.set_debug(debug) + try: + return loop.run_until_complete(main) + finally: + asyncio.set_event_loop(None) # type: ignore # not a bug + loop.close() + + def _set_result_unless_cancelled(fut: Future, result: Any) -> None: """Set the result only if the Future was not cancelled.""" if fut.cancelled(): diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 0a4ba0916eaeef..a0793943c2f7b5 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -17,7 +17,7 @@ def hass(loop): hass.data['spc_registry'] = SpcRegistry() hass.data['spc_api'] = None yield hass - loop.run_until_complete(hass.async_stop()) + loop.run_until_complete(hass.async_stop(force=True)) @asyncio.coroutine diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 0a91b59e14dcf8..966f73682e8c3d 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -14,7 +14,7 @@ def hass(loop): hass = loop.run_until_complete(async_test_home_assistant(loop)) hass.data['spc_registry'] = SpcRegistry() yield hass - loop.run_until_complete(hass.async_stop()) + loop.run_until_complete(hass.async_stop(force=True)) @asyncio.coroutine diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index e5fca461a23dc6..9ab8d61f739f58 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -182,10 +182,14 @@ def test_reconnect(hass, monkeypatch, mock_connection_factory): # mock waiting coroutine while connection lasts closed = asyncio.Event(loop=hass.loop) + # Handshake so that `hass.async_block_till_done()` doesn't cycle forever + closed2 = asyncio.Event(loop=hass.loop) @asyncio.coroutine def wait_closed(): yield from closed.wait() + closed2.set() + closed.clear() protocol.wait_closed = wait_closed yield from async_setup_component(hass, 'sensor', {'sensor': config}) @@ -195,8 +199,11 @@ def wait_closed(): # indicate disconnect, release wait lock and allow reconnect to happen closed.set() # wait for lock set to resolve - yield from hass.async_block_till_done() - # wait for sleep to resolve + yield from closed2.wait() + closed2.clear() + assert not closed.is_set() + + closed.set() yield from hass.async_block_till_done() assert connection_factory.call_count >= 2, \ diff --git a/tests/conftest.py b/tests/conftest.py index 61c5c1c7dd5e02..84b72189a8d2f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,7 +73,7 @@ def hass(loop, hass_storage): yield hass - loop.run_until_complete(hass.async_stop()) + loop.run_until_complete(hass.async_stop(force=True)) @pytest.fixture diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index a8d78bde1f40b6..64f90ee7452af2 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -154,7 +154,7 @@ def setup_platform(hass, config, add_entities_callback, assert 'test_component' in self.hass.config.components assert 'switch' in self.hass.config.components - @patch('homeassistant.bootstrap.async_register_signal_handling') + @patch('homeassistant.helpers.signal.async_register_signal_handling') def test_1st_discovers_2nd_component(self, mock_signal): """Test that we don't break if one component discovers the other. diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 4f258bc2b099d4..978b0b9d450cc2 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -22,7 +22,6 @@ 'homeassistant.bootstrap.conf_util.process_ha_config_upgrade', Mock()) @patch('homeassistant.util.location.detect_location_info', Mock(return_value=None)) -@patch('homeassistant.bootstrap.async_register_signal_handling', Mock()) @patch('os.path.isfile', Mock(return_value=True)) @patch('os.access', Mock(return_value=True)) @patch('homeassistant.bootstrap.async_enable_logging', @@ -41,7 +40,6 @@ def test_from_config_file(hass): @patch('homeassistant.bootstrap.async_enable_logging', Mock()) -@patch('homeassistant.bootstrap.async_register_signal_handling', Mock()) @asyncio.coroutine def test_home_assistant_core_config_validation(hass): """Test if we pass in wrong information for HA conf.""" From 227a1b919b5bd30b8f841a77a03b744ab66db405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 19 Sep 2018 16:40:36 +0300 Subject: [PATCH 090/178] More isort preparations (#16633) * Adjust config closer to currently established style conventions * Adjust some imports for better outcome with their comments --- homeassistant/components/binary_sensor/zwave.py | 4 ++-- homeassistant/components/climate/zwave.py | 4 ++-- homeassistant/components/cover/zwave.py | 5 ++--- homeassistant/components/ios/__init__.py | 6 +++--- setup.cfg | 5 +++++ 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index 784a96d8615733..3bb3a3c79c5c9b 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -9,8 +9,8 @@ import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_point_in_time from homeassistant.components import zwave -from homeassistant.components.zwave import workaround -from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import +from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import + async_setup_platform, workaround) from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDevice) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index f87f2e83f5da96..77b5e111686f6f 100644 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -10,8 +10,8 @@ DOMAIN, ClimateDevice, STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) -from homeassistant.components.zwave import ZWaveDeviceEntity -from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import +from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import + ZWaveDeviceEntity, async_setup_platform) from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 8c8c88ecb87f71..258087702e0536 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -9,10 +9,9 @@ import logging from homeassistant.components.cover import ( DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) -from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import -from homeassistant.components.zwave import workaround +from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import + ZWaveDeviceEntity, async_setup_platform, workaround) from homeassistant.components.cover import CoverDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index a4884739f96331..a67be0a63de914 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -9,7 +9,6 @@ import datetime import voluptuous as vol -# from voluptuous.humanize import humanize_error from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView @@ -274,8 +273,9 @@ def post(self, request): # try: # data = IDENTIFY_SCHEMA(req_data) # except vol.Invalid as ex: - # return self.json_message(humanize_error(request.json, ex), - # HTTP_BAD_REQUEST) + # return self.json_message( + # vol.humanize.humanize_error(request.json, ex), + # HTTP_BAD_REQUEST) data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat() diff --git a/setup.cfg b/setup.cfg index a8bd819c792134..b2bbb4081f004a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,3 +33,8 @@ not_skip = __init__.py # will group `import x` and `from x import` of the same module. force_sort_within_sections = true sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = homeassistant,tests +forced_separate = tests +combine_as_imports = true +use_parentheses = true From d4b7057a3de1c120df5c2daac016b6190a3b986f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 19 Sep 2018 16:48:04 +0300 Subject: [PATCH 091/178] Use posargs in tox lint env (#16646) Allows linting specified files or dirs only. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index e1261457c47bd5..60dacd5d8cb027 100644 --- a/tox.ini +++ b/tox.ini @@ -49,8 +49,8 @@ deps = -r{toxinidir}/requirements_test.txt commands = python script/gen_requirements_all.py validate - flake8 - pydocstyle homeassistant tests + flake8 {posargs} + pydocstyle {posargs:homeassistant tests} [testenv:typing] basepython = {env:PYTHON3_PATH:python3} From 3160fa5de869cbc6f8fa1eebff75c966e2a022bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 19 Sep 2018 16:51:57 +0300 Subject: [PATCH 092/178] Make pylint report non-LF linefeeds per the style guidelines (#16601) --- pylintrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pylintrc b/pylintrc index b72502248d7b29..be06f83e6f256b 100644 --- a/pylintrc +++ b/pylintrc @@ -42,5 +42,8 @@ reports=no ignored-classes=_CountingAttr generated-members=botocore.errorfactory +[FORMAT] +expected-line-ending-format=LF + [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError From a1c524d3728abbe0bad5de229e514f5e13b537a8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Sep 2018 21:21:43 +0200 Subject: [PATCH 093/178] Config flow tradfri (#16665) * Fix comments * Add config flow tests * Fix Tradfri light tests * Lint * Remove import group from config flow * fix stale comments --- homeassistant/components/discovery.py | 2 +- homeassistant/components/light/tradfri.py | 28 ++- homeassistant/components/sensor/tradfri.py | 20 +- homeassistant/components/tradfri.py | 173 ------------------ .../components/tradfri/.translations/en.json | 23 +++ homeassistant/components/tradfri/__init__.py | 102 +++++++++++ .../components/tradfri/config_flow.py | 172 +++++++++++++++++ homeassistant/components/tradfri/const.py | 7 + homeassistant/components/tradfri/strings.json | 23 +++ homeassistant/config_entries.py | 1 + tests/components/light/test_tradfri.py | 75 ++------ tests/components/tradfri/__init__.py | 1 + tests/components/tradfri/test_config_flow.py | 156 ++++++++++++++++ 13 files changed, 520 insertions(+), 263 deletions(-) delete mode 100644 homeassistant/components/tradfri.py create mode 100644 homeassistant/components/tradfri/.translations/en.json create mode 100644 homeassistant/components/tradfri/__init__.py create mode 100644 homeassistant/components/tradfri/config_flow.py create mode 100644 homeassistant/components/tradfri/const.py create mode 100644 homeassistant/components/tradfri/strings.json create mode 100644 tests/components/tradfri/__init__.py create mode 100644 tests/components/tradfri/test_config_flow.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 41cf3791256d7a..22d7ae87b8d4b0 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -48,6 +48,7 @@ SERVICE_DECONZ: 'deconz', 'google_cast': 'cast', SERVICE_HUE: 'hue', + SERVICE_IKEA_TRADFRI: 'tradfri', 'sonos': 'sonos', } @@ -55,7 +56,6 @@ SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), SERVICE_WEMO: ('wemo', None), - SERVICE_IKEA_TRADFRI: ('tradfri', None), SERVICE_HASSIO: ('hassio', None), SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 0d12d095bb68a3..bd432b5dedce55 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -13,8 +13,9 @@ SUPPORT_COLOR, Light) from homeassistant.components.light import \ PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS, \ - KEY_API +from homeassistant.components.tradfri import KEY_GATEWAY, KEY_API +from homeassistant.components.tradfri.const import ( + CONF_IMPORT_GROUPS, CONF_GATEWAY_ID) import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -31,28 +32,21 @@ SUPPORTED_GROUP_FEATURES = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION -async def async_setup_platform(hass, config, - async_add_entities, discovery_info=None): - """Set up the IKEA Tradfri Light platform.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config_entry, async_add_entities): + """Load Tradfri lights based on a config entry.""" + gateway_id = config_entry.data[CONF_GATEWAY_ID] + api = hass.data[KEY_API][config_entry.entry_id] + gateway = hass.data[KEY_GATEWAY][config_entry.entry_id] - gateway_id = discovery_info['gateway'] - api = hass.data[KEY_API][gateway_id] - gateway = hass.data[KEY_GATEWAY][gateway_id] - - devices_command = gateway.get_devices() - devices_commands = await api(devices_command) + devices_commands = await api(gateway.get_devices()) devices = await api(devices_commands) lights = [dev for dev in devices if dev.has_light_control] if lights: async_add_entities( TradfriLight(light, api, gateway_id) for light in lights) - allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] - if allow_tradfri_groups: - groups_command = gateway.get_groups() - groups_commands = await api(groups_command) + if config_entry.data[CONF_IMPORT_GROUPS]: + groups_commands = await api(gateway.get_groups()) groups = await api(groups_commands) if groups: async_add_entities( diff --git a/homeassistant/components/sensor/tradfri.py b/homeassistant/components/sensor/tradfri.py index 0849169b7474ca..86d0c1abc19711 100644 --- a/homeassistant/components/sensor/tradfri.py +++ b/homeassistant/components/sensor/tradfri.py @@ -19,20 +19,14 @@ SCAN_INTERVAL = timedelta(minutes=5) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the IKEA Tradfri device platform.""" - if discovery_info is None: - return - - gateway_id = discovery_info['gateway'] - api = hass.data[KEY_API][gateway_id] - gateway = hass.data[KEY_GATEWAY][gateway_id] - - devices_command = gateway.get_devices() - devices_commands = await api(devices_command) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Tradfri config entry.""" + api = hass.data[KEY_API][config_entry.entry_id] + gateway = hass.data[KEY_GATEWAY][config_entry.entry_id] + + devices_commands = await api(gateway.get_devices()) all_devices = await api(devices_commands) - devices = [dev for dev in all_devices if not dev.has_light_control] + devices = (dev for dev in all_devices if not dev.has_light_control) async_add_entities(TradfriDevice(device, api) for device in devices) diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py deleted file mode 100644 index b2e41902552bf9..00000000000000 --- a/homeassistant/components/tradfri.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Support for IKEA Tradfri. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/ikea_tradfri/ -""" -import logging -from uuid import uuid4 - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery -from homeassistant.const import CONF_HOST -from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['pytradfri[async]==5.5.1'] - -DOMAIN = 'tradfri' -GATEWAY_IDENTITY = 'homeassistant' -CONFIG_FILE = '.tradfri_psk.conf' -KEY_CONFIG = 'tradfri_configuring' -KEY_GATEWAY = 'tradfri_gateway' -KEY_API = 'tradfri_api' -KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' -CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' -DEFAULT_ALLOW_TRADFRI_GROUPS = True - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Inclusive(CONF_HOST, 'gateway'): cv.string, - vol.Optional(CONF_ALLOW_TRADFRI_GROUPS, - default=DEFAULT_ALLOW_TRADFRI_GROUPS): cv.boolean, - }) -}, extra=vol.ALLOW_EXTRA) - -_LOGGER = logging.getLogger(__name__) - - -def request_configuration(hass, config, host): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - hass.data.setdefault(KEY_CONFIG, {}) - instance = hass.data[KEY_CONFIG].get(host) - - # Configuration already in progress - if instance: - return - - async def configuration_callback(callback_data): - """Handle the submitted configuration.""" - try: - from pytradfri.api.aiocoap_api import APIFactory - from pytradfri import RequestError - except ImportError: - _LOGGER.exception("Looks like something isn't installed!") - return - - identity = uuid4().hex - security_code = callback_data.get('security_code') - - api_factory = APIFactory(host, psk_id=identity, loop=hass.loop) - # Need To Fix: currently entering a wrong security code sends - # pytradfri aiocoap API into an endless loop. - # Should just raise a requestError or something. - try: - key = await api_factory.generate_psk(security_code) - except RequestError: - configurator.async_notify_errors(hass, instance, - "Security Code not accepted.") - return - - res = await _setup_gateway(hass, config, host, identity, key, - DEFAULT_ALLOW_TRADFRI_GROUPS) - - if not res: - configurator.async_notify_errors(hass, instance, - "Unable to connect.") - return - - def success(): - """Set up was successful.""" - conf = load_json(hass.config.path(CONFIG_FILE)) - conf[host] = {'identity': identity, - 'key': key} - save_json(hass.config.path(CONFIG_FILE), conf) - configurator.request_done(instance) - - hass.async_add_job(success) - - instance = configurator.request_config( - "IKEA Trådfri", configuration_callback, - description='Please enter the security code written at the bottom of ' - 'your IKEA Trådfri Gateway.', - submit_caption="Confirm", - fields=[{'id': 'security_code', 'name': 'Security Code', - 'type': 'password'}] - ) - - -async def async_setup(hass, config): - """Set up the Tradfri component.""" - conf = config.get(DOMAIN, {}) - host = conf.get(CONF_HOST) - allow_tradfri_groups = conf.get(CONF_ALLOW_TRADFRI_GROUPS) - known_hosts = await hass.async_add_job(load_json, - hass.config.path(CONFIG_FILE)) - - async def gateway_discovered(service, info, - allow_groups=DEFAULT_ALLOW_TRADFRI_GROUPS): - """Run when a gateway is discovered.""" - host = info['host'] - - if host in known_hosts: - # use fallbacks for old config style - # identity was hard coded as 'homeassistant' - identity = known_hosts[host].get('identity', 'homeassistant') - key = known_hosts[host].get('key') - await _setup_gateway(hass, config, host, identity, key, - allow_groups) - else: - hass.async_add_job(request_configuration, hass, config, host) - - discovery.async_listen(hass, SERVICE_IKEA_TRADFRI, gateway_discovered) - - if host: - await gateway_discovered(None, - {'host': host}, - allow_tradfri_groups) - return True - - -async def _setup_gateway(hass, hass_config, host, identity, key, - allow_tradfri_groups): - """Create a gateway.""" - from pytradfri import Gateway, RequestError # pylint: disable=import-error - try: - from pytradfri.api.aiocoap_api import APIFactory - except ImportError: - _LOGGER.exception("Looks like something isn't installed!") - return False - - try: - factory = APIFactory(host, psk_id=identity, psk=key, - loop=hass.loop) - api = factory.request - gateway = Gateway() - gateway_info_result = await api(gateway.get_gateway_info()) - except RequestError: - _LOGGER.exception("Tradfri setup failed.") - return False - - gateway_id = gateway_info_result.id - hass.data.setdefault(KEY_API, {}) - hass.data.setdefault(KEY_GATEWAY, {}) - gateways = hass.data[KEY_GATEWAY] - hass.data[KEY_API][gateway_id] = api - - hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) - tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] - tradfri_groups[gateway_id] = allow_tradfri_groups - - # Check if already set up - if gateway_id in gateways: - return True - - gateways[gateway_id] = gateway - hass.async_create_task(discovery.async_load_platform( - hass, 'light', DOMAIN, {'gateway': gateway_id}, hass_config)) - hass.async_create_task(discovery.async_load_platform( - hass, 'sensor', DOMAIN, {'gateway': gateway_id}, hass_config)) - return True diff --git a/homeassistant/components/tradfri/.translations/en.json b/homeassistant/components/tradfri/.translations/en.json new file mode 100644 index 00000000000000..7b0d2005c2a1e9 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is already configured" + }, + "error": { + "cannot_connect": "Unable to connect to the gateway.", + "invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", + "timeout": "Timeout validating the code." + }, + "step": { + "auth": { + "data": { + "host": "Host", + "security_code": "Security Code" + }, + "description": "You can find the security code on the back of your gateway.", + "title": "Enter security code" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py new file mode 100644 index 00000000000000..771f2b44c3dd68 --- /dev/null +++ b/homeassistant/components/tradfri/__init__.py @@ -0,0 +1,102 @@ +""" +Support for IKEA Tradfri. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ikea_tradfri/ +""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +import homeassistant.helpers.config_validation as cv +from homeassistant.util.json import load_json + +from .const import CONF_IMPORT_GROUPS, CONF_IDENTITY, CONF_HOST, CONF_KEY + +from . import config_flow # noqa pylint_disable=unused-import + +REQUIREMENTS = ['pytradfri[async]==5.5.1'] + +DOMAIN = 'tradfri' +CONFIG_FILE = '.tradfri_psk.conf' +KEY_GATEWAY = 'tradfri_gateway' +KEY_API = 'tradfri_api' +CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' +DEFAULT_ALLOW_TRADFRI_GROUPS = True + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Inclusive(CONF_HOST, 'gateway'): cv.string, + vol.Optional(CONF_ALLOW_TRADFRI_GROUPS, + default=DEFAULT_ALLOW_TRADFRI_GROUPS): cv.boolean, + }) +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the Tradfri component.""" + conf = config.get(DOMAIN) + + if conf is None: + return True + + known_hosts = await hass.async_add_executor_job( + load_json, hass.config.path(CONFIG_FILE)) + + for host, info in known_hosts.items(): + info[CONF_HOST] = host + info[CONF_IMPORT_GROUPS] = conf[CONF_ALLOW_TRADFRI_GROUPS] + + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data=info + )) + + host = conf.get(CONF_HOST) + + if host is None or host in known_hosts: + return True + + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={'host': host} + )) + + return True + + +async def async_setup_entry(hass, entry): + """Create a gateway.""" + # host, identity, key, allow_tradfri_groups + from pytradfri import Gateway, RequestError # pylint: disable=import-error + from pytradfri.api.aiocoap_api import APIFactory + + factory = APIFactory( + entry.data[CONF_HOST], + psk_id=entry.data[CONF_IDENTITY], + psk=entry.data[CONF_KEY], + loop=hass.loop + ) + api = factory.request + gateway = Gateway() + + try: + await api(gateway.get_gateway_info()) + except RequestError: + _LOGGER.error("Tradfri setup failed.") + return False + + hass.data.setdefault(KEY_API, {})[entry.entry_id] = api + hass.data.setdefault(KEY_GATEWAY, {})[entry.entry_id] = gateway + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'light' + )) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'sensor' + )) + + return True diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py new file mode 100644 index 00000000000000..4de43c79e0c655 --- /dev/null +++ b/homeassistant/components/tradfri/config_flow.py @@ -0,0 +1,172 @@ +"""Config flow for Tradfri.""" +import asyncio +from collections import OrderedDict +from uuid import uuid4 + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries + +from .const import ( + CONF_IMPORT_GROUPS, CONF_IDENTITY, CONF_HOST, CONF_KEY, CONF_GATEWAY_ID) + +KEY_HOST = 'host' +KEY_SECURITY_CODE = 'security_code' +KEY_IMPORT_GROUPS = 'import_groups' + + +class AuthError(Exception): + """Exception if authentication occurs.""" + + def __init__(self, code): + """Initialize exception.""" + super().__init__() + self.code = code + + +@config_entries.HANDLERS.register('tradfri') +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize flow.""" + self._host = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None): + """Handle the authentication with a gateway.""" + errors = {} + + if user_input is not None: + host = user_input.get(KEY_HOST, self._host) + try: + auth = await authenticate( + self.hass, host, + user_input[KEY_SECURITY_CODE]) + + # We don't ask for import group anymore as group state + # is not reliable, don't want to show that to the user. + auth[CONF_IMPORT_GROUPS] = False + + return await self._entry_from_data(auth) + + except AuthError as err: + if err.code == 'invalid_security_code': + errors[KEY_SECURITY_CODE] = err.code + else: + errors['base'] = err.code + + fields = OrderedDict() + + if self._host is None: + fields[vol.Required(KEY_HOST)] = str + + fields[vol.Required(KEY_SECURITY_CODE)] = str + + return self.async_show_form( + step_id='auth', + data_schema=vol.Schema(fields), + errors=errors, + ) + + async def async_step_discovery(self, user_input): + """Handle discovery.""" + self._host = user_input['host'] + return await self.async_step_auth() + + async def async_step_import(self, user_input): + """Import a config entry.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == user_input['host']: + return self.async_abort( + reason='already_configured' + ) + + # Happens if user has host directly in configuration.yaml + if 'key' not in user_input: + self._host = user_input['host'] + return await self.async_step_auth() + + try: + data = await get_gateway_info( + self.hass, user_input['host'], user_input['identity'], + user_input['key']) + + data[CONF_IMPORT_GROUPS] = user_input[CONF_IMPORT_GROUPS] + + return await self._entry_from_data(data) + except AuthError: + # If we fail to connect, just pass it on to discovery + self._host = user_input['host'] + return await self.async_step_auth() + + async def _entry_from_data(self, data): + """Create an entry from data.""" + host = data[CONF_HOST] + gateway_id = data[CONF_GATEWAY_ID] + + same_hub_entries = [entry.entry_id for entry + in self._async_current_entries() + if entry.data[CONF_GATEWAY_ID] == gateway_id or + entry.data[CONF_HOST] == host] + + if same_hub_entries: + await asyncio.wait([self.hass.config_entries.async_remove(entry_id) + for entry_id in same_hub_entries]) + + return self.async_create_entry( + title=host, + data=data + ) + + +async def authenticate(hass, host, security_code): + """Authenticate with a Tradfri hub.""" + from pytradfri.api.aiocoap_api import APIFactory + from pytradfri import RequestError + + identity = uuid4().hex + + api_factory = APIFactory(host, psk_id=identity, loop=hass.loop) + + try: + with async_timeout.timeout(5): + key = await api_factory.generate_psk(security_code) + except RequestError: + raise AuthError('invalid_security_code') + except asyncio.TimeoutError: + raise AuthError('timeout') + + return await get_gateway_info(hass, host, identity, key) + + +async def get_gateway_info(hass, host, identity, key): + """Return info for the gateway.""" + from pytradfri.api.aiocoap_api import APIFactory + from pytradfri import Gateway, RequestError + + try: + factory = APIFactory( + host, + psk_id=identity, + psk=key, + loop=hass.loop + ) + api = factory.request + gateway = Gateway() + gateway_info_result = await api(gateway.get_gateway_info()) + except RequestError: + raise AuthError('cannot_connect') + + return { + CONF_HOST: host, + CONF_IDENTITY: identity, + CONF_KEY: key, + CONF_GATEWAY_ID: gateway_info_result.id, + } diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py new file mode 100644 index 00000000000000..15177bc1a201f3 --- /dev/null +++ b/homeassistant/components/tradfri/const.py @@ -0,0 +1,7 @@ +"""Consts used by Tradfri.""" +from homeassistant.const import CONF_HOST # noqa pylint: disable=unused-import + +CONF_IMPORT_GROUPS = 'import_groups' +CONF_IDENTITY = 'identity' +CONF_KEY = 'key' +CONF_GATEWAY_ID = 'gateway_id' diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json new file mode 100644 index 00000000000000..38c58486a6a738 --- /dev/null +++ b/homeassistant/components/tradfri/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "IKEA TRÅDFRI", + "step": { + "auth": { + "title": "Enter security code", + "description": "You can find the security code on the back of your gateway.", + "data": { + "host": "Host", + "security_code": "Security Code" + } + } + }, + "error": { + "invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", + "cannot_connect": "Unable to connect to the gateway.", + "timeout": "Timeout validating the code." + }, + "abort": { + "already_configured": "Bridge is already configured" + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e4c4b5c03276b1..7763594e0e1a0e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -146,6 +146,7 @@ async def async_step_discovery(info): 'nest', 'openuv', 'sonos', + 'tradfri', 'zone', ] diff --git a/tests/components/light/test_tradfri.py b/tests/components/light/test_tradfri.py index 12c596f3f097a9..337031cf92c766 100644 --- a/tests/components/light/test_tradfri.py +++ b/tests/components/light/test_tradfri.py @@ -5,10 +5,10 @@ import pytest from pytradfri.device import Device, LightControl, Light -from pytradfri import RequestError from homeassistant.components import tradfri -from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry DEFAULT_TEST_FEATURES = {'can_set_dimmer': False, @@ -199,7 +199,7 @@ def get_groups(): @pytest.fixture def mock_api(mock_gateway): """Mock api.""" - async def api(self, command): + async def api(command): """Mock api function.""" # Store the data for "real" command objects. if(hasattr(command, '_data') and not isinstance(command, Mock)): @@ -213,63 +213,20 @@ async def generate_psk(self, code): return "mock" -async def setup_gateway(hass, mock_gateway, mock_api, - generate_psk=generate_psk, - known_hosts=None): +async def setup_gateway(hass, mock_gateway, mock_api): """Load the Tradfri platform with a mock gateway.""" - def request_config(_, callback, description, submit_caption, fields): - """Mock request_config.""" - hass.async_add_job(callback, {'security_code': 'mock'}) - - if known_hosts is None: - known_hosts = {} - - with patch('pytradfri.api.aiocoap_api.APIFactory.generate_psk', - generate_psk), \ - patch('pytradfri.api.aiocoap_api.APIFactory.request', mock_api), \ - patch('pytradfri.Gateway', return_value=mock_gateway), \ - patch.object(tradfri, 'load_json', return_value=known_hosts), \ - patch.object(tradfri, 'save_json'), \ - patch.object(hass.components.configurator, 'request_config', - request_config): - - await async_setup_component(hass, tradfri.DOMAIN, - { - tradfri.DOMAIN: { - 'host': 'mock-host', - 'allow_tradfri_groups': True - } - }) - await hass.async_block_till_done() - - -async def test_setup_gateway(hass, mock_gateway, mock_api): - """Test that the gateway can be setup without errors.""" - await setup_gateway(hass, mock_gateway, mock_api) - - -async def test_setup_gateway_known_host(hass, mock_gateway, mock_api): - """Test gateway setup with a known host.""" - await setup_gateway(hass, mock_gateway, mock_api, - known_hosts={ - 'mock-host': { - 'identity': 'mock', - 'key': 'mock-key' - } - }) - - -async def test_incorrect_security_code(hass, mock_gateway, mock_api): - """Test that an error is shown if the security code is incorrect.""" - async def psk_error(self, code): - """Raise RequestError when called.""" - raise RequestError - - with patch.object(hass.components.configurator, 'async_notify_errors') \ - as notify_error: - await setup_gateway(hass, mock_gateway, mock_api, - generate_psk=psk_error) - assert len(notify_error.mock_calls) > 0 + entry = MockConfigEntry(domain=tradfri.DOMAIN, data={ + 'host': 'mock-host', + 'identity': 'mock-identity', + 'key': 'mock-key', + 'import_groups': True, + 'gateway_id': 'mock-gateway-id', + }) + hass.data[tradfri.KEY_GATEWAY] = {entry.entry_id: mock_gateway} + hass.data[tradfri.KEY_API] = {entry.entry_id: mock_api} + await hass.config_entries.async_forward_entry_setup( + entry, 'light' + ) def mock_light(test_features={}, test_state={}, n=0): diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py new file mode 100644 index 00000000000000..4d1b505abc9942 --- /dev/null +++ b/tests/components/tradfri/__init__.py @@ -0,0 +1 @@ +"""Tests for the tradfri component.""" diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py new file mode 100644 index 00000000000000..4650fb5d9bc11e --- /dev/null +++ b/tests/components/tradfri/test_config_flow.py @@ -0,0 +1,156 @@ +"""Test the Tradfri config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.tradfri import config_flow + +from tests.common import mock_coro + + +@pytest.fixture +def mock_auth(): + """Mock authenticate.""" + with patch('homeassistant.components.tradfri.config_flow.' + 'authenticate') as mock_auth: + yield mock_auth + + +@pytest.fixture +def mock_gateway_info(): + """Mock get_gateway_info.""" + with patch('homeassistant.components.tradfri.config_flow.' + 'get_gateway_info') as mock_gateway: + yield mock_gateway + + +@pytest.fixture +def mock_entry_setup(): + """Mock entry setup.""" + with patch('homeassistant.components.tradfri.' + 'async_setup_entry') as mock_setup: + mock_setup.return_value = mock_coro(True) + yield mock_setup + + +async def test_user_connection_successful(hass, mock_auth, mock_entry_setup): + """Test a successful connection.""" + mock_auth.side_effect = lambda hass, host, code: mock_coro({ + 'host': host, + 'gateway_id': 'bla' + }) + + flow = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'user'}) + + result = await hass.config_entries.flow.async_configure(flow['flow_id'], { + 'host': '123.123.123.123', + 'security_code': 'abcd', + }) + + assert len(mock_entry_setup.mock_calls) == 1 + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['result'].data == { + 'host': '123.123.123.123', + 'gateway_id': 'bla', + 'import_groups': False + } + + +async def test_user_connection_timeout(hass, mock_auth, mock_entry_setup): + """Test a connection timeout.""" + mock_auth.side_effect = config_flow.AuthError('timeout') + + flow = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'user'}) + + result = await hass.config_entries.flow.async_configure(flow['flow_id'], { + 'host': '127.0.0.1', + 'security_code': 'abcd', + }) + + assert len(mock_entry_setup.mock_calls) == 0 + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors'] == { + 'base': 'timeout' + } + + +async def test_user_connection_bad_key(hass, mock_auth, mock_entry_setup): + """Test a connection with bad key.""" + mock_auth.side_effect = config_flow.AuthError('invalid_security_code') + + flow = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'user'}) + + result = await hass.config_entries.flow.async_configure(flow['flow_id'], { + 'host': '127.0.0.1', + 'security_code': 'abcd', + }) + + assert len(mock_entry_setup.mock_calls) == 0 + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors'] == { + 'security_code': 'invalid_security_code' + } + + +async def test_discovery_connection(hass, mock_auth, mock_entry_setup): + """Test a connection via discovery.""" + mock_auth.side_effect = lambda hass, host, code: mock_coro({ + 'host': host, + 'gateway_id': 'bla' + }) + + flow = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'discovery'}, data={ + 'host': '123.123.123.123' + }) + + result = await hass.config_entries.flow.async_configure(flow['flow_id'], { + 'security_code': 'abcd', + }) + + assert len(mock_entry_setup.mock_calls) == 1 + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['result'].data == { + 'host': '123.123.123.123', + 'gateway_id': 'bla', + 'import_groups': False + } + + +async def test_import_connection(hass, mock_gateway_info, mock_entry_setup): + """Test a connection via import.""" + mock_gateway_info.side_effect = \ + lambda hass, host, identity, key: mock_coro({ + 'host': host, + 'identity': identity, + 'key': key, + 'gateway_id': 'mock-gateway' + }) + + result = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'import'}, data={ + 'host': '123.123.123.123', + 'identity': 'mock-iden', + 'key': 'mock-key', + 'import_groups': True + }) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['result'].data == { + 'host': '123.123.123.123', + 'gateway_id': 'mock-gateway', + 'identity': 'mock-iden', + 'key': 'mock-key', + 'import_groups': True + } + + assert len(mock_gateway_info.mock_calls) == 1 + assert len(mock_entry_setup.mock_calls) == 1 From 6e4a6cc06979512a7aaf79caf4b8e3cc97764155 Mon Sep 17 00:00:00 2001 From: Andreas Oberritter Date: Wed, 19 Sep 2018 22:01:54 +0200 Subject: [PATCH 094/178] SnmpSensor: Fix async_update (#16679) (#16716) Bugfix provided by awarecan. --- homeassistant/components/sensor/snmp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index d66a24524fb1a1..718e4f6fb0d49f 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -182,7 +182,7 @@ async def async_update(self): if value is None: value = STATE_UNKNOWN elif self._value_template is not None: - value = self._value_template.render_with_possible_json_value( + value = self._value_template.async_render_with_possible_json_value( value, STATE_UNKNOWN) self._state = value From 60dfd680834ef3f384f27756d26cdf625ef37413 Mon Sep 17 00:00:00 2001 From: geekofweek Date: Wed, 19 Sep 2018 16:03:47 -0400 Subject: [PATCH 095/178] MyQ Open State Fix (#16681) * MyQ Open State Fix Fixes issues with MyQ reporting an open state * Update myq.py * MyQ STATE_OPEN Fix remove un-needed code --- homeassistant/components/cover/myq.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 413794505dbb24..78b6f891f113d3 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -11,8 +11,8 @@ from homeassistant.components.cover import ( CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN) from homeassistant.const import ( - CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_CLOSING, - STATE_OPENING) + CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_OPEN, + STATE_CLOSING, STATE_OPENING) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pymyq==0.0.15'] @@ -23,6 +23,7 @@ MYQ_TO_HASS = { 'closed': STATE_CLOSED, + 'open': STATE_OPEN, 'closing': STATE_CLOSING, 'opening': STATE_OPENING } From da882672bd12c65de6d9a4bf3fb1b3a65f7d358e Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 19 Sep 2018 18:52:10 -0400 Subject: [PATCH 096/178] Save disabled_by in entity registry (#16699) * Save disabled_by in entity registry * Add trailing comma --- homeassistant/helpers/entity_registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 01c8419dc040c8..4a5daa182fa631 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -240,6 +240,7 @@ def _data_to_save(self): 'unique_id': entry.unique_id, 'platform': entry.platform, 'name': entry.name, + 'disabled_by': entry.disabled_by, } for entry in self.entities.values() ] From 258beb9cd3a8a30157fecf43482102bea6c03482 Mon Sep 17 00:00:00 2001 From: kunago Date: Thu, 20 Sep 2018 07:52:06 +0200 Subject: [PATCH 097/178] Upgrading librouteros version (#16718) --- homeassistant/components/device_tracker/mikrotik.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index dfc66a412c39f8..320468159e0ba4 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['librouteros==2.1.0'] +REQUIREMENTS = ['librouteros==2.1.1'] MTK_DEFAULT_API_PORT = '8728' diff --git a/requirements_all.txt b/requirements_all.txt index ca477091dc56a9..e77995681e57bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -532,7 +532,7 @@ libpurecoollink==0.4.2 libpyfoscam==1.0 # homeassistant.components.device_tracker.mikrotik -librouteros==2.1.0 +librouteros==2.1.1 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 From 27eede724cc7cfe4dd9eab4efd59ce53b81e3a85 Mon Sep 17 00:00:00 2001 From: Nikolay Kasyanov Date: Thu, 20 Sep 2018 08:15:17 +0200 Subject: [PATCH 098/178] Add unique_id to mqtt_json light (#16721) Applies changes from #16303 to mqtt_json component. Fixes #16600. --- homeassistant/components/light/mqtt_json.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 239c924ed2b6ac..ed4d350d96dbd3 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -53,6 +53,7 @@ CONF_FLASH_TIME_LONG = 'flash_time_long' CONF_FLASH_TIME_SHORT = 'flash_time_short' CONF_HS = 'hs' +CONF_UNIQUE_ID = 'unique_id' # Stealing some of these from the base MQTT configs. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -67,6 +68,7 @@ vol.Optional(CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), @@ -87,6 +89,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, config = PLATFORM_SCHEMA(discovery_info) async_add_entities([MqttJson( config.get(CONF_NAME), + config.get(CONF_UNIQUE_ID), config.get(CONF_EFFECT_LIST), { key: config.get(key) for key in ( @@ -120,14 +123,15 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, class MqttJson(MqttAvailability, Light): """Representation of a MQTT JSON light.""" - def __init__(self, name, effect_list, topic, qos, retain, optimistic, - brightness, color_temp, effect, rgb, white_value, xy, hs, - flash_times, availability_topic, payload_available, + def __init__(self, name, unique_id, effect_list, topic, qos, retain, + optimistic, brightness, color_temp, effect, rgb, white_value, + xy, hs, flash_times, availability_topic, payload_available, payload_not_available, brightness_scale): """Initialize MQTT JSON light.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) self._name = name + self._unique_id = unique_id self._effect_list = effect_list self._topic = topic self._qos = qos @@ -316,6 +320,11 @@ def name(self): """Return the name of the device if any.""" return self._name + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def is_on(self): """Return true if device is on.""" From fcb84d951e8022ace87bb19be259f401c9920ddc Mon Sep 17 00:00:00 2001 From: Vikram Gorla Date: Thu, 20 Sep 2018 08:45:16 +0200 Subject: [PATCH 099/178] On-demand update of swiss public transport sensor (#16723) * Sensor values to be updated only when required (only after the train has crossed). Looking at the documentation on https://transport.opendata.ch/docs.html, delay information is available only on stationboard, so no need to query often if there is no "real"time info. * Bumping up version of python_opendata_transport to 0.1.4 in order to catch client errors like throttling rejection (HTTP 429) * pleasing the hound * bumping python_opendata_transport to 0.1.4 --- .../components/sensor/swiss_public_transport.py | 10 ++++++---- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 6f44350c5cf61f..6b34930075afca 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['python_opendata_transport==0.1.3'] +REQUIREMENTS = ['python_opendata_transport==0.1.4'] _LOGGER = logging.getLogger(__name__) @@ -80,6 +80,7 @@ def __init__(self, opendata, start, destination, name): self._name = name self._from = start self._to = destination + self._remaining_time = "" @property def name(self): @@ -98,7 +99,7 @@ def device_state_attributes(self): if self._opendata is None: return - remaining_time = dt_util.parse_datetime( + self._remaining_time = dt_util.parse_datetime( self._opendata.connections[0]['departure']) -\ dt_util.as_local(dt_util.utcnow()) @@ -111,7 +112,7 @@ def device_state_attributes(self): ATTR_DEPARTURE_TIME2: self._opendata.connections[2]['departure'], ATTR_START: self._opendata.from_name, ATTR_TARGET: self._opendata.to_name, - ATTR_REMAINING_TIME: '{}'.format(remaining_time), + ATTR_REMAINING_TIME: '{}'.format(self._remaining_time), ATTR_ATTRIBUTION: CONF_ATTRIBUTION, } return attr @@ -126,6 +127,7 @@ async def async_update(self): from opendata_transport.exceptions import OpendataTransportError try: - await self._opendata.async_get_data() + if self._remaining_time.total_seconds() < 0: + await self._opendata.async_get_data() except OpendataTransportError: _LOGGER.error("Unable to retrieve data from transport.opendata.ch") diff --git a/requirements_all.txt b/requirements_all.txt index e77995681e57bb..cea393faa6b842 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,7 +1170,7 @@ python-vlc==1.1.2 python-wink==1.10.1 # homeassistant.components.sensor.swiss_public_transport -python_opendata_transport==0.1.3 +python_opendata_transport==0.1.4 # homeassistant.components.zwave python_openzwave==0.4.9 From dfe38b4d5afd258f2e980737ed3fe495c168408e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 20 Sep 2018 09:37:44 +0200 Subject: [PATCH 100/178] Upgrade youtube_dl to 2018.09.18 (#16729) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 0d7d76c44476ea..a7093579805d4b 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.09.10'] +REQUIREMENTS = ['youtube_dl==2018.09.18'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cea393faa6b842..d03cb3ccce0261 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1531,7 +1531,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.09.10 +youtube_dl==2018.09.18 # homeassistant.components.light.zengge zengge==0.2 From d3658c4af9fcabeab8ad876b36653d21982a1d88 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 20 Sep 2018 09:38:35 +0200 Subject: [PATCH 101/178] Upgrade zeroconf to 0.21.2 (#16730) --- homeassistant/components/zeroconf.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf.py b/homeassistant/components/zeroconf.py index d63b532ccbbfa1..f3917078f34468 100644 --- a/homeassistant/components/zeroconf.py +++ b/homeassistant/components/zeroconf.py @@ -12,7 +12,7 @@ from homeassistant import util from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -REQUIREMENTS = ['zeroconf==0.21.1'] +REQUIREMENTS = ['zeroconf==0.21.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d03cb3ccce0261..563a6497644656 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1537,7 +1537,7 @@ youtube_dl==2018.09.18 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.21.1 +zeroconf==0.21.2 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 From aa7635398ac270d2e7fb0c077a25ebd9424d2f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 20 Sep 2018 10:32:14 +0200 Subject: [PATCH 102/178] Met.no weather platform (#16582) * Add met.no weather component * requirements .coveragerx * use lib * style * style * renam function to _fetch_data * Only update once per hour * style --- .coveragerc | 1 + homeassistant/components/weather/met.py | 225 ++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 229 insertions(+) create mode 100644 homeassistant/components/weather/met.py diff --git a/.coveragerc b/.coveragerc index 25765254c4db39..2345cc13df21e0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -821,6 +821,7 @@ omit = homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py homeassistant/components/weather/darksky.py + homeassistant/components/weather/met.py homeassistant/components/weather/metoffice.py homeassistant/components/weather/openweathermap.py homeassistant/components/weather/zamg.py diff --git a/homeassistant/components/weather/met.py b/homeassistant/components/weather/met.py new file mode 100644 index 00000000000000..f888af2e909252 --- /dev/null +++ b/homeassistant/components/weather/met.py @@ -0,0 +1,225 @@ +""" +Support for Met.no weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/weather.met/ +""" +import logging +from random import randrange + +import voluptuous as vol + +from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity +from homeassistant.const import (CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, + CONF_NAME, TEMP_CELSIUS) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import (async_track_utc_time_change, + async_call_later) +from homeassistant.util import dt as dt_util + +REQUIREMENTS = ['pyMetno==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Weather forecast from met.no, delivered " \ + "by the Norwegian Meteorological Institute." +DEFAULT_NAME = "Met.no" + +# https://api.met.no/weatherapi/weathericon/_/documentation/#___top +CONDITIONS = {1: 'sunny', + 2: 'partlycloudy', + 3: 'partlycloudy', + 4: 'cloudy', + 5: 'rainy', + 6: 'lightning-rainy', + 7: 'snowy-rainy', + 8: 'snowy', + 9: 'rainy', + 10: 'rainy', + 11: 'lightning-rainy', + 12: 'snowy-rainy', + 13: 'snowy', + 14: 'snowy', + 15: 'fog', + 20: 'lightning-rainy', + 21: 'lightning-rainy', + 22: 'lightning-rainy', + 23: 'lightning-rainy', + 24: 'lightning-rainy', + 25: 'lightning-rainy', + 26: 'lightning-rainy', + 27: 'lightning-rainy', + 28: 'lightning-rainy', + 29: 'lightning-rainy', + 30: 'lightning-rainy', + 31: 'lightning-rainy', + 32: 'lightning-rainy', + 33: 'lightning-rainy', + 34: 'lightning-rainy', + 40: 'rainy', + 41: 'rainy', + 42: 'snowy-rainy', + 43: 'snowy-rainy', + 44: 'snowy', + 45: 'snowy', + 46: 'rainy', + 47: 'snowy-rainy', + 48: 'snowy-rainy', + 49: 'snowy', + 50: 'snowy', + } +URL = 'https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.longitude, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Met.no weather platform.""" + elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return + + coordinates = { + 'lat': str(latitude), + 'lon': str(longitude), + 'msl': str(elevation), + } + + async_add_entities([MetWeather(name, coordinates, + async_get_clientsession(hass))]) + + +class MetWeather(WeatherEntity): + """Implementation of a Met.no weather condition.""" + + def __init__(self, name, coordinates, clientsession): + """Initialise the platform with a data instance and site.""" + import metno + self._name = name + self._weather_data = metno.MetWeatherData(coordinates, + clientsession, + URL + ) + self._temperature = None + self._condition = None + self._pressure = None + self._humidity = None + self._wind_speed = None + self._wind_bearing = None + + async def async_added_to_hass(self): + """Start fetching data.""" + await self._fetch_data() + async_track_utc_time_change(self.hass, self._update, + minute=31, second=0) + + async def _fetch_data(self, *_): + """Get the latest data from met.no.""" + if not await self._weather_data.fetching_data(): + # Retry in 15 to 20 minutes. + minutes = 15 + randrange(6) + _LOGGER.error("Retrying in %i minutes", minutes) + async_call_later(self.hass, minutes*60, self._fetch_data) + return + + async_call_later(self.hass, 60*60, self._fetch_data) + await self._update() + + @property + def should_poll(self): + """No polling needed.""" + return False + + async def _update(self, *_): + """Get the latest data from Met.no.""" + import metno + if self._weather_data is None: + return + + now = dt_util.utcnow() + + ordered_entries = [] + for time_entry in self._weather_data.data['product']['time']: + valid_from = dt_util.parse_datetime(time_entry['@from']) + valid_to = dt_util.parse_datetime(time_entry['@to']) + + if now >= valid_to: + # Has already passed. Never select this. + continue + + average_dist = (abs((valid_to - now).total_seconds()) + + abs((valid_from - now).total_seconds())) + + ordered_entries.append((average_dist, time_entry)) + + if not ordered_entries: + return + ordered_entries.sort(key=lambda item: item[0]) + + self._temperature = metno.get_forecast('temperature', ordered_entries) + self._condition = CONDITIONS.get(metno.get_forecast('symbol', + ordered_entries)) + self._pressure = metno.get_forecast('pressure', ordered_entries) + self._humidity = metno.get_forecast('humidity', ordered_entries) + self._wind_speed = metno.get_forecast('windSpeed', ordered_entries) + self._wind_bearing = metno.get_forecast('windDirection', + ordered_entries) + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def condition(self): + """Return the current condition.""" + return self._condition + + @property + def temperature(self): + """Return the temperature.""" + return self._temperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def pressure(self): + """Return the pressure.""" + return self._pressure + + @property + def humidity(self): + """Return the humidity.""" + return self._humidity + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._wind_speed + + @property + def wind_bearing(self): + """Return the wind direction.""" + return self._wind_bearing + + @property + def attribution(self): + """Return the attribution.""" + return CONF_ATTRIBUTION diff --git a/requirements_all.txt b/requirements_all.txt index 563a6497644656..fbab5bf68f29ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -751,6 +751,9 @@ pyCEC==0.4.13 # homeassistant.components.switch.tplink pyHS100==0.3.3 +# homeassistant.components.weather.met +pyMetno==0.2.0 + # homeassistant.components.rfxtrx pyRFXtrx==0.23 From 3a45481b5b11aa22f8dcd121af89e72ea296f602 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 20 Sep 2018 01:48:45 -0700 Subject: [PATCH 103/178] Handle chromecast CONNECTION_STATUS_DISCONNECTED event (#16732) --- homeassistant/components/media_player/cast.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index beec1e5609cd4f..a05653cd115545 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -368,7 +368,8 @@ async def async_set_cast_info(self, cast_info): if self._chromecast is not None: if old_cast_info.host_port == cast_info.host_port: - # Nothing connection-related updated + _LOGGER.debug("No connection related update: %s", + cast_info.host_port) return await self._async_disconnect() @@ -402,7 +403,12 @@ async def _async_disconnect(self): await self.hass.async_add_job(self._chromecast.disconnect) - # Invalidate some attributes + self._invalidate() + + self.async_schedule_update_ha_state() + + def _invalidate(self): + """Invalidate some attributes.""" self._chromecast = None self.cast_status = None self.media_status = None @@ -411,8 +417,6 @@ async def _async_disconnect(self): self._status_listener.invalidate() self._status_listener = None - self.async_schedule_update_ha_state() - # ========== Callbacks ========== def new_cast_status(self, cast_status): """Handle updates of the cast status.""" @@ -427,7 +431,16 @@ def new_media_status(self, media_status): def new_connection_status(self, connection_status): """Handle updates of connection status.""" - from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED + from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, \ + CONNECTION_STATUS_DISCONNECTED + + _LOGGER.debug("Received cast device connection status: %s", + connection_status.status) + if connection_status.status == CONNECTION_STATUS_DISCONNECTED: + self._available = False + self._invalidate() + self.schedule_update_ha_state() + return new_available = connection_status.status == CONNECTION_STATUS_CONNECTED if new_available != self._available: From 35005474f8760da8b4f2a49e66441f8722b34d21 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 20 Sep 2018 11:11:08 +0200 Subject: [PATCH 104/178] Upgrade keyring to 15.1.0 (#16734) --- homeassistant/scripts/keyring.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index e48fa809d19aaf..76a9d9318f206e 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==15.0.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==15.1.0', 'keyrings.alt==3.1'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index fbab5bf68f29ba..8050cc9a67a51a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -507,7 +507,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==15.0.0 +keyring==15.1.0 # homeassistant.scripts.keyring keyrings.alt==3.1 From d1acb0326c1aeb3cdea857435bcb5db5ec67564b Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 20 Sep 2018 02:11:39 -0700 Subject: [PATCH 105/178] Upgrade netdisco to 2.1.0 (#16735) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 22d7ae87b8d4b0..91f9dea704bf85 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==2.0.0'] +REQUIREMENTS = ['netdisco==2.1.0'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 8050cc9a67a51a..e51e94fd85f075 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -619,7 +619,7 @@ ndms2_client==0.0.4 netdata==0.1.2 # homeassistant.components.discovery -netdisco==2.0.0 +netdisco==2.1.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From 93af3c57ff8c213bc1933444bb4cd732ea475d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 20 Sep 2018 11:31:05 +0200 Subject: [PATCH 106/178] Avoid calling yr update every second for a minute ones every hour (#16731) * Avoid calling yr update every second for a minute one every hour * style --- homeassistant/components/sensor/yr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index f081be1a2f13a9..0cb9c3765ecab5 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -91,7 +91,8 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities(dev) weather = YrData(hass, coordinates, forecast, dev) - async_track_utc_time_change(hass, weather.updating_devices, minute=31) + async_track_utc_time_change(hass, weather.updating_devices, + minute=31, second=0) await weather.fetching_data() From 39ea9a8c90ee2a9a6311d2adc21c5e71eb8c392d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 20 Sep 2018 11:34:37 +0200 Subject: [PATCH 107/178] Upgrade shodan to 1.10.2 (#16736) --- homeassistant/components/sensor/shodan.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 6f228df3a930e8..bd74bcaeb2c99e 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.10.1'] +REQUIREMENTS = ['shodan==1.10.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e51e94fd85f075..93097e3abc00d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1309,7 +1309,7 @@ sense_energy==0.4.2 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.10.1 +shodan==1.10.2 # homeassistant.components.notify.simplepush simplepush==1.1.4 From fc6cc22b6ddc1a075ab0ba7f99fc0879606f6d11 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Sep 2018 10:54:37 +0200 Subject: [PATCH 108/178] Bump frontend to 20180920.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 861c4a95d69776..73282830bbc2e1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180919.0'] +REQUIREMENTS = ['home-assistant-frontend==20180920.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 93097e3abc00d0..21104fab416d1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,7 +454,7 @@ hole==0.3.0 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180919.0 +home-assistant-frontend==20180920.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2d556511b9608..83566a0bc30d30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ hdate==0.6.3 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180919.0 +home-assistant-frontend==20180920.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From e58836f99f60a7d5e91c3a27481303e8c4985cbc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Sep 2018 14:53:13 +0200 Subject: [PATCH 109/178] Add subscription info endpoint (#16727) * Add subscription info endpoint * Lint * Lint * Make decorator * Lint --- homeassistant/components/cloud/__init__.py | 18 ++- homeassistant/components/cloud/const.py | 2 + homeassistant/components/cloud/http_api.py | 86 ++++++++--- homeassistant/components/websocket_api.py | 58 +++++--- tests/components/cloud/test_http_api.py | 157 +++++++++++++++------ tests/components/cloud/test_init.py | 2 + 6 files changed, 236 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8c1a9751c1957c..74625f7363b1f0 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -23,7 +23,7 @@ from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c -from . import http_api, iot +from . import http_api, iot, auth_api from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] @@ -39,6 +39,7 @@ CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' +CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -79,6 +80,7 @@ vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, + vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -114,7 +116,8 @@ class Cloud: def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, - relayer=None, google_actions_sync_url=None): + relayer=None, google_actions_sync_url=None, + subscription_info_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode @@ -133,6 +136,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.region = region self.relayer = relayer self.google_actions_sync_url = google_actions_sync_url + self.subscription_info_url = subscription_info_url else: info = SERVERS[mode] @@ -142,6 +146,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.region = info['region'] self.relayer = info['relayer'] self.google_actions_sync_url = info['google_actions_sync_url'] + self.subscription_info_url = info['subscription_info_url'] @property def is_logged_in(self): @@ -195,6 +200,15 @@ def path(self, *parts): """ return self.hass.config.path(CONFIG_DIR, *parts) + async def fetch_subscription_info(self): + """Fetch subscription info.""" + await self.hass.async_add_executor_job(auth_api.check_token, self) + websession = self.hass.helpers.aiohttp_client.async_get_clientsession() + return await websession.get( + self.subscription_info_url, headers={ + 'authorization': self.id_token + }) + @asyncio.coroutine def logout(self): """Close connection and remove all credentials.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 82128206d47cb9..88fb88474a1330 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -11,6 +11,8 @@ 'relayer': 'wss://cloud.hass.io:8000/websocket', 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' 'amazonaws.com/prod/smart_home_sync'), + 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' + 'subscription_info') } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index a4b3b59f3331f6..24617bb1f17ea5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -6,22 +6,44 @@ import async_timeout import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import ( RequestDataValidator) +from homeassistant.components import websocket_api from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT +from .iot import STATE_DISCONNECTED _LOGGER = logging.getLogger(__name__) +WS_TYPE_STATUS = 'cloud/status' +SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_STATUS, +}) + + +WS_TYPE_SUBSCRIPTION = 'cloud/subscription' +SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SUBSCRIPTION, +}) + + async def async_setup(hass): """Initialize the HTTP API.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_STATUS, websocket_cloud_status, + SCHEMA_WS_STATUS + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_SUBSCRIPTION, websocket_subscription, + SCHEMA_WS_SUBSCRIPTION + ) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) - hass.http.register_view(CloudAccountView) hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) @@ -102,9 +124,7 @@ async def post(self, request, data): data['password']) hass.async_add_job(cloud.iot.connect) - # Allow cloud to start connecting. - await asyncio.sleep(0, loop=hass.loop) - return self.json(_account_data(cloud)) + return self.json({'success': True}) class CloudLogoutView(HomeAssistantView): @@ -125,23 +145,6 @@ async def post(self, request): return self.json_message('ok') -class CloudAccountView(HomeAssistantView): - """View to retrieve account info.""" - - url = '/api/cloud/account' - name = 'api:cloud:account' - - async def get(self, request): - """Get account info.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - - if not cloud.is_logged_in: - return self.json_message('Not logged in', 400) - - return self.json(_account_data(cloud)) - - class CloudRegisterView(HomeAssistantView): """Register on the Home Assistant cloud.""" @@ -209,12 +212,51 @@ async def post(self, request, data): return self.json_message('ok') +@callback +def websocket_cloud_status(hass, connection, msg): + """Handle request for account info. + + Async friendly. + """ + cloud = hass.data[DOMAIN] + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'], _account_data(cloud))) + + +@websocket_api.async_response +async def websocket_subscription(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + + if not cloud.is_logged_in: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + response = await cloud.fetch_subscription_info() + + if response.status == 200: + connection.send_message_outside(websocket_api.result_message( + msg['id'], await response.json())) + else: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'request_failed', 'Failed to request subscription')) + + def _account_data(cloud): """Generate the auth data JSON response.""" + if not cloud.is_logged_in: + return { + 'logged_in': False, + 'cloud': STATE_DISCONNECTED, + } + claims = cloud.claims return { + 'logged_in': True, 'email': claims['email'], - 'sub_exp': claims['custom:sub-exp'], 'cloud': cloud.iot.state, } diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 6f4c4d47fe9560..9bd4aac4b6a970 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -480,6 +480,26 @@ def handle_hass_stop(event): return wsock +def async_response(func): + """Decorate an async function to handle WebSocket API messages.""" + async def handle_msg_response(hass, connection, msg): + """Create a response and handle exception.""" + try: + await func(hass, connection, msg) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + connection.send_message_outside(error_message( + msg['id'], 'unknown', 'Unexpected error occurred')) + + @callback + @wraps(func) + def schedule_handler(hass, connection, msg): + """Schedule the handler.""" + hass.async_create_task(handle_msg_response(hass, connection, msg)) + + return schedule_handler + + @callback def handle_subscribe_events(hass, connection, msg): """Handle subscribe events command. @@ -515,24 +535,20 @@ def handle_unsubscribe_events(hass, connection, msg): msg['id'], ERR_NOT_FOUND, 'Subscription not found.')) -@callback -def handle_call_service(hass, connection, msg): +@async_response +async def handle_call_service(hass, connection, msg): """Handle call service command. Async friendly. """ - async def call_service_helper(msg): - """Call a service and fire complete message.""" - blocking = True - if (msg['domain'] == 'homeassistant' and - msg['service'] in ['restart', 'stop']): - blocking = False - await hass.services.async_call( - msg['domain'], msg['service'], msg.get('service_data'), blocking, - connection.context(msg)) - connection.send_message_outside(result_message(msg['id'])) - - hass.async_add_job(call_service_helper(msg)) + blocking = True + if (msg['domain'] == 'homeassistant' and + msg['service'] in ['restart', 'stop']): + blocking = False + await hass.services.async_call( + msg['domain'], msg['service'], msg.get('service_data'), blocking, + connection.context(msg)) + connection.send_message_outside(result_message(msg['id'])) @callback @@ -545,19 +561,15 @@ def handle_get_states(hass, connection, msg): msg['id'], hass.states.async_all())) -@callback -def handle_get_services(hass, connection, msg): +@async_response +async def handle_get_services(hass, connection, msg): """Handle get services command. Async friendly. """ - async def get_services_helper(msg): - """Get available services and fire complete message.""" - descriptions = await async_get_all_descriptions(hass) - connection.send_message_outside( - result_message(msg['id'], descriptions)) - - hass.async_add_job(get_services_helper(msg)) + descriptions = await async_get_all_descriptions(hass) + connection.send_message_outside( + result_message(msg['id'], descriptions)) @callback diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 55c6290c158e4f..531cd09f011764 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -12,25 +12,40 @@ GOOGLE_ACTIONS_SYNC_URL = 'https://api-test.hass.io/google_actions_sync' +SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info' -@pytest.fixture -def cloud_client(hass, aiohttp_client): - """Fixture that can fetch from the cloud client.""" +@pytest.fixture() +def mock_auth(): + """Mock check token.""" + with patch('homeassistant.components.cloud.auth_api.check_token'): + yield + + +@pytest.fixture(autouse=True) +def setup_api(hass): + """Initialize HTTP API.""" with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): - hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { - 'cloud': { - 'mode': 'development', - 'cognito_client_id': 'cognito_client_id', - 'user_pool_id': 'user_pool_id', - 'region': 'region', - 'relayer': 'relayer', - 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, - } - })) + assert hass.loop.run_until_complete(async_setup_component( + hass, 'cloud', { + 'cloud': { + 'mode': 'development', + 'cognito_client_id': 'cognito_client_id', + 'user_pool_id': 'user_pool_id', + 'region': 'region', + 'relayer': 'relayer', + 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, + 'subscription_info_url': SUBSCRIPTION_INFO_URL, + } + })) hass.data['cloud']._decode_claims = \ lambda token: jwt.get_unverified_claims(token) + + +@pytest.fixture +def cloud_client(hass, aiohttp_client): + """Fixture that can fetch from the cloud client.""" with patch('homeassistant.components.cloud.Cloud.write_user_info'): yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @@ -57,31 +72,6 @@ async def test_google_actions_sync_fails(mock_cognito, cloud_client, assert req.status == 403 -@asyncio.coroutine -def test_account_view_no_account(cloud_client): - """Test fetching account if no account available.""" - req = yield from cloud_client.get('/api/cloud/account') - assert req.status == 400 - - -@asyncio.coroutine -def test_account_view(hass, cloud_client): - """Test fetching account if no account available.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') - hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED - req = yield from cloud_client.get('/api/cloud/account') - assert req.status == 200 - result = yield from req.json() - assert result == { - 'email': 'hello@home-assistant.io', - 'sub_exp': '2018-01-03', - 'cloud': iot.STATE_CONNECTED, - } - - @asyncio.coroutine def test_login_view(hass, cloud_client, mock_cognito): """Test logging in.""" @@ -103,8 +93,7 @@ def test_login_view(hass, cloud_client, mock_cognito): assert req.status == 200 result = yield from req.json() - assert result['email'] == 'hello@home-assistant.io' - assert result['sub_exp'] == '2018-01-03' + assert result == {'success': True} assert len(mock_connect.mock_calls) == 1 @@ -330,3 +319,91 @@ def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): 'email': 'hello@bla.com', }) assert req.status == 502 + + +async def test_websocket_status(hass, hass_ws_client): + """Test querying the status.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/status' + }) + response = await client.receive_json() + assert response['result'] == { + 'logged_in': True, + 'email': 'hello@home-assistant.io', + 'cloud': 'connected', + } + + +async def test_websocket_status_not_logged_in(hass, hass_ws_client): + """Test querying the status.""" + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/status' + }) + response = await client.receive_json() + assert response['result'] == { + 'logged_in': False, + 'cloud': 'disconnected' + } + + +async def test_websocket_subscription(hass, hass_ws_client, aioclient_mock, + mock_auth): + """Test querying the status.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'return': 'value'}) + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'return': 'value' + } + + +async def test_websocket_subscription_fail(hass, hass_ws_client, + aioclient_mock, mock_auth): + """Test querying the status.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=500) + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'request_failed' + + +async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): + """Test querying the status.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.Cloud.fetch_subscription_info', + return_value=mock_coro({'return': 'value'})): + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'not_logged_in' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 014cdb1c6c6f18..9b090a96ca1d86 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -30,6 +30,7 @@ def test_constructor_loads_info_from_constant(): 'region': 'test-region', 'relayer': 'test-relayer', 'google_actions_sync_url': 'test-google_actions_sync_url', + 'subscription_info_url': 'test-subscription-info-url' } }), patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', return_value=mock_coro(True)): @@ -45,6 +46,7 @@ def test_constructor_loads_info_from_constant(): assert cl.region == 'test-region' assert cl.relayer == 'test-relayer' assert cl.google_actions_sync_url == 'test-google_actions_sync_url' + assert cl.subscription_info_url == 'test-subscription-info-url' @asyncio.coroutine From c6ccbed8283ecca4a589415e4f30a2eb3563415d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 20 Sep 2018 15:14:43 +0200 Subject: [PATCH 110/178] Small cleanup for slack (#16743) --- homeassistant/components/notify/slack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index d4c5a196a3fbbe..d576cdcc95e780 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -136,9 +136,9 @@ def load_file(self, url=None, local_path=None, username=None, password=None, auth=None): """Load image/document/etc from a local path or URL.""" try: - if url is not None: + if url: # Check whether authentication parameters are provided - if username is not None and password is not None: + if username: # Use digest or basic authentication if ATTR_FILE_AUTH_DIGEST == auth: auth_ = HTTPDigestAuth(username, password) @@ -151,7 +151,7 @@ def load_file(self, url=None, local_path=None, username=None, req = requests.get(url, timeout=CONF_TIMEOUT) return req.content - elif local_path is not None: + elif local_path: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): return open(local_path, "rb") From 3ea8c25e1fc483fc6018e828e990106fc5228096 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 20 Sep 2018 14:23:09 -0400 Subject: [PATCH 111/178] light.zha: Catch exceptions for all commands. (#16752) Catch exceptions for all operations which may fail because of device reachibility More verbose debug logging on operations --- homeassistant/components/light/zha.py | 59 +++++++++++++++++++-------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index dd675ee674deae..56a1e9e5169bb0 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -81,40 +81,65 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs): """Turn the entity on.""" + from zigpy.exceptions import DeliveryError + duration = kwargs.get(light.ATTR_TRANSITION, DEFAULT_DURATION) duration = duration * 10 # tenths of s if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] - await self._endpoint.light_color.move_to_color_temp( - temperature, duration) + try: + res = await self._endpoint.light_color.move_to_color_temp( + temperature, duration) + _LOGGER.debug("%s: moved to %i color temp: %s", + self.entity_id, temperature, res) + except DeliveryError as ex: + _LOGGER.error("%s: Couldn't change color temp: %s", + self.entity_id, ex) + return self._color_temp = temperature if light.ATTR_HS_COLOR in kwargs: self._hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*self._hs_color) - await self._endpoint.light_color.move_to_color( - int(xy_color[0] * 65535), - int(xy_color[1] * 65535), - duration, - ) + try: + res = await self._endpoint.light_color.move_to_color( + int(xy_color[0] * 65535), + int(xy_color[1] * 65535), + duration, + ) + _LOGGER.debug("%s: moved XY color to (%1.2f, %1.2f): %s", + self.entity_id, xy_color[0], xy_color[1], res) + except DeliveryError as ex: + _LOGGER.error("%s: Couldn't change color temp: %s", + self.entity_id, ex) + return if self._brightness is not None: brightness = kwargs.get( light.ATTR_BRIGHTNESS, self._brightness or 255) self._brightness = brightness # Move to level with on/off: - await self._endpoint.level.move_to_level_with_on_off( - brightness, - duration - ) + try: + res = await self._endpoint.level.move_to_level_with_on_off( + brightness, + duration + ) + _LOGGER.debug("%s: moved to %i level with on/off: %s", + self.entity_id, brightness, res) + except DeliveryError as ex: + _LOGGER.error("%s: Couldn't change brightness level: %s", + self.entity_id, ex) + return self._state = 1 self.async_schedule_update_ha_state() return - from zigpy.exceptions import DeliveryError + try: - await self._endpoint.on_off.on() + res = await self._endpoint.on_off.on() + _LOGGER.debug("%s was turned on: %s", self.entity_id, res) except DeliveryError as ex: - _LOGGER.error("Unable to turn the light on: %s", ex) + _LOGGER.error("%s: Unable to turn the light on: %s", + self.entity_id, ex) return self._state = 1 @@ -124,9 +149,11 @@ async def async_turn_off(self, **kwargs): """Turn the entity off.""" from zigpy.exceptions import DeliveryError try: - await self._endpoint.on_off.off() + res = await self._endpoint.on_off.off() + _LOGGER.debug("%s was turned off: %s", self.entity_id, res) except DeliveryError as ex: - _LOGGER.error("Unable to turn the light off: %s", ex) + _LOGGER.error("%s: Unable to turn the light off: %s", + self.entity_id, ex) return self._state = 0 From 03de658d4d6700fb4aafa727390919a4bdbbd1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9=20B=C5=91le?= Date: Thu, 20 Sep 2018 20:24:01 +0200 Subject: [PATCH 112/178] Changed save_on_change to default to False (#16744) --- homeassistant/components/light/yeelight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index a08ebe459b48f6..b14b1f96e692b4 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -45,7 +45,7 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, - vol.Optional(CONF_SAVE_ON_CHANGE, default=True): cv.boolean, + vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( From 092c146eaef6054011f26a4cd73420693a9030fa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Sep 2018 23:46:51 +0200 Subject: [PATCH 113/178] Add option to disable specific integrations (#16757) * Add option to disable specific integrations * Lint --- homeassistant/components/alexa/smart_home.py | 5 ++ homeassistant/components/cloud/__init__.py | 47 +++++++++++++-- homeassistant/components/cloud/http_api.py | 34 +++++++++++ homeassistant/components/cloud/iot.py | 6 ++ .../components/google_assistant/smart_home.py | 8 +++ tests/components/cloud/__init__.py | 31 ++++++++++ tests/components/cloud/conftest.py | 11 ++++ tests/components/cloud/test_http_api.py | 58 +++++++++++++------ tests/components/cloud/test_init.py | 8 +-- tests/components/cloud/test_iot.py | 42 +++++++++++++- 10 files changed, 222 insertions(+), 28 deletions(-) create mode 100644 tests/components/cloud/conftest.py diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index eab725c4653efc..176c286ebc3b8a 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1529,3 +1529,8 @@ async def async_api_reportstate(hass, config, request, context, entity): name='StateReport', context={'properties': properties} ) + + +def turned_off_response(message): + """Return a device turned off response.""" + return api_error(message[API_DIRECTIVE], error_type='BRIDGE_UNREACHABLE') diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 74625f7363b1f0..33a939bf9d042d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -27,8 +27,13 @@ from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +STORAGE_ENABLE_ALEXA = 'alexa_enabled' +STORAGE_ENABLE_GOOGLE = 'google_enabled' _LOGGER = logging.getLogger(__name__) +_UNDEF = object() CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -124,11 +129,13 @@ def __init__(self, hass, mode, alexa, google_actions, self.alexa_config = alexa self._google_actions = google_actions self._gactions_config = None + self._prefs = None self.jwt_keyset = None self.id_token = None self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -193,6 +200,16 @@ def should_expose(entity): return self._gactions_config + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[STORAGE_ENABLE_ALEXA] + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[STORAGE_ENABLE_GOOGLE] + def path(self, *parts): """Get config path inside cloud dir. @@ -231,10 +248,23 @@ def write_user_info(self): 'refresh_token': self.refresh_token, }, indent=4)) - @asyncio.coroutine - def async_start(self, _): + async def async_start(self, _): """Start the cloud component.""" - success = yield from self._fetch_jwt_keyset() + prefs = await self._store.async_load() + if prefs is None: + prefs = {} + if self.mode not in prefs: + # Default to True if already logged in to make this not a + # breaking change. + enabled = await self.hass.async_add_executor_job( + os.path.isfile, self.user_info_path) + prefs = { + STORAGE_ENABLE_ALEXA: enabled, + STORAGE_ENABLE_GOOGLE: enabled, + } + self._prefs = prefs + + success = await self._fetch_jwt_keyset() # Fetching keyset can fail if internet is not up yet. if not success: @@ -255,7 +285,7 @@ def load_config(): with open(user_info, 'rt') as file: return json.loads(file.read()) - info = yield from self.hass.async_add_job(load_config) + info = await self.hass.async_add_job(load_config) if info is None: return @@ -274,6 +304,15 @@ def load_config(): self.hass.add_job(self.iot.connect()) + async def update_preferences(self, *, google_enabled=_UNDEF, + alexa_enabled=_UNDEF): + """Update user preferences.""" + if google_enabled is not _UNDEF: + self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled + if alexa_enabled is not _UNDEF: + self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled + await self._store.async_save(self._prefs) + @asyncio.coroutine def _fetch_jwt_keyset(self): """Fetch the JWT keyset for the Cognito instance.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 24617bb1f17ea5..c81ec38bace589 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -25,6 +25,14 @@ }) +WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' +SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE_PREFS, + vol.Optional('google_enabled'): bool, + vol.Optional('alexa_enabled'): bool, +}) + + WS_TYPE_SUBSCRIPTION = 'cloud/subscription' SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SUBSCRIPTION, @@ -41,6 +49,10 @@ async def async_setup(hass): WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE_PREFS, websocket_update_prefs, + SCHEMA_WS_UPDATE_PREFS + ) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -245,6 +257,26 @@ async def websocket_subscription(hass, connection, msg): msg['id'], 'request_failed', 'Failed to request subscription')) +@websocket_api.async_response +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + + if not cloud.is_logged_in: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + changes = dict(msg) + changes.pop('id') + changes.pop('type') + await cloud.update_preferences(**changes) + + connection.send_message_outside(websocket_api.result_message( + msg['id'], {'success': True})) + + def _account_data(cloud): """Generate the auth data JSON response.""" if not cloud.is_logged_in: @@ -259,4 +291,6 @@ def _account_data(cloud): 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, + 'google_enabled': cloud.google_enabled, + 'alexa_enabled': cloud.alexa_enabled, } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index f4ce7bb3d1af0a..fd525ed33a8fb0 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -227,6 +227,9 @@ def async_handle_message(hass, cloud, handler_name, payload): @asyncio.coroutine def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" + if not cloud.alexa_enabled: + return alexa.turned_off_response(payload) + result = yield from alexa.async_handle_message( hass, cloud.alexa_config, payload) return result @@ -236,6 +239,9 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" + if not cloud.google_enabled: + return ga.turned_off_response(payload) + result = yield from ga.async_handle_message( hass, cloud.gactions_config, payload) return result diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 675e86f9d3938d..1cb4bf4cb32de0 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -324,3 +324,11 @@ async def handle_devices_execute(hass, config, payload): }) return {'commands': final_results} + + +def turned_off_response(message): + """Return a device turned off response.""" + return { + 'requestId': message.get('requestId'), + 'payload': {'errorCode': 'deviceTurnedOff'} + } diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 7a4e9f2950ec85..108e5c45137c01 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1 +1,32 @@ """Tests for the cloud component.""" +from unittest.mock import patch +from homeassistant.setup import async_setup_component +from homeassistant.components import cloud + +from jose import jwt + +from tests.common import mock_coro + + +def mock_cloud(hass, config={}): + """Mock cloud.""" + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): + assert hass.loop.run_until_complete(async_setup_component( + hass, cloud.DOMAIN, { + 'cloud': config + })) + + hass.data[cloud.DOMAIN]._decode_claims = \ + lambda token: jwt.get_unverified_claims(token) + + +def mock_cloud_prefs(hass, prefs={}): + """Fixture for cloud component.""" + prefs_to_set = { + cloud.STORAGE_ENABLE_ALEXA: True, + cloud.STORAGE_ENABLE_GOOGLE: True, + } + prefs_to_set.update(prefs) + hass.data[cloud.DOMAIN]._prefs = prefs_to_set + return prefs_to_set diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py new file mode 100644 index 00000000000000..81ecb7250efaa7 --- /dev/null +++ b/tests/components/cloud/conftest.py @@ -0,0 +1,11 @@ +"""Fixtures for cloud tests.""" +import pytest + +from . import mock_cloud, mock_cloud_prefs + + +@pytest.fixture +def mock_cloud_fixture(hass): + """Fixture for cloud component.""" + mock_cloud(hass) + return mock_cloud_prefs(hass) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 531cd09f011764..5d4b356b9b244c 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -5,11 +5,12 @@ import pytest from jose import jwt -from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, auth_api, iot +from homeassistant.components.cloud import ( + DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) from tests.common import mock_coro +from . import mock_cloud, mock_cloud_prefs GOOGLE_ACTIONS_SYNC_URL = 'https://api-test.hass.io/google_actions_sync' SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info' @@ -25,22 +26,16 @@ def mock_auth(): @pytest.fixture(autouse=True) def setup_api(hass): """Initialize HTTP API.""" - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - assert hass.loop.run_until_complete(async_setup_component( - hass, 'cloud', { - 'cloud': { - 'mode': 'development', - 'cognito_client_id': 'cognito_client_id', - 'user_pool_id': 'user_pool_id', - 'region': 'region', - 'relayer': 'relayer', - 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, - 'subscription_info_url': SUBSCRIPTION_INFO_URL, - } - })) - hass.data['cloud']._decode_claims = \ - lambda token: jwt.get_unverified_claims(token) + mock_cloud(hass, { + 'mode': 'development', + 'cognito_client_id': 'cognito_client_id', + 'user_pool_id': 'user_pool_id', + 'region': 'region', + 'relayer': 'relayer', + 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, + 'subscription_info_url': SUBSCRIPTION_INFO_URL, + }) + return mock_cloud_prefs(hass) @pytest.fixture @@ -321,7 +316,7 @@ def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): assert req.status == 502 -async def test_websocket_status(hass, hass_ws_client): +async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): """Test querying the status.""" hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', @@ -338,6 +333,8 @@ async def test_websocket_status(hass, hass_ws_client): 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', + 'alexa_enabled': True, + 'google_enabled': True, } @@ -407,3 +404,26 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): assert not response['success'] assert response['error']['code'] == 'not_logged_in' + + +async def test_websocket_update_preferences(hass, hass_ws_client, + aioclient_mock, setup_api): + """Test updating preference.""" + assert setup_api[STORAGE_ENABLE_GOOGLE] + assert setup_api[STORAGE_ENABLE_ALEXA] + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/update_prefs', + 'alexa_enabled': False, + 'google_enabled': False, + }) + response = await client.receive_json() + + assert response['success'] + assert not setup_api[STORAGE_ENABLE_GOOGLE] + assert not setup_api[STORAGE_ENABLE_ALEXA] diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 9b090a96ca1d86..1fdbda496a9645 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -141,9 +141,9 @@ def test_write_user_info(): @asyncio.coroutine -def test_subscription_expired(): +def test_subscription_expired(hass): """Test subscription being expired.""" - cl = cloud.Cloud(None, cloud.MODE_DEV, None, None) + cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) token_val = { 'custom:sub-exp': '2017-11-13' } @@ -154,9 +154,9 @@ def test_subscription_expired(): @asyncio.coroutine -def test_subscription_not_expired(): +def test_subscription_not_expired(hass): """Test subscription not being expired.""" - cl = cloud.Cloud(None, cloud.MODE_DEV, None, None) + cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) token_val = { 'custom:sub-exp': '2017-11-13' } diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 1b580d0eb9b70b..07ec1851fbe5a6 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -6,10 +6,14 @@ import pytest from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import Cloud, iot, auth_api, MODE_DEV +from homeassistant.components.cloud import ( + Cloud, iot, auth_api, MODE_DEV, STORAGE_ENABLE_ALEXA, + STORAGE_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro +from . import mock_cloud_prefs + @pytest.fixture def mock_client(): @@ -284,6 +288,8 @@ def test_handler_alexa(hass): }) assert setup + mock_cloud_prefs(hass) + resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], test_alexa.get_new_request('Alexa.Discovery', 'Discover')) @@ -299,6 +305,20 @@ def test_handler_alexa(hass): assert device['manufacturerName'] == 'Home Assistant' +@asyncio.coroutine +def test_handler_alexa_disabled(hass, mock_cloud_fixture): + """Test handler Alexa when user has disabled it.""" + mock_cloud_fixture[STORAGE_ENABLE_ALEXA] = False + + resp = yield from iot.async_handle_alexa( + hass, hass.data['cloud'], + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + assert resp['event']['header']['namespace'] == 'Alexa' + assert resp['event']['header']['name'] == 'ErrorResponse' + assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' + + @asyncio.coroutine def test_handler_google_actions(hass): """Test handler Google Actions.""" @@ -327,6 +347,8 @@ def test_handler_google_actions(hass): }) assert setup + mock_cloud_prefs(hass) + reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} @@ -351,6 +373,24 @@ def test_handler_google_actions(hass): assert device['roomHint'] == 'living room' +async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): + """Test handler Google Actions when user has disabled it.""" + mock_cloud_fixture[STORAGE_ENABLE_GOOGLE] = False + + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): + assert await async_setup_component(hass, 'cloud', {}) + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + resp = await iot.async_handle_google_actions( + hass, hass.data['cloud'], data) + + assert resp['requestId'] == reqid + assert resp['payload']['errorCode'] == 'deviceTurnedOff' + + async def test_refresh_token_expired(hass): """Test handling Unauthenticated error raised if refresh token expired.""" cloud = Cloud(hass, MODE_DEV, None, None) From 78b6439ee6a07e3aacbd6555dca2595e74d2e8b5 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 20 Sep 2018 23:50:11 +0200 Subject: [PATCH 114/178] Use pysonos for Sonos media player (#16753) --- .../components/media_player/sonos.py | 38 +++++----- homeassistant/components/sonos/__init__.py | 6 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- script/gen_requirements_all.py | 2 +- tests/components/media_player/test_sonos.py | 70 +++++++++---------- tests/components/sonos/test_init.py | 6 +- 7 files changed, 67 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 72ac0a046a3592..fd735a5b830c6e 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -1,5 +1,5 @@ """ -Support to interface with Sonos players (via SoCo). +Support to interface with Sonos players. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.sonos/ @@ -31,11 +31,11 @@ _LOGGER = logging.getLogger(__name__) -# Quiet down soco logging to just actual problems. -logging.getLogger('soco').setLevel(logging.WARNING) -logging.getLogger('soco.events').setLevel(logging.ERROR) -logging.getLogger('soco.data_structures_entry').setLevel(logging.ERROR) -_SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') +# Quiet down pysonos logging to just actual problems. +logging.getLogger('pysonos').setLevel(logging.WARNING) +logging.getLogger('pysonos.events').setLevel(logging.ERROR) +logging.getLogger('pysonos.data_structures_entry').setLevel(logging.ERROR) +_SOCO_SERVICES_LOGGER = logging.getLogger('pysonos.services') SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE |\ @@ -143,18 +143,18 @@ def add_entities(devices, update_before_add=False): def _setup_platform(hass, config, add_entities, discovery_info): """Set up the Sonos platform.""" - import soco + import pysonos if DATA_SONOS not in hass.data: hass.data[DATA_SONOS] = SonosData() advertise_addr = config.get(CONF_ADVERTISE_ADDR) if advertise_addr: - soco.config.EVENT_ADVERTISE_IP = advertise_addr + pysonos.config.EVENT_ADVERTISE_IP = advertise_addr players = [] if discovery_info: - player = soco.SoCo(discovery_info.get('host')) + player = pysonos.SoCo(discovery_info.get('host')) # If device already exists by config if player.uid in hass.data[DATA_SONOS].uids: @@ -174,11 +174,11 @@ def _setup_platform(hass, config, add_entities, discovery_info): hosts = hosts.split(',') if isinstance(hosts, str) else hosts for host in hosts: try: - players.append(soco.SoCo(socket.gethostbyname(host))) + players.append(pysonos.SoCo(socket.gethostbyname(host))) except OSError: _LOGGER.warning("Failed to initialize '%s'", host) else: - players = soco.discover( + players = pysonos.discover( interface_addr=config.get(CONF_INTERFACE_ADDR)) if not players: @@ -287,7 +287,7 @@ def decorator(funct): @ft.wraps(funct) def wrapper(*args, **kwargs): """Wrap for all soco UPnP exception.""" - from soco.exceptions import SoCoUPnPException, SoCoException + from pysonos.exceptions import SoCoUPnPException, SoCoException # Temporarily disable SoCo logging because it will log the # UPnP exception otherwise @@ -612,9 +612,9 @@ def update_media_radio(self, variables, track_info): current_uri_metadata = media_info["CurrentURIMetaData"] if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): # currently soco does not have an API for this - import soco - current_uri_metadata = soco.xml.XML.fromstring( - soco.utils.really_utf8(current_uri_metadata)) + import pysonos + current_uri_metadata = pysonos.xml.XML.fromstring( + pysonos.utils.really_utf8(current_uri_metadata)) md_title = current_uri_metadata.findtext( './/{http://purl.org/dc/elements/1.1/}title') @@ -950,7 +950,7 @@ def play_media(self, media_type, media_id, **kwargs): If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ if kwargs.get(ATTR_MEDIA_ENQUEUE): - from soco.exceptions import SoCoUPnPException + from pysonos.exceptions import SoCoUPnPException try: self.soco.add_uri_to_queue(media_id) except SoCoUPnPException: @@ -981,7 +981,7 @@ def unjoin(self): @soco_error() def snapshot(self, with_group=True): """Snapshot the player.""" - from soco.snapshot import Snapshot + from pysonos.snapshot import Snapshot self._soco_snapshot = Snapshot(self.soco) self._soco_snapshot.snapshot() @@ -996,7 +996,7 @@ def snapshot(self, with_group=True): @soco_error() def restore(self, with_group=True): """Restore snapshot for the player.""" - from soco.exceptions import SoCoException + from pysonos.exceptions import SoCoException try: # need catch exception if a coordinator is going to slave. # this state will recover with group part. @@ -1060,7 +1060,7 @@ def clear_sleep_timer(self): @soco_coordinator def set_alarm(self, **data): """Set the alarm clock on the player.""" - from soco import alarms + from pysonos import alarms alarm = None for one_alarm in alarms.get_alarms(self.soco): # pylint: disable=protected-access diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 60f3a8858b2e11..30573ee03ee98a 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,7 +4,7 @@ DOMAIN = 'sonos' -REQUIREMENTS = ['SoCo==0.16'] +REQUIREMENTS = ['pysonos==0.0.1'] async def async_setup(hass, config): @@ -29,9 +29,9 @@ async def async_setup_entry(hass, entry): async def _async_has_devices(hass): """Return if there are devices that can be discovered.""" - import soco + import pysonos - return await hass.async_add_executor_job(soco.discover) + return await hass.async_add_executor_job(pysonos.discover) config_entry_flow.register_discovery_flow( diff --git a/requirements_all.txt b/requirements_all.txt index 21104fab416d1e..f9296ae28cdc53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -66,9 +66,6 @@ PyXiaomiGateway==0.10.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.0 -# homeassistant.components.sonos -SoCo==0.16 - # homeassistant.components.sensor.travisci TravisPy==0.3.5 @@ -1060,6 +1057,9 @@ pysma==0.2 # homeassistant.components.switch.snmp pysnmp==4.4.5 +# homeassistant.components.sonos +pysonos==0.0.1 + # homeassistant.components.notify.stride pystride==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83566a0bc30d30..78fc33dde71728 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,9 +24,6 @@ HAP-python==2.2.2 # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.1 -# homeassistant.components.sonos -SoCo==0.16 - # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -167,6 +164,9 @@ pyotp==2.2.6 # homeassistant.components.qwikswitch pyqwikswitch==0.8 +# homeassistant.components.sonos +pysonos==0.0.1 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky python-forecastio==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3cde13a2a9755e..c4776e74f93403 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -79,6 +79,7 @@ 'pynx584', 'pyopenuv', 'pyotp', + 'pysonos', 'pyqwikswitch', 'PyRMVtransport', 'python-forecastio', @@ -92,7 +93,6 @@ 'ring_doorbell', 'rxv', 'sleepyq', - 'SoCo', 'somecomfort', 'sqlalchemy', 'statsd', diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 5a845738fa394a..cb3da3ab8998d7 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -2,10 +2,10 @@ import datetime import socket import unittest -import soco.snapshot +import pysonos.snapshot from unittest import mock -import soco -from soco import alarms +import pysonos +from pysonos import alarms from homeassistant.setup import setup_component from homeassistant.components.media_player import sonos, DOMAIN @@ -17,16 +17,16 @@ ENTITY_ID = 'media_player.kitchen' -class socoDiscoverMock(): - """Mock class for the soco.discover method.""" +class pysonosDiscoverMock(): + """Mock class for the pysonos.discover method.""" def discover(interface_addr): - """Return tuple of soco.SoCo objects representing found speakers.""" + """Return tuple of pysonos.SoCo objects representing found speakers.""" return {SoCoMock('192.0.2.1')} class AvTransportMock(): - """Mock class for the avTransport property on soco.SoCo object.""" + """Mock class for the avTransport property on pysonos.SoCo object.""" def __init__(self): """Initialize ethe Transport mock.""" @@ -41,7 +41,7 @@ def GetMediaInfo(self, _): class MusicLibraryMock(): - """Mock class for the music_library property on soco.SoCo object.""" + """Mock class for the music_library property on pysonos.SoCo object.""" def get_sonos_favorites(self): """Return favorites.""" @@ -49,10 +49,10 @@ def get_sonos_favorites(self): class SoCoMock(): - """Mock class for the soco.SoCo object.""" + """Mock class for the pysonos.SoCo object.""" def __init__(self, ip): - """Initialize soco object.""" + """Initialize SoCo object.""" self.ip_address = ip self.is_visible = True self.volume = 50 @@ -153,7 +153,7 @@ def tearDown(self): sonos.SonosDevice.available = self.real_available self.hass.stop() - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_discovery(self, *args): """Test a single device using the autodiscovery provided by HASS.""" @@ -165,9 +165,9 @@ def test_ensure_setup_discovery(self, *args): self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch('soco.discover') + @mock.patch('pysonos.discover') def test_ensure_setup_config_interface_addr(self, discover_mock, *args): """Test an interface address config'd by the HASS config file.""" discover_mock.return_value = {SoCoMock('192.0.2.1')} @@ -184,7 +184,7 @@ def test_ensure_setup_config_interface_addr(self, discover_mock, *args): self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_config_hosts_string_single(self, *args): """Test a single address config'd by the HASS config file.""" @@ -201,7 +201,7 @@ def test_ensure_setup_config_hosts_string_single(self, *args): self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_config_hosts_string_multiple(self, *args): """Test multiple address string config'd by the HASS config file.""" @@ -218,7 +218,7 @@ def test_ensure_setup_config_hosts_string_multiple(self, *args): self.assertEqual(len(devices), 2) self.assertEqual(devices[0].name, 'Kitchen') - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_config_hosts_list(self, *args): """Test a multiple address list config'd by the HASS config file.""" @@ -235,8 +235,8 @@ def test_ensure_setup_config_hosts_list(self, *args): self.assertEqual(len(devices), 2) self.assertEqual(devices[0].name, 'Kitchen') - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch.object(soco, 'discover', new=socoDiscoverMock.discover) + @mock.patch('pysonos.SoCo', new=SoCoMock) + @mock.patch.object(pysonos, 'discover', new=pysonosDiscoverMock.discover) @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" @@ -245,11 +245,11 @@ def test_ensure_setup_sonos_discovery(self, *args): self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(SoCoMock, 'set_sleep_timer') def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args): - """Ensuring soco methods called for sonos_set_sleep_timer service.""" + """Ensure pysonos methods called for sonos_set_sleep_timer service.""" sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { 'host': '192.0.2.1' }) @@ -259,11 +259,11 @@ def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args): device.set_sleep_timer(30) set_sleep_timerMock.assert_called_once_with(30) - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(SoCoMock, 'set_sleep_timer') def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args): - """Ensuring soco methods called for sonos_clear_sleep_timer service.""" + """Ensure pysonos method called for sonos_clear_sleep_timer service.""" sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { 'host': '192.0.2.1' }) @@ -273,20 +273,20 @@ def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args): device.set_sleep_timer(None) set_sleep_timerMock.assert_called_once_with(None) - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('soco.alarms.Alarm') + @mock.patch('pysonos.SoCo', new=SoCoMock) + @mock.patch('pysonos.alarms.Alarm') @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_set_alarm(self, soco_mock, alarm_mock, *args): - """Ensuring soco methods called for sonos_set_sleep_timer service.""" + def test_set_alarm(self, pysonos_mock, alarm_mock, *args): + """Ensure pysonos methods called for sonos_set_sleep_timer service.""" sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { 'host': '192.0.2.1' }) device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] device.hass = self.hass - alarm1 = alarms.Alarm(soco_mock) + alarm1 = alarms.Alarm(pysonos_mock) alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, include_linked_zones=False, volume=100) - with mock.patch('soco.alarms.get_alarms', return_value=[alarm1]): + with mock.patch('pysonos.alarms.get_alarms', return_value=[alarm1]): attrs = { 'time': datetime.time(12, 00), 'enabled': True, @@ -303,11 +303,11 @@ def test_set_alarm(self, soco_mock, alarm_mock, *args): self.assertEqual(alarm1.volume, 30) alarm1.save.assert_called_once_with() - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(soco.snapshot.Snapshot, 'snapshot') + @mock.patch.object(pysonos.snapshot.Snapshot, 'snapshot') def test_sonos_snapshot(self, snapshotMock, *args): - """Ensuring soco methods called for sonos_snapshot service.""" + """Ensure pysonos methods called for sonos_snapshot service.""" sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { 'host': '192.0.2.1' }) @@ -319,12 +319,12 @@ def test_sonos_snapshot(self, snapshotMock, *args): self.assertEqual(snapshotMock.call_count, 1) self.assertEqual(snapshotMock.call_args, mock.call()) - @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(soco.snapshot.Snapshot, 'restore') + @mock.patch.object(pysonos.snapshot.Snapshot, 'restore') def test_sonos_restore(self, restoreMock, *args): - """Ensuring soco methods called for sonos_restor service.""" - from soco.snapshot import Snapshot + """Ensure pysonos methods called for sonos_restore service.""" + from pysonos.snapshot import Snapshot sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { 'host': '192.0.2.1' diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index ab4eed31fee702..455ce6d4cc25b0 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -12,7 +12,7 @@ async def test_creating_entry_sets_up_media_player(hass): """Test setting up Sonos loads the media player.""" with patch('homeassistant.components.media_player.sonos.async_setup_entry', return_value=mock_coro(True)) as mock_setup, \ - patch('soco.discover', return_value=True): + patch('pysonos.discover', return_value=True): result = await hass.config_entries.flow.async_init( sonos.DOMAIN, context={'source': config_entries.SOURCE_USER}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -26,7 +26,7 @@ async def test_configuring_sonos_creates_entry(hass): """Test that specifying config will create an entry.""" with patch('homeassistant.components.sonos.async_setup_entry', return_value=mock_coro(True)) as mock_setup, \ - patch('soco.discover', return_value=True): + patch('pysonos.discover', return_value=True): await async_setup_component(hass, sonos.DOMAIN, { 'sonos': { 'some_config': 'to_trigger_import' @@ -41,7 +41,7 @@ async def test_not_configuring_sonos_not_creates_entry(hass): """Test that no config will not create an entry.""" with patch('homeassistant.components.sonos.async_setup_entry', return_value=mock_coro(True)) as mock_setup, \ - patch('soco.discover', return_value=True): + patch('pysonos.discover', return_value=True): await async_setup_component(hass, sonos.DOMAIN, {}) await hass.async_block_till_done() From 90c18d1c157712491500b5c5a910a13fe86af65e Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 21 Sep 2018 09:21:44 +0200 Subject: [PATCH 115/178] deCONZ add via_hub attribute for device registry (#16760) * deCONZ add via_hub attribute for device registry * A shorter way to get bridgeid --- homeassistant/components/binary_sensor/deconz.py | 2 ++ homeassistant/components/light/deconz.py | 2 ++ homeassistant/components/sensor/deconz.py | 4 ++++ homeassistant/components/switch/deconz.py | 2 ++ tests/components/binary_sensor/test_deconz.py | 1 + tests/components/light/test_deconz.py | 1 + tests/components/sensor/test_deconz.py | 1 + tests/components/switch/test_deconz.py | 1 + 8 files changed, 14 insertions(+) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index d2ca9e7c5e880f..b0728ad167c580 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -127,6 +127,7 @@ def device_info(self): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -134,4 +135,5 @@ def device_info(self): 'model': self._sensor.modelid, 'name': self._sensor.name, 'sw_version': self._sensor.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), } diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index ff3fe609924333..3fb6e1dff006d4 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -213,6 +213,7 @@ def device_info(self): self._light.uniqueid.count(':') != 7): return None serial = self._light.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -220,4 +221,5 @@ def device_info(self): 'model': self._light.modelid, 'name': self._light.name, 'sw_version': self._light.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), } diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 37fab727299a85..c66bda2bc1d809 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -147,6 +147,7 @@ def device_info(self): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -154,6 +155,7 @@ def device_info(self): 'model': self._sensor.modelid, 'name': self._sensor.name, 'sw_version': self._sensor.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), } @@ -227,6 +229,7 @@ def device_info(self): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -234,4 +237,5 @@ def device_info(self): 'model': self._sensor.modelid, 'name': self._sensor.name, 'sw_version': self._sensor.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), } diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py index bd8167d89a0831..f8911d65d987d9 100644 --- a/homeassistant/components/switch/deconz.py +++ b/homeassistant/components/switch/deconz.py @@ -92,6 +92,7 @@ def device_info(self): self._switch.uniqueid.count(':') != 7): return None serial = self._switch.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -99,6 +100,7 @@ def device_info(self): 'model': self._switch.modelid, 'name': self._switch.name, 'sw_version': self._switch.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), } diff --git a/tests/components/binary_sensor/test_deconz.py b/tests/components/binary_sensor/test_deconz.py index c2d20bd47c8535..5fd6e132e032c7 100644 --- a/tests/components/binary_sensor/test_deconz.py +++ b/tests/components/binary_sensor/test_deconz.py @@ -34,6 +34,7 @@ async def setup_bridge(hass, data, allow_clip_sensor=True): entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} bridge = DeconzSession(loop, session, **entry.data) + bridge.config = Mock() with patch('pydeconz.DeconzSession.async_get_state', return_value=mock_coro(data)): await bridge.async_load_parameters() diff --git a/tests/components/light/test_deconz.py b/tests/components/light/test_deconz.py index f52219beaa833f..96f180505b8c85 100644 --- a/tests/components/light/test_deconz.py +++ b/tests/components/light/test_deconz.py @@ -55,6 +55,7 @@ async def setup_bridge(hass, data, allow_deconz_groups=True): entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} bridge = DeconzSession(loop, session, **entry.data) + bridge.config = Mock() with patch('pydeconz.DeconzSession.async_get_state', return_value=mock_coro(data)): await bridge.async_load_parameters() diff --git a/tests/components/sensor/test_deconz.py b/tests/components/sensor/test_deconz.py index f40ba631d94232..ae9e75d6a416a1 100644 --- a/tests/components/sensor/test_deconz.py +++ b/tests/components/sensor/test_deconz.py @@ -49,6 +49,7 @@ async def setup_bridge(hass, data, allow_clip_sensor=True): entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} bridge = DeconzSession(loop, session, **entry.data) + bridge.config = Mock() with patch('pydeconz.DeconzSession.async_get_state', return_value=mock_coro(data)): await bridge.async_load_parameters() diff --git a/tests/components/switch/test_deconz.py b/tests/components/switch/test_deconz.py index f4aac63166f29e..7d2bea7a9fa9b5 100644 --- a/tests/components/switch/test_deconz.py +++ b/tests/components/switch/test_deconz.py @@ -47,6 +47,7 @@ async def setup_bridge(hass, data): entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} bridge = DeconzSession(loop, session, **entry.data) + bridge.config = Mock() with patch('pydeconz.DeconzSession.async_get_state', return_value=mock_coro(data)): await bridge.async_load_parameters() From c475a876ce888305ab982a5140f4812b5920b39e Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 21 Sep 2018 09:21:56 +0200 Subject: [PATCH 116/178] Upgrade pysonos to 0.0.2 (#16761) --- homeassistant/components/sonos/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 30573ee03ee98a..b4565794844d0e 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,7 +4,7 @@ DOMAIN = 'sonos' -REQUIREMENTS = ['pysonos==0.0.1'] +REQUIREMENTS = ['pysonos==0.0.2'] async def async_setup(hass, config): diff --git a/requirements_all.txt b/requirements_all.txt index f9296ae28cdc53..1758e96c6431b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1058,7 +1058,7 @@ pysma==0.2 pysnmp==4.4.5 # homeassistant.components.sonos -pysonos==0.0.1 +pysonos==0.0.2 # homeassistant.components.notify.stride pystride==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78fc33dde71728..a62b5534c30f58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -165,7 +165,7 @@ pyotp==2.2.6 pyqwikswitch==0.8 # homeassistant.components.sonos -pysonos==0.0.1 +pysonos==0.0.2 # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky From df6709344196b1789fb5572545948bf4b3b252a7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Sep 2018 10:51:46 +0200 Subject: [PATCH 117/178] Fix faulty color temp crashing google (#16758) * Fix faulty color temp crashing google * Rename * Print warning for incorrect color temp --- .../components/google_assistant/trait.py | 10 +++++++++- tests/components/google_assistant/test_trait.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 26e80e6f03b52b..1ee9d4e2364f66 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,4 +1,6 @@ """Implement the Smart Home traits.""" +import logging + from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components import ( climate, @@ -25,6 +27,8 @@ from .const import ERR_VALUE_OUT_OF_RANGE from .helpers import SmartHomeError +_LOGGER = logging.getLogger(__name__) + PREFIX_TRAITS = 'action.devices.traits.' TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' @@ -317,7 +321,11 @@ def query_attributes(self): response = {} temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) - if temp is not None: + # Some faulty integrations might put 0 in here, raising exception. + if temp == 0: + _LOGGER.warning('Entity %s has incorrect color temperature %s', + self.state.entity_id, temp) + elif temp is not None: response['color'] = { 'temperature': color_util.color_temperature_mired_to_kelvin(temp) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index c18ed4b7bf34aa..52dac7ddb61eea 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -457,6 +457,22 @@ async def test_color_temperature_light(hass): } +async def test_color_temperature_light_bad_temp(hass): + """Test ColorTemperature trait support for light domain.""" + assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0) + assert trait.ColorTemperatureTrait.supported(light.DOMAIN, + light.SUPPORT_COLOR_TEMP) + + trt = trait.ColorTemperatureTrait(hass, State('light.bla', STATE_ON, { + light.ATTR_MIN_MIREDS: 200, + light.ATTR_COLOR_TEMP: 0, + light.ATTR_MAX_MIREDS: 500, + })) + + assert trt.query_attributes() == { + } + + async def test_scene_scene(hass): """Test Scene trait support for scene domain.""" assert trait.SceneTrait.supported(scene.DOMAIN, 0) From 98b92c78c0b1bdef993b20fcdb0581d9e23aed59 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Fri, 21 Sep 2018 02:55:12 -0700 Subject: [PATCH 118/178] Add Call Data Log platform. Mailboxes no longer require media (#16579) * Add multiple mailbox support * Fix extraneous debugging * Add cdr support * liniting errors * Mailbox log messages should mostly be debug. Fix race condition with initializing CDR * async decorators to async * Lint fixes * Typo * remove unneeded parameter * Fix variable names * Fix async calls from worker thread. Other minor cleanups * more variable renames --- .coveragerc | 1 + homeassistant/components/asterisk_mbox.py | 67 ++++++++++++--- homeassistant/components/mailbox/__init__.py | 83 ++++++++++--------- .../components/mailbox/asterisk_cdr.py | 64 ++++++++++++++ .../components/mailbox/asterisk_mbox.py | 30 ++++--- homeassistant/components/mailbox/demo.py | 24 ++++-- tests/components/mailbox/test_init.py | 2 +- 7 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 homeassistant/components/mailbox/asterisk_cdr.py diff --git a/.coveragerc b/.coveragerc index 2345cc13df21e0..39c2bce5ae5d9b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -42,6 +42,7 @@ omit = homeassistant/components/asterisk_mbox.py homeassistant/components/*/asterisk_mbox.py + homeassistant/components/*/asterisk_cdr.py homeassistant/components/august.py homeassistant/components/*/august.py diff --git a/homeassistant/components/asterisk_mbox.py b/homeassistant/components/asterisk_mbox.py index 0d6d811db7092d..0907e48b256ab9 100644 --- a/homeassistant/components/asterisk_mbox.py +++ b/homeassistant/components/asterisk_mbox.py @@ -13,7 +13,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, async_dispatcher_send) + async_dispatcher_send, dispatcher_connect) REQUIREMENTS = ['asterisk_mbox==0.5.0'] @@ -21,8 +21,11 @@ DOMAIN = 'asterisk_mbox' +SIGNAL_DISCOVER_PLATFORM = "asterisk_mbox.discover_platform" SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_CDR_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_CDR_REQUEST = 'asterisk_mbox.message_request' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -41,9 +44,7 @@ def setup(hass, config): port = conf.get(CONF_PORT) password = conf.get(CONF_PASSWORD) - hass.data[DOMAIN] = AsteriskData(hass, host, port, password) - - discovery.load_platform(hass, 'mailbox', DOMAIN, {}, config) + hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) return True @@ -51,31 +52,71 @@ def setup(hass, config): class AsteriskData: """Store Asterisk mailbox data.""" - def __init__(self, hass, host, port, password): + def __init__(self, hass, host, port, password, config): """Init the Asterisk data object.""" from asterisk_mbox import Client as asteriskClient - self.hass = hass - self.client = asteriskClient(host, port, password, self.handle_data) - self.messages = [] + self.config = config + self.messages = None + self.cdr = None - async_dispatcher_connect( + dispatcher_connect( self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) + dispatcher_connect( + self.hass, SIGNAL_CDR_REQUEST, self._request_cdr) + dispatcher_connect( + self.hass, SIGNAL_DISCOVER_PLATFORM, self._discover_platform) + # Only connect after signal connection to ensure we don't miss any + self.client = asteriskClient(host, port, password, self.handle_data) + + @callback + def _discover_platform(self, component): + _LOGGER.debug("Adding mailbox %s", component) + self.hass.async_create_task(discovery.async_load_platform( + self.hass, "mailbox", component, {}, self.config)) @callback def handle_data(self, command, msg): """Handle changes to the mailbox.""" - from asterisk_mbox.commands import CMD_MESSAGE_LIST + from asterisk_mbox.commands import (CMD_MESSAGE_LIST, + CMD_MESSAGE_CDR_AVAILABLE, + CMD_MESSAGE_CDR) if command == CMD_MESSAGE_LIST: - _LOGGER.debug("AsteriskVM sent updated message list") + _LOGGER.debug("AsteriskVM sent updated message list: Len %d", + len(msg)) + old_messages = self.messages self.messages = sorted( msg, key=lambda item: item['info']['origtime'], reverse=True) - async_dispatcher_send( - self.hass, SIGNAL_MESSAGE_UPDATE, self.messages) + if not isinstance(old_messages, list): + async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, + DOMAIN) + async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, + self.messages) + elif command == CMD_MESSAGE_CDR: + _LOGGER.debug("AsteriskVM sent updated CDR list: Len %d", + len(msg.get('entries', []))) + self.cdr = msg['entries'] + async_dispatcher_send(self.hass, SIGNAL_CDR_UPDATE, self.cdr) + elif command == CMD_MESSAGE_CDR_AVAILABLE: + if not isinstance(self.cdr, list): + _LOGGER.debug("AsteriskVM adding CDR platform") + self.cdr = [] + async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, + "asterisk_cdr") + async_dispatcher_send(self.hass, SIGNAL_CDR_REQUEST) + else: + _LOGGER.debug("AsteriskVM sent unknown message '%d' len: %d", + command, len(msg)) @callback def _request_messages(self): """Handle changes to the mailbox.""" _LOGGER.debug("Requesting message list") self.client.messages() + + @callback + def _request_cdr(self): + """Handle changes to the CDR.""" + _LOGGER.debug("Requesting CDR list") + self.client.get_cdr() diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 0c5dabb6eeb0aa..2ed12b231649ac 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -23,36 +23,34 @@ _LOGGER = logging.getLogger(__name__) -CONTENT_TYPE_MPEG = 'audio/mpeg' - DEPENDENCIES = ['http'] DOMAIN = 'mailbox' EVENT = 'mailbox_updated' +CONTENT_TYPE_MPEG = 'audio/mpeg' +CONTENT_TYPE_NONE = 'none' SCAN_INTERVAL = timedelta(seconds=30) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for mailboxes.""" mailboxes = [] - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'mailbox', 'mailbox', 'mdi:mailbox') hass.http.register_view(MailboxPlatformsView(mailboxes)) hass.http.register_view(MailboxMessageView(mailboxes)) hass.http.register_view(MailboxMediaView(mailboxes)) hass.http.register_view(MailboxDeleteView(mailboxes)) - @asyncio.coroutine - def async_setup_platform(p_type, p_config=None, discovery_info=None): + async def async_setup_platform(p_type, p_config=None, discovery_info=None): """Set up a mailbox platform.""" if p_config is None: p_config = {} if discovery_info is None: discovery_info = {} - platform = yield from async_prepare_setup_platform( + platform = await async_prepare_setup_platform( hass, config, DOMAIN, p_type) if platform is None: @@ -63,10 +61,10 @@ def async_setup_platform(p_type, p_config=None, discovery_info=None): mailbox = None try: if hasattr(platform, 'async_get_handler'): - mailbox = yield from \ + mailbox = await \ platform.async_get_handler(hass, p_config, discovery_info) elif hasattr(platform, 'get_handler'): - mailbox = yield from hass.async_add_job( + mailbox = await hass.async_add_executor_job( platform.get_handler, hass, p_config, discovery_info) else: raise HomeAssistantError("Invalid mailbox platform.") @@ -81,21 +79,20 @@ def async_setup_platform(p_type, p_config=None, discovery_info=None): return mailboxes.append(mailbox) - mailbox_entity = MailboxEntity(hass, mailbox) + mailbox_entity = MailboxEntity(mailbox) component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_add_entities([mailbox_entity]) + await component.async_add_entities([mailbox_entity]) setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks, loop=hass.loop) - @asyncio.coroutine - def async_platform_discovered(platform, info): + async def async_platform_discovered(platform, info): """Handle for discovered platform.""" - yield from async_setup_platform(platform, discovery_info=info) + await async_setup_platform(platform, discovery_info=info) discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) @@ -103,19 +100,21 @@ def async_platform_discovered(platform, info): class MailboxEntity(Entity): - """Entity for each mailbox platform.""" + """Entity for each mailbox platform to provide a badge display.""" - def __init__(self, hass, mailbox): + def __init__(self, mailbox): """Initialize mailbox entity.""" self.mailbox = mailbox - self.hass = hass self.message_count = 0 + async def async_added_to_hass(self): + """Complete entity initialization.""" @callback def _mailbox_updated(event): self.async_schedule_update_ha_state(True) - hass.bus.async_listen(EVENT, _mailbox_updated) + self.hass.bus.async_listen(EVENT, _mailbox_updated) + self.async_schedule_update_ha_state(True) @property def state(self): @@ -127,10 +126,9 @@ def name(self): """Return the name of the entity.""" return self.mailbox.name - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve messages from platform.""" - messages = yield from self.mailbox.async_get_messages() + messages = await self.mailbox.async_get_messages() self.message_count = len(messages) @@ -151,13 +149,21 @@ def media_type(self): """Return the supported media type.""" raise NotImplementedError() - @asyncio.coroutine - def async_get_media(self, msgid): + @property + def can_delete(self): + """Return if messages can be deleted.""" + return False + + @property + def has_media(self): + """Return if messages have attached media files.""" + return False + + async def async_get_media(self, msgid): """Return the media blob for the msgid.""" raise NotImplementedError() - @asyncio.coroutine - def async_get_messages(self): + async def async_get_messages(self): """Return a list of the current messages.""" raise NotImplementedError() @@ -193,12 +199,16 @@ class MailboxPlatformsView(MailboxView): url = "/api/mailbox/platforms" name = "api:mailbox:platforms" - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Retrieve list of platforms.""" platforms = [] for mailbox in self.mailboxes: - platforms.append(mailbox.name) + platforms.append( + { + 'name': mailbox.name, + 'has_media': mailbox.has_media, + 'can_delete': mailbox.can_delete + }) return self.json(platforms) @@ -208,11 +218,10 @@ class MailboxMessageView(MailboxView): url = "/api/mailbox/messages/{platform}" name = "api:mailbox:messages" - @asyncio.coroutine - def get(self, request, platform): + async def get(self, request, platform): """Retrieve messages.""" mailbox = self.get_mailbox(platform) - messages = yield from mailbox.async_get_messages() + messages = await mailbox.async_get_messages() return self.json(messages) @@ -222,8 +231,7 @@ class MailboxDeleteView(MailboxView): url = "/api/mailbox/delete/{platform}/{msgid}" name = "api:mailbox:delete" - @asyncio.coroutine - def delete(self, request, platform, msgid): + async def delete(self, request, platform, msgid): """Delete items.""" mailbox = self.get_mailbox(platform) mailbox.async_delete(msgid) @@ -235,8 +243,7 @@ class MailboxMediaView(MailboxView): url = r"/api/mailbox/media/{platform}/{msgid}" name = "api:asteriskmbox:media" - @asyncio.coroutine - def get(self, request, platform, msgid): + async def get(self, request, platform, msgid): """Retrieve media.""" mailbox = self.get_mailbox(platform) @@ -244,7 +251,7 @@ def get(self, request, platform, msgid): with suppress(asyncio.CancelledError, asyncio.TimeoutError): with async_timeout.timeout(10, loop=hass.loop): try: - stream = yield from mailbox.async_get_media(msgid) + stream = await mailbox.async_get_media(msgid) except StreamError as err: error_msg = "Error getting media: %s" % (err) _LOGGER.error(error_msg) diff --git a/homeassistant/components/mailbox/asterisk_cdr.py b/homeassistant/components/mailbox/asterisk_cdr.py new file mode 100644 index 00000000000000..ae0939c3da5d6a --- /dev/null +++ b/homeassistant/components/mailbox/asterisk_cdr.py @@ -0,0 +1,64 @@ +""" +Asterisk CDR interface. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/mailbox.asterisk_cdr/ +""" +import logging +import hashlib +import datetime + +from homeassistant.core import callback +from homeassistant.components.asterisk_mbox import SIGNAL_CDR_UPDATE +from homeassistant.components.asterisk_mbox import DOMAIN as ASTERISK_DOMAIN +from homeassistant.components.mailbox import Mailbox +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['asterisk_mbox'] +_LOGGER = logging.getLogger(__name__) +MAILBOX_NAME = "asterisk_cdr" + + +async def async_get_handler(hass, config, discovery_info=None): + """Set up the Asterix CDR platform.""" + return AsteriskCDR(hass, MAILBOX_NAME) + + +class AsteriskCDR(Mailbox): + """Asterisk VM Call Data Record mailbox.""" + + def __init__(self, hass, name): + """Initialize Asterisk CDR.""" + super().__init__(hass, name) + self.cdr = [] + async_dispatcher_connect( + self.hass, SIGNAL_CDR_UPDATE, self._update_callback) + + @callback + def _update_callback(self, msg): + """Update the message count in HA, if needed.""" + self._build_message() + self.async_update() + + def _build_message(self): + """Build message structure.""" + cdr = [] + for entry in self.hass.data[ASTERISK_DOMAIN].cdr: + timestamp = datetime.datetime.strptime( + entry['time'], "%Y-%m-%d %H:%M:%S").timestamp() + info = { + 'origtime': timestamp, + 'callerid': entry['callerid'], + 'duration': entry['duration'], + } + sha = hashlib.sha256(str(entry).encode('utf-8')).hexdigest() + msg = "Destination: {}\nApplication: {}\n Context: {}".format( + entry['dest'], entry['application'], entry['context']) + cdr.append({'info': info, 'sha': sha, 'text': msg}) + self.cdr = cdr + + async def async_get_messages(self): + """Return a list of the current messages.""" + if not self.cdr: + self._build_message() + return self.cdr diff --git a/homeassistant/components/mailbox/asterisk_mbox.py b/homeassistant/components/mailbox/asterisk_mbox.py index 29b34f3e5121f8..087018084f2c78 100644 --- a/homeassistant/components/mailbox/asterisk_mbox.py +++ b/homeassistant/components/mailbox/asterisk_mbox.py @@ -4,10 +4,9 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/mailbox.asteriskvm/ """ -import asyncio import logging -from homeassistant.components.asterisk_mbox import DOMAIN +from homeassistant.components.asterisk_mbox import DOMAIN as ASTERISK_DOMAIN from homeassistant.components.mailbox import ( CONTENT_TYPE_MPEG, Mailbox, StreamError) from homeassistant.core import callback @@ -21,10 +20,9 @@ SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' -@asyncio.coroutine -def async_get_handler(hass, config, async_add_entities, discovery_info=None): +async def async_get_handler(hass, config, discovery_info=None): """Set up the Asterix VM platform.""" - return AsteriskMailbox(hass, DOMAIN) + return AsteriskMailbox(hass, ASTERISK_DOMAIN) class AsteriskMailbox(Mailbox): @@ -46,24 +44,32 @@ def media_type(self): """Return the supported media type.""" return CONTENT_TYPE_MPEG - @asyncio.coroutine - def async_get_media(self, msgid): + @property + def can_delete(self): + """Return if messages can be deleted.""" + return True + + @property + def has_media(self): + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid): """Return the media blob for the msgid.""" from asterisk_mbox import ServerError - client = self.hass.data[DOMAIN].client + client = self.hass.data[ASTERISK_DOMAIN].client try: return client.mp3(msgid, sync=True) except ServerError as err: raise StreamError(err) - @asyncio.coroutine - def async_get_messages(self): + async def async_get_messages(self): """Return a list of the current messages.""" - return self.hass.data[DOMAIN].messages + return self.hass.data[ASTERISK_DOMAIN].messages def async_delete(self, msgid): """Delete the specified messages.""" - client = self.hass.data[DOMAIN].client + client = self.hass.data[ASTERISK_DOMAIN].client _LOGGER.info("Deleting: %s", msgid) client.delete(msgid) return True diff --git a/homeassistant/components/mailbox/demo.py b/homeassistant/components/mailbox/demo.py index e0d2618ac4e4d9..2aabde42b36e40 100644 --- a/homeassistant/components/mailbox/demo.py +++ b/homeassistant/components/mailbox/demo.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/mailbox.asteriskvm/ """ -import asyncio from hashlib import sha1 import logging import os @@ -15,13 +14,12 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = "DemoMailbox" +MAILBOX_NAME = "DemoMailbox" -@asyncio.coroutine -def async_get_handler(hass, config, discovery_info=None): +async def async_get_handler(hass, config, discovery_info=None): """Set up the Demo mailbox.""" - return DemoMailbox(hass, DOMAIN) + return DemoMailbox(hass, MAILBOX_NAME) class DemoMailbox(Mailbox): @@ -54,8 +52,17 @@ def media_type(self): """Return the supported media type.""" return CONTENT_TYPE_MPEG - @asyncio.coroutine - def async_get_media(self, msgid): + @property + def can_delete(self): + """Return if messages can be deleted.""" + return True + + @property + def has_media(self): + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid): """Return the media blob for the msgid.""" if msgid not in self._messages: raise StreamError("Message not found") @@ -65,8 +72,7 @@ def async_get_media(self, msgid): with open(audio_path, 'rb') as file: return file.read() - @asyncio.coroutine - def async_get_messages(self): + async def async_get_messages(self): """Return a list of the current messages.""" return sorted(self._messages.values(), key=lambda item: item['info']['origtime'], diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index 3377fcefcf5afb..2c69a5effa7efc 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -29,7 +29,7 @@ def test_get_platforms_from_mailbox(mock_http_client): req = yield from mock_http_client.get(url) assert req.status == 200 result = yield from req.json() - assert len(result) == 1 and "DemoMailbox" in result + assert len(result) == 1 and "DemoMailbox" == result[0].get('name', None) @asyncio.coroutine From 3d1c8ee467697805f383158af20f14fb6eee4b71 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Fri, 21 Sep 2018 02:57:01 -0700 Subject: [PATCH 119/178] Implement support for complex templates in script delays (#16442) * Implement support for complex templates in script delays * Swap out dict instead of collections.Mapping --- homeassistant/helpers/config_validation.py | 2 +- homeassistant/helpers/script.py | 5 ++ tests/helpers/test_script.py | 64 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3363b199b0beac..bb4dcf6a55fe18 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -590,7 +590,7 @@ def validator(value): vol.Optional(CONF_ALIAS): string, vol.Required("delay"): vol.Any( vol.All(time_period, positive_timedelta), - template) + template, template_complex) }) _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema({ diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 8a33b57e9d13fe..96f9b2d5069edb 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -107,6 +107,11 @@ def async_script_delay(now): cv.time_period, cv.positive_timedelta)( delay.async_render(variables)) + elif isinstance(delay, dict): + delay_data = {} + delay_data.update( + template.render_complex(delay, variables)) + delay = cv.time_period(delay_data) except (TemplateError, vol.Invalid) as ex: _LOGGER.error("Error rendering '%s' delay template: %s", self.name, ex) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 2956d82c2ba506..d217b99b3a8fdc 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -255,6 +255,70 @@ def record_event(event): assert not script_obj.is_running assert len(events) == 1 + def test_delay_complex_template(self): + """Test the delay with a working complex template.""" + event = 'test_event' + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(event, record_event) + + script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ + {'event': event}, + {'delay': { + 'seconds': '{{ 5 }}' + }}, + {'event': event}])) + + script_obj.run() + self.hass.block_till_done() + + assert script_obj.is_running + assert script_obj.can_cancel + assert script_obj.last_action == event + assert len(events) == 1 + + future = dt_util.utcnow() + timedelta(seconds=5) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + assert not script_obj.is_running + assert len(events) == 2 + + def test_delay_complex_invalid_template(self): + """Test the delay with a complex template that fails.""" + event = 'test_event' + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(event, record_event) + + script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ + {'event': event}, + {'delay': { + 'seconds': '{{ invalid_delay }}' + }}, + {'delay': { + 'seconds': '{{ 5 }}' + }}, + {'event': event}])) + + with mock.patch.object(script, '_LOGGER') as mock_logger: + script_obj.run() + self.hass.block_till_done() + assert mock_logger.error.called + + assert not script_obj.is_running + assert len(events) == 1 + def test_cancel_while_delay(self): """Test the cancelling while the delay is present.""" event = 'test_event' From aeaf6945521ae37a232435c32ee6991bd86e98db Mon Sep 17 00:00:00 2001 From: Evan Bruhn Date: Fri, 21 Sep 2018 20:00:15 +1000 Subject: [PATCH 120/178] Add Logi Circle component, camera and sensor platform (#16540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added Logi Circle platform, camera and sensor * Integrated with Logo Circle API’s feature detection to exclude sensors not supported by device. Added services for recording livestream and taking a snapshot from the livestream. * Migrated livestream snapshot and recording functionality out of home assistant components and into the logi circle API wrapper. Added services.yaml entries for logi services. * Added new Logi sensor types, updated to latest version of `logi_circle` and tidy up in preparation for pull request. - Renamed `logi_set_mode` to `logi_set_config`. - Live stream recording and snapshot methods now respect whitelisted path configuration. - Added `streaming_mode` and `speaker_volume` sensors. - Moved model-specific turn on/off logic to `logi_circle` library. * Renamed `logi` component domain to `logi_circle`. * Updates based on PR feedback * Added timeout of 15s to logi_circle initial setup requests (login and grabbing cameras). * Added unique ID (uses MAC address for camera platform, MAC address + sensor type for sensor platform). * Added battery level and battery charging attributes to camera. * Removed static attributes from device_state_attributes. * Replaced STATE_UNKNOWN with None, replaced ‘on’ & ‘off’ with STATE_ON and STATE_OFF. * Removed redundant SCAN_INTERVAL in sensor, removed redundant hass param from async_setup_platform in camera and sensor. * Style tweaks. * Replaced `asyncio.wait_for` with `async_timeout` to be consistent with other components. --- .coveragerc | 3 + .../components/camera/logi_circle.py | 210 ++++++++++++++++++ homeassistant/components/camera/services.yaml | 36 +++ homeassistant/components/logi_circle.py | 80 +++++++ .../components/sensor/logi_circle.py | 156 +++++++++++++ requirements_all.txt | 3 + 6 files changed, 488 insertions(+) create mode 100644 homeassistant/components/camera/logi_circle.py create mode 100644 homeassistant/components/logi_circle.py create mode 100644 homeassistant/components/sensor/logi_circle.py diff --git a/.coveragerc b/.coveragerc index 39c2bce5ae5d9b..79afac5709fe01 100644 --- a/.coveragerc +++ b/.coveragerc @@ -191,6 +191,9 @@ omit = homeassistant/components/linode.py homeassistant/components/*/linode.py + homeassistant/components/logi_circle.py + homeassistant/components/*/logi_circle.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py diff --git a/homeassistant/components/camera/logi_circle.py b/homeassistant/components/camera/logi_circle.py new file mode 100644 index 00000000000000..1dae58ad0f7af9 --- /dev/null +++ b/homeassistant/components/camera/logi_circle.py @@ -0,0 +1,210 @@ +""" +This component provides support to the Logi Circle camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.logi_circle/ +""" +import logging +import asyncio +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.logi_circle import ( + DOMAIN as LOGI_CIRCLE_DOMAIN, CONF_ATTRIBUTION) +from homeassistant.components.camera import ( + Camera, PLATFORM_SCHEMA, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, + ATTR_ENTITY_ID, ATTR_FILENAME, DOMAIN) +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF) + +DEPENDENCIES = ['logi_circle'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + +SERVICE_SET_CONFIG = 'logi_circle_set_config' +SERVICE_LIVESTREAM_SNAPSHOT = 'logi_circle_livestream_snapshot' +SERVICE_LIVESTREAM_RECORD = 'logi_circle_livestream_record' +DATA_KEY = 'camera.logi_circle' + +BATTERY_SAVING_MODE_KEY = 'BATTERY_SAVING' +PRIVACY_MODE_KEY = 'PRIVACY_MODE' +LED_MODE_KEY = 'LED' + +ATTR_MODE = 'mode' +ATTR_VALUE = 'value' +ATTR_DURATION = 'duration' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, +}) + +LOGI_CIRCLE_SERVICE_SET_CONFIG = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MODE): vol.In([BATTERY_SAVING_MODE_KEY, LED_MODE_KEY, + PRIVACY_MODE_KEY]), + vol.Required(ATTR_VALUE): cv.boolean +}) + +LOGI_CIRCLE_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FILENAME): cv.template +}) + +LOGI_CIRCLE_SERVICE_RECORD = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FILENAME): cv.template, + vol.Required(ATTR_DURATION): cv.positive_int +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up a Logi Circle Camera.""" + devices = hass.data[LOGI_CIRCLE_DOMAIN] + + cameras = [] + for device in devices: + cameras.append(LogiCam(device, config)) + + async_add_entities(cameras, True) + + async def service_handler(service): + """Dispatch service calls to target entities.""" + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_devices = [dev for dev in cameras + if dev.entity_id in entity_ids] + else: + target_devices = cameras + + for target_device in target_devices: + if service.service == SERVICE_SET_CONFIG: + await target_device.set_config(**params) + if service.service == SERVICE_LIVESTREAM_SNAPSHOT: + await target_device.livestream_snapshot(**params) + if service.service == SERVICE_LIVESTREAM_RECORD: + await target_device.download_livestream(**params) + + hass.services.async_register( + DOMAIN, SERVICE_SET_CONFIG, service_handler, + schema=LOGI_CIRCLE_SERVICE_SET_CONFIG) + + hass.services.async_register( + DOMAIN, SERVICE_LIVESTREAM_SNAPSHOT, service_handler, + schema=LOGI_CIRCLE_SERVICE_SNAPSHOT) + + hass.services.async_register( + DOMAIN, SERVICE_LIVESTREAM_RECORD, service_handler, + schema=LOGI_CIRCLE_SERVICE_RECORD) + + +class LogiCam(Camera): + """An implementation of a Logi Circle camera.""" + + def __init__(self, camera, device_info): + """Initialize Logi Circle camera.""" + super().__init__() + self._camera = camera + self._name = self._camera.name + self._id = self._camera.mac_address + self._has_battery = self._camera.supports_feature('battery_level') + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def supported_features(self): + """Logi Circle camera's support turning on and off ("soft" switch).""" + return SUPPORT_ON_OFF + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'battery_saving_mode': ( + STATE_ON if self._camera.battery_saving else STATE_OFF), + 'ip_address': self._camera.ip_address, + 'microphone_gain': self._camera.microphone_gain + } + + # Add battery attributes if camera is battery-powered + if self._has_battery: + state[ATTR_BATTERY_CHARGING] = self._camera.is_charging + state[ATTR_BATTERY_LEVEL] = self._camera.battery_level + + return state + + async def async_camera_image(self): + """Return a still image from the camera.""" + return await self._camera.get_snapshot_image() + + async def async_turn_off(self): + """Disable streaming mode for this camera.""" + await self._camera.set_streaming_mode(False) + + async def async_turn_on(self): + """Enable streaming mode for this camera.""" + await self._camera.set_streaming_mode(True) + + @property + def should_poll(self): + """Update the image periodically.""" + return True + + async def set_config(self, mode, value): + """Set an configuration property for the target camera.""" + if mode == LED_MODE_KEY: + await self._camera.set_led(value) + if mode == PRIVACY_MODE_KEY: + await self._camera.set_privacy_mode(value) + if mode == BATTERY_SAVING_MODE_KEY: + await self._camera.set_battery_saving_mode(value) + + async def download_livestream(self, filename, duration): + """Download a recording from the camera's livestream.""" + # Render filename from template. + filename.hass = self.hass + stream_file = filename.async_render( + variables={ATTR_ENTITY_ID: self.entity_id}) + + # Respect configured path whitelist. + if not self.hass.config.is_allowed_path(stream_file): + _LOGGER.error( + "Can't write %s, no access to path!", stream_file) + return + + asyncio.shield(self._camera.record_livestream( + stream_file, timedelta(seconds=duration)), loop=self.hass.loop) + + async def livestream_snapshot(self, filename): + """Download a still frame from the camera's livestream.""" + # Render filename from template. + filename.hass = self.hass + snapshot_file = filename.async_render( + variables={ATTR_ENTITY_ID: self.entity_id}) + + # Respect configured path whitelist. + if not self.hass.config.is_allowed_path(snapshot_file): + _LOGGER.error( + "Can't write %s, no access to path!", snapshot_file) + return + + asyncio.shield(self._camera.get_livestream_image( + snapshot_file), loop=self.hass.loop) + + async def async_update(self): + """Update camera entity and refresh attributes.""" + await self._camera.update() diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index b977fcd5c52f89..1cae5baf1cfa86 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -63,3 +63,39 @@ onvif_ptz: zoom: description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" example: "ZOOM_IN" + +logi_circle_set_config: + description: Set a configuration property. + fields: + entity_id: + description: Name(s) of entities to apply the operation mode to. + example: "camera.living_room_camera" + mode: + description: "Operation mode. Allowed values: BATTERY_SAVING, LED, PRIVACY_MODE." + example: "PRIVACY_MODE" + value: + description: "Operation value. Allowed values: true, false" + example: true + +logi_circle_livestream_snapshot: + description: Take a snapshot from the camera's livestream. Will wake the camera from sleep if required. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: "camera.living_room_camera" + filename: + description: Template of a Filename. Variable is entity_id. + example: "/tmp/snapshot_{{ entity_id }}.jpg" + +logi_circle_livestream_record: + description: Take a video recording from the camera's livestream. + fields: + entity_id: + description: Name(s) of entities to create recordings from. + example: "camera.living_room_camera" + filename: + description: Template of a Filename. Variable is entity_id. + example: "/tmp/snapshot_{{ entity_id }}.mp4" + duration: + description: Recording duration in seconds. + example: 60 diff --git a/homeassistant/components/logi_circle.py b/homeassistant/components/logi_circle.py new file mode 100644 index 00000000000000..c0a7f4c262178e --- /dev/null +++ b/homeassistant/components/logi_circle.py @@ -0,0 +1,80 @@ +""" +Support for Logi Circle cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/logi_circle/ +""" +import logging +import asyncio + +import voluptuous as vol +import async_timeout + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + +REQUIREMENTS = ['logi_circle==0.1.7'] + +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 15 # seconds + +CONF_ATTRIBUTION = "Data provided by circle.logi.com" + +NOTIFICATION_ID = 'logi_notification' +NOTIFICATION_TITLE = 'Logi Circle Setup' + +DOMAIN = 'logi_circle' +DEFAULT_CACHEDB = '.logi_cache.pickle' +DEFAULT_ENTITY_NAMESPACE = 'logi_circle' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Logi Circle component.""" + conf = config[DOMAIN] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + + try: + from logi_circle import Logi + from logi_circle.exception import BadLogin + from aiohttp.client_exceptions import ClientResponseError + + cache = hass.config.path(DEFAULT_CACHEDB) + logi = Logi(username=username, password=password, cache_file=cache) + + with async_timeout.timeout(_TIMEOUT, loop=hass.loop): + await logi.login() + hass.data[DOMAIN] = await logi.cameras + + if not logi.is_connected: + return False + except (BadLogin, ClientResponseError) as ex: + _LOGGER.error('Unable to connect to Logi Circle API: %s', str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + except asyncio.TimeoutError: + # The TimeoutError exception object returns nothing when casted to a + # string, so we'll handle it separately. + err = '{}s timeout exceeded when connecting to Logi Circle API'.format( + _TIMEOUT) + _LOGGER.error(err) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + return True diff --git a/homeassistant/components/sensor/logi_circle.py b/homeassistant/components/sensor/logi_circle.py new file mode 100644 index 00000000000000..a0a2ca96444aa0 --- /dev/null +++ b/homeassistant/components/sensor/logi_circle.py @@ -0,0 +1,156 @@ +""" +This component provides HA sensor support for Logi Circle cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.logi_circle/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.logi_circle import ( + CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DOMAIN as LOGI_CIRCLE_DOMAIN) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, + STATE_ON, STATE_OFF) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util.dt import as_local + +DEPENDENCIES = ['logi_circle'] + +_LOGGER = logging.getLogger(__name__) + +# Sensor types: Name, unit of measure, icon per sensor key. +SENSOR_TYPES = { + 'battery_level': [ + 'Battery', '%', 'battery-50'], + + 'last_activity_time': [ + 'Last Activity', None, 'history'], + + 'privacy_mode': [ + 'Privacy Mode', None, 'eye'], + + 'signal_strength_category': [ + 'WiFi Signal Category', None, 'wifi'], + + 'signal_strength_percentage': [ + 'WiFi Signal Strength', '%', 'wifi'], + + 'speaker_volume': [ + 'Volume', '%', 'volume-high'], + + 'streaming_mode': [ + 'Streaming Mode', None, 'camera'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up a sensor for a Logi Circle device.""" + devices = hass.data[LOGI_CIRCLE_DOMAIN] + time_zone = str(hass.config.time_zone) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for device in devices: + if device.supports_feature(sensor_type): + sensors.append(LogiSensor(device, time_zone, sensor_type)) + + async_add_entities(sensors, True) + + +class LogiSensor(Entity): + """A sensor implementation for a Logi Circle camera.""" + + def __init__(self, camera, time_zone, sensor_type): + """Initialize a sensor for Logi Circle camera.""" + self._sensor_type = sensor_type + self._camera = camera + self._id = '{}-{}'.format(self._camera.mac_address, self._sensor_type) + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) + self._name = "{0} {1}".format( + self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0]) + self._state = None + self._tz = time_zone + + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'battery_saving_mode': ( + STATE_ON if self._camera.battery_saving else STATE_OFF), + 'ip_address': self._camera.ip_address, + 'microphone_gain': self._camera.microphone_gain + } + + if self._sensor_type == 'battery_level': + state[ATTR_BATTERY_CHARGING] = self._camera.is_charging + + return state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if (self._sensor_type == 'battery_level' and + self._state is not None): + return icon_for_battery_level(battery_level=int(self._state), + charging=False) + if (self._sensor_type == 'privacy_mode' and + self._state is not None): + return 'mdi:eye-off' if self._state == STATE_ON else 'mdi:eye' + if (self._sensor_type == 'streaming_mode' and + self._state is not None): + return ( + 'mdi:camera' if self._state == STATE_ON else 'mdi:camera-off') + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + async def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Pulling data from %s sensor", self._name) + await self._camera.update() + + if self._sensor_type == 'last_activity_time': + last_activity = await self._camera.last_activity + if last_activity is not None: + last_activity_time = as_local(last_activity.end_time_utc) + self._state = '{0:0>2}:{1:0>2}'.format( + last_activity_time.hour, last_activity_time.minute) + else: + state = getattr(self._camera, self._sensor_type, None) + if isinstance(state, bool): + self._state = STATE_ON if state is True else STATE_OFF + else: + self._state = state diff --git a/requirements_all.txt b/requirements_all.txt index 1758e96c6431b9..2c9871962f9360 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -556,6 +556,9 @@ lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps locationsharinglib==2.0.11 +# homeassistant.components.logi_circle +logi_circle==0.1.7 + # homeassistant.components.sensor.luftdaten luftdaten==0.2.0 From 9fdf123a2fa290f7d39f7f6564e327ffb415f1e7 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 21 Sep 2018 06:11:23 -0400 Subject: [PATCH 121/178] Zha switch schedule update state (#16621) * switch.zha: Schedule state update. if turning switch On or Off operation was successful, then schedule state update * switch.zha: Update debug logging. --- homeassistant/components/switch/zha.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 4fde35653482cb..68a94cc1ca514c 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -44,7 +44,10 @@ class Switch(zha.Entity, SwitchDevice): def attribute_updated(self, attribute, value): """Handle attribute update from device.""" - _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) + cluster = self._endpoint.on_off + attr_name = cluster.attributes.get(attribute, [attribute])[0] + _LOGGER.debug("%s: Attribute '%s' on cluster '%s' updated to %s", + self.entity_id, attr_name, cluster.ep_attribute, value) if attribute == self.value_attribute: self._state = value self.async_schedule_update_ha_state() @@ -65,23 +68,29 @@ async def async_turn_on(self, **kwargs): """Turn the entity on.""" from zigpy.exceptions import DeliveryError try: - await self._endpoint.on_off.on() + res = await self._endpoint.on_off.on() + _LOGGER.debug("%s: turned 'on': %s", self.entity_id, res[1]) except DeliveryError as ex: - _LOGGER.error("Unable to turn the switch on: %s", ex) + _LOGGER.error("%s: Unable to turn the switch on: %s", + self.entity_id, ex) return self._state = 1 + self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" from zigpy.exceptions import DeliveryError try: - await self._endpoint.on_off.off() + res = await self._endpoint.on_off.off() + _LOGGER.debug("%s: turned 'off': %s", self.entity_id, res[1]) except DeliveryError as ex: - _LOGGER.error("Unable to turn the switch off: %s", ex) + _LOGGER.error("%s: Unable to turn the switch off: %s", + self.entity_id, ex) return self._state = 0 + self.async_schedule_update_ha_state() async def async_update(self): """Retrieve latest state.""" From 3bfe9e757ef950d7c4a80d6c8c6ef582d36758ec Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 21 Sep 2018 12:51:02 +0200 Subject: [PATCH 122/178] Add Carbon Monoxide HomeKit Sensor (#16664) --- homeassistant/components/homekit/__init__.py | 8 ++- homeassistant/components/homekit/const.py | 7 +++ .../components/homekit/type_sensors.py | 47 +++++++++++--- .../homekit/test_get_accessories.py | 2 + tests/components/homekit/test_type_sensors.py | 62 ++++++++++++++++--- 5 files changed, 105 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index eac02855b0bc6c..8c12243ee8fe80 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -22,9 +22,9 @@ from homeassistant.util.decorator import Registry from .const import ( BRIDGE_NAME, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, - CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, - DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, - TYPE_OUTLET, TYPE_SWITCH) + CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, + SERVICE_HOMEKIT_START, TYPE_OUTLET, TYPE_SWITCH) from .util import ( show_setup_message, validate_entity_config, validate_media_player_features) @@ -150,6 +150,8 @@ def get_accessory(hass, driver, state, aid, config): elif device_class == DEVICE_CLASS_PM25 \ or DEVICE_CLASS_PM25 in state.entity_id: a_type = 'AirQualitySensor' + elif device_class == DEVICE_CLASS_CO: + a_type = 'CarbonMonoxideSensor' elif device_class == DEVICE_CLASS_CO2 \ or DEVICE_CLASS_CO2 in state.entity_id: a_type = 'CarbonDioxideSensor' diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 33d2c0bfb85004..df488d4a73a2ae 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -69,6 +69,8 @@ CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' +CHAR_CARBON_MONOXIDE_LEVEL = 'CarbonMonoxideLevel' +CHAR_CARBON_MONOXIDE_PEAK_LEVEL = 'CarbonMonoxidePeakLevel' CHAR_CHARGING_STATE = 'ChargingState' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' @@ -114,6 +116,7 @@ PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} # #### Device Classes #### +DEVICE_CLASS_CO = 'co' DEVICE_CLASS_CO2 = 'co2' DEVICE_CLASS_DOOR = 'door' DEVICE_CLASS_GARAGE_DOOR = 'garage_door' @@ -125,3 +128,7 @@ DEVICE_CLASS_PM25 = 'pm25' DEVICE_CLASS_SMOKE = 'smoke' DEVICE_CLASS_WINDOW = 'window' + +# #### Thresholds #### +THRESHOLD_CO = 25 +THRESHOLD_CO2 = 1000 diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index d4c2cb58209c5b..d2101b1e6f93e9 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -13,6 +13,7 @@ CHAR_AIR_PARTICULATE_DENSITY, CHAR_AIR_QUALITY, CHAR_CARBON_DIOXIDE_DETECTED, CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL, CHAR_CARBON_MONOXIDE_DETECTED, + CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL, CHAR_CONTACT_SENSOR_STATE, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_LEAK_DETECTED, CHAR_MOTION_DETECTED, CHAR_OCCUPANCY_DETECTED, CHAR_SMOKE_DETECTED, @@ -23,7 +24,7 @@ SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR, SERV_CONTACT_SENSOR, SERV_HUMIDITY_SENSOR, SERV_LEAK_SENSOR, SERV_LIGHT_SENSOR, SERV_MOTION_SENSOR, SERV_OCCUPANCY_SENSOR, - SERV_SMOKE_SENSOR, SERV_TEMPERATURE_SENSOR) + SERV_SMOKE_SENSOR, SERV_TEMPERATURE_SENSOR, THRESHOLD_CO, THRESHOLD_CO2) from .util import ( convert_to_float, temperature_to_homekit, density_to_air_quality) @@ -114,6 +115,34 @@ def update_state(self, new_state): _LOGGER.debug('%s: Set to %d', self.entity_id, density) +@TYPES.register('CarbonMonoxideSensor') +class CarbonMonoxideSensor(HomeAccessory): + """Generate a CarbonMonoxidSensor accessory as CO sensor.""" + + def __init__(self, *args): + """Initialize a CarbonMonoxideSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_co = self.add_preload_service(SERV_CARBON_MONOXIDE_SENSOR, [ + CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL]) + self.char_level = serv_co.configure_char( + CHAR_CARBON_MONOXIDE_LEVEL, value=0) + self.char_peak = serv_co.configure_char( + CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0) + self.char_detected = serv_co.configure_char( + CHAR_CARBON_MONOXIDE_DETECTED, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + value = convert_to_float(new_state.state) + if value: + self.char_level.set_value(value) + if value > self.char_peak.value: + self.char_peak.set_value(value) + self.char_detected.set_value(value > THRESHOLD_CO) + _LOGGER.debug('%s: Set to %d', self.entity_id, value) + + @TYPES.register('CarbonDioxideSensor') class CarbonDioxideSensor(HomeAccessory): """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" @@ -124,7 +153,7 @@ def __init__(self, *args): serv_co2 = self.add_preload_service(SERV_CARBON_DIOXIDE_SENSOR, [ CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL]) - self.char_co2 = serv_co2.configure_char( + self.char_level = serv_co2.configure_char( CHAR_CARBON_DIOXIDE_LEVEL, value=0) self.char_peak = serv_co2.configure_char( CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0) @@ -133,13 +162,13 @@ def __init__(self, *args): def update_state(self, new_state): """Update accessory after state change.""" - co2 = convert_to_float(new_state.state) - if co2: - self.char_co2.set_value(co2) - if co2 > self.char_peak.value: - self.char_peak.set_value(co2) - self.char_detected.set_value(co2 > 1000) - _LOGGER.debug('%s: Set to %d', self.entity_id, co2) + value = convert_to_float(new_state.state) + if value: + self.char_level.set_value(value) + if value > self.char_peak.value: + self.char_peak.set_value(value) + self.char_detected.set_value(value > THRESHOLD_CO2) + _LOGGER.debug('%s: Set to %d', self.entity_id, value) @TYPES.register('LightSensor') diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 92f8736d1fe753..5b76618d4607e3 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -106,6 +106,8 @@ def test_type_covers(type_name, entity_id, state, attrs): ('AirQualitySensor', 'sensor.air_quality_pm25', '40', {}), ('AirQualitySensor', 'sensor.air_quality', '40', {ATTR_DEVICE_CLASS: 'pm25'}), + ('CarbonMonoxideSensor', 'sensor.airmeter', '2', + {ATTR_DEVICE_CLASS: 'co'}), ('CarbonDioxideSensor', 'sensor.airmeter_co2', '500', {}), ('CarbonDioxideSensor', 'sensor.airmeter', '500', {ATTR_DEVICE_CLASS: 'co2'}), diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 901a8e768563c7..ebc1c3e1306a4f 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -1,8 +1,9 @@ """Test different accessory types: Sensors.""" -from homeassistant.components.homekit.const import PROP_CELSIUS +from homeassistant.components.homekit.const import ( + PROP_CELSIUS, THRESHOLD_CO, THRESHOLD_CO2) from homeassistant.components.homekit.type_sensors import ( - AirQualitySensor, BinarySensor, CarbonDioxideSensor, HumiditySensor, - LightSensor, TemperatureSensor, BINARY_SENSOR_SERVICE_MAP) + AirQualitySensor, BinarySensor, CarbonMonoxideSensor, CarbonDioxideSensor, + HumiditySensor, LightSensor, TemperatureSensor, BINARY_SENSOR_SERVICE_MAP) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -94,6 +95,45 @@ async def test_air_quality(hass, hk_driver): assert acc.char_quality.value == 5 +async def test_co(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.co' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = CarbonMonoxideSensor(hass, hk_driver, 'CO', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_level.value == 0 + assert acc.char_peak.value == 0 + assert acc.char_detected.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_level.value == 0 + assert acc.char_peak.value == 0 + assert acc.char_detected.value == 0 + + value = 32 + assert value > THRESHOLD_CO + hass.states.async_set(entity_id, str(value)) + await hass.async_block_till_done() + assert acc.char_level.value == 32 + assert acc.char_peak.value == 32 + assert acc.char_detected.value == 1 + + value = 10 + assert value < THRESHOLD_CO + hass.states.async_set(entity_id, str(value)) + await hass.async_block_till_done() + assert acc.char_level.value == 10 + assert acc.char_peak.value == 32 + assert acc.char_detected.value == 0 + + async def test_co2(hass, hk_driver): """Test if accessory is updated after state change.""" entity_id = 'sensor.co2' @@ -106,25 +146,29 @@ async def test_co2(hass, hk_driver): assert acc.aid == 2 assert acc.category == 10 # Sensor - assert acc.char_co2.value == 0 + assert acc.char_level.value == 0 assert acc.char_peak.value == 0 assert acc.char_detected.value == 0 hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() - assert acc.char_co2.value == 0 + assert acc.char_level.value == 0 assert acc.char_peak.value == 0 assert acc.char_detected.value == 0 - hass.states.async_set(entity_id, '1100') + value = 1100 + assert value > THRESHOLD_CO2 + hass.states.async_set(entity_id, str(value)) await hass.async_block_till_done() - assert acc.char_co2.value == 1100 + assert acc.char_level.value == 1100 assert acc.char_peak.value == 1100 assert acc.char_detected.value == 1 - hass.states.async_set(entity_id, '800') + value = 800 + assert value < THRESHOLD_CO2 + hass.states.async_set(entity_id, str(value)) await hass.async_block_till_done() - assert acc.char_co2.value == 800 + assert acc.char_level.value == 800 assert acc.char_peak.value == 1100 assert acc.char_detected.value == 0 From a0a54dfd5beb1ed24bd29d690d18503a571583a9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 21 Sep 2018 13:09:54 +0200 Subject: [PATCH 123/178] Add unique_id to mqtt camera (#16569) * Add unique_id to mqtt camera * Remove whitespaces * Add test for unique_id * Add blank line --- homeassistant/components/camera/mqtt.py | 11 ++++++++++- tests/components/camera/test_mqtt.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py index cf5c969c6505ff..13c1745615d1b1 100644 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -19,12 +19,14 @@ _LOGGER = logging.getLogger(__name__) CONF_TOPIC = 'topic' +CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Camera' DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string }) @@ -38,6 +40,7 @@ def async_setup_platform(hass, config, async_add_entities, async_add_entities([MqttCamera( config.get(CONF_NAME), + config.get(CONF_UNIQUE_ID), config.get(CONF_TOPIC) )]) @@ -45,11 +48,12 @@ def async_setup_platform(hass, config, async_add_entities, class MqttCamera(Camera): """representation of a MQTT camera.""" - def __init__(self, name, topic): + def __init__(self, name, unique_id, topic): """Initialize the MQTT Camera.""" super().__init__() self._name = name + self._unique_id = unique_id self._topic = topic self._qos = 0 self._last_image = None @@ -64,6 +68,11 @@ def name(self): """Return the name of this camera.""" return self._name + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @asyncio.coroutine def async_added_to_hass(self): """Subscribe MQTT events.""" diff --git a/tests/components/camera/test_mqtt.py b/tests/components/camera/test_mqtt.py index d83054d7732622..8665f26aba9439 100644 --- a/tests/components/camera/test_mqtt.py +++ b/tests/components/camera/test_mqtt.py @@ -29,3 +29,26 @@ def test_run_camera_setup(hass, aiohttp_client): assert resp.status == 200 body = yield from resp.text() assert body == 'beer' + + +@asyncio.coroutine +def test_unique_id(hass): + """Test unique id option only creates one camera per unique_id.""" + yield from async_mock_mqtt_component(hass) + yield from async_setup_component(hass, 'camera', { + 'camera': [{ + 'platform': 'mqtt', + 'name': 'Test Camera 1', + 'topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test Camera 2', + 'topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + async_fire_mqtt_message(hass, 'test-topic', 'payload') + yield from hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 From 3e59ffb33a1542bf94199160f17de53fdec05abf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Sep 2018 14:47:52 +0200 Subject: [PATCH 124/178] Add tradfri device info (#16768) --- homeassistant/components/light/tradfri.py | 20 +++++++++++++++++++- homeassistant/components/nest/local_auth.py | 3 ++- homeassistant/components/tradfri/__init__.py | 19 +++++++++++++++++-- tests/common.py | 2 +- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index bd432b5dedce55..b62900b204c6db 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -13,7 +13,8 @@ SUPPORT_COLOR, Light) from homeassistant.components.light import \ PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_API +from homeassistant.components.tradfri import ( + KEY_GATEWAY, KEY_API, DOMAIN as TRADFRI_DOMAIN) from homeassistant.components.tradfri.const import ( CONF_IMPORT_GROUPS, CONF_GATEWAY_ID) import homeassistant.util.color as color_util @@ -161,6 +162,7 @@ def __init__(self, light, api, gateway_id): self._hs_color = None self._features = SUPPORTED_FEATURES self._available = True + self._gateway_id = gateway_id self._refresh(light) @@ -169,6 +171,22 @@ def unique_id(self): """Return unique ID for light.""" return self._unique_id + @property + def device_info(self): + """Return the device info.""" + info = self._light.device_info + + return { + 'identifiers': { + (TRADFRI_DOMAIN, self._light.id) + }, + 'name': self._name, + 'manufacturer': info.manufacturer, + 'model': info.model_number, + 'sw_version': info.firmware_version, + 'via_hub': (TRADFRI_DOMAIN, self._gateway_id), + } + @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" diff --git a/homeassistant/components/nest/local_auth.py b/homeassistant/components/nest/local_auth.py index 5ab10cc2a5e25c..393a36e4a9ce2a 100644 --- a/homeassistant/components/nest/local_auth.py +++ b/homeassistant/components/nest/local_auth.py @@ -11,7 +11,8 @@ def initialize(hass, client_id, client_secret): """Initialize a local auth provider.""" config_flow.register_flow_implementation( - hass, DOMAIN, 'local', partial(generate_auth_url, client_id), + hass, DOMAIN, 'configuration.yaml', + partial(generate_auth_url, client_id), partial(resolve_auth_code, hass, client_id, client_secret) ) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 771f2b44c3dd68..079381f8b451ef 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -12,7 +12,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json -from .const import CONF_IMPORT_GROUPS, CONF_IDENTITY, CONF_HOST, CONF_KEY +from .const import ( + CONF_IMPORT_GROUPS, CONF_IDENTITY, CONF_HOST, CONF_KEY, CONF_GATEWAY_ID) from . import config_flow # noqa pylint_disable=unused-import @@ -84,7 +85,7 @@ async def async_setup_entry(hass, entry): gateway = Gateway() try: - await api(gateway.get_gateway_info()) + gateway_info = await api(gateway.get_gateway_info()) except RequestError: _LOGGER.error("Tradfri setup failed.") return False @@ -92,6 +93,20 @@ async def async_setup_entry(hass, entry): hass.data.setdefault(KEY_API, {})[entry.entry_id] = api hass.data.setdefault(KEY_GATEWAY, {})[entry.entry_id] = gateway + dev_reg = await hass.helpers.device_registry.async_get_registry() + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={ + (DOMAIN, entry.data[CONF_GATEWAY_ID]) + }, + manufacturer='IKEA', + name='Gateway', + # They just have 1 gateway model. Type is not exposed yet. + model='E1526', + sw_version=gateway_info.firmware_version, + ) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, 'light' )) diff --git a/tests/common.py b/tests/common.py index 56e86a4cd5c166..287f1e24ee50b5 100644 --- a/tests/common.py +++ b/tests/common.py @@ -322,7 +322,7 @@ def mock_component(hass, component): def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) - registry.entities = mock_entries or {} + registry.entities = mock_entries or OrderedDict() async def _get_reg(): return registry From d5813cf167dc4899e241c823d71243375c5d40c4 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Fri, 21 Sep 2018 23:24:50 +0930 Subject: [PATCH 125/178] Make rest sensor and binary sensor more efficient (#14484) * create binary sensor even if initial update fails * fixed broken test assertion * fixed broken test assertion * avoid fetching resource twice - manually in the setup_platform and then through add_devices * raising PlatformNotReady instead of creating the sensor if the initial rest call fails; throttling the update to avoid fetching the same resource again immediately after setting up sensor * rolled back throttling of the rest update call; can still avoid updating the binary sensor's rest resoure twice; fixed tests * typo --- .../components/binary_sensor/rest.py | 9 +- homeassistant/components/sensor/rest.py | 6 + tests/components/binary_sensor/test_rest.py | 117 ++++++++++-------- tests/components/sensor/test_rest.py | 100 ++++++++------- 4 files changed, 129 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index 412aeb46a3a423..ac82ab126fd407 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -18,6 +18,7 @@ CONF_HEADERS, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_DEVICE_CLASS) import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady _LOGGER = logging.getLogger(__name__) @@ -66,13 +67,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): rest = RestData(method, resource, auth, headers, payload, verify_ssl) rest.update() - if rest.data is None: - _LOGGER.error("Unable to fetch REST data from %s", resource) - return False + raise PlatformNotReady + # No need to update the sensor now because it will determine its state + # based in the rest resource that has just been retrieved. add_entities([RestBinarySensor( - hass, rest, name, device_class, value_template)], True) + hass, rest, name, device_class, value_template)]) class RestBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 53aab3f1ff798c..f2fbe6cd191600 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -18,6 +18,7 @@ CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -76,7 +77,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): auth = None rest = RestData(method, resource, auth, headers, payload, verify_ssl) rest.update() + if rest.data is None: + raise PlatformNotReady + # Must update the sensor now (including fetching the rest resource) to + # ensure it's updating its state. add_entities([RestSensor( hass, rest, name, unit, value_template, json_attrs, force_update )], True) @@ -170,6 +175,7 @@ def __init__(self, method, resource, auth, headers, data, verify_ssl): def update(self): """Get the latest data from REST service with provided method.""" + _LOGGER.debug("Updating from %s", self._request.url) try: with requests.Session() as sess: response = sess.send( diff --git a/tests/components/binary_sensor/test_rest.py b/tests/components/binary_sensor/test_rest.py index d1c266244520c4..db70303e217ad7 100644 --- a/tests/components/binary_sensor/test_rest.py +++ b/tests/components/binary_sensor/test_rest.py @@ -1,11 +1,13 @@ """The tests for the REST binary sensor platform.""" import unittest +from pytest import raises from unittest.mock import patch, Mock import requests from requests.exceptions import Timeout, MissingSchema import requests_mock +from homeassistant.exceptions import PlatformNotReady from homeassistant.setup import setup_component import homeassistant.components.binary_sensor as binary_sensor import homeassistant.components.binary_sensor.rest as rest @@ -18,9 +20,18 @@ class TestRestBinarySensorSetup(unittest.TestCase): """Tests for setting up the REST binary sensor platform.""" + DEVICES = [] + + def add_devices(self, devices, update_before_add=False): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + def setUp(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + # Reset for this test. + self.DEVICES = [] def tearDown(self): """Stop everything that was started.""" @@ -45,76 +56,80 @@ def test_setup_missing_schema(self): side_effect=requests.exceptions.ConnectionError()) def test_setup_failed_connect(self, mock_req): """Test setup when connection error occurs.""" - self.assertFalse(rest.setup_platform(self.hass, { - 'platform': 'rest', - 'resource': 'http://localhost', - }, lambda devices, update=True: None)) + with raises(PlatformNotReady): + rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'http://localhost', + }, self.add_devices, None) + self.assertEqual(len(self.DEVICES), 0) @patch('requests.Session.send', side_effect=Timeout()) def test_setup_timeout(self, mock_req): """Test setup when connection timeout occurs.""" - self.assertFalse(rest.setup_platform(self.hass, { - 'platform': 'rest', - 'resource': 'http://localhost', - }, lambda devices, update=True: None)) + with raises(PlatformNotReady): + rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'http://localhost', + }, self.add_devices, None) + self.assertEqual(len(self.DEVICES), 0) @requests_mock.Mocker() def test_setup_minimum(self, mock_req): """Test setup with minimum configuration.""" mock_req.get('http://localhost', status_code=200) - self.assertTrue(setup_component(self.hass, 'binary_sensor', { - 'binary_sensor': { - 'platform': 'rest', - 'resource': 'http://localhost' - } - })) - self.assertEqual(2, mock_req.call_count) - assert_setup_component(1, 'switch') + with assert_setup_component(1, 'binary_sensor'): + self.assertTrue(setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'rest', + 'resource': 'http://localhost' + } + })) + self.assertEqual(1, mock_req.call_count) @requests_mock.Mocker() def test_setup_get(self, mock_req): """Test setup with valid configuration.""" mock_req.get('http://localhost', status_code=200) - self.assertTrue(setup_component(self.hass, 'binary_sensor', { - 'binary_sensor': { - 'platform': 'rest', - 'resource': 'http://localhost', - 'method': 'GET', - 'value_template': '{{ value_json.key }}', - 'name': 'foo', - 'unit_of_measurement': 'MB', - 'verify_ssl': 'true', - 'authentication': 'basic', - 'username': 'my username', - 'password': 'my password', - 'headers': {'Accept': 'application/json'} - } - })) - self.assertEqual(2, mock_req.call_count) - assert_setup_component(1, 'binary_sensor') + with assert_setup_component(1, 'binary_sensor'): + self.assertTrue(setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'rest', + 'resource': 'http://localhost', + 'method': 'GET', + 'value_template': '{{ value_json.key }}', + 'name': 'foo', + 'unit_of_measurement': 'MB', + 'verify_ssl': 'true', + 'authentication': 'basic', + 'username': 'my username', + 'password': 'my password', + 'headers': {'Accept': 'application/json'} + } + })) + self.assertEqual(1, mock_req.call_count) @requests_mock.Mocker() def test_setup_post(self, mock_req): """Test setup with valid configuration.""" mock_req.post('http://localhost', status_code=200) - self.assertTrue(setup_component(self.hass, 'binary_sensor', { - 'binary_sensor': { - 'platform': 'rest', - 'resource': 'http://localhost', - 'method': 'POST', - 'value_template': '{{ value_json.key }}', - 'payload': '{ "device": "toaster"}', - 'name': 'foo', - 'unit_of_measurement': 'MB', - 'verify_ssl': 'true', - 'authentication': 'basic', - 'username': 'my username', - 'password': 'my password', - 'headers': {'Accept': 'application/json'} - } - })) - self.assertEqual(2, mock_req.call_count) - assert_setup_component(1, 'binary_sensor') + with assert_setup_component(1, 'binary_sensor'): + self.assertTrue(setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'rest', + 'resource': 'http://localhost', + 'method': 'POST', + 'value_template': '{{ value_json.key }}', + 'payload': '{ "device": "toaster"}', + 'name': 'foo', + 'unit_of_measurement': 'MB', + 'verify_ssl': 'true', + 'authentication': 'basic', + 'username': 'my username', + 'password': 'my password', + 'headers': {'Accept': 'application/json'} + } + })) + self.assertEqual(1, mock_req.call_count) class TestRestBinarySensor(unittest.TestCase): diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 4d40ad394cdaab..7f818193a29a86 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -1,11 +1,13 @@ """The tests for the REST sensor platform.""" import unittest +from pytest import raises from unittest.mock import patch, Mock import requests from requests.exceptions import Timeout, MissingSchema, RequestException import requests_mock +from homeassistant.exceptions import PlatformNotReady from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor import homeassistant.components.sensor.rest as rest @@ -45,76 +47,78 @@ def test_setup_missing_schema(self): side_effect=requests.exceptions.ConnectionError()) def test_setup_failed_connect(self, mock_req): """Test setup when connection error occurs.""" - self.assertTrue(rest.setup_platform(self.hass, { - 'platform': 'rest', - 'resource': 'http://localhost', - }, lambda devices, update=True: None) is None) + with raises(PlatformNotReady): + rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'http://localhost', + }, lambda devices, update=True: None) @patch('requests.Session.send', side_effect=Timeout()) def test_setup_timeout(self, mock_req): """Test setup when connection timeout occurs.""" - self.assertTrue(rest.setup_platform(self.hass, { - 'platform': 'rest', - 'resource': 'http://localhost', - }, lambda devices, update=True: None) is None) + with raises(PlatformNotReady): + rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'http://localhost', + }, lambda devices, update=True: None) @requests_mock.Mocker() def test_setup_minimum(self, mock_req): """Test setup with minimum configuration.""" mock_req.get('http://localhost', status_code=200) - self.assertTrue(setup_component(self.hass, 'sensor', { - 'sensor': { - 'platform': 'rest', - 'resource': 'http://localhost' - } - })) + with assert_setup_component(1, 'sensor'): + self.assertTrue(setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'rest', + 'resource': 'http://localhost' + } + })) self.assertEqual(2, mock_req.call_count) - assert_setup_component(1, 'switch') @requests_mock.Mocker() def test_setup_get(self, mock_req): """Test setup with valid configuration.""" mock_req.get('http://localhost', status_code=200) - self.assertTrue(setup_component(self.hass, 'sensor', { - 'sensor': { - 'platform': 'rest', - 'resource': 'http://localhost', - 'method': 'GET', - 'value_template': '{{ value_json.key }}', - 'name': 'foo', - 'unit_of_measurement': 'MB', - 'verify_ssl': 'true', - 'authentication': 'basic', - 'username': 'my username', - 'password': 'my password', - 'headers': {'Accept': 'application/json'} - } - })) + with assert_setup_component(1, 'sensor'): + self.assertTrue(setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'rest', + 'resource': 'http://localhost', + 'method': 'GET', + 'value_template': '{{ value_json.key }}', + 'name': 'foo', + 'unit_of_measurement': 'MB', + 'verify_ssl': 'true', + 'authentication': 'basic', + 'username': 'my username', + 'password': 'my password', + 'headers': {'Accept': 'application/json'} + } + })) self.assertEqual(2, mock_req.call_count) - assert_setup_component(1, 'sensor') @requests_mock.Mocker() def test_setup_post(self, mock_req): """Test setup with valid configuration.""" mock_req.post('http://localhost', status_code=200) - self.assertTrue(setup_component(self.hass, 'sensor', { - 'sensor': { - 'platform': 'rest', - 'resource': 'http://localhost', - 'method': 'POST', - 'value_template': '{{ value_json.key }}', - 'payload': '{ "device": "toaster"}', - 'name': 'foo', - 'unit_of_measurement': 'MB', - 'verify_ssl': 'true', - 'authentication': 'basic', - 'username': 'my username', - 'password': 'my password', - 'headers': {'Accept': 'application/json'} - } - })) + with assert_setup_component(1, 'sensor'): + self.assertTrue(setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'rest', + 'resource': 'http://localhost', + 'method': 'POST', + 'value_template': '{{ value_json.key }}', + 'payload': '{ "device": "toaster"}', + 'name': 'foo', + 'unit_of_measurement': 'MB', + 'verify_ssl': 'true', + 'authentication': 'basic', + 'username': 'my username', + 'password': 'my password', + 'headers': {'Accept': 'application/json'} + } + })) self.assertEqual(2, mock_req.call_count) - assert_setup_component(1, 'sensor') class TestRestSensor(unittest.TestCase): From 213171769d63a77c173ed5fbec050896dea80c52 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Fri, 21 Sep 2018 15:55:07 +0200 Subject: [PATCH 126/178] Refactored units and icons for the Dyson sensors (#14550) * Refactored units and icons for the Dyson sensors * Adapted unit tests to the new device names and unit of measurements * Use None as empty unit of measurement * Unrelated overall improvements following code review * Adapted tests to new constructors as per previous commit * Make sure the sensors have their `hass` attribute set in the test environment --- homeassistant/components/sensor/dyson.py | 100 +++++++++++------------ tests/components/sensor/test_dyson.py | 92 +++++++++++---------- 2 files changed, 97 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/sensor/dyson.py b/homeassistant/components/sensor/dyson.py index 0619e3f606997b..4097bff32bfbf8 100644 --- a/homeassistant/components/sensor/dyson.py +++ b/homeassistant/components/sensor/dyson.py @@ -14,12 +14,20 @@ DEPENDENCIES = ['dyson'] SENSOR_UNITS = { - 'air_quality': 'level', - 'dust': 'level', + 'air_quality': None, + 'dust': None, 'filter_life': 'hours', 'humidity': '%', } +SENSOR_ICONS = { + 'air_quality': 'mdi:fan', + 'dust': 'mdi:cloud', + 'filter_life': 'mdi:filter-outline', + 'humidity': 'mdi:water-percent', + 'temperature': 'mdi:thermometer', +} + _LOGGER = logging.getLogger(__name__) @@ -32,23 +40,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink for device in [d for d in hass.data[DYSON_DEVICES] if isinstance(d, DysonPureCoolLink)]: - devices.append(DysonFilterLifeSensor(hass, device)) - devices.append(DysonDustSensor(hass, device)) - devices.append(DysonHumiditySensor(hass, device)) - devices.append(DysonTemperatureSensor(hass, device, unit)) - devices.append(DysonAirQualitySensor(hass, device)) + devices.append(DysonFilterLifeSensor(device)) + devices.append(DysonDustSensor(device)) + devices.append(DysonHumiditySensor(device)) + devices.append(DysonTemperatureSensor(device, unit)) + devices.append(DysonAirQualitySensor(device)) add_entities(devices) class DysonSensor(Entity): - """Representation of Dyson sensor.""" + """Representation of a generic Dyson sensor.""" - def __init__(self, hass, device): - """Create a new Dyson filter life sensor.""" - self.hass = hass + def __init__(self, device, sensor_type): + """Create a new generic Dyson sensor.""" self._device = device self._old_value = None self._name = None + self._sensor_type = sensor_type @asyncio.coroutine def async_added_to_hass(self): @@ -72,17 +80,27 @@ def should_poll(self): @property def name(self): - """Return the name of the dyson sensor name.""" + """Return the name of the Dyson sensor name.""" return self._name + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return SENSOR_UNITS[self._sensor_type] + + @property + def icon(self): + """Return the icon for this sensor.""" + return SENSOR_ICONS[self._sensor_type] + class DysonFilterLifeSensor(DysonSensor): - """Representation of Dyson filter life sensor (in hours).""" + """Representation of Dyson Filter Life sensor (in hours).""" - def __init__(self, hass, device): - """Create a new Dyson filter life sensor.""" - DysonSensor.__init__(self, hass, device) - self._name = "{} filter life".format(self._device.name) + def __init__(self, device): + """Create a new Dyson Filter Life sensor.""" + super().__init__(device, 'filter_life') + self._name = "{} Filter Life".format(self._device.name) @property def state(self): @@ -91,19 +109,14 @@ def state(self): return int(self._device.state.filter_life) return None - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_UNITS['filter_life'] - class DysonDustSensor(DysonSensor): """Representation of Dyson Dust sensor (lower is better).""" - def __init__(self, hass, device): + def __init__(self, device): """Create a new Dyson Dust sensor.""" - DysonSensor.__init__(self, hass, device) - self._name = "{} dust".format(self._device.name) + super().__init__(device, 'dust') + self._name = "{} Dust".format(self._device.name) @property def state(self): @@ -112,47 +125,37 @@ def state(self): return self._device.environmental_state.dust return None - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_UNITS['dust'] - class DysonHumiditySensor(DysonSensor): """Representation of Dyson Humidity sensor.""" - def __init__(self, hass, device): + def __init__(self, device): """Create a new Dyson Humidity sensor.""" - DysonSensor.__init__(self, hass, device) - self._name = "{} humidity".format(self._device.name) + super().__init__(device, 'humidity') + self._name = "{} Humidity".format(self._device.name) @property def state(self): - """Return Dust value.""" + """Return Humidity value.""" if self._device.environmental_state: if self._device.environmental_state.humidity == 0: return STATE_OFF return self._device.environmental_state.humidity return None - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_UNITS['humidity'] - class DysonTemperatureSensor(DysonSensor): """Representation of Dyson Temperature sensor.""" - def __init__(self, hass, device, unit): + def __init__(self, device, unit): """Create a new Dyson Temperature sensor.""" - DysonSensor.__init__(self, hass, device) - self._name = "{} temperature".format(self._device.name) + super().__init__(device, 'temperature') + self._name = "{} Temperature".format(self._device.name) self._unit = unit @property def state(self): - """Return Dust value.""" + """Return Temperature value.""" if self._device.environmental_state: temperature_kelvin = self._device.environmental_state.temperature if temperature_kelvin == 0: @@ -171,10 +174,10 @@ def unit_of_measurement(self): class DysonAirQualitySensor(DysonSensor): """Representation of Dyson Air Quality sensor (lower is better).""" - def __init__(self, hass, device): + def __init__(self, device): """Create a new Dyson Air Quality sensor.""" - DysonSensor.__init__(self, hass, device) - self._name = "{} air quality".format(self._device.name) + super().__init__(device, 'air_quality') + self._name = "{} AQI".format(self._device.name) @property def state(self): @@ -182,8 +185,3 @@ def state(self): if self._device.environmental_state: return self._device.environmental_state.volatil_organic_compounds return None - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_UNITS['air_quality'] diff --git a/tests/components/sensor/test_dyson.py b/tests/components/sensor/test_dyson.py index 5b5be4a587d8b1..baab96a61f0f8d 100644 --- a/tests/components/sensor/test_dyson.py +++ b/tests/components/sensor/test_dyson.py @@ -70,11 +70,11 @@ def test_setup_component(self): """Test setup component with devices.""" def _add_device(devices): assert len(devices) == 5 - assert devices[0].name == "Device_name filter life" - assert devices[1].name == "Device_name dust" - assert devices[2].name == "Device_name humidity" - assert devices[3].name == "Device_name temperature" - assert devices[4].name == "Device_name air quality" + assert devices[0].name == "Device_name Filter Life" + assert devices[1].name == "Device_name Dust" + assert devices[2].name == "Device_name Humidity" + assert devices[3].name == "Device_name Temperature" + assert devices[4].name == "Device_name AQI" device_fan = _get_device_without_state() device_non_fan = _get_with_state() @@ -83,143 +83,147 @@ def _add_device(devices): def test_dyson_filter_life_sensor(self): """Test filter life sensor with no value.""" - sensor = dyson.DysonFilterLifeSensor(self.hass, - _get_device_without_state()) + sensor = dyson.DysonFilterLifeSensor(_get_device_without_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertIsNone(sensor.state) self.assertEqual(sensor.unit_of_measurement, "hours") - self.assertEqual(sensor.name, "Device_name filter life") + self.assertEqual(sensor.name, "Device_name Filter Life") self.assertEqual(sensor.entity_id, "sensor.dyson_1") sensor.on_message('message') def test_dyson_filter_life_sensor_with_values(self): """Test filter sensor with values.""" - sensor = dyson.DysonFilterLifeSensor(self.hass, _get_with_state()) + sensor = dyson.DysonFilterLifeSensor(_get_with_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, 100) self.assertEqual(sensor.unit_of_measurement, "hours") - self.assertEqual(sensor.name, "Device_name filter life") + self.assertEqual(sensor.name, "Device_name Filter Life") self.assertEqual(sensor.entity_id, "sensor.dyson_1") sensor.on_message('message') def test_dyson_dust_sensor(self): """Test dust sensor with no value.""" - sensor = dyson.DysonDustSensor(self.hass, - _get_device_without_state()) + sensor = dyson.DysonDustSensor(_get_device_without_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertIsNone(sensor.state) - self.assertEqual(sensor.unit_of_measurement, 'level') - self.assertEqual(sensor.name, "Device_name dust") + self.assertEqual(sensor.unit_of_measurement, None) + self.assertEqual(sensor.name, "Device_name Dust") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_dust_sensor_with_values(self): """Test dust sensor with values.""" - sensor = dyson.DysonDustSensor(self.hass, _get_with_state()) + sensor = dyson.DysonDustSensor(_get_with_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, 5) - self.assertEqual(sensor.unit_of_measurement, 'level') - self.assertEqual(sensor.name, "Device_name dust") + self.assertEqual(sensor.unit_of_measurement, None) + self.assertEqual(sensor.name, "Device_name Dust") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_humidity_sensor(self): """Test humidity sensor with no value.""" - sensor = dyson.DysonHumiditySensor(self.hass, - _get_device_without_state()) + sensor = dyson.DysonHumiditySensor(_get_device_without_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertIsNone(sensor.state) self.assertEqual(sensor.unit_of_measurement, '%') - self.assertEqual(sensor.name, "Device_name humidity") + self.assertEqual(sensor.name, "Device_name Humidity") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_humidity_sensor_with_values(self): """Test humidity sensor with values.""" - sensor = dyson.DysonHumiditySensor(self.hass, _get_with_state()) + sensor = dyson.DysonHumiditySensor(_get_with_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, 45) self.assertEqual(sensor.unit_of_measurement, '%') - self.assertEqual(sensor.name, "Device_name humidity") + self.assertEqual(sensor.name, "Device_name Humidity") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_humidity_standby_monitoring(self): """Test humidity sensor while device is in standby monitoring.""" - sensor = dyson.DysonHumiditySensor(self.hass, - _get_with_standby_monitoring()) + sensor = dyson.DysonHumiditySensor(_get_with_standby_monitoring()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, STATE_OFF) self.assertEqual(sensor.unit_of_measurement, '%') - self.assertEqual(sensor.name, "Device_name humidity") + self.assertEqual(sensor.name, "Device_name Humidity") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_temperature_sensor(self): """Test temperature sensor with no value.""" - sensor = dyson.DysonTemperatureSensor(self.hass, - _get_device_without_state(), + sensor = dyson.DysonTemperatureSensor(_get_device_without_state(), TEMP_CELSIUS) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertIsNone(sensor.state) self.assertEqual(sensor.unit_of_measurement, '°C') - self.assertEqual(sensor.name, "Device_name temperature") + self.assertEqual(sensor.name, "Device_name Temperature") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_temperature_sensor_with_values(self): """Test temperature sensor with values.""" - sensor = dyson.DysonTemperatureSensor(self.hass, - _get_with_state(), + sensor = dyson.DysonTemperatureSensor(_get_with_state(), TEMP_CELSIUS) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, 21.9) self.assertEqual(sensor.unit_of_measurement, '°C') - self.assertEqual(sensor.name, "Device_name temperature") + self.assertEqual(sensor.name, "Device_name Temperature") self.assertEqual(sensor.entity_id, "sensor.dyson_1") - sensor = dyson.DysonTemperatureSensor(self.hass, - _get_with_state(), + sensor = dyson.DysonTemperatureSensor(_get_with_state(), TEMP_FAHRENHEIT) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, 71.3) self.assertEqual(sensor.unit_of_measurement, '°F') - self.assertEqual(sensor.name, "Device_name temperature") + self.assertEqual(sensor.name, "Device_name Temperature") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_temperature_standby_monitoring(self): """Test temperature sensor while device is in standby monitoring.""" - sensor = dyson.DysonTemperatureSensor(self.hass, - _get_with_standby_monitoring(), + sensor = dyson.DysonTemperatureSensor(_get_with_standby_monitoring(), TEMP_CELSIUS) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, STATE_OFF) self.assertEqual(sensor.unit_of_measurement, '°C') - self.assertEqual(sensor.name, "Device_name temperature") + self.assertEqual(sensor.name, "Device_name Temperature") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_air_quality_sensor(self): """Test air quality sensor with no value.""" - sensor = dyson.DysonAirQualitySensor(self.hass, - _get_device_without_state()) + sensor = dyson.DysonAirQualitySensor(_get_device_without_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertIsNone(sensor.state) - self.assertEqual(sensor.unit_of_measurement, 'level') - self.assertEqual(sensor.name, "Device_name air quality") + self.assertEqual(sensor.unit_of_measurement, None) + self.assertEqual(sensor.name, "Device_name AQI") self.assertEqual(sensor.entity_id, "sensor.dyson_1") def test_dyson_air_quality_sensor_with_values(self): """Test air quality sensor with values.""" - sensor = dyson.DysonAirQualitySensor(self.hass, _get_with_state()) + sensor = dyson.DysonAirQualitySensor(_get_with_state()) + sensor.hass = self.hass sensor.entity_id = "sensor.dyson_1" self.assertFalse(sensor.should_poll) self.assertEqual(sensor.state, 2) - self.assertEqual(sensor.unit_of_measurement, 'level') - self.assertEqual(sensor.name, "Device_name air quality") + self.assertEqual(sensor.unit_of_measurement, None) + self.assertEqual(sensor.name, "Device_name AQI") self.assertEqual(sensor.entity_id, "sensor.dyson_1") From 8b42d0c4718e275893cc6a9ad482cb13cb1248c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Sep 2018 16:34:37 +0200 Subject: [PATCH 127/178] Add confirmation to Cast/Sonos/iOS config entries (#16769) * Add confirmation to Cast/Sonos/iOS config entries * Remove redundant code --- homeassistant/helpers/config_entry_flow.py | 49 +++++++++++----------- tests/components/cast/test_init.py | 6 +++ tests/components/ios/test_init.py | 6 +++ tests/components/sonos/test_init.py | 6 +++ tests/helpers/test_config_entry_flow.py | 25 +++++++---- 5 files changed, 59 insertions(+), 33 deletions(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index d940afc1152329..569a101b3dd383 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -31,40 +31,39 @@ async def async_step_user(self, user_input=None): reason='single_instance_allowed' ) - # Get current discovered entries. - in_progress = self._async_in_progress() - - has_devices = in_progress - if not has_devices: - has_devices = await self.hass.async_add_job( - self._discovery_function, self.hass) + return await self.async_step_confirm() - if not has_devices: - return self.async_abort( - reason='no_devices_found' + async def async_step_confirm(self, user_input=None): + """Confirm setup.""" + if user_input is None: + return self.async_show_form( + step_id='confirm', ) - # Cancel the discovered one. - for flow in in_progress: - self.hass.config_entries.flow.async_abort(flow['flow_id']) + if self.context and self.context.get('source') != \ + config_entries.SOURCE_DISCOVERY: + # Get current discovered entries. + in_progress = self._async_in_progress() + + has_devices = in_progress + if not has_devices: + has_devices = await self.hass.async_add_job( + self._discovery_function, self.hass) + + if not has_devices: + return self.async_abort( + reason='no_devices_found' + ) + + # Cancel the discovered one. + for flow in in_progress: + self.hass.config_entries.flow.async_abort(flow['flow_id']) return self.async_create_entry( title=self._title, data={}, ) - async def async_step_confirm(self, user_input=None): - """Confirm setup.""" - if user_input is not None: - return self.async_create_entry( - title=self._title, - data={}, - ) - - return self.async_show_form( - step_id='confirm', - ) - async def async_step_discovery(self, discovery_info): """Handle a flow initialized by discovery.""" if self._async_in_progress() or self._async_current_entries(): diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py index 1ffbd375b753a7..0121cd1c79475d 100644 --- a/tests/components/cast/test_init.py +++ b/tests/components/cast/test_init.py @@ -17,6 +17,12 @@ async def test_creating_entry_sets_up_media_player(hass): return_value=True): result = await hass.config_entries.flow.async_init( cast.DOMAIN, context={'source': config_entries.SOURCE_USER}) + + # Confirmation form + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY await hass.async_block_till_done() diff --git a/tests/components/ios/test_init.py b/tests/components/ios/test_init.py index 9a45fac3ce15d8..ad1ab32832506f 100644 --- a/tests/components/ios/test_init.py +++ b/tests/components/ios/test_init.py @@ -30,6 +30,12 @@ async def test_creating_entry_sets_up_sensor(hass): return_value=mock_coro(True)) as mock_setup: result = await hass.config_entries.flow.async_init( ios.DOMAIN, context={'source': config_entries.SOURCE_USER}) + + # Confirmation form + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY await hass.async_block_till_done() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 455ce6d4cc25b0..8d46f4d57a39ab 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -15,6 +15,12 @@ async def test_creating_entry_sets_up_media_player(hass): patch('pysonos.discover', return_value=True): result = await hass.config_entries.flow.async_init( sonos.DOMAIN, context={'source': config_entries.SOURCE_USER}) + + # Confirmation form + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY await hass.async_block_till_done() diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index a578afae1d2ed7..9d858e31a06621 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -42,22 +42,24 @@ async def test_user_no_devices_found(hass, flow_conf): """Test if no devices found.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - - result = await flow.async_step_user() + flow.context = { + 'source': config_entries.SOURCE_USER + } + result = await flow.async_step_confirm(user_input={}) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'no_devices_found' -async def test_user_no_confirmation(hass, flow_conf): - """Test user requires no confirmation to set up.""" +async def test_user_has_confirmation(hass, flow_conf): + """Test user requires no confirmation to setup.""" flow = config_entries.HANDLERS['test']() flow.hass = hass flow_conf['discovered'] = True result = await flow.async_step_user() - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM async def test_discovery_single_instance(hass, flow_conf): @@ -100,7 +102,7 @@ async def test_multiple_discoveries(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT -async def test_user_init_trumps_discovery(hass, flow_conf): +async def test_only_one_in_progress(hass, flow_conf): """Test a user initialized one will finish and cancel discovered one.""" loader.set_component(hass, 'test', MockModule('test')) @@ -112,9 +114,16 @@ async def test_user_init_trumps_discovery(hass, flow_conf): # User starts flow result = await hass.config_entries.flow.async_init( 'test', context={'source': config_entries.SOURCE_USER}, data={}) - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - # Discovery flow has been aborted + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + # Discovery flow has not been aborted + assert len(hass.config_entries.flow.async_progress()) == 2 + + # Discovery should be aborted once user confirms + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert len(hass.config_entries.flow.async_progress()) == 0 From 7fe0d8b2f44c16d8d3f126e489b13e23994177bf Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 21 Sep 2018 19:59:20 +0200 Subject: [PATCH 128/178] deCONZ cover support (#16759) deCONZ cover platform for Keen vents --- homeassistant/components/cover/__init__.py | 15 +- homeassistant/components/cover/deconz.py | 146 ++++++++++++++++++++ homeassistant/components/deconz/__init__.py | 7 +- homeassistant/components/deconz/const.py | 2 + homeassistant/components/light/deconz.py | 5 +- tests/components/cover/test_deconz.py | 84 +++++++++++ tests/components/deconz/test_init.py | 14 +- tests/components/switch/test_deconz.py | 2 +- 8 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/cover/deconz.py create mode 100644 tests/components/cover/test_deconz.py diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 05c5e46e44ea13..e9a33c27d341f3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -35,8 +35,9 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' DEVICE_CLASSES = [ - 'window', # Window control + 'damper', 'garage', # Garage door control + 'window', # Window control ] DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) @@ -140,7 +141,7 @@ def stop_cover_tilt(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for covers.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) await component.async_setup(config) @@ -195,6 +196,16 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class CoverDevice(Entity): """Representation a cover.""" diff --git a/homeassistant/components/cover/deconz.py b/homeassistant/components/cover/deconz.py new file mode 100644 index 00000000000000..9fe6559633623d --- /dev/null +++ b/homeassistant/components/cover/deconz.py @@ -0,0 +1,146 @@ +""" +Support for deCONZ covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.deconz/ +""" +from homeassistant.components.deconz.const import ( + COVER_TYPES, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, + DECONZ_DOMAIN) +from homeassistant.components.cover import ( + ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, + SUPPORT_SET_POSITION) +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Unsupported way of setting up deCONZ covers.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up covers for deCONZ component. + + Covers are based on same device class as lights in deCONZ. + """ + @callback + def async_add_cover(lights): + """Add cover from deCONZ.""" + entities = [] + for light in lights: + if light.type in COVER_TYPES: + entities.append(DeconzCover(light)) + async_add_entities(entities, True) + + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_cover)) + + async_add_cover(hass.data[DATA_DECONZ].lights.values()) + + +class DeconzCover(CoverDevice): + """Representation of a deCONZ cover.""" + + def __init__(self, cover): + """Set up cover and add update callback to get data from websocket.""" + self._cover = cover + self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + + async def async_added_to_hass(self): + """Subscribe to covers events.""" + self._cover.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._cover.deconz_id + + async def async_will_remove_from_hass(self) -> None: + """Disconnect cover object when removed.""" + self._cover.remove_callback(self.async_update_callback) + self._cover = None + + @callback + def async_update_callback(self, reason): + """Update the cover's state.""" + self.async_schedule_update_ha_state() + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + if self.is_closed: + return 0 + return int(self._cover.brightness / 255 * 100) + + @property + def is_closed(self): + """Return if the cover is closed.""" + return not self._cover.state + + @property + def name(self): + """Return the name of the cover.""" + return self._cover.name + + @property + def unique_id(self): + """Return a unique identifier for this cover.""" + return self._cover.uniqueid + + @property + def device_class(self): + """Return the class of the cover.""" + return 'damper' + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + @property + def available(self): + """Return True if light is available.""" + return self._cover.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + data = {'on': False} + if position > 0: + data['on'] = True + data['bri'] = int(position / 100 * 255) + await self._cover.async_set_state(data) + + async def async_open_cover(self, **kwargs): + """Open cover.""" + data = {ATTR_POSITION: 100} + await self.async_set_cover_position(**data) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + data = {ATTR_POSITION: 0} + await self.async_set_cover_position(**data) + + @property + def device_info(self): + """Return a device description for device registry.""" + if (self._cover.uniqueid is None or + self._cover.uniqueid.count(':') != 7): + return None + serial = self._cover.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid + return { + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, + 'manufacturer': self._cover.manufacturer, + 'model': self._cover.modelid, + 'name': self._cover.name, + 'sw_version': self._cover.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), + } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 82f4233a7da799..56b03c89a3772b 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -26,6 +26,9 @@ REQUIREMENTS = ['pydeconz==47'] +SUPPORTED_PLATFORMS = ['binary_sensor', 'cover', + 'light', 'scene', 'sensor', 'switch'] + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_API_KEY): cv.string, @@ -104,7 +107,7 @@ def async_add_device_callback(device_type, device): hass.data[DATA_DECONZ_EVENT] = [] hass.data[DATA_DECONZ_UNSUB] = [] - for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: + for component in SUPPORTED_PLATFORMS: hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, component)) @@ -228,7 +231,7 @@ async def async_unload_entry(hass, config_entry): hass.services.async_remove(DOMAIN, SERVICE_DECONZ) deconz.close() - for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: + for component in SUPPORTED_PLATFORMS: await hass.config_entries.async_forward_entry_unload( config_entry, component) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e629d57f2017f4..617d231f92eed7 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -16,6 +16,8 @@ ATTR_DARK = 'dark' ATTR_ON = 'on' +COVER_TYPES = ["Level controllable output"] + POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 3fb6e1dff006d4..d3bec079a4c958 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -6,7 +6,8 @@ """ from homeassistant.components.deconz.const import ( CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN, SWITCH_TYPES) + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN, + COVER_TYPES, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -33,7 +34,7 @@ def async_add_light(lights): """Add light from deCONZ.""" entities = [] for light in lights: - if light.type not in SWITCH_TYPES: + if light.type not in COVER_TYPES + SWITCH_TYPES: entities.append(DeconzLight(light)) async_add_entities(entities, True) diff --git a/tests/components/cover/test_deconz.py b/tests/components/cover/test_deconz.py new file mode 100644 index 00000000000000..60de9cffdc19f5 --- /dev/null +++ b/tests/components/cover/test_deconz.py @@ -0,0 +1,84 @@ +"""deCONZ cover platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.components.deconz.const import COVER_TYPES +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + +SUPPORTED_COVERS = { + "1": { + "id": "Cover 1 id", + "name": "Cover 1 name", + "type": "Level controllable output", + "state": {} + } +} + +UNSUPPORTED_COVER = { + "1": { + "id": "Cover id", + "name": "Unsupported switch", + "type": "Not a cover", + "state": {} + } +} + + +async def setup_bridge(hass, data): + """Load the deCONZ cover platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH) + await hass.config_entries.async_forward_entry_setup(config_entry, 'cover') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_switches(hass): + """Test that no cover entities are created.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_cover(hass): + """Test that all supported cover entities are created.""" + await setup_bridge(hass, {"lights": SUPPORTED_COVERS}) + assert "cover.cover_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert len(SUPPORTED_COVERS) == len(COVER_TYPES) + assert len(hass.states.async_all()) == 2 + + +async def test_add_new_cover(hass): + """Test successful creation of cover entity.""" + data = {} + await setup_bridge(hass, data) + cover = Mock() + cover.name = 'name' + cover.type = "Level controllable output" + cover.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_light', [cover]) + await hass.async_block_till_done() + assert "cover.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_unsupported_cover(hass): + """Test that unsupported covers are not created.""" + await setup_bridge(hass, {"lights": UNSUPPORTED_COVER}) + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 049a3b961b6109..cfda1232e93764 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -112,17 +112,21 @@ async def test_setup_entry_successful(hass): assert hass.data[deconz.DOMAIN] assert hass.data[deconz.DATA_DECONZ_ID] == {} assert len(hass.data[deconz.DATA_DECONZ_UNSUB]) == 1 - assert len(mock_add_job.mock_calls) == 5 - assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 5 + assert len(mock_add_job.mock_calls) == \ + len(deconz.SUPPORTED_PLATFORMS) + assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == \ + len(deconz.SUPPORTED_PLATFORMS) assert mock_config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'binary_sensor') assert mock_config_entries.async_forward_entry_setup.mock_calls[1][1] == \ - (entry, 'light') + (entry, 'cover') assert mock_config_entries.async_forward_entry_setup.mock_calls[2][1] == \ - (entry, 'scene') + (entry, 'light') assert mock_config_entries.async_forward_entry_setup.mock_calls[3][1] == \ - (entry, 'sensor') + (entry, 'scene') assert mock_config_entries.async_forward_entry_setup.mock_calls[4][1] == \ + (entry, 'sensor') + assert mock_config_entries.async_forward_entry_setup.mock_calls[5][1] == \ (entry, 'switch') diff --git a/tests/components/switch/test_deconz.py b/tests/components/switch/test_deconz.py index 7d2bea7a9fa9b5..6833cab33d7902 100644 --- a/tests/components/switch/test_deconz.py +++ b/tests/components/switch/test_deconz.py @@ -71,7 +71,7 @@ async def test_no_switches(hass): async def test_switch(hass): - """Test that all supported switch entities and switch group are created.""" + """Test that all supported switch entities are created.""" await setup_bridge(hass, {"lights": SUPPORTED_SWITCHES}) assert "switch.switch_1_name" in hass.data[deconz.DATA_DECONZ_ID] assert "switch.switch_2_name" in hass.data[deconz.DATA_DECONZ_ID] From 18d37ff0fd2d108eebaaacdc4d9fa9cd4dfd8f97 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Sat, 22 Sep 2018 04:45:57 +0930 Subject: [PATCH 129/178] GeoJSON platform (#16610) * initial version of geojson platform * unit tests for geo json platform added; smaller bugfixes and code cleanups * fixing pylint issues * moved all code accessing the external feed into separate library; refactored platform and tests to work with that new library * fixing lint * small refactorings --- .../components/geo_location/__init__.py | 1 + .../geo_location/geo_json_events.py | 196 ++++++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../geo_location/test_geo_json_events.py | 136 ++++++++++++ 6 files changed, 340 insertions(+) create mode 100644 homeassistant/components/geo_location/geo_json_events.py create mode 100644 tests/components/geo_location/test_geo_json_events.py diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 67ed9520fa4d13..66753aad22117c 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py new file mode 100644 index 00000000000000..bb17fb2450e633 --- /dev/null +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -0,0 +1,196 @@ +""" +Generic GeoJSON events platform. + +Retrieves current events (typically incidents or alerts) in GeoJSON format, and +displays information on events filtered by distance to the HA instance's +location. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/geo_location/geo_json_events/ +""" +import logging +from datetime import timedelta +from typing import Optional + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.geo_location import GeoLocationEvent +from homeassistant.const import CONF_RADIUS, CONF_URL, CONF_SCAN_INTERVAL, \ + EVENT_HOMEASSISTANT_START +from homeassistant.components.geo_location import PLATFORM_SCHEMA +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['geojson_client==0.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_EXTERNAL_ID = 'external_id' + +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = "km" + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): + vol.Coerce(float), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GeoJSON Events platform.""" + url = config[CONF_URL] + scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + radius_in_km = config[CONF_RADIUS] + # Initialize the entity manager. + GeoJsonFeedManager(hass, add_entities, scan_interval, url, radius_in_km) + + +class GeoJsonFeedManager: + """Feed Manager for GeoJSON feeds.""" + + def __init__(self, hass, add_entities, scan_interval, url, radius_in_km): + """Initialize the GeoJSON Feed Manager.""" + from geojson_client.generic_feed import GenericFeed + self._hass = hass + self._feed = GenericFeed((hass.config.latitude, hass.config.longitude), + filter_radius=radius_in_km, url=url) + self._add_entities = add_entities + self._scan_interval = scan_interval + self._feed_entries = [] + self._managed_entities = [] + hass.bus.listen_once( + EVENT_HOMEASSISTANT_START, lambda _: self._update()) + self._init_regular_updates() + + def _init_regular_updates(self): + """Schedule regular updates at the specified interval.""" + track_time_interval(self._hass, lambda now: self._update(), + self._scan_interval) + + def _update(self): + """Update the feed and then update connected entities.""" + import geojson_client + status, feed_entries = self._feed.update() + if status == geojson_client.UPDATE_OK: + _LOGGER.debug("Data retrieved %s", feed_entries) + # Keep a copy of all feed entries for future lookups by entities. + self._feed_entries = feed_entries.copy() + keep_entries = self._update_or_remove_entities(feed_entries) + self._generate_new_entities(keep_entries) + elif status == geojson_client.UPDATE_OK_NO_DATA: + _LOGGER.debug("Update successful, but no data received from %s", + self._feed) + else: + _LOGGER.warning("Update not successful, no data received from %s", + self._feed) + # Remove all entities. + self._update_or_remove_entities([]) + + def _update_or_remove_entities(self, feed_entries): + """Update existing entries and remove obsolete entities.""" + _LOGGER.debug("Entries for updating: %s", feed_entries) + remove_entry = None + # Remove obsolete entities for events that have disappeared + managed_entities = self._managed_entities.copy() + for entity in managed_entities: + # Remove entry from previous iteration - if applicable. + if remove_entry: + feed_entries.remove(remove_entry) + remove_entry = None + for entry in feed_entries: + if entity.external_id == entry.external_id: + # Existing entity - update details. + _LOGGER.debug("Existing entity found %s", entity) + remove_entry = entry + entity.schedule_update_ha_state(True) + break + else: + # Remove obsolete entity. + _LOGGER.debug("Entity not current anymore %s", entity) + self._managed_entities.remove(entity) + self._hass.add_job(entity.async_remove()) + # Remove entry from very last iteration - if applicable. + if remove_entry: + feed_entries.remove(remove_entry) + # Return the remaining entries that new entities must be created for. + return feed_entries + + def _generate_new_entities(self, entries): + """Generate new entities for events.""" + new_entities = [] + for entry in entries: + new_entity = GeoJsonLocationEvent(self, entry) + _LOGGER.debug("New entity added %s", new_entity) + new_entities.append(new_entity) + # Add new entities to HA and keep track of them in this manager. + self._add_entities(new_entities, True) + self._managed_entities.extend(new_entities) + + def get_feed_entry(self, external_id): + """Return a feed entry identified by external id.""" + return next((entry for entry in self._feed_entries + if entry.external_id == external_id), None) + + +class GeoJsonLocationEvent(GeoLocationEvent): + """This represents an external event with GeoJSON data.""" + + def __init__(self, feed_manager, feed_entry): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._update_from_feed(feed_entry) + + @property + def should_poll(self): + """No polling needed for GeoJSON location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + feed_entry = self._feed_manager.get_feed_entry(self.external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._name = feed_entry.title + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self.external_id = feed_entry.external_id + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._name + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + if self.external_id: + attributes[ATTR_EXTERNAL_ID] = self.external_id + return attributes diff --git a/requirements_all.txt b/requirements_all.txt index 2c9871962f9360..2caa1b0b14e9ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -390,6 +390,9 @@ gearbest_parser==1.0.7 # homeassistant.components.sensor.geizhals geizhals==0.0.7 +# homeassistant.components.geo_location.geo_json_events +geojson_client==0.1 + # homeassistant.components.sensor.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a62b5534c30f58..3262e1849c1ac4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -65,6 +65,9 @@ foobot_async==0.3.1 # homeassistant.components.tts.google gTTS-token==1.1.1 +# homeassistant.components.geo_location.geo_json_events +geojson_client==0.1 + # homeassistant.components.ffmpeg ha-ffmpeg==1.9 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c4776e74f93403..90036c5bf33c04 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,6 +50,7 @@ 'feedparser', 'foobot_async', 'gTTS-token', + 'geojson_client', 'hangups', 'HAP-python', 'ha-ffmpeg', diff --git a/tests/components/geo_location/test_geo_json_events.py b/tests/components/geo_location/test_geo_json_events.py new file mode 100644 index 00000000000000..5ce508289ddf20 --- /dev/null +++ b/tests/components/geo_location/test_geo_json_events.py @@ -0,0 +1,136 @@ +"""The tests for the geojson platform.""" +import unittest +from unittest import mock +from unittest.mock import patch, MagicMock + +from homeassistant.components import geo_location +from homeassistant.components.geo_location.geo_json_events import \ + SCAN_INTERVAL, ATTR_EXTERNAL_ID +from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ + CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ + ATTR_UNIT_OF_MEASUREMENT +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, assert_setup_component, \ + fire_time_changed +import homeassistant.util.dt as dt_util + +URL = 'http://geo.json.local/geo_json_events.json' +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'geo_json_events', + CONF_URL: URL, + CONF_RADIUS: 200 + } + ] +} + + +class TestGeoJsonPlatform(unittest.TestCase): + """Test the geojson platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @staticmethod + def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + return feed_entry + + @mock.patch('geojson_client.generic_feed.GenericFeed') + def test_setup(self, mock_feed): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, + (-31.0, 150.0)) + mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5, + (-31.1, 150.1)) + mock_entry_3 = self._generate_mock_feed_entry('3456', 'Title 3', 25.5, + (-31.2, 150.2)) + mock_entry_4 = self._generate_mock_feed_entry('4567', 'Title 4', 12.5, + (-31.3, 150.3)) + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, + mock_entry_2, + mock_entry_3] + + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow): + with assert_setup_component(1, geo_location.DOMAIN): + self.assertTrue(setup_component(self.hass, geo_location.DOMAIN, + CONFIG)) + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 3 + + state = self.hass.states.get("geo_location.title_1") + self.assertIsNotNone(state) + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_UNIT_OF_MEASUREMENT: "km"} + self.assertAlmostEqual(float(state.state), 15.5) + + state = self.hass.states.get("geo_location.title_2") + self.assertIsNotNone(state) + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km"} + self.assertAlmostEqual(float(state.state), 20.5) + + state = self.hass.states.get("geo_location.title_3") + self.assertIsNotNone(state) + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km"} + self.assertAlmostEqual(float(state.state), 25.5) + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + # mock_restdata.return_value.data = None + fire_time_changed(self.hass, utcnow + + 2 * SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + fire_time_changed(self.hass, utcnow + + 2 * SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 0 From ee3f17d5c7bc7f573a2ad9d3f969768133d81414 Mon Sep 17 00:00:00 2001 From: edif30 Date: Fri, 21 Sep 2018 15:22:27 -0400 Subject: [PATCH 130/178] Bump gtts-token to 1.1.2 (#16775) * bump gtts-token to 1.1.2 * bump gtts-token to 1.1.2 * bump gtts-token to 1.1.2 --- homeassistant/components/tts/google.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index cb05795c445388..35a07ed8d22ad2 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -17,7 +17,7 @@ from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['gTTS-token==1.1.1'] +REQUIREMENTS = ['gTTS-token==1.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2caa1b0b14e9ba..75103ba0893aa2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -382,7 +382,7 @@ freesms==0.1.2 fritzhome==1.0.4 # homeassistant.components.tts.google -gTTS-token==1.1.1 +gTTS-token==1.1.2 # homeassistant.components.sensor.gearbest gearbest_parser==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3262e1849c1ac4..a360b7ecbef388 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -63,7 +63,7 @@ feedparser==5.2.1 foobot_async==0.3.1 # homeassistant.components.tts.google -gTTS-token==1.1.1 +gTTS-token==1.1.2 # homeassistant.components.geo_location.geo_json_events geojson_client==0.1 From 41bb4760f7f975e1998d54c35b4d0605c3ba055c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 21 Sep 2018 23:21:00 +0200 Subject: [PATCH 131/178] Upgrade restrictedpython to 4.0b5 (#16779) --- homeassistant/components/python_script.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index bbc6e07f2b0c90..5c56caf6470dbb 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.util import sanitize_filename import homeassistant.util.dt as dt_util -REQUIREMENTS = ['restrictedpython==4.0b4'] +REQUIREMENTS = ['restrictedpython==4.0b5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 75103ba0893aa2..f356e0a24eb050 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ raincloudy==0.0.5 regenmaschine==1.0.2 # homeassistant.components.python_script -restrictedpython==4.0b4 +restrictedpython==4.0b5 # homeassistant.components.rflink rflink==0.0.37 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a360b7ecbef388..dc263fd4e9c726 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -193,7 +193,7 @@ pyupnp-async==0.1.1.1 pywebpush==1.6.0 # homeassistant.components.python_script -restrictedpython==4.0b4 +restrictedpython==4.0b5 # homeassistant.components.rflink rflink==0.0.37 From e9c7fe184da1718466d9d662529db95cf2744436 Mon Sep 17 00:00:00 2001 From: Gerard Date: Sat, 22 Sep 2018 08:52:57 +0200 Subject: [PATCH 132/178] Upgrade bimmer_connected to 0.5.2 (#16780) --- homeassistant/components/bmw_connected_drive/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index b0ad1a867a8269..12363627003974 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['bimmer_connected==0.5.1'] +REQUIREMENTS = ['bimmer_connected==0.5.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f356e0a24eb050..2f3eea9148b6c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,7 +172,7 @@ beautifulsoup4==4.6.3 bellows==0.7.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.5.1 +bimmer_connected==0.5.2 # homeassistant.components.blink blinkpy==0.6.0 From 56138164763439a8aa8926be1dfd28ca42c54655 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Sep 2018 09:54:37 +0200 Subject: [PATCH 133/178] Fix Windows loop (#16737) * Fix Windows loop * Fix windows Ctrl+C * Move windows restart handler out of setup_and_run_hass --- homeassistant/__main__.py | 42 +++++++++++++++++++-------------- homeassistant/core.py | 5 +--- homeassistant/helpers/signal.py | 25 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index af89564f102d9d..91b7a7f8466d01 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -19,15 +19,19 @@ ) -def attempt_use_uvloop() -> None: +def set_loop() -> None: """Attempt to use uvloop.""" import asyncio - try: - import uvloop - except ImportError: - pass + + if sys.platform == 'win32': + asyncio.set_event_loop(asyncio.ProactorEventLoop()) else: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + try: + import uvloop + except ImportError: + pass + else: + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) def validate_python() -> None: @@ -244,17 +248,6 @@ async def setup_and_run_hass(config_dir: str, """Set up HASS and run.""" from homeassistant import bootstrap, core - # Run a simple daemon runner process on Windows to handle restarts - if os.name == 'nt' and '--runner' not in sys.argv: - nt_args = cmdline() + ['--runner'] - while True: - try: - subprocess.check_call(nt_args) - sys.exit(0) - except subprocess.CalledProcessError as exc: - if exc.returncode != RESTART_EXIT_CODE: - sys.exit(exc.returncode) - hass = core.HomeAssistant() if args.demo_mode: @@ -345,7 +338,20 @@ def main() -> int: monkey_patch.disable_c_asyncio() monkey_patch.patch_weakref_tasks() - attempt_use_uvloop() + set_loop() + + # Run a simple daemon runner process on Windows to handle restarts + if os.name == 'nt' and '--runner' not in sys.argv: + nt_args = cmdline() + ['--runner'] + while True: + try: + subprocess.check_call(nt_args) + sys.exit(0) + except KeyboardInterrupt: + sys.exit(0) + except subprocess.CalledProcessError as exc: + if exc.returncode != RESTART_EXIT_CODE: + sys.exit(exc.returncode) args = get_arguments() diff --git a/homeassistant/core.py b/homeassistant/core.py index 653f95cceceb08..d1f811502e07cb 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -129,10 +129,7 @@ def __init__( self, loop: Optional[asyncio.events.AbstractEventLoop] = None) -> None: """Initialize new Home Assistant object.""" - if sys.platform == 'win32': - self.loop = loop or asyncio.ProactorEventLoop() - else: - self.loop = loop or asyncio.get_event_loop() + self.loop = loop or asyncio.get_event_loop() executor_opts = {'max_workers': None} # type: Dict[str, Any] if sys.version_info[:2] >= (3, 6): diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 6068cad33af34c..7496388fb52e37 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -43,3 +43,28 @@ def async_signal_handle(exit_code): signal.SIGHUP, async_signal_handle, RESTART_EXIT_CODE) except ValueError: _LOGGER.warning("Could not bind to SIGHUP") + + else: + old_sigterm = None + old_sigint = None + + @callback + def async_signal_handle(exit_code, frame): + """Wrap signal handling. + + * queue call to shutdown task + * re-instate default handler + """ + signal.signal(signal.SIGTERM, old_sigterm) + signal.signal(signal.SIGINT, old_sigint) + hass.async_create_task(hass.async_stop(exit_code)) + + try: + old_sigterm = signal.signal(signal.SIGTERM, async_signal_handle) + except ValueError: + _LOGGER.warning("Could not bind to SIGTERM") + + try: + old_sigint = signal.signal(signal.SIGINT, async_signal_handle) + except ValueError: + _LOGGER.warning("Could not bind to SIGINT") From eec6722cf4c13c178f89032fcac0a4895641f5f1 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sat, 22 Sep 2018 02:34:46 -0700 Subject: [PATCH 134/178] Fix return to base logic for neato (#16776) --- homeassistant/components/vacuum/neato.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index dd27b2a33d242b..2b601f9fb05e48 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -188,6 +188,8 @@ def pause(self): def return_to_base(self, **kwargs): """Set the vacuum cleaner to return to the dock.""" + if self._clean_state == STATE_CLEANING: + self.robot.pause_cleaning() self._clean_state = STATE_RETURNING self.robot.send_to_base() From cfd1aec741a3e1700d576f3f475357196b8acfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 22 Sep 2018 18:25:17 +0200 Subject: [PATCH 135/178] Update Tibber lib (#16795) --- homeassistant/components/sensor/tibber.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 2dee84982da71d..dbea54ff353209 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -21,7 +21,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util import Throttle -REQUIREMENTS = ['pyTibber==0.5.0'] +REQUIREMENTS = ['pyTibber==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2f3eea9148b6c6..bb41c1a9d29bb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -764,7 +764,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.1 # homeassistant.components.sensor.tibber -pyTibber==0.5.0 +pyTibber==0.5.1 # homeassistant.components.switch.dlink pyW215==0.6.0 From 94d38a1c6bf24962abdc5cb3fad87191de3c3e2c Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sun, 23 Sep 2018 00:41:45 -0700 Subject: [PATCH 136/178] Bump pybotvac to 0.0.10 (#16799) --- homeassistant/components/neato.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 25da38e7f755c1..38af84e3176f79 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pybotvac==0.0.9'] +REQUIREMENTS = ['pybotvac==0.0.10'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' diff --git a/requirements_all.txt b/requirements_all.txt index bb41c1a9d29bb1..c382b7aad2dc49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -810,7 +810,7 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato -pybotvac==0.0.9 +pybotvac==0.0.10 # homeassistant.components.cloudflare pycfdns==0.0.1 From 0d681b0ba651f450d795d40a667dc7229c20ef18 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sun, 23 Sep 2018 00:42:11 -0700 Subject: [PATCH 137/178] Bump zm-py up to 0.0.2 (#16800) --- homeassistant/components/zoneminder.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py index 0778d9d7ef703b..5cfd324448bee5 100644 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['zm-py==0.0.1'] +REQUIREMENTS = ['zm-py==0.0.2'] CONF_PATH_ZMS = 'path_zms' diff --git a/requirements_all.txt b/requirements_all.txt index c382b7aad2dc49..9033b531e641b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1561,4 +1561,4 @@ zigpy-xbee==0.1.1 zigpy==0.2.0 # homeassistant.components.zoneminder -zm-py==0.0.1 +zm-py==0.0.2 From eca1f050cdcb0cffb588bfd997a832287b546d7d Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Sun, 23 Sep 2018 00:43:01 -0700 Subject: [PATCH 138/178] Bump sucks (Ecovacs) lib to 0.9.3 (#16803) * Bump sucks (Ecovacs) lib to 0.9.3 Changed code that was in place as a workaround pre-0.9.2. This version bump fixes a few issues. * Update requirements_all --- homeassistant/components/ecovacs.py | 2 +- homeassistant/components/vacuum/ecovacs.py | 6 +----- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ecovacs.py b/homeassistant/components/ecovacs.py index 53761a1013fa51..8cbe95ee685b58 100644 --- a/homeassistant/components/ecovacs.py +++ b/homeassistant/components/ecovacs.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, \ EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['sucks==0.9.1'] +REQUIREMENTS = ['sucks==0.9.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vacuum/ecovacs.py b/homeassistant/components/vacuum/ecovacs.py index 927362ac9db268..7f05554c496b02 100644 --- a/homeassistant/components/vacuum/ecovacs.py +++ b/homeassistant/components/vacuum/ecovacs.py @@ -189,10 +189,6 @@ def device_state_attributes(self): for key, val in self.device.components.items(): attr_name = ATTR_COMPONENT_PREFIX + key - data[attr_name] = int(val * 100 / 0.2777778) - # The above calculation includes a fix for a bug in sucks 0.9.1 - # When sucks 0.9.2+ is released, it should be changed to the - # following: - # data[attr_name] = int(val * 100) + data[attr_name] = int(val * 100) return data diff --git a/requirements_all.txt b/requirements_all.txt index 9033b531e641b1..a2313275680eab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1385,7 +1385,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.ecovacs -sucks==0.9.1 +sucks==0.9.3 # homeassistant.components.camera.onvif suds-passworddigest-homeassistant==0.1.2a0.dev0 From 127395ae8d8977ba53a2728a2c769871209586b0 Mon Sep 17 00:00:00 2001 From: tadly Date: Sun, 23 Sep 2018 09:50:59 +0200 Subject: [PATCH 139/178] Upgrade zeroconf to 0.21.3 (#16789) --- homeassistant/components/zeroconf.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf.py b/homeassistant/components/zeroconf.py index f3917078f34468..5d6161da904ebc 100644 --- a/homeassistant/components/zeroconf.py +++ b/homeassistant/components/zeroconf.py @@ -12,7 +12,7 @@ from homeassistant import util from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -REQUIREMENTS = ['zeroconf==0.21.2'] +REQUIREMENTS = ['zeroconf==0.21.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a2313275680eab..152440d52f3973 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1546,7 +1546,7 @@ youtube_dl==2018.09.18 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.21.2 +zeroconf==0.21.3 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 From 539b86e079a2436a03888d992c033196c2cf37b3 Mon Sep 17 00:00:00 2001 From: huangyupeng Date: Sun, 23 Sep 2018 16:36:33 +0800 Subject: [PATCH 140/178] Add Tuya cover state (#16709) * add cover open and close state * add tuya cover state * fix pylint * fix pylint problem --- homeassistant/components/cover/tuya.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/cover/tuya.py b/homeassistant/components/cover/tuya.py index 6ab8581602f18f..a3a3db972e9f9e 100644 --- a/homeassistant/components/cover/tuya.py +++ b/homeassistant/components/cover/tuya.py @@ -45,6 +45,11 @@ def supported_features(self): @property def is_closed(self): """Return if the cover is closed or not.""" + state = self.tuya.state() + if state == 1: + return False + if state == 2: + return True return None def open_cover(self, **kwargs): From eaee55175b18a9a6d834162ea23fc19664a94ec5 Mon Sep 17 00:00:00 2001 From: Paul Biester Date: Sun, 23 Sep 2018 10:37:53 +0200 Subject: [PATCH 141/178] Add configurable host for bbox routers (#16778) * Add configurable host for bbox routers Add configurable host for bbox router running on non-default IP addresses. * Fix unused import Fix unused import which also resolves "line too long" * Fix wrong import order * Update validation --- .../components/device_tracker/bbox.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/bbox.py b/homeassistant/components/device_tracker/bbox.py index 6d870364dcb641..297e98e548a7c6 100644 --- a/homeassistant/components/device_tracker/bbox.py +++ b/homeassistant/components/device_tracker/bbox.py @@ -5,19 +5,30 @@ https://home-assistant.io/components/device_tracker.bbox/ """ from collections import namedtuple -import logging from datetime import timedelta +import logging -import homeassistant.util.dt as dt_util -from homeassistant.components.device_tracker import DOMAIN, DeviceScanner +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util REQUIREMENTS = ['pybbox==0.0.5-alpha'] _LOGGER = logging.getLogger(__name__) +DEFAULT_HOST = '192.168.1.254' + MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, +}) + def get_scanner(hass, config): """Validate the configuration and return a Bbox scanner.""" @@ -33,6 +44,9 @@ class BboxDeviceScanner(DeviceScanner): """This class scans for devices connected to the bbox.""" def __init__(self, config): + """Get host from config.""" + self.host = config[CONF_HOST] + """Initialize the scanner.""" self.last_results = [] # type: List[Device] @@ -64,7 +78,7 @@ def _update_info(self): import pybbox - box = pybbox.Bbox() + box = pybbox.Bbox(ip=self.host) result = box.get_all_connected_devices() now = dt_util.now() From e90abf1007d1be08d4875d2a23c5c1408a4388a9 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sun, 23 Sep 2018 03:35:18 -0700 Subject: [PATCH 142/178] Set botvac state when offline (#16805) --- homeassistant/components/vacuum/neato.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 2b601f9fb05e48..29db94de762070 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -75,6 +75,8 @@ def update(self): requests.exceptions.HTTPError) as ex: _LOGGER.warning("Neato connection error: %s", ex) self._state = None + self._clean_state = STATE_ERROR + self._status_state = 'Robot Offline' return _LOGGER.debug('self._state=%s', self._state) if self._state['state'] == 1: From 2258c56d34ae905f33171b285d53894c0e5d30c2 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 23 Sep 2018 18:58:09 +0200 Subject: [PATCH 143/178] Handle netgear_lte connection errors (#16806) --- homeassistant/components/netgear_lte.py | 39 ++++++++++++++----- .../components/notify/netgear_lte.py | 26 +++++++++---- .../components/sensor/netgear_lte.py | 7 ++++ requirements_all.txt | 2 +- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/netgear_lte.py b/homeassistant/components/netgear_lte.py index 7f54e6fd6f91e1..e5e9a0fc2e94c9 100644 --- a/homeassistant/components/netgear_lte.py +++ b/homeassistant/components/netgear_lte.py @@ -6,6 +6,7 @@ """ import asyncio from datetime import timedelta +import logging import voluptuous as vol import attr @@ -17,7 +18,9 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.util import Throttle -REQUIREMENTS = ['eternalegypt==0.0.3'] +REQUIREMENTS = ['eternalegypt==0.0.5'] + +_LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -37,17 +40,23 @@ class ModemData: """Class for modem state.""" modem = attr.ib() - serial_number = attr.ib(init=False) - unread_count = attr.ib(init=False) - usage = attr.ib(init=False) + + serial_number = attr.ib(init=False, default=None) + unread_count = attr.ib(init=False, default=None) + usage = attr.ib(init=False, default=None) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Call the API to update the data.""" - information = await self.modem.information() - self.serial_number = information.serial_number - self.unread_count = sum(1 for x in information.sms if x.unread) - self.usage = information.usage + import eternalegypt + try: + information = await self.modem.information() + self.serial_number = information.serial_number + self.unread_count = sum(1 for x in information.sms if x.unread) + self.usage = information.usage + except eternalegypt.Error: + self.unread_count = None + self.usage = None @attr.s @@ -81,17 +90,27 @@ async def async_setup(hass, config): return True -async def _setup_lte(hass, lte_config): +async def _setup_lte(hass, lte_config, delay=0): """Set up a Netgear LTE modem.""" import eternalegypt + if delay: + await asyncio.sleep(delay) + host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] websession = hass.data[DATA_KEY].websession modem = eternalegypt.Modem(hostname=host, websession=websession) - await modem.login(password=password) + + try: + await modem.login(password=password) + except eternalegypt.Error: + delay = max(15, min(2*delay, 300)) + _LOGGER.warning("Retrying %s in %d seconds", host, delay) + hass.loop.create_task(_setup_lte(hass, lte_config, delay)) + return modem_data = ModemData(modem) await modem_data.async_update() diff --git a/homeassistant/components/notify/netgear_lte.py b/homeassistant/components/notify/netgear_lte.py index 97dfe504a51308..9ba804e193d815 100644 --- a/homeassistant/components/notify/netgear_lte.py +++ b/homeassistant/components/notify/netgear_lte.py @@ -4,6 +4,8 @@ https://home-assistant.io/components/notify.netgear_lte/ """ +import logging + import voluptuous as vol import attr @@ -17,6 +19,8 @@ DEPENDENCIES = ['netgear_lte'] +_LOGGER = logging.getLogger(__name__) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), @@ -25,21 +29,29 @@ async def async_get_service(hass, config, discovery_info=None): """Get the notification service.""" - modem_data = hass.data[DATA_KEY].get_modem_data(config) - phone = config.get(ATTR_TARGET) - return NetgearNotifyService(modem_data, phone) + return NetgearNotifyService(hass, config) @attr.s class NetgearNotifyService(BaseNotificationService): """Implementation of a notification service.""" - modem_data = attr.ib() - phone = attr.ib() + hass = attr.ib() + config = attr.ib() async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - targets = kwargs.get(ATTR_TARGET, self.phone) + modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + if not modem_data: + _LOGGER.error("No modem available") + return + + phone = self.config.get(ATTR_TARGET) + targets = kwargs.get(ATTR_TARGET, phone) if targets and message: for target in targets: - await self.modem_data.modem.sms(target, message) + import eternalegypt + try: + await modem_data.modem.sms(target, message) + except eternalegypt.Error: + _LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/sensor/netgear_lte.py b/homeassistant/components/sensor/netgear_lte.py index b13a8e39132713..3c17750d6adebd 100644 --- a/homeassistant/components/sensor/netgear_lte.py +++ b/homeassistant/components/sensor/netgear_lte.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_SENSORS from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -31,6 +32,9 @@ async def async_setup_platform( """Set up Netgear LTE sensor devices.""" modem_data = hass.data[DATA_KEY].get_modem_data(config) + if not modem_data: + raise PlatformNotReady + sensors = [] for sensor_type in config[CONF_SENSORS]: if sensor_type == SENSOR_SMS: @@ -88,4 +92,7 @@ def name(self): @property def state(self): """Return the state of the sensor.""" + if self.modem_data.usage is None: + return None + return round(self.modem_data.usage / 1024**2, 1) diff --git a/requirements_all.txt b/requirements_all.txt index 152440d52f3973..8e29b7eb74bd9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -333,7 +333,7 @@ ephem==3.7.6.0 epson-projector==0.1.3 # homeassistant.components.netgear_lte -eternalegypt==0.0.3 +eternalegypt==0.0.5 # homeassistant.components.keyboard_remote # evdev==0.6.1 From 329d9dfc061ce423bc6c515387fee8919eb1586c Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 23 Sep 2018 22:16:24 +0200 Subject: [PATCH 144/178] Improve opentherm_gw state detection (#16809) Only show the platform in STATE_HEAT when the boiler is actually heating. --- homeassistant/components/climate/opentherm_gw.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/opentherm_gw.py b/homeassistant/components/climate/opentherm_gw.py index c1f7afa61b0aee..00049d26b7f994 100644 --- a/homeassistant/components/climate/opentherm_gw.py +++ b/homeassistant/components/climate/opentherm_gw.py @@ -72,8 +72,9 @@ async def receive_report(self, status): """Receive and handle a new report from the Gateway.""" _LOGGER.debug("Received report: %s", status) ch_active = status.get(self.pyotgw.DATA_SLAVE_CH_ACTIVE) + flame_on = status.get(self.pyotgw.DATA_SLAVE_FLAME_ON) cooling_active = status.get(self.pyotgw.DATA_SLAVE_COOLING_ACTIVE) - if ch_active: + if ch_active and flame_on: self._current_operation = STATE_HEAT elif cooling_active: self._current_operation = STATE_COOL From 564ad7e22a04273a6c9bee6d096b72b6ef2bc11a Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 23 Sep 2018 14:35:07 -0700 Subject: [PATCH 145/178] Rework chromecast fix (#16804) * Revert changes of #16471, and fix the platform setup issue * Fix unit test * Fix * Fix comment * Fix test * Address review comment * Address review comment --- homeassistant/components/media_player/cast.py | 20 ++++++++++--------- tests/common.py | 10 ++++++---- tests/components/media_player/test_cast.py | 20 +++++++++++++++++++ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index a05653cd115545..67d8ea0b419a84 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -61,10 +61,6 @@ vol.All(cv.ensure_list, [cv.string]), }) -CONNECTION_RETRY = 3 -CONNECTION_RETRY_WAIT = 2 -CONNECTION_TIMEOUT = 10 - @attr.s(slots=True, frozen=True) class ChromecastInfo: @@ -216,9 +212,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if not isinstance(config, list): config = [config] - await asyncio.wait([ + # no pending task + done, _ = await asyncio.wait([ _async_setup_platform(hass, cfg, async_add_entities, None) for cfg in config]) + if any([task.exception() for task in done]): + raise PlatformNotReady async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -250,8 +249,8 @@ def async_cast_discovered(discover: ChromecastInfo) -> None: if cast_device is not None: async_add_entities([cast_device]) - async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, - async_cast_discovered) + remove_handler = async_dispatcher_connect( + hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered) # Re-play the callback for all past chromecasts, store the objects in # a list to avoid concurrent modification resulting in exception. for chromecast in list(hass.data[KNOWN_CHROMECAST_INFO_KEY]): @@ -265,8 +264,11 @@ def async_cast_discovered(discover: ChromecastInfo) -> None: info = await hass.async_add_job(_fill_out_missing_chromecast_info, info) if info.friendly_name is None: - # HTTP dial failed, so we won't be able to connect. + _LOGGER.debug("Cannot retrieve detail information for chromecast" + " %s, the device may not be online", info) + remove_handler() raise PlatformNotReady + hass.async_add_job(_discover_chromecast, hass, info) @@ -379,7 +381,7 @@ async def async_set_cast_info(self, cast_info): pychromecast._get_chromecast_from_host, ( cast_info.host, cast_info.port, cast_info.uuid, cast_info.model_name, cast_info.friendly_name - ), CONNECTION_RETRY, CONNECTION_RETRY_WAIT, CONNECTION_TIMEOUT) + )) self._chromecast = chromecast self._status_listener = CastStatusListener(self, chromecast) # Initialise connection status as connected because we can only diff --git a/tests/common.py b/tests/common.py index 287f1e24ee50b5..0cb15d683b5664 100644 --- a/tests/common.py +++ b/tests/common.py @@ -609,16 +609,18 @@ def mock_open_f(fname, **_): return patch.object(yaml, 'open', mock_open_f, create=True) -def mock_coro(return_value=None): - """Return a coro that returns a value.""" - return mock_coro_func(return_value)() +def mock_coro(return_value=None, exception=None): + """Return a coro that returns a value or raise an exception.""" + return mock_coro_func(return_value, exception)() -def mock_coro_func(return_value=None): +def mock_coro_func(return_value=None, exception=None): """Return a method to create a coro function that returns a value.""" @asyncio.coroutine def coro(*args, **kwargs): """Fake coroutine.""" + if exception: + raise exception return return_value return coro diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 7345fd0c1582a3..8fd1ae18841185 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -415,3 +415,23 @@ async def test_entry_setup_list_config(hass: HomeAssistantType): assert len(mock_setup.mock_calls) == 2 assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'} assert mock_setup.mock_calls[1][1][1] == {'host': 'blu'} + + +async def test_entry_setup_platform_not_ready(hass: HomeAssistantType): + """Test failed setting up entry will raise PlatformNotReady.""" + await async_setup_component(hass, 'cast', { + 'cast': { + 'media_player': { + 'host': 'bla' + } + } + }) + + with patch( + 'homeassistant.components.media_player.cast._async_setup_platform', + return_value=mock_coro(exception=Exception)) as mock_setup: + with pytest.raises(PlatformNotReady): + await cast.async_setup_entry(hass, MockConfigEntry(), None) + + assert len(mock_setup.mock_calls) == 1 + assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'} From 4fd2f773ad0f8fb8eca22cd1f8ccf3bf3d8a6cf0 Mon Sep 17 00:00:00 2001 From: Baptiste Lecocq Date: Mon, 24 Sep 2018 08:09:34 +0200 Subject: [PATCH 146/178] Add linky sensor (#16468) * Add linky component * Update requirements_all.txt * Add timeout to pylinky requests * Make linky platform fail fast --- .coveragerc | 1 + homeassistant/components/sensor/linky.py | 91 ++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 95 insertions(+) create mode 100644 homeassistant/components/sensor/linky.py diff --git a/.coveragerc b/.coveragerc index 79afac5709fe01..a8d7d89544d2b7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -695,6 +695,7 @@ omit = homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py + homeassistant/components/sensor/linky.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/luftdaten.py diff --git a/homeassistant/components/sensor/linky.py b/homeassistant/components/sensor/linky.py new file mode 100644 index 00000000000000..83a6d793085737 --- /dev/null +++ b/homeassistant/components/sensor/linky.py @@ -0,0 +1,91 @@ +""" +Support for Linky. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.linky/ +""" +import logging +import json +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pylinky==0.1.6'] +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=10) +DEFAULT_TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Configure the platform and add the Linky sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + timeout = config[CONF_TIMEOUT] + + from pylinky.client import LinkyClient, PyLinkyError + client = LinkyClient(username, password, None, timeout) + + try: + client.fetch_data() + except PyLinkyError as exp: + _LOGGER.error(exp) + return + + devices = [LinkySensor('Linky', client)] + add_entities(devices, True) + + +class LinkySensor(Entity): + """Representation of a sensor entity for Linky.""" + + def __init__(self, name, client): + """Initialize the sensor.""" + self._name = name + self._client = client + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return 'kWh' + + @Throttle(SCAN_INTERVAL) + def update(self): + """Fetch new state data for the sensor.""" + from pylinky.client import PyLinkyError + try: + self._client.fetch_data() + except PyLinkyError as exp: + _LOGGER.error(exp) + return + + _LOGGER.debug(json.dumps(self._client.get_data(), indent=2)) + + if self._client.get_data(): + # get the last past day data + self._state = self._client.get_data()['daily'][-2]['conso'] + else: + self._state = None diff --git a/requirements_all.txt b/requirements_all.txt index 8e29b7eb74bd9d..d901a7874930fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -950,6 +950,9 @@ pylgnetcast-homeassistant==0.2.0.dev0 # homeassistant.components.notify.webostv pylgtv==0.1.7 +# homeassistant.components.sensor.linky +pylinky==0.1.6 + # homeassistant.components.litejet pylitejet==0.1 From a5cb4e6c2b13afd1b86f47952f0a7132c7ab9a13 Mon Sep 17 00:00:00 2001 From: Martin Berg <2682426+mbrrg@users.noreply.github.com> Date: Mon, 24 Sep 2018 10:10:10 +0200 Subject: [PATCH 147/178] Use pyspcwebgw for SPC component (#16214) * Use pyspcwebgw library. * Support alarm triggering. * Update requirements. * Add pyspcwebgw to test reqs. * Also update script. * Use dispatcher. * Address review feedback. --- .../components/alarm_control_panel/spc.py | 102 +++---- homeassistant/components/binary_sensor/spc.py | 83 ++--- homeassistant/components/spc.py | 283 +++--------------- requirements_all.txt | 4 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../alarm_control_panel/test_spc.py | 38 +-- tests/components/binary_sensor/test_spc.py | 34 +-- tests/components/test_spc.py | 205 ++++--------- 9 files changed, 207 insertions(+), 546 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index 2aa157a5cadbdc..9150518022f6b0 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -4,71 +4,65 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.spc/ """ -import asyncio import logging import homeassistant.components.alarm_control_panel as alarm +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import callback from homeassistant.components.spc import ( - ATTR_DISCOVER_AREAS, DATA_API, DATA_REGISTRY, SpcWebGateway) + ATTR_DISCOVER_AREAS, DATA_API, SIGNAL_UPDATE_ALARM) from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) _LOGGER = logging.getLogger(__name__) -SPC_AREA_MODE_TO_STATE = { - '0': STATE_ALARM_DISARMED, - '1': STATE_ALARM_ARMED_HOME, - '3': STATE_ALARM_ARMED_AWAY, -} - -def _get_alarm_state(spc_mode): +def _get_alarm_state(area): """Get the alarm state.""" - return SPC_AREA_MODE_TO_STATE.get(spc_mode, STATE_UNKNOWN) + from pyspcwebgw.const import AreaMode + + if area.verified_alarm: + return STATE_ALARM_TRIGGERED + + mode_to_state = { + AreaMode.UNSET: STATE_ALARM_DISARMED, + AreaMode.PART_SET_A: STATE_ALARM_ARMED_HOME, + AreaMode.PART_SET_B: STATE_ALARM_ARMED_NIGHT, + AreaMode.FULL_SET: STATE_ALARM_ARMED_AWAY, + } + return mode_to_state.get(area.mode) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - api = hass.data[DATA_API] - devices = [SpcAlarm(api, area) - for area in discovery_info[ATTR_DISCOVER_AREAS]] - - async_add_entities(devices) + async_add_entities([SpcAlarm(area=area, api=hass.data[DATA_API]) + for area in discovery_info[ATTR_DISCOVER_AREAS]]) class SpcAlarm(alarm.AlarmControlPanel): """Representation of the SPC alarm panel.""" - def __init__(self, api, area): + def __init__(self, area, api): """Initialize the SPC alarm panel.""" - self._area_id = area['id'] - self._name = area['name'] - self._state = _get_alarm_state(area['mode']) - if self._state == STATE_ALARM_DISARMED: - self._changed_by = area.get('last_unset_user_name', 'unknown') - else: - self._changed_by = area.get('last_set_user_name', 'unknown') + self._area = area self._api = api - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call for adding new entities.""" - self.hass.data[DATA_REGISTRY].register_alarm_device( - self._area_id, self) + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_ALARM.format(self._area.id), + self._update_callback) - @asyncio.coroutine - def async_update_from_spc(self, state, extra): - """Update the alarm panel with a new state.""" - self._state = state - self._changed_by = extra.get('changed_by', 'unknown') - self.async_schedule_update_ha_state() + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) @property def should_poll(self): @@ -78,32 +72,34 @@ def should_poll(self): @property def name(self): """Return the name of the device.""" - return self._name + return self._area.name @property def changed_by(self): """Return the user the last change was triggered by.""" - return self._changed_by + return self._area.last_changed_by @property def state(self): """Return the state of the device.""" - return self._state + return _get_alarm_state(self._area) - @asyncio.coroutine - def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code=None): """Send disarm command.""" - yield from self._api.send_area_command( - self._area_id, SpcWebGateway.AREA_COMMAND_UNSET) + from pyspcwebgw.const import AreaMode + self._api.change_mode(area=self._area, new_mode=AreaMode.UNSET) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + from pyspcwebgw.const import AreaMode + self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_A) - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_night(self, code=None): """Send arm home command.""" - yield from self._api.send_area_command( - self._area_id, SpcWebGateway.AREA_COMMAND_PART_SET) + from pyspcwebgw.const import AreaMode + self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_B) - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None): """Send arm away command.""" - yield from self._api.send_area_command( - self._area_id, SpcWebGateway.AREA_COMMAND_SET) + from pyspcwebgw.const import AreaMode + self._api.change_mode(area=self._area, new_mode=AreaMode.FULL_SET) diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 9afd4fe40151e6..c1be72db374a59 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -4,87 +4,66 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.spc/ """ -import asyncio import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.spc import ATTR_DISCOVER_DEVICES, DATA_REGISTRY -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import callback +from homeassistant.components.spc import ( + ATTR_DISCOVER_DEVICES, SIGNAL_UPDATE_SENSOR) _LOGGER = logging.getLogger(__name__) -SPC_TYPE_TO_DEVICE_CLASS = { - '0': 'motion', - '1': 'opening', - '3': 'smoke', -} -SPC_INPUT_TO_SENSOR_STATE = { - '0': STATE_OFF, - '1': STATE_ON, -} +def _get_device_class(zone_type): + from pyspcwebgw.const import ZoneType + return { + ZoneType.ALARM: 'motion', + ZoneType.ENTRY_EXIT: 'opening', + ZoneType.FIRE: 'smoke', + }.get(zone_type) -def _get_device_class(spc_type): - """Get the device class.""" - return SPC_TYPE_TO_DEVICE_CLASS.get(spc_type, None) - - -def _get_sensor_state(spc_input): - """Get the sensor state.""" - return SPC_INPUT_TO_SENSOR_STATE.get(spc_input, STATE_UNAVAILABLE) - - -def _create_sensor(hass, zone): - """Create a SPC sensor.""" - return SpcBinarySensor( - zone_id=zone['id'], name=zone['zone_name'], - state=_get_sensor_state(zone['input']), - device_class=_get_device_class(zone['type']), - spc_registry=hass.data[DATA_REGISTRY]) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the SPC binary sensor.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( - _create_sensor(hass, zone) - for zone in discovery_info[ATTR_DISCOVER_DEVICES] - if _get_device_class(zone['type'])) + async_add_entities(SpcBinarySensor(zone) + for zone in discovery_info[ATTR_DISCOVER_DEVICES] + if _get_device_class(zone.type)) class SpcBinarySensor(BinarySensorDevice): """Representation of a sensor based on a SPC zone.""" - def __init__(self, zone_id, name, state, device_class, spc_registry): + def __init__(self, zone): """Initialize the sensor device.""" - self._zone_id = zone_id - self._name = name - self._state = state - self._device_class = device_class + self._zone = zone - spc_registry.register_sensor_device(zone_id, self) + async def async_added_to_hass(self): + """Call for adding new entities.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SENSOR.format(self._zone.id), + self._update_callback) - @asyncio.coroutine - def async_update_from_spc(self, state, extra): - """Update the state of the device.""" - self._state = state - yield from self.async_update_ha_state() + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) @property def name(self): """Return the name of the device.""" - return self._name + return self._zone.name @property def is_on(self): """Whether the device is switched on.""" - return self._state == STATE_ON + from pyspcwebgw.const import ZoneInput + return self._zone.input == ZoneInput.OPEN @property def hidden(self) -> bool: @@ -100,4 +79,4 @@ def should_poll(self): @property def device_class(self): """Return the device class.""" - return self._device_class + return _get_device_class(self._zone.type) diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index 5b357efcabd779..b00a4aeed2cccf 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -4,23 +4,15 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/spc/ """ -import asyncio -import json import logging -from urllib.parse import urljoin -import aiohttp -import async_timeout import voluptuous as vol -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN) -from homeassistant.helpers import discovery +from homeassistant.helpers import discovery, aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['websockets==6.0'] +REQUIREMENTS = ['pyspcwebgw==0.4.0'] _LOGGER = logging.getLogger(__name__) @@ -30,9 +22,11 @@ CONF_WS_URL = 'ws_url' CONF_API_URL = 'api_url' -DATA_REGISTRY = 'spc_registry' -DATA_API = 'spc_api' DOMAIN = 'spc' +DATA_API = 'spc_api' + +SIGNAL_UPDATE_ALARM = 'spc_update_alarm_{}' +SIGNAL_UPDATE_SENSOR = 'spc_update_sensor_{}' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -42,244 +36,45 @@ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): - """Set up the SPC platform.""" - hass.data[DATA_REGISTRY] = SpcRegistry() - - api = SpcWebGateway(hass, - config[DOMAIN].get(CONF_API_URL), - config[DOMAIN].get(CONF_WS_URL)) - - hass.data[DATA_API] = api - - # add sensor devices for each zone (typically motion/fire/door sensors) - zones = yield from api.get_zones() - if zones: - hass.async_create_task(discovery.async_load_platform( - hass, 'binary_sensor', DOMAIN, - {ATTR_DISCOVER_DEVICES: zones}, config)) - - # create a separate alarm panel for each area - areas = yield from api.get_areas() - if areas: - hass.async_create_task(discovery.async_load_platform( - hass, 'alarm_control_panel', DOMAIN, - {ATTR_DISCOVER_AREAS: areas}, config)) - - # start listening for incoming events over websocket - api.start_listener(_async_process_message, hass.data[DATA_REGISTRY]) - - return True - - -@asyncio.coroutine -def _async_process_message(sia_message, spc_registry): - spc_id = sia_message['sia_address'] - sia_code = sia_message['sia_code'] - - # BA - Burglary Alarm - # CG - Close Area - # NL - Perimeter Armed - # OG - Open Area - # ZO - Zone Open - # ZC - Zone Close - # ZX - Zone Short - # ZD - Zone Disconnected - - extra = {} - - if sia_code in ('BA', 'CG', 'NL', 'OG'): - # change in area status, notify alarm panel device - device = spc_registry.get_alarm_device(spc_id) - data = sia_message['description'].split('¦') - if len(data) == 3: - extra['changed_by'] = data[1] - else: - # Change in zone status, notify sensor device - device = spc_registry.get_sensor_device(spc_id) - - sia_code_to_state_map = { - 'BA': STATE_ALARM_TRIGGERED, - 'CG': STATE_ALARM_ARMED_AWAY, - 'NL': STATE_ALARM_ARMED_HOME, - 'OG': STATE_ALARM_DISARMED, - 'ZO': STATE_ON, - 'ZC': STATE_OFF, - 'ZX': STATE_UNKNOWN, - 'ZD': STATE_UNAVAILABLE, - } - - new_state = sia_code_to_state_map.get(sia_code, None) - - if new_state and not device: - _LOGGER.warning( - "No device mapping found for SPC area/zone id %s", spc_id) - elif new_state: - yield from device.async_update_from_spc(new_state, extra) - +async def async_setup(hass, config): + """Set up the SPC component.""" + from pyspcwebgw import SpcWebGateway -class SpcRegistry: - """Maintain mappings between SPC zones/areas and HA entities.""" + async def async_upate_callback(spc_object): + from pyspcwebgw.area import Area + from pyspcwebgw.zone import Zone - def __init__(self): - """Initialize the registry.""" - self._zone_id_to_sensor_map = {} - self._area_id_to_alarm_map = {} + if isinstance(spc_object, Area): + async_dispatcher_send(hass, + SIGNAL_UPDATE_ALARM.format(spc_object.id)) + elif isinstance(spc_object, Zone): + async_dispatcher_send(hass, + SIGNAL_UPDATE_SENSOR.format(spc_object.id)) - def register_sensor_device(self, zone_id, device): - """Add a sensor device to the registry.""" - self._zone_id_to_sensor_map[zone_id] = device + session = aiohttp_client.async_get_clientsession(hass) - def get_sensor_device(self, zone_id): - """Retrieve a sensor device for a specific zone.""" - return self._zone_id_to_sensor_map.get(zone_id, None) + spc = SpcWebGateway(loop=hass.loop, session=session, + api_url=config[DOMAIN].get(CONF_API_URL), + ws_url=config[DOMAIN].get(CONF_WS_URL), + async_callback=async_upate_callback) - def register_alarm_device(self, area_id, device): - """Add an alarm device to the registry.""" - self._area_id_to_alarm_map[area_id] = device + hass.data[DATA_API] = spc - def get_alarm_device(self, area_id): - """Retrieve an alarm device for a specific area.""" - return self._area_id_to_alarm_map.get(area_id, None) + if not await spc.async_load_parameters(): + _LOGGER.error('Failed to load area/zone information from SPC.') + return False + # add sensor devices for each zone (typically motion/fire/door sensors) + hass.async_create_task(discovery.async_load_platform( + hass, 'binary_sensor', DOMAIN, + {ATTR_DISCOVER_DEVICES: spc.zones.values()}, config)) -@asyncio.coroutine -def _ws_process_message(message, async_callback, *args): - if message.get('status', '') != 'success': - _LOGGER.warning( - "Unsuccessful websocket message delivered, ignoring: %s", message) - try: - yield from async_callback(message['data']['sia'], *args) - except: # noqa: E722 pylint: disable=bare-except - _LOGGER.exception("Exception in callback, ignoring") - - -class SpcWebGateway: - """Simple binding for the Lundix SPC Web Gateway REST API.""" - - AREA_COMMAND_SET = 'set' - AREA_COMMAND_PART_SET = 'set_a' - AREA_COMMAND_UNSET = 'unset' - - def __init__(self, hass, api_url, ws_url): - """Initialize the web gateway client.""" - self._hass = hass - self._api_url = api_url - self._ws_url = ws_url - self._ws = None - - @asyncio.coroutine - def get_zones(self): - """Retrieve all available zones.""" - return (yield from self._get_data('zone')) - - @asyncio.coroutine - def get_areas(self): - """Retrieve all available areas.""" - return (yield from self._get_data('area')) - - @asyncio.coroutine - def send_area_command(self, area_id, command): - """Send an area command.""" - _LOGGER.debug( - "Sending SPC area command '%s' to area %s", command, area_id) - resource = "area/{}/{}".format(area_id, command) - return (yield from self._call_web_gateway(resource, use_get=False)) - - def start_listener(self, async_callback, *args): - """Start the websocket listener.""" - asyncio.ensure_future(self._ws_listen(async_callback, *args)) - - def _build_url(self, resource): - return urljoin(self._api_url, "spc/{}".format(resource)) - - @asyncio.coroutine - def _get_data(self, resource): - """Get the data from the resource.""" - data = yield from self._call_web_gateway(resource) - if not data: - return False - if data['status'] != 'success': - _LOGGER.error( - "SPC Web Gateway call unsuccessful for resource: %s", resource) - return False - return [item for item in data['data'][resource]] - - @asyncio.coroutine - def _call_web_gateway(self, resource, use_get=True): - """Call web gateway for data.""" - response = None - session = None - url = self._build_url(resource) - try: - _LOGGER.debug("Attempting to retrieve SPC data from %s", url) - session = \ - self._hass.helpers.aiohttp_client.async_get_clientsession() - with async_timeout.timeout(10, loop=self._hass.loop): - action = session.get if use_get else session.put - response = yield from action(url) - if response.status != 200: - _LOGGER.error( - "SPC Web Gateway returned http status %d, response %s", - response.status, (yield from response.text())) - return False - result = yield from response.json() - except asyncio.TimeoutError: - _LOGGER.error("Timeout getting SPC data from %s", url) - return False - except aiohttp.ClientError: - _LOGGER.exception("Error getting SPC data from %s", url) - return False - finally: - if session: - yield from session.close() - if response: - yield from response.release() - _LOGGER.debug("Data from SPC: %s", result) - return result - - @asyncio.coroutine - def _ws_read(self): - """Read from websocket.""" - import websockets as wslib - - try: - if not self._ws: - self._ws = yield from wslib.connect(self._ws_url) - _LOGGER.info("Connected to websocket at %s", self._ws_url) - except Exception as ws_exc: # pylint: disable=broad-except - _LOGGER.error("Failed to connect to websocket: %s", ws_exc) - return - - result = None - - try: - result = yield from self._ws.recv() - _LOGGER.debug("Data from websocket: %s", result) - except Exception as ws_exc: # pylint: disable=broad-except - _LOGGER.error("Failed to read from websocket: %s", ws_exc) - try: - yield from self._ws.close() - finally: - self._ws = None - - return result - - @asyncio.coroutine - def _ws_listen(self, async_callback, *args): - """Listen on websocket.""" - try: - while True: - result = yield from self._ws_read() + # create a separate alarm panel for each area + hass.async_create_task(discovery.async_load_platform( + hass, 'alarm_control_panel', DOMAIN, + {ATTR_DISCOVER_AREAS: spc.areas.values()}, config)) - if result: - yield from _ws_process_message( - json.loads(result), async_callback, *args) - else: - _LOGGER.info("Trying again in 30 seconds") - yield from asyncio.sleep(30) + # start listening for incoming events over websocket + spc.start() - finally: - if self._ws: - yield from self._ws.close() + return True diff --git a/requirements_all.txt b/requirements_all.txt index d901a7874930fd..ba353af0d48f52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1069,6 +1069,9 @@ pysnmp==4.4.5 # homeassistant.components.sonos pysonos==0.0.2 +# homeassistant.components.spc +pyspcwebgw==0.4.0 + # homeassistant.components.notify.stride pystride==0.1.7 @@ -1502,7 +1505,6 @@ waterfurnace==0.7.0 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 -# homeassistant.components.spc # homeassistant.components.media_player.webostv websockets==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc263fd4e9c726..171650b867d4bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,6 +170,9 @@ pyqwikswitch==0.8 # homeassistant.components.sonos pysonos==0.0.2 +# homeassistant.components.spc +pyspcwebgw==0.4.0 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky python-forecastio==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 90036c5bf33c04..7493e5232731d0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -83,6 +83,7 @@ 'pysonos', 'pyqwikswitch', 'PyRMVtransport', + 'pyspcwebgw', 'python-forecastio', 'python-nest', 'pytradfri\[async\]', diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index a0793943c2f7b5..b1078e1b14fe9f 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -1,27 +1,11 @@ """Tests for Vanderbilt SPC alarm control panel platform.""" -import asyncio - -import pytest - -from homeassistant.components.spc import SpcRegistry from homeassistant.components.alarm_control_panel import spc -from tests.common import async_test_home_assistant from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) +from homeassistant.components.spc import (DATA_API) -@pytest.fixture -def hass(loop): - """Home Assistant fixture with device mapping registry.""" - hass = loop.run_until_complete(async_test_home_assistant(loop)) - hass.data['spc_registry'] = SpcRegistry() - hass.data['spc_api'] = None - yield hass - loop.run_until_complete(hass.async_stop(force=True)) - - -@asyncio.coroutine -def test_setup_platform(hass): +async def test_setup_platform(hass): """Test adding areas as separate alarm control panel devices.""" added_entities = [] @@ -29,7 +13,7 @@ def add_entities(entities): nonlocal added_entities added_entities = list(entities) - areas = {'areas': [{ + area_defs = [{ 'id': '1', 'name': 'House', 'mode': '3', @@ -50,12 +34,18 @@ def add_entities(entities): 'last_unset_time': '1483705808', 'last_unset_user_id': '9998', 'last_unset_user_name': 'Lisa' - }]} + }] + + from pyspcwebgw import Area + + areas = [Area(gateway=None, spc_area=a) for a in area_defs] + + hass.data[DATA_API] = None - yield from spc.async_setup_platform(hass=hass, - config={}, - async_add_entities=add_entities, - discovery_info=areas) + await spc.async_setup_platform(hass=hass, + config={}, + async_add_entities=add_entities, + discovery_info={'areas': areas}) assert len(added_entities) == 2 diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 966f73682e8c3d..ec0886aeed8307 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -1,28 +1,12 @@ """Tests for Vanderbilt SPC binary sensor platform.""" -import asyncio - -import pytest - -from homeassistant.components.spc import SpcRegistry from homeassistant.components.binary_sensor import spc -from tests.common import async_test_home_assistant - -@pytest.fixture -def hass(loop): - """Home Assistant fixture with device mapping registry.""" - hass = loop.run_until_complete(async_test_home_assistant(loop)) - hass.data['spc_registry'] = SpcRegistry() - yield hass - loop.run_until_complete(hass.async_stop(force=True)) - -@asyncio.coroutine -def test_setup_platform(hass): +async def test_setup_platform(hass): """Test autodiscovery of supported device types.""" added_entities = [] - zones = {'devices': [{ + zone_defs = [{ 'id': '1', 'type': '3', 'zone_name': 'Kitchen smoke', @@ -46,16 +30,20 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '1', 'status': '0', - }]} + }] def add_entities(entities): nonlocal added_entities added_entities = list(entities) - yield from spc.async_setup_platform(hass=hass, - config={}, - async_add_entities=add_entities, - discovery_info=zones) + from pyspcwebgw import Zone + + zones = [Zone(area=None, spc_zone=z) for z in zone_defs] + + await spc.async_setup_platform(hass=hass, + config={}, + async_add_entities=add_entities, + discovery_info={'devices': zones}) assert len(added_entities) == 3 assert added_entities[0].device_class == 'smoke' diff --git a/tests/components/test_spc.py b/tests/components/test_spc.py index 7837abd8007587..d4bedda4e967e7 100644 --- a/tests/components/test_spc.py +++ b/tests/components/test_spc.py @@ -1,167 +1,74 @@ """Tests for Vanderbilt SPC component.""" -import asyncio +from unittest.mock import patch, PropertyMock, Mock -import pytest - -from homeassistant.components import spc from homeassistant.bootstrap import async_setup_component -from tests.common import async_test_home_assistant -from tests.test_util.aiohttp import mock_aiohttp_client -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) - - -@pytest.fixture -def hass(loop): - """Home Assistant fixture with device mapping registry.""" - hass = loop.run_until_complete(async_test_home_assistant(loop)) - hass.data[spc.DATA_REGISTRY] = spc.SpcRegistry() - hass.data[spc.DATA_API] = None - yield hass - loop.run_until_complete(hass.async_stop()) - - -@pytest.fixture -def spcwebgw(hass): - """Fixture for the SPC Web Gateway API configured for localhost.""" - yield spc.SpcWebGateway(hass=hass, - api_url='http://localhost/', - ws_url='ws://localhost/') - - -@pytest.fixture -def aioclient_mock(): - """HTTP client mock for areas and zones.""" - areas = """{"status":"success","data":{"area":[{"id":"1","name":"House", - "mode":"0","last_set_time":"1485759851","last_set_user_id":"1", - "last_set_user_name":"Pelle","last_unset_time":"1485800564", - "last_unset_user_id":"1","last_unset_user_name":"Pelle","last_alarm": - "1478174896"},{"id":"3","name":"Garage","mode":"0","last_set_time": - "1483705803","last_set_user_id":"9998","last_set_user_name":"Lisa", - "last_unset_time":"1483705808","last_unset_user_id":"9998", - "last_unset_user_name":"Lisa"}]}}""" - - zones = """{"status":"success","data":{"zone":[{"id":"1","type":"3", - "zone_name":"Kitchen smoke","area":"1","area_name":"House","input":"0", - "logic_input":"0","status":"0","proc_state":"0","inhibit_allowed":"1", - "isolate_allowed":"1"},{"id":"3","type":"0","zone_name":"Hallway PIR", - "area":"1","area_name":"House","input":"0","logic_input":"0","status": - "0","proc_state":"0","inhibit_allowed":"1","isolate_allowed":"1"}, - {"id":"5","type":"1","zone_name":"Front door","area":"1","area_name": - "House","input":"1","logic_input":"0","status":"0","proc_state":"0", - "inhibit_allowed":"1","isolate_allowed":"1"}]}}""" - - with mock_aiohttp_client() as mock_session: - mock_session.get('http://localhost/spc/area', text=areas) - mock_session.get('http://localhost/spc/zone', text=zones) - yield mock_session - - -@asyncio.coroutine -@pytest.mark.parametrize("sia_code,state", [ - ('NL', STATE_ALARM_ARMED_HOME), - ('CG', STATE_ALARM_ARMED_AWAY), - ('OG', STATE_ALARM_DISARMED) -]) -def test_update_alarm_device(hass, aioclient_mock, monkeypatch, - sia_code, state): - """Test that alarm panel state changes on incoming websocket data.""" - monkeypatch.setattr("homeassistant.components.spc.SpcWebGateway." - "start_listener", lambda x, *args: None) +from homeassistant.components.spc import DATA_API +from homeassistant.const import (STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) + +from tests.common import mock_coro + + +async def test_valid_device_config(hass, monkeypatch): + """Test valid device config.""" config = { 'spc': { 'api_url': 'http://localhost/', 'ws_url': 'ws://localhost/' } } - yield from async_setup_component(hass, 'spc', config) - yield from hass.async_block_till_done() - entity_id = 'alarm_control_panel.house' + with patch('pyspcwebgw.SpcWebGateway.async_load_parameters', + return_value=mock_coro(True)): + assert await async_setup_component(hass, 'spc', config) is True - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - msg = {"sia_code": sia_code, "sia_address": "1", - "description": "House¦Sam¦1"} - yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - yield from hass.async_block_till_done() - - state_obj = hass.states.get(entity_id) - assert state_obj.state == state - assert state_obj.attributes['changed_by'] == 'Sam' - - -@asyncio.coroutine -@pytest.mark.parametrize("sia_code,state", [ - ('ZO', STATE_ON), - ('ZC', STATE_OFF) -]) -def test_update_sensor_device(hass, aioclient_mock, monkeypatch, - sia_code, state): - """ - Test that sensors change state on incoming websocket data. - - Note that we don't test for the ZD (disconnected) and ZX (problem/short) - codes since the binary sensor component is hardcoded to only - let on/off states through. - """ - monkeypatch.setattr("homeassistant.components.spc.SpcWebGateway." - "start_listener", lambda x, *args: None) +async def test_invalid_device_config(hass, monkeypatch): + """Test valid device config.""" + config = { + 'spc': { + 'api_url': 'http://localhost/' + } + } + + with patch('pyspcwebgw.SpcWebGateway.async_load_parameters', + return_value=mock_coro(True)): + assert await async_setup_component(hass, 'spc', config) is False + + +async def test_update_alarm_device(hass): + """Test that alarm panel state changes on incoming websocket data.""" + import pyspcwebgw + from pyspcwebgw.const import AreaMode + config = { 'spc': { 'api_url': 'http://localhost/', 'ws_url': 'ws://localhost/' } } - yield from async_setup_component(hass, 'spc', config) - yield from hass.async_block_till_done() - - assert hass.states.get('binary_sensor.hallway_pir').state == STATE_OFF - - msg = {"sia_code": sia_code, "sia_address": "3", - "description": "Hallway PIR"} - yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - yield from hass.async_block_till_done() - assert hass.states.get('binary_sensor.hallway_pir').state == state - - -class TestSpcRegistry: - """Test the device mapping registry.""" - - def test_sensor_device(self): - """Test retrieving device based on ID.""" - r = spc.SpcRegistry() - r.register_sensor_device('1', 'dummy') - assert r.get_sensor_device('1') == 'dummy' - - def test_alarm_device(self): - """Test retrieving device based on zone name.""" - r = spc.SpcRegistry() - r.register_alarm_device('Area 51', 'dummy') - assert r.get_alarm_device('Area 51') == 'dummy' - - -class TestSpcWebGateway: - """Test the SPC Web Gateway API wrapper.""" - - @asyncio.coroutine - def test_get_areas(self, spcwebgw, aioclient_mock): - """Test area retrieval.""" - result = yield from spcwebgw.get_areas() - assert aioclient_mock.call_count == 1 - assert len(list(result)) == 2 - - @asyncio.coroutine - @pytest.mark.parametrize("url_command,command", [ - ('set', spc.SpcWebGateway.AREA_COMMAND_SET), - ('unset', spc.SpcWebGateway.AREA_COMMAND_UNSET), - ('set_a', spc.SpcWebGateway.AREA_COMMAND_PART_SET) - ]) - def test_area_commands(self, spcwebgw, url_command, command): - """Test alarm arming/disarming.""" - with mock_aiohttp_client() as aioclient_mock: - url = "http://localhost/spc/area/1/{}".format(url_command) - aioclient_mock.put(url, text='{}') - yield from spcwebgw.send_area_command('1', command) - assert aioclient_mock.call_count == 1 + + area_mock = Mock(spec=pyspcwebgw.area.Area, id='1', + mode=AreaMode.FULL_SET, last_changed_by='Sven') + area_mock.name = 'House' + area_mock.verified_alarm = False + + with patch('pyspcwebgw.SpcWebGateway.areas', + new_callable=PropertyMock) as mock_areas: + mock_areas.return_value = {'1': area_mock} + with patch('pyspcwebgw.SpcWebGateway.async_load_parameters', + return_value=mock_coro(True)): + assert await async_setup_component(hass, 'spc', config) is True + + await hass.async_block_till_done() + entity_id = 'alarm_control_panel.house' + + assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY + assert hass.states.get(entity_id).attributes['changed_by'] == 'Sven' + + area_mock.mode = AreaMode.UNSET + area_mock.last_changed_by = 'Anna' + await hass.data[DATA_API]._async_callback(area_mock) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED + assert hass.states.get(entity_id).attributes['changed_by'] == 'Anna' From 5ee4718e24bf0a0e44ab4cd5f930fdb8aa400c53 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Mon, 24 Sep 2018 10:11:49 +0200 Subject: [PATCH 148/178] Remove discovered MQTT Switch device when discovery topic is cleared (#16605) * Remove discovered device when discovery topic is cleared * Move entity removal away from mqtt discovery * Move discovery update to mixin class * Add testcase * Review comments --- homeassistant/components/mqtt/__init__.py | 34 ++++++++++ homeassistant/components/mqtt/discovery.py | 74 ++++++++++++---------- homeassistant/components/switch/mqtt.py | 31 ++++++--- tests/components/mqtt/test_discovery.py | 28 ++++++++ 4 files changed, 124 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index bcb0d60902bd8e..abc240a65cb8b6 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -92,6 +92,7 @@ ATTR_PAYLOAD_TEMPLATE = 'payload_template' ATTR_QOS = CONF_QOS ATTR_RETAIN = CONF_RETAIN +ATTR_DISCOVERY_HASH = 'discovery_hash' MAX_RECONNECT_WAIT = 300 # seconds @@ -833,3 +834,36 @@ def availability_message_received(topic: str, def available(self) -> bool: """Return if the device is available.""" return self._available + + +class MqttDiscoveryUpdate(Entity): + """Mixin used to handle updated discovery message.""" + + def __init__(self, discovery_hash) -> None: + """Initialize the discovery update mixin.""" + self._discovery_hash = discovery_hash + self._remove_signal = None + + async def async_added_to_hass(self) -> None: + """Subscribe to discovery updates.""" + from homeassistant.helpers.dispatcher import async_dispatcher_connect + from homeassistant.components.mqtt.discovery import ( + ALREADY_DISCOVERED, MQTT_DISCOVERY_UPDATED) + + @callback + def discovery_callback(payload): + """Handle discovery update.""" + _LOGGER.info("Got update for entity with hash: %s '%s'", + self._discovery_hash, payload) + if not payload: + # Empty payload: Remove component + _LOGGER.info("Removing component: %s", self.entity_id) + self.hass.async_create_task(self.async_remove()) + del self.hass.data[ALREADY_DISCOVERED][self._discovery_hash] + self._remove_signal() + + if self._discovery_hash: + self._remove_signal = async_dispatcher_connect( + self.hass, + MQTT_DISCOVERY_UPDATED.format(self._discovery_hash), + discovery_callback) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 689515f64c81e9..f42c1ed58e94c6 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -9,9 +9,10 @@ import re from homeassistant.components import mqtt -from homeassistant.components.mqtt import CONF_STATE_TOPIC +from homeassistant.components.mqtt import CONF_STATE_TOPIC, ATTR_DISCOVERY_HASH from homeassistant.const import CONF_PLATFORM from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) @@ -38,6 +39,7 @@ } ALREADY_DISCOVERED = 'mqtt_discovered_components' +MQTT_DISCOVERY_UPDATED = 'mqtt_discovery_updated_{}' async def async_start(hass, discovery_topic, hass_config): @@ -51,47 +53,53 @@ async def async_device_message_received(topic, payload, qos): _prefix_topic, component, node_id, object_id = match.groups() - try: - payload = json.loads(payload) - except ValueError: - _LOGGER.warning("Unable to parse JSON %s: %s", object_id, payload) - return - if component not in SUPPORTED_COMPONENTS: _LOGGER.warning("Component %s is not supported", component) return - payload = dict(payload) - platform = payload.get(CONF_PLATFORM, 'mqtt') - if platform not in ALLOWED_PLATFORMS.get(component, []): - _LOGGER.warning("Platform %s (component %s) is not allowed", - platform, component) - return - - payload[CONF_PLATFORM] = platform - if CONF_STATE_TOPIC not in payload: - payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( - discovery_topic, component, '%s/' % node_id if node_id else '', - object_id) - - if ALREADY_DISCOVERED not in hass.data: - hass.data[ALREADY_DISCOVERED] = set() - # If present, the node_id will be included in the discovered object id discovery_id = '_'.join((node_id, object_id)) if node_id else object_id - discovery_hash = (component, discovery_id) - if discovery_hash in hass.data[ALREADY_DISCOVERED]: - _LOGGER.info("Component has already been discovered: %s %s", - component, discovery_id) - return - - hass.data[ALREADY_DISCOVERED].add(discovery_hash) + if ALREADY_DISCOVERED not in hass.data: + hass.data[ALREADY_DISCOVERED] = {} - _LOGGER.info("Found new component: %s %s", component, discovery_id) + discovery_hash = (component, discovery_id) - await async_load_platform( - hass, component, platform, payload, hass_config) + if discovery_hash in hass.data[ALREADY_DISCOVERED]: + _LOGGER.info( + "Component has already been discovered: %s %s, sending update", + component, discovery_id) + async_dispatcher_send( + hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload) + elif payload: + # Add component + try: + payload = json.loads(payload) + except ValueError: + _LOGGER.warning("Unable to parse JSON %s: '%s'", + object_id, payload) + return + + payload = dict(payload) + platform = payload.get(CONF_PLATFORM, 'mqtt') + if platform not in ALLOWED_PLATFORMS.get(component, []): + _LOGGER.warning("Platform %s (component %s) is not allowed", + platform, component) + return + + payload[CONF_PLATFORM] = platform + if CONF_STATE_TOPIC not in payload: + payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( + discovery_topic, component, + '%s/' % node_id if node_id else '', object_id) + + hass.data[ALREADY_DISCOVERED][discovery_hash] = None + payload[ATTR_DISCOVERY_HASH] = discovery_hash + + _LOGGER.info("Found new component: %s %s", component, discovery_id) + + await async_load_platform( + hass, component, platform, payload, hass_config) await mqtt.async_subscribe( hass, discovery_topic + '/#', async_device_message_received, 0) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index f6075d5e49f86a..b79f8f12b877f6 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -11,9 +11,10 @@ from homeassistant.core import callback from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - MqttAvailability) + ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, + MqttDiscoveryUpdate) from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, @@ -56,7 +57,11 @@ async def async_setup_platform(hass, config, async_add_entities, if value_template is not None: value_template.hass = hass - async_add_entities([MqttSwitch( + discovery_hash = None + if discovery_info is not None and ATTR_DISCOVERY_HASH in discovery_info: + discovery_hash = discovery_info[ATTR_DISCOVERY_HASH] + + newswitch = MqttSwitch( config.get(CONF_NAME), config.get(CONF_ICON), config.get(CONF_STATE_TOPIC), @@ -73,10 +78,13 @@ async def async_setup_platform(hass, config, async_add_entities, config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_UNIQUE_ID), value_template, - )]) + discovery_hash, + ) + + async_add_entities([newswitch]) -class MqttSwitch(MqttAvailability, SwitchDevice): +class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, SwitchDevice): """Representation of a switch that can be toggled using MQTT.""" def __init__(self, name, icon, @@ -84,10 +92,11 @@ def __init__(self, name, icon, qos, retain, payload_on, payload_off, state_on, state_off, optimistic, payload_available, payload_not_available, unique_id: Optional[str], - value_template): + value_template, discovery_hash): """Initialize the MQTT switch.""" - super().__init__(availability_topic, qos, payload_available, - payload_not_available) + MqttAvailability.__init__(self, availability_topic, qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash) self._state = False self._name = name self._icon = icon @@ -102,10 +111,12 @@ def __init__(self, name, icon, self._optimistic = optimistic self._template = value_template self._unique_id = unique_id + self._discovery_hash = discovery_hash async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await super().async_added_to_hass() + await MqttAvailability.async_added_to_hass(self) + await MqttDiscoveryUpdate.async_added_to_hass(self) @callback def state_message_received(topic, payload, qos): diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 9e0ef14a3faca5..6de277eb48dec9 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -181,3 +181,31 @@ def test_non_duplicate_discovery(hass, mqtt_mock, caplog): assert state_duplicate is None assert 'Component has already been discovered: ' \ 'binary_sensor bla' in caplog.text + + +@asyncio.coroutine +def test_discovery_removal(hass, mqtt_mock, caplog): + """Test expansion of abbreviated discovery payload.""" + yield from async_start(hass, 'homeassistant', {}) + + data = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data) + yield from hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + '') + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is None From ad47ece5c6959d3af65a22b7316dc6c597b932b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Mon, 24 Sep 2018 10:17:24 +0200 Subject: [PATCH 149/178] Allow split component definitions in packages (#16177) * Allow split component definitions in packages Two different configuration styles are described in https://www.home-assistant.io/docs/configuration/devices/#style-2-list-each-device-separately But only one is allowed in packages according to https://www.home-assistant.io/docs/configuration/packages/ This change allows "Style 2" configuration in packages. * Added test for split component definition in packages --- homeassistant/config.py | 7 +++++-- tests/test_config.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 5474b283494e39..abcf027c49fe97 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -171,7 +171,7 @@ def _no_duplicate_auth_mfa_module(configs: Sequence[Dict[str, Any]]) \ PACKAGES_CONFIG_SCHEMA = vol.Schema({ cv.slug: vol.Schema( # Package names are slugs - {cv.slug: vol.Any(dict, list, None)}) # Only slugs for component names + {cv.string: vol.Any(dict, list, None)}) # Component configuration }) CUSTOMIZE_DICT_SCHEMA = vol.Schema({ @@ -662,7 +662,10 @@ def merge_packages_config(hass: HomeAssistant, config: Dict, packages: Dict, for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - component = get_component(hass, comp_name) + # If component name is given with a trailing description, remove it + # when looking for component + domain = comp_name.split(' ')[0] + component = get_component(hass, domain) if component is None: _log_pkg_error(pack_name, comp_name, config, "does not exist") diff --git a/tests/test_config.py b/tests/test_config.py index e4a6798093ffbb..0e53bc0cdfb228 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -965,3 +965,21 @@ async def test_disallowed_duplicated_auth_mfa_module_config(hass): } with pytest.raises(Invalid): await config_util.async_process_ha_core_config(hass, core_config) + + +def test_merge_split_component_definition(hass): + """Test components with trailing description in packages are merged.""" + packages = { + 'pack_1': {'light one': {'l1': None}}, + 'pack_2': {'light two': {'l2': None}, + 'light three': {'l3': None}}, + } + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + } + config_util.merge_packages_config(hass, config, packages) + + assert len(config) == 4 + assert len(config['light one']) == 1 + assert len(config['light two']) == 1 + assert len(config['light three']) == 1 From 7a77951bb4ebb46cc4e5e628e42ddd06d00d7ba3 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Mon, 24 Sep 2018 02:06:50 -0700 Subject: [PATCH 150/178] Add Notify MFA module (#16314) * Add Notify MFA * Fix unit test * Address review comment, change storage implementation * Add retry limit to mfa module * Fix loading * Fix invalaid login log processing * Typing * Change default message template * Change one-time password to 8 digit * Refactoring to not save secret * Bug fixing * Change async_initialize method name to aysnc_initialize_login_mfa_step * Address some simple fix code review comment --- homeassistant/auth/const.py | 1 + homeassistant/auth/mfa_modules/__init__.py | 6 +- .../auth/mfa_modules/insecure_example.py | 2 +- homeassistant/auth/mfa_modules/notify.py | 325 ++++++++++++++ homeassistant/auth/mfa_modules/totp.py | 3 +- homeassistant/auth/providers/__init__.py | 24 +- .../components/auth/.translations/en.json | 19 + homeassistant/components/auth/login_flow.py | 5 +- homeassistant/components/auth/strings.json | 19 + homeassistant/config.py | 2 +- requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../auth/mfa_modules/test_insecure_example.py | 8 +- tests/auth/mfa_modules/test_notify.py | 397 ++++++++++++++++++ tests/auth/mfa_modules/test_totp.py | 6 +- tests/auth/test_init.py | 4 +- 16 files changed, 799 insertions(+), 24 deletions(-) create mode 100644 homeassistant/auth/mfa_modules/notify.py create mode 100644 tests/auth/mfa_modules/test_notify.py diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 082d8966275670..2e57986958c39a 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -2,3 +2,4 @@ from datetime import timedelta ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) +MFA_SESSION_EXPIRATION = timedelta(minutes=5) diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 603ca6ff3b16d7..1746ef38f9580a 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -1,5 +1,4 @@ """Plugable auth modules for Home Assistant.""" -from datetime import timedelta import importlib import logging import types @@ -23,8 +22,6 @@ vol.Optional(CONF_ID): str, }, extra=vol.ALLOW_EXTRA) -SESSION_EXPIRATION = timedelta(minutes=5) - DATA_REQS = 'mfa_auth_module_reqs_processed' _LOGGER = logging.getLogger(__name__) @@ -34,6 +31,7 @@ class MultiFactorAuthModule: """Multi-factor Auth Module of validation function.""" DEFAULT_TITLE = 'Unnamed auth module' + MAX_RETRY_TIME = 3 def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize an auth module.""" @@ -84,7 +82,7 @@ async def async_is_user_setup(self, user_id: str) -> bool: """Return whether user is setup.""" raise NotImplementedError - async def async_validation( + async def async_validate( self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" raise NotImplementedError diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 9c72111ef9697f..9804cbcf635883 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -77,7 +77,7 @@ async def async_is_user_setup(self, user_id: str) -> bool: return True return False - async def async_validation( + async def async_validate( self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" for data in self._data: diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py new file mode 100644 index 00000000000000..84f9de614c169a --- /dev/null +++ b/homeassistant/auth/mfa_modules/notify.py @@ -0,0 +1,325 @@ +"""HMAC-based One-time Password auth module. + +Sending HOTP through notify service +""" +import logging +from collections import OrderedDict +from typing import Any, Dict, Optional, Tuple, List # noqa: F401 + +import attr +import voluptuous as vol + +from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv + +from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow + +REQUIREMENTS = ['pyotp==2.2.6'] + +CONF_MESSAGE = 'message' + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MESSAGE, + default='{} is your Home Assistant login code'): str +}, extra=vol.PREVENT_EXTRA) + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_module.notify' +STORAGE_USERS = 'users' +STORAGE_USER_ID = 'user_id' + +INPUT_FIELD_CODE = 'code' + +_LOGGER = logging.getLogger(__name__) + + +def _generate_secret() -> str: + """Generate a secret.""" + import pyotp + return str(pyotp.random_base32()) + + +def _generate_random() -> int: + """Generate a 8 digit number.""" + import pyotp + return int(pyotp.random_base32(length=8, chars=list('1234567890'))) + + +def _generate_otp(secret: str, count: int) -> str: + """Generate one time password.""" + import pyotp + return str(pyotp.HOTP(secret).at(count)) + + +def _verify_otp(secret: str, otp: str, count: int) -> bool: + """Verify one time password.""" + import pyotp + return bool(pyotp.HOTP(secret).verify(otp, count)) + + +@attr.s(slots=True) +class NotifySetting: + """Store notify setting for one user.""" + + secret = attr.ib(type=str, factory=_generate_secret) # not persistent + counter = attr.ib(type=int, factory=_generate_random) # not persistent + notify_service = attr.ib(type=Optional[str], default=None) + target = attr.ib(type=Optional[str], default=None) + + +_UsersDict = Dict[str, NotifySetting] + + +@MULTI_FACTOR_AUTH_MODULES.register('notify') +class NotifyAuthModule(MultiFactorAuthModule): + """Auth module send hmac-based one time password by notify service.""" + + DEFAULT_TITLE = 'Notify One-Time Password' + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._user_settings = None # type: Optional[_UsersDict] + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY) + self._include = config.get(CONF_INCLUDE, []) + self._exclude = config.get(CONF_EXCLUDE, []) + self._message_template = config[CONF_MESSAGE] + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({INPUT_FIELD_CODE: str}) + + async def _async_load(self) -> None: + """Load stored data.""" + data = await self._user_store.async_load() + + if data is None: + data = {STORAGE_USERS: {}} + + self._user_settings = { + user_id: NotifySetting(**setting) + for user_id, setting in data.get(STORAGE_USERS, {}).items() + } + + async def _async_save(self) -> None: + """Save data.""" + if self._user_settings is None: + return + + await self._user_store.async_save({STORAGE_USERS: { + user_id: attr.asdict( + notify_setting, filter=attr.filters.exclude( + attr.fields(NotifySetting).secret, + attr.fields(NotifySetting).counter, + )) + for user_id, notify_setting + in self._user_settings.items() + }}) + + @callback + def aync_get_available_notify_services(self) -> List[str]: + """Return list of notify services.""" + unordered_services = set() + + for service in self.hass.services.async_services().get('notify', {}): + if service not in self._exclude: + unordered_services.add(service) + + if self._include: + unordered_services &= set(self._include) + + return sorted(unordered_services) + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + return NotifySetupFlow( + self, self.input_schema, user_id, + self.aync_get_available_notify_services()) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: + """Set up auth module for user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + self._user_settings[user_id] = NotifySetting( + notify_service=setup_data.get('notify_service'), + target=setup_data.get('target'), + ) + + await self._async_save() + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + if self._user_settings.pop(user_id, None): + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + return user_id in self._user_settings + + async def async_validate( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + return False + + # user_input has been validate in caller + return await self.hass.async_add_executor_job( + _verify_otp, notify_setting.secret, + user_input.get(INPUT_FIELD_CODE, ''), + notify_setting.counter) + + async def async_initialize_login_mfa_step(self, user_id: str) -> None: + """Generate code and notify user.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + raise ValueError('Cannot find user_id') + + def generate_secret_and_one_time_password() -> str: + """Generate and send one time password.""" + assert notify_setting + # secret and counter are not persistent + notify_setting.secret = _generate_secret() + notify_setting.counter = _generate_random() + return _generate_otp( + notify_setting.secret, notify_setting.counter) + + code = await self.hass.async_add_executor_job( + generate_secret_and_one_time_password) + + await self.async_notify_user(user_id, code) + + async def async_notify_user(self, user_id: str, code: str) -> None: + """Send code by user's notify service.""" + if self._user_settings is None: + await self._async_load() + assert self._user_settings is not None + + notify_setting = self._user_settings.get(user_id, None) + if notify_setting is None: + _LOGGER.error('Cannot find user %s', user_id) + return + + await self.async_notify( # type: ignore + code, notify_setting.notify_service, notify_setting.target) + + async def async_notify(self, code: str, notify_service: str, + target: Optional[str] = None) -> None: + """Send code by notify service.""" + data = {'message': self._message_template.format(code)} + if target: + data['target'] = [target] + + await self.hass.services.async_call('notify', notify_service, data) + + +class NotifySetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__(self, auth_module: NotifyAuthModule, + setup_schema: vol.Schema, + user_id: str, + available_notify_services: List[str]) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, setup_schema, user_id) + # to fix typing complaint + self._auth_module = auth_module # type: NotifyAuthModule + self._available_notify_services = available_notify_services + self._secret = None # type: Optional[str] + self._count = None # type: Optional[int] + self._notify_service = None # type: Optional[str] + self._target = None # type: Optional[str] + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Let user select available notify services.""" + errors = {} # type: Dict[str, str] + + hass = self._auth_module.hass + if user_input: + self._notify_service = user_input['notify_service'] + self._target = user_input.get('target') + self._secret = await hass.async_add_executor_job(_generate_secret) + self._count = await hass.async_add_executor_job(_generate_random) + + return await self.async_step_setup() + + if not self._available_notify_services: + return self.async_abort(reason='no_available_service') + + schema = OrderedDict() # type: Dict[str, Any] + schema['notify_service'] = vol.In(self._available_notify_services) + schema['target'] = vol.Optional(str) + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors + ) + + async def async_step_setup( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Verify user can recevie one-time password.""" + errors = {} # type: Dict[str, str] + + hass = self._auth_module.hass + if user_input: + verified = await hass.async_add_executor_job( + _verify_otp, self._secret, user_input['code'], self._count) + if verified: + await self._auth_module.async_setup_user( + self._user_id, { + 'notify_service': self._notify_service, + 'target': self._target, + }) + return self.async_create_entry( + title=self._auth_module.name, + data={} + ) + + errors['base'] = 'invalid_code' + + # generate code every time, no retry logic + assert self._secret and self._count + code = await hass.async_add_executor_job( + _generate_otp, self._secret, self._count) + + assert self._notify_service + await self._auth_module.async_notify( + code, self._notify_service, self._target) + + return self.async_show_form( + step_id='setup', + data_schema=self._setup_schema, + description_placeholders={'notify_service': self._notify_service}, + errors=errors, + ) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 50cd9d334660b0..625cc0302e1a26 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -60,6 +60,7 @@ class TotpAuthModule(MultiFactorAuthModule): """Auth module validate time-based one time password.""" DEFAULT_TITLE = 'Time-based One Time Password' + MAX_RETRY_TIME = 5 def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize the user data store.""" @@ -130,7 +131,7 @@ async def async_is_user_setup(self, user_id: str) -> bool: return user_id in self._users # type: ignore - async def async_validation( + async def async_validate( self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" if self._users is None: diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 3cb1c6b121e4cc..e96f6d7ebbaf38 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -15,8 +15,8 @@ from homeassistant.util.decorator import Registry from ..auth_store import AuthStore +from ..const import MFA_SESSION_EXPIRATION from ..models import Credentials, User, UserMeta # noqa: F401 -from ..mfa_modules import SESSION_EXPIRATION _LOGGER = logging.getLogger(__name__) DATA_REQS = 'auth_prov_reqs_processed' @@ -171,6 +171,7 @@ def __init__(self, auth_provider: AuthProvider) -> None: self._auth_manager = auth_provider.hass.auth # type: ignore self.available_mfa_modules = {} # type: Dict[str, str] self.created_at = dt_util.utcnow() + self.invalid_mfa_times = 0 self.user = None # type: Optional[User] async def async_step_init( @@ -212,6 +213,8 @@ async def async_step_mfa( self, user_input: Optional[Dict[str, str]] = None) \ -> Dict[str, Any]: """Handle the step of mfa validation.""" + assert self.user + errors = {} auth_module = self._auth_manager.get_auth_mfa_module( @@ -221,25 +224,34 @@ async def async_step_mfa( # will show invalid_auth_module error return await self.async_step_select_mfa_module(user_input={}) + if user_input is None and hasattr(auth_module, + 'async_initialize_login_mfa_step'): + await auth_module.async_initialize_login_mfa_step(self.user.id) + if user_input is not None: - expires = self.created_at + SESSION_EXPIRATION + expires = self.created_at + MFA_SESSION_EXPIRATION if dt_util.utcnow() > expires: return self.async_abort( reason='login_expired' ) - result = await auth_module.async_validation( - self.user.id, user_input) # type: ignore + result = await auth_module.async_validate( + self.user.id, user_input) if not result: errors['base'] = 'invalid_code' + self.invalid_mfa_times += 1 + if self.invalid_mfa_times >= auth_module.MAX_RETRY_TIME > 0: + return self.async_abort( + reason='too_many_retry' + ) if not errors: return await self.async_finish(self.user) description_placeholders = { 'mfa_module_name': auth_module.name, - 'mfa_module_id': auth_module.id - } # type: Dict[str, str] + 'mfa_module_id': auth_module.id, + } # type: Dict[str, Optional[str]] return self.async_show_form( step_id='mfa', diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json index a0fd20e9d083b1..21cb45e3050a22 100644 --- a/homeassistant/components/auth/.translations/en.json +++ b/homeassistant/components/auth/.translations/en.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No available notify services." + }, + "error": { + "invalid_code": "Invalid code, please try again." + }, + "step": { + "init": { + "description": "Please select one of notify service:", + "title": "Set up one-time password delivered by notify component" + }, + "setup": { + "description": "A one-time password have sent by **notify.{notify_service}**. Please input it in below:", + "title": "Verify setup" + } + }, + "title": "Notify One-Time Password" + }, "totp": { "error": { "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 73a739c2960187..3a51cf8066f783 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -226,8 +226,9 @@ async def post(self, request, flow_id, data): if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200 # need manually log failed login attempts - if result['errors'] is not None and \ - result['errors'].get('base') == 'invalid_auth': + if (result.get('errors') is not None and + result['errors'].get('base') in ['invalid_auth', + 'invalid_code']): await process_wrong_login(request) return self.json(_prepare_result_json(result)) diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index b0083ab577b4c0..2b1fc0c94f6f74 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -11,6 +11,25 @@ "error": { "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." } + }, + "notify": { + "title": "Notify One-Time Password", + "step": { + "init": { + "title": "Set up one-time password delivered by notify component", + "description": "Please select one of notify service:" + }, + "setup": { + "title": "Verify setup", + "description": "A one-time password have sent by **notify.{notify_service}**. Please input it in below:" + } + }, + "abort": { + "no_available_service": "No available notify services." + }, + "error": { + "invalid_code": "Invalid code, please try again." + } } } } diff --git a/homeassistant/config.py b/homeassistant/config.py index abcf027c49fe97..98857d8a83d6b7 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -475,7 +475,7 @@ async def async_process_ha_core_config( auth_conf.append({'type': 'trusted_networks'}) mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [ - {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'} + {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'}, ]) setattr(hass, 'auth', await auth.auth_manager_from_config( diff --git a/requirements_all.txt b/requirements_all.txt index ba353af0d48f52..70c05090fb01bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,6 +1020,7 @@ pyota==2.0.5 # homeassistant.components.climate.opentherm_gw pyotgw==0.1b0 +# homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp # homeassistant.components.sensor.otp pyotp==2.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 171650b867d4bd..133b97c268733c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,6 +160,7 @@ pynx584==0.4 # homeassistant.components.openuv pyopenuv==1.0.4 +# homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp # homeassistant.components.sensor.otp pyotp==2.2.6 diff --git a/tests/auth/mfa_modules/test_insecure_example.py b/tests/auth/mfa_modules/test_insecure_example.py index 80109627140d58..d9330d5f6e8c7a 100644 --- a/tests/auth/mfa_modules/test_insecure_example.py +++ b/tests/auth/mfa_modules/test_insecure_example.py @@ -12,15 +12,15 @@ async def test_validate(hass): 'data': [{'user_id': 'test-user', 'pin': '123456'}] }) - result = await auth_module.async_validation( + result = await auth_module.async_validate( 'test-user', {'pin': '123456'}) assert result is True - result = await auth_module.async_validation( + result = await auth_module.async_validate( 'test-user', {'pin': 'invalid'}) assert result is False - result = await auth_module.async_validation( + result = await auth_module.async_validate( 'invalid-user', {'pin': '123456'}) assert result is False @@ -36,7 +36,7 @@ async def test_setup_user(hass): 'test-user', {'pin': '123456'}) assert len(auth_module._data) == 1 - result = await auth_module.async_validation( + result = await auth_module.async_validate( 'test-user', {'pin': '123456'}) assert result is True diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py new file mode 100644 index 00000000000000..ffe0b103fc955f --- /dev/null +++ b/tests/auth/mfa_modules/test_notify.py @@ -0,0 +1,397 @@ +"""Test the HMAC-based One Time Password (MFA) auth module.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.auth import models as auth_models, auth_manager_from_config +from homeassistant.auth.mfa_modules import auth_mfa_module_from_config +from homeassistant.components.notify import NOTIFY_SERVICE_SCHEMA +from tests.common import MockUser, async_mock_service + +MOCK_CODE = '123456' +MOCK_CODE_2 = '654321' + + +async def test_validating_mfa(hass): + """Test validating mfa code.""" + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify' + }) + await notify_auth_module.async_setup_user('test-user', { + 'notify_service': 'dummy' + }) + + with patch('pyotp.HOTP.verify', return_value=True): + assert await notify_auth_module.async_validate( + 'test-user', {'code': MOCK_CODE}) + + +async def test_validating_mfa_invalid_code(hass): + """Test validating an invalid mfa code.""" + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify' + }) + await notify_auth_module.async_setup_user('test-user', { + 'notify_service': 'dummy' + }) + + with patch('pyotp.HOTP.verify', return_value=False): + assert await notify_auth_module.async_validate( + 'test-user', {'code': MOCK_CODE}) is False + + +async def test_validating_mfa_invalid_user(hass): + """Test validating an mfa code with invalid user.""" + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify' + }) + await notify_auth_module.async_setup_user('test-user', { + 'notify_service': 'dummy' + }) + + assert await notify_auth_module.async_validate( + 'invalid-user', {'code': MOCK_CODE}) is False + + +async def test_validating_mfa_counter(hass): + """Test counter will move only after generate code.""" + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify' + }) + await notify_auth_module.async_setup_user('test-user', { + 'counter': 0, + 'notify_service': 'dummy', + }) + + assert notify_auth_module._user_settings + notify_setting = list(notify_auth_module._user_settings.values())[0] + init_count = notify_setting.counter + assert init_count is not None + + with patch('pyotp.HOTP.at', return_value=MOCK_CODE): + await notify_auth_module.async_initialize_login_mfa_step('test-user') + + notify_setting = list(notify_auth_module._user_settings.values())[0] + after_generate_count = notify_setting.counter + assert after_generate_count != init_count + + with patch('pyotp.HOTP.verify', return_value=True): + assert await notify_auth_module.async_validate( + 'test-user', {'code': MOCK_CODE}) + + notify_setting = list(notify_auth_module._user_settings.values())[0] + assert after_generate_count == notify_setting.counter + + with patch('pyotp.HOTP.verify', return_value=False): + assert await notify_auth_module.async_validate( + 'test-user', {'code': MOCK_CODE}) is False + + notify_setting = list(notify_auth_module._user_settings.values())[0] + assert after_generate_count == notify_setting.counter + + +async def test_setup_depose_user(hass): + """Test set up and despose user.""" + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify' + }) + await notify_auth_module.async_setup_user('test-user', {}) + assert len(notify_auth_module._user_settings) == 1 + await notify_auth_module.async_setup_user('test-user', {}) + assert len(notify_auth_module._user_settings) == 1 + + await notify_auth_module.async_depose_user('test-user') + assert len(notify_auth_module._user_settings) == 0 + + await notify_auth_module.async_setup_user( + 'test-user2', {'secret': 'secret-code'}) + assert len(notify_auth_module._user_settings) == 1 + + +async def test_login_flow_validates_mfa(hass): + """Test login flow with mfa enabled.""" + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{'username': 'test-user', 'password': 'test-pass'}], + }], [{ + 'type': 'notify', + }]) + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(hass.auth) + await hass.auth.async_link_user(user, auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + + notify_calls = async_mock_service(hass, 'notify', 'test-notify', + NOTIFY_SERVICE_SCHEMA) + + await hass.auth.async_enable_user_mfa(user, 'notify', { + 'notify_service': 'test-notify', + }) + + provider = hass.auth.auth_providers[0] + + result = await hass.auth.login_flow.async_init( + (provider.type, provider.id)) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'test-user', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + with patch('pyotp.HOTP.at', return_value=MOCK_CODE): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], + { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['data_schema'].schema.get('code') == str + + # wait service call finished + await hass.async_block_till_done() + + assert len(notify_calls) == 1 + notify_call = notify_calls[0] + assert notify_call.domain == 'notify' + assert notify_call.service == 'test-notify' + message = notify_call.data['message'] + message.hass = hass + assert MOCK_CODE in message.async_render() + + with patch('pyotp.HOTP.verify', return_value=False): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': 'invalid-code'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['errors']['base'] == 'invalid_code' + + # wait service call finished + await hass.async_block_till_done() + + # would not send new code, allow user retry + assert len(notify_calls) == 1 + + # retry twice + with patch('pyotp.HOTP.verify', return_value=False), \ + patch('pyotp.HOTP.at', return_value=MOCK_CODE_2): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': 'invalid-code'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['errors']['base'] == 'invalid_code' + + # after the 3rd failure, flow abort + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': 'invalid-code'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'too_many_retry' + + # wait service call finished + await hass.async_block_till_done() + + # restart login + result = await hass.auth.login_flow.async_init( + (provider.type, provider.id)) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + with patch('pyotp.HOTP.at', return_value=MOCK_CODE): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], + { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['data_schema'].schema.get('code') == str + + # wait service call finished + await hass.async_block_till_done() + + assert len(notify_calls) == 2 + notify_call = notify_calls[1] + assert notify_call.domain == 'notify' + assert notify_call.service == 'test-notify' + message = notify_call.data['message'] + message.hass = hass + assert MOCK_CODE in message.async_render() + + with patch('pyotp.HOTP.verify', return_value=True): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': MOCK_CODE}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'].id == 'mock-user' + + +async def test_setup_user_notify_service(hass): + """Test allow select notify service during mfa setup.""" + notify_calls = async_mock_service( + hass, 'notify', 'test1', NOTIFY_SERVICE_SCHEMA) + async_mock_service(hass, 'notify', 'test2', NOTIFY_SERVICE_SCHEMA) + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify', + }) + + services = notify_auth_module.aync_get_available_notify_services() + assert services == ['test1', 'test2'] + + flow = await notify_auth_module.async_setup_flow('test-user') + step = await flow.async_step_init() + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + assert step['step_id'] == 'init' + schema = step['data_schema'] + schema({'notify_service': 'test2'}) + + with patch('pyotp.HOTP.at', return_value=MOCK_CODE): + step = await flow.async_step_init({'notify_service': 'test1'}) + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + assert step['step_id'] == 'setup' + + # wait service call finished + await hass.async_block_till_done() + + assert len(notify_calls) == 1 + notify_call = notify_calls[0] + assert notify_call.domain == 'notify' + assert notify_call.service == 'test1' + message = notify_call.data['message'] + message.hass = hass + assert MOCK_CODE in message.async_render() + + with patch('pyotp.HOTP.at', return_value=MOCK_CODE_2): + step = await flow.async_step_setup({'code': 'invalid'}) + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + assert step['step_id'] == 'setup' + assert step['errors']['base'] == 'invalid_code' + + # wait service call finished + await hass.async_block_till_done() + + assert len(notify_calls) == 2 + notify_call = notify_calls[1] + assert notify_call.domain == 'notify' + assert notify_call.service == 'test1' + message = notify_call.data['message'] + message.hass = hass + assert MOCK_CODE_2 in message.async_render() + + with patch('pyotp.HOTP.verify', return_value=True): + step = await flow.async_step_setup({'code': MOCK_CODE_2}) + assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_include_exclude_config(hass): + """Test allow include exclude config.""" + async_mock_service(hass, 'notify', 'include1', NOTIFY_SERVICE_SCHEMA) + async_mock_service(hass, 'notify', 'include2', NOTIFY_SERVICE_SCHEMA) + async_mock_service(hass, 'notify', 'exclude1', NOTIFY_SERVICE_SCHEMA) + async_mock_service(hass, 'notify', 'exclude2', NOTIFY_SERVICE_SCHEMA) + async_mock_service(hass, 'other', 'include3', NOTIFY_SERVICE_SCHEMA) + async_mock_service(hass, 'other', 'exclude3', NOTIFY_SERVICE_SCHEMA) + + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify', + 'exclude': ['exclude1', 'exclude2', 'exclude3'], + }) + services = notify_auth_module.aync_get_available_notify_services() + assert services == ['include1', 'include2'] + + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify', + 'include': ['include1', 'include2', 'include3'], + }) + services = notify_auth_module.aync_get_available_notify_services() + assert services == ['include1', 'include2'] + + # exclude has high priority than include + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify', + 'include': ['include1', 'include2', 'include3'], + 'exclude': ['exclude1', 'exclude2', 'include2'], + }) + services = notify_auth_module.aync_get_available_notify_services() + assert services == ['include1'] + + +async def test_setup_user_no_notify_service(hass): + """Test setup flow abort if there is no avilable notify service.""" + async_mock_service(hass, 'notify', 'test1', NOTIFY_SERVICE_SCHEMA) + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify', + 'exclude': 'test1', + }) + + services = notify_auth_module.aync_get_available_notify_services() + assert services == [] + + flow = await notify_auth_module.async_setup_flow('test-user') + step = await flow.async_step_init() + assert step['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert step['reason'] == 'no_available_service' + + +async def test_not_raise_exception_when_service_not_exist(hass): + """Test login flow will not raise exception when notify service error.""" + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{'username': 'test-user', 'password': 'test-pass'}], + }], [{ + 'type': 'notify', + }]) + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(hass.auth) + await hass.auth.async_link_user(user, auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + + await hass.auth.async_enable_user_mfa(user, 'notify', { + 'notify_service': 'invalid-notify', + }) + + provider = hass.auth.auth_providers[0] + + result = await hass.auth.login_flow.async_init( + (provider.type, provider.id)) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + with patch('pyotp.HOTP.at', return_value=MOCK_CODE): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], + { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['data_schema'].schema.get('code') == str + + # wait service call finished + await hass.async_block_till_done() diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py index 6e3558ec5496e3..d400fe80672d89 100644 --- a/tests/auth/mfa_modules/test_totp.py +++ b/tests/auth/mfa_modules/test_totp.py @@ -17,7 +17,7 @@ async def test_validating_mfa(hass): await totp_auth_module.async_setup_user('test-user', {}) with patch('pyotp.TOTP.verify', return_value=True): - assert await totp_auth_module.async_validation( + assert await totp_auth_module.async_validate( 'test-user', {'code': MOCK_CODE}) @@ -29,7 +29,7 @@ async def test_validating_mfa_invalid_code(hass): await totp_auth_module.async_setup_user('test-user', {}) with patch('pyotp.TOTP.verify', return_value=False): - assert await totp_auth_module.async_validation( + assert await totp_auth_module.async_validate( 'test-user', {'code': MOCK_CODE}) is False @@ -40,7 +40,7 @@ async def test_validating_mfa_invalid_user(hass): }) await totp_auth_module.async_setup_user('test-user', {}) - assert await totp_auth_module.async_validation( + assert await totp_auth_module.async_validate( 'invalid-user', {'code': MOCK_CODE}) is False diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 8325bd2551aa30..8fd9b8930e4109 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -9,7 +9,7 @@ from homeassistant import auth, data_entry_flow from homeassistant.auth import ( models as auth_models, auth_store, const as auth_const) -from homeassistant.auth.mfa_modules import SESSION_EXPIRATION +from homeassistant.auth.const import MFA_SESSION_EXPIRATION from homeassistant.util import dt as dt_util from tests.common import ( MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID) @@ -720,7 +720,7 @@ async def test_auth_module_expired_session(mock_hass): assert step['step_id'] == 'mfa' with patch('homeassistant.util.dt.utcnow', - return_value=dt_util.utcnow() + SESSION_EXPIRATION): + return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure(step['flow_id'], { 'pin': 'test-pin', }) From 1f74adae2a87aac84dc2499af625e26252eda043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 24 Sep 2018 11:10:33 +0200 Subject: [PATCH 151/178] Broadlink service name (#16345) * Broadlink. slugify service name * style --- homeassistant/components/switch/broadlink.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 3dd8eafcf1f267..e61158723904be 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -142,9 +142,11 @@ def _get_mp1_slot_name(switch_friendly_name, slot): if switch_type in RM_TYPES: broadlink_device = broadlink.rm((ip_addr, 80), mac_addr, None) hass.services.register(DOMAIN, SERVICE_LEARN + '_' + - ip_addr.replace('.', '_'), _learn_command) + slugify(ip_addr.replace('.', '_')), + _learn_command) hass.services.register(DOMAIN, SERVICE_SEND + '_' + - ip_addr.replace('.', '_'), _send_packet, + slugify(ip_addr.replace('.', '_')), + _send_packet, vol.Schema({'packet': cv.ensure_list})) switches = [] for object_id, device_config in devices.items(): From 33d6c99f19fdd22b6ec7fca5bc4ba94da95b410a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 24 Sep 2018 12:11:24 +0300 Subject: [PATCH 152/178] Add Python 3.7 classifier (#16645) --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index b2bbb4081f004a..936840acfaa1d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,6 +13,7 @@ classifier = Operating System :: OS Independent Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 Topic :: Home Automation [tool:pytest] From 589554ad16887ef0cd01a732201449fa370cbc1c Mon Sep 17 00:00:00 2001 From: Robin Clarke Date: Mon, 24 Sep 2018 11:16:28 +0200 Subject: [PATCH 153/178] Allow soundtouch to play https content too (#16713) --- homeassistant/components/media_player/soundtouch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index a16658501cb9f4..b8ade374a46daa 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -297,7 +297,7 @@ def media_album_name(self): def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) - if re.match(r'http://', str(media_id)): + if re.match(r'https?://', str(media_id)): # URL _LOGGER.debug("Playing URL %s", str(media_id)) self._device.play_url(str(media_id)) From dc1534c6d148d9a387f4b882980b864a81b4ffc7 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Mon, 24 Sep 2018 02:43:00 -0700 Subject: [PATCH 154/178] Fix some unhandled exceptions due to missing null checks (#16812) Fixed a couple cases that would produce errors when the ISY node status was None or `-inf`. --- homeassistant/components/cover/isy994.py | 2 ++ homeassistant/components/light/isy994.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 428c1f326e4116..4ead61e6b7a0f0 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -44,6 +44,8 @@ class ISYCoverDevice(ISYDevice, CoverDevice): @property def current_cover_position(self) -> int: """Return the current cover position.""" + if self.is_unknown() or self.value is None: + return None return sorted((0, self.value, 100))[1] @property diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 06507eaeca6af9..4349bfa1467947 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -31,7 +31,7 @@ class ISYLightDevice(ISYDevice, Light): @property def is_on(self) -> bool: """Get whether the ISY994 light is on.""" - return self.value > 0 + return self.value != 0 @property def brightness(self) -> float: From b2a9e203f2c38b2f783b2ae3cecc53249cf3db2c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 11:57:56 +0200 Subject: [PATCH 155/178] Update frontend to 20180924.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 73282830bbc2e1..d3d25255508c27 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180920.0'] +REQUIREMENTS = ['home-assistant-frontend==20180924.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 70c05090fb01bd..b9550d032ab080 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,7 +454,7 @@ hole==0.3.0 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180920.0 +home-assistant-frontend==20180924.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 133b97c268733c..44baabc7f5d34a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ hdate==0.6.3 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180920.0 +home-assistant-frontend==20180924.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From d4b239d1d46df6bcdd6fb8bb729eb3a0cfb6e087 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 12:01:01 +0200 Subject: [PATCH 156/178] Update translations --- .../components/auth/.translations/id.json | 16 +++++++++ .../components/auth/.translations/nn.json | 16 +++++++++ .../components/auth/.translations/pt-BR.json | 15 +++++++++ .../components/cast/.translations/id.json | 15 +++++++++ .../components/cast/.translations/nn.json | 15 +++++++++ .../components/deconz/.translations/ca.json | 2 +- .../components/deconz/.translations/id.json | 33 +++++++++++++++++++ .../components/deconz/.translations/ko.json | 2 +- .../components/deconz/.translations/nn.json | 33 +++++++++++++++++++ .../components/hangouts/.translations/id.json | 31 +++++++++++++++++ .../components/hangouts/.translations/nn.json | 29 ++++++++++++++++ .../hangouts/.translations/pt-BR.json | 9 +++-- .../components/hangouts/strings.json | 2 -- .../homematicip_cloud/.translations/en.json | 1 - .../homematicip_cloud/.translations/id.json | 31 +++++++++++++++++ .../homematicip_cloud/.translations/ko.json | 2 +- .../homematicip_cloud/.translations/nn.json | 31 +++++++++++++++++ .../components/hue/.translations/id.json | 29 ++++++++++++++++ .../components/hue/.translations/nn.json | 29 ++++++++++++++++ .../components/ios/.translations/de.json | 12 +++++++ .../components/ios/.translations/id.json | 14 ++++++++ .../components/ios/.translations/ko.json | 4 +-- .../components/ios/.translations/lb.json | 14 ++++++++ .../components/ios/.translations/nn.json | 14 ++++++++ .../components/ios/.translations/no.json | 14 ++++++++ .../components/ios/.translations/pt-BR.json | 14 ++++++++ .../components/ios/.translations/pt.json | 14 ++++++++ .../components/mqtt/.translations/de.json | 15 +++++++++ .../components/mqtt/.translations/id.json | 23 +++++++++++++ .../components/mqtt/.translations/ko.json | 4 +-- .../components/mqtt/.translations/lb.json | 23 +++++++++++++ .../components/mqtt/.translations/nl.json | 17 ++++++++++ .../components/mqtt/.translations/nn.json | 23 +++++++++++++ .../components/mqtt/.translations/no.json | 23 +++++++++++++ .../components/mqtt/.translations/pt-BR.json | 19 +++++++++++ .../components/mqtt/.translations/pt.json | 23 +++++++++++++ .../components/nest/.translations/ca.json | 2 +- .../components/nest/.translations/id.json | 33 +++++++++++++++++++ .../components/nest/.translations/nn.json | 33 +++++++++++++++++++ .../components/openuv/.translations/id.json | 20 +++++++++++ .../components/openuv/.translations/lb.json | 20 +++++++++++ .../components/openuv/.translations/nn.json | 20 +++++++++++ .../openuv/.translations/pt-BR.json | 20 +++++++++++ .../components/openuv/.translations/pt.json | 20 +++++++++++ .../sensor/.translations/moon.id.json | 12 +++++++ .../sensor/.translations/moon.nn.json | 12 +++++++ .../sensor/.translations/season.id.json | 8 +++++ .../sensor/.translations/season.nn.json | 8 +++++ .../components/sonos/.translations/id.json | 15 +++++++++ .../components/sonos/.translations/nn.json | 15 +++++++++ .../components/tradfri/.translations/ca.json | 23 +++++++++++++ .../components/tradfri/.translations/fr.json | 23 +++++++++++++ .../components/tradfri/.translations/hu.json | 13 ++++++++ .../components/tradfri/.translations/id.json | 23 +++++++++++++ .../components/tradfri/.translations/ko.json | 23 +++++++++++++ .../components/tradfri/.translations/lb.json | 23 +++++++++++++ .../components/tradfri/.translations/nl.json | 23 +++++++++++++ .../components/tradfri/.translations/nn.json | 23 +++++++++++++ .../components/tradfri/.translations/no.json | 23 +++++++++++++ .../tradfri/.translations/pt-BR.json | 21 ++++++++++++ .../components/tradfri/.translations/pt.json | 23 +++++++++++++ .../components/tradfri/.translations/ru.json | 23 +++++++++++++ .../tradfri/.translations/zh-Hans.json | 12 +++++++ .../tradfri/.translations/zh-Hant.json | 23 +++++++++++++ .../components/zone/.translations/id.json | 21 ++++++++++++ .../components/zone/.translations/nn.json | 21 ++++++++++++ 66 files changed, 1177 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/auth/.translations/id.json create mode 100644 homeassistant/components/auth/.translations/nn.json create mode 100644 homeassistant/components/auth/.translations/pt-BR.json create mode 100644 homeassistant/components/cast/.translations/id.json create mode 100644 homeassistant/components/cast/.translations/nn.json create mode 100644 homeassistant/components/deconz/.translations/id.json create mode 100644 homeassistant/components/deconz/.translations/nn.json create mode 100644 homeassistant/components/hangouts/.translations/id.json create mode 100644 homeassistant/components/hangouts/.translations/nn.json create mode 100644 homeassistant/components/homematicip_cloud/.translations/id.json create mode 100644 homeassistant/components/homematicip_cloud/.translations/nn.json create mode 100644 homeassistant/components/hue/.translations/id.json create mode 100644 homeassistant/components/hue/.translations/nn.json create mode 100644 homeassistant/components/ios/.translations/de.json create mode 100644 homeassistant/components/ios/.translations/id.json create mode 100644 homeassistant/components/ios/.translations/lb.json create mode 100644 homeassistant/components/ios/.translations/nn.json create mode 100644 homeassistant/components/ios/.translations/no.json create mode 100644 homeassistant/components/ios/.translations/pt-BR.json create mode 100644 homeassistant/components/ios/.translations/pt.json create mode 100644 homeassistant/components/mqtt/.translations/de.json create mode 100644 homeassistant/components/mqtt/.translations/id.json create mode 100644 homeassistant/components/mqtt/.translations/lb.json create mode 100644 homeassistant/components/mqtt/.translations/nl.json create mode 100644 homeassistant/components/mqtt/.translations/nn.json create mode 100644 homeassistant/components/mqtt/.translations/no.json create mode 100644 homeassistant/components/mqtt/.translations/pt-BR.json create mode 100644 homeassistant/components/mqtt/.translations/pt.json create mode 100644 homeassistant/components/nest/.translations/id.json create mode 100644 homeassistant/components/nest/.translations/nn.json create mode 100644 homeassistant/components/openuv/.translations/id.json create mode 100644 homeassistant/components/openuv/.translations/lb.json create mode 100644 homeassistant/components/openuv/.translations/nn.json create mode 100644 homeassistant/components/openuv/.translations/pt-BR.json create mode 100644 homeassistant/components/openuv/.translations/pt.json create mode 100644 homeassistant/components/sensor/.translations/moon.id.json create mode 100644 homeassistant/components/sensor/.translations/moon.nn.json create mode 100644 homeassistant/components/sensor/.translations/season.id.json create mode 100644 homeassistant/components/sensor/.translations/season.nn.json create mode 100644 homeassistant/components/sonos/.translations/id.json create mode 100644 homeassistant/components/sonos/.translations/nn.json create mode 100644 homeassistant/components/tradfri/.translations/ca.json create mode 100644 homeassistant/components/tradfri/.translations/fr.json create mode 100644 homeassistant/components/tradfri/.translations/hu.json create mode 100644 homeassistant/components/tradfri/.translations/id.json create mode 100644 homeassistant/components/tradfri/.translations/ko.json create mode 100644 homeassistant/components/tradfri/.translations/lb.json create mode 100644 homeassistant/components/tradfri/.translations/nl.json create mode 100644 homeassistant/components/tradfri/.translations/nn.json create mode 100644 homeassistant/components/tradfri/.translations/no.json create mode 100644 homeassistant/components/tradfri/.translations/pt-BR.json create mode 100644 homeassistant/components/tradfri/.translations/pt.json create mode 100644 homeassistant/components/tradfri/.translations/ru.json create mode 100644 homeassistant/components/tradfri/.translations/zh-Hans.json create mode 100644 homeassistant/components/tradfri/.translations/zh-Hant.json create mode 100644 homeassistant/components/zone/.translations/id.json create mode 100644 homeassistant/components/zone/.translations/nn.json diff --git a/homeassistant/components/auth/.translations/id.json b/homeassistant/components/auth/.translations/id.json new file mode 100644 index 00000000000000..f6a22386f99bfb --- /dev/null +++ b/homeassistant/components/auth/.translations/id.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Kode salah, coba lagi. Jika Anda mendapatkan kesalahan ini secara konsisten, pastikan jam pada sistem Home Assistant anda akurat." + }, + "step": { + "init": { + "description": "Untuk mengaktifkan otentikasi dua faktor menggunakan password satu kali berbasis waktu, pindai kode QR dengan aplikasi otentikasi Anda. Jika Anda tidak memilikinya, kami menyarankan [Google Authenticator] (https://support.google.com/accounts/answer/1066447) atau [Authy] (https://authy.com/). \n\n {qr_code} \n \n Setelah memindai kode, masukkan kode enam digit dari aplikasi Anda untuk memverifikasi pengaturan. Jika Anda mengalami masalah saat memindai kode QR, lakukan pengaturan manual dengan kode ** ` {code} ` **.", + "title": "Siapkan otentikasi dua faktor menggunakan TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/nn.json b/homeassistant/components/auth/.translations/nn.json new file mode 100644 index 00000000000000..24d756f938bc51 --- /dev/null +++ b/homeassistant/components/auth/.translations/nn.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Dersom du heile tida f\u00e5r denne feilen, m\u00e5 du s\u00f8rge for at klokka p\u00e5 Home Assistant-systemet ditt er n\u00f8yaktig." + }, + "step": { + "init": { + "description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte eingangspassord, skann QR-koden med autentiseringsappen din. Dersom du ikkje har ein, vil vi r\u00e5de deg til \u00e5 bruke anten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har skanna koda, skriv du inn den sekssifra koda fr\u00e5 appen din for \u00e5 stadfeste oppsettet. Dersom du har problemer med \u00e5 skanne QR-koda, gjer du eit manuelt oppsett med kode ** ` {code} ` **.", + "title": "Konfigurer to-faktor-autentisering ved hjelp av TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/pt-BR.json b/homeassistant/components/auth/.translations/pt-BR.json new file mode 100644 index 00000000000000..58c785a5b95c68 --- /dev/null +++ b/homeassistant/components/auth/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente. Se voc\u00ea obtiver este erro de forma consistente, certifique-se de que o rel\u00f3gio do sistema Home Assistant esteja correto." + }, + "step": { + "init": { + "title": "Configure a autentica\u00e7\u00e3o de dois fatores usando o TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/id.json b/homeassistant/components/cast/.translations/id.json new file mode 100644 index 00000000000000..86fb32c0844fac --- /dev/null +++ b/homeassistant/components/cast/.translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat Google Cast yang ditemukan pada jaringan.", + "single_instance_allowed": "Hanya satu konfigurasi Google Cast yang diperlukan." + }, + "step": { + "confirm": { + "description": "Apakah Anda ingin menyiapkan Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/nn.json b/homeassistant/components/cast/.translations/nn.json new file mode 100644 index 00000000000000..7f550155658926 --- /dev/null +++ b/homeassistant/components/cast/.translations/nn.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Klar", + "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Google Cast-konfigurasjon." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 0a9e6fdee3f68e..10eb9f5bc7316b 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -28,6 +28,6 @@ "title": "Opcions de configuraci\u00f3 addicionals per deCONZ" } }, - "title": "deCONZ" + "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/id.json b/homeassistant/components/deconz/.translations/id.json new file mode 100644 index 00000000000000..7d0b3163a40b85 --- /dev/null +++ b/homeassistant/components/deconz/.translations/id.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge sudah dikonfigurasi", + "no_bridges": "deCONZ bridges tidak ditemukan", + "one_instance_only": "Komponen hanya mendukung satu instance deCONZ" + }, + "error": { + "no_key": "Tidak bisa mendapatkan kunci API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (nilai default: '80')" + }, + "title": "Tentukan deCONZ gateway" + }, + "link": { + "description": "Buka gerbang deCONZ Anda untuk mendaftar dengan Home Assistant. \n\n 1. Pergi ke pengaturan sistem deCONZ \n 2. Tekan tombol \"Buka Kunci Gateway\"", + "title": "Tautan dengan deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Izinkan mengimpor sensor virtual", + "allow_deconz_groups": "Izinkan mengimpor grup deCONZ" + }, + "title": "Opsi konfigurasi tambahan untuk deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index a584a1db9b50e2..a501951540b3e7 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", - "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4 \ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4" + "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4" }, "error": { "no_key": "API \ud0a4\ub97c \uac00\uc838\uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/deconz/.translations/nn.json b/homeassistant/components/deconz/.translations/nn.json new file mode 100644 index 00000000000000..4bdc4b4c1bee38 --- /dev/null +++ b/homeassistant/components/deconz/.translations/nn.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Brua er allereie konfigurert", + "no_bridges": "Oppdaga ingen deCONZ-bruer", + "one_instance_only": "Komponenten st\u00f8ttar berre \u00e9in deCONZ-instans" + }, + "error": { + "no_key": "Kunne ikkje f\u00e5 ein API-n\u00f8kkel" + }, + "step": { + "init": { + "data": { + "host": "Vert", + "port": "Port (standardverdi: '80')" + }, + "title": "Definer deCONZ-gateway" + }, + "link": { + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere den med Home Assistant.\n\n1. G\u00e5 til systeminnstillingane til deCONZ\n2. Trykk p\u00e5 \"L\u00e5s opp gateway\"-knappen", + "title": "Link med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillat importering av virtuelle sensorar", + "allow_deconz_groups": "Tillat importering av deCONZ-grupper" + }, + "title": "Ekstra konfigurasjonsalternativ for deCONZ" + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/id.json b/homeassistant/components/hangouts/.translations/id.json new file mode 100644 index 00000000000000..46a574bdf8a71f --- /dev/null +++ b/homeassistant/components/hangouts/.translations/id.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts sudah dikonfigurasikan", + "unknown": "Kesalahan tidak dikenal terjadi." + }, + "error": { + "invalid_2fa": "Autentikasi 2 Faktor Tidak Valid, silakan coba lagi.", + "invalid_2fa_method": "Metode 2FA Tidak Sah (Verifikasi di Ponsel).", + "invalid_login": "Login tidak valid, silahkan coba lagi." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "Kosong", + "title": "2-Faktor-Otentikasi" + }, + "user": { + "data": { + "email": "Alamat email", + "password": "Kata sandi" + }, + "description": "Kosong", + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/nn.json b/homeassistant/components/hangouts/.translations/nn.json new file mode 100644 index 00000000000000..58e5f4f45fdc56 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/nn.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts er allereie konfigurert", + "unknown": "Det hende ein ukjent feil" + }, + "error": { + "invalid_2fa": "Ugyldig to-faktor-autentisering. Ver vennleg og pr\u00f8v igjen.", + "invalid_2fa_method": "Ugyldig 2FA-metode (godkjenn p\u00e5 telefonen).", + "invalid_login": "Ugyldig innlogging. Pr\u00f8v igjen." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "To-faktor-autentiserin" + }, + "user": { + "data": { + "email": "Epostadresse", + "password": "Passord" + }, + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pt-BR.json b/homeassistant/components/hangouts/.translations/pt-BR.json index 516229c3871dfa..00c533311fc25a 100644 --- a/homeassistant/components/hangouts/.translations/pt-BR.json +++ b/homeassistant/components/hangouts/.translations/pt-BR.json @@ -5,16 +5,21 @@ "unknown": "Ocorreu um erro desconhecido." }, "error": { - "invalid_2fa": "Autentica\u00e7\u00e3o de 2 fatores inv\u00e1lida, por favor, tente novamente." + "invalid_2fa": "Autentica\u00e7\u00e3o de 2 fatores inv\u00e1lida, por favor, tente novamente.", + "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).", + "invalid_login": "Login inv\u00e1lido, por favor, tente novamente." }, "step": { "2fa": { - "title": "" + "description": "Vazio", + "title": "Autentica\u00e7\u00e3o de 2 Fatores" }, "user": { "data": { + "email": "Endere\u00e7o de e-mail", "password": "Senha" }, + "description": "Vazio", "title": "Login do Hangouts do Google" } }, diff --git a/homeassistant/components/hangouts/strings.json b/homeassistant/components/hangouts/strings.json index dd421fee57a9cb..c83a0ae08764d8 100644 --- a/homeassistant/components/hangouts/strings.json +++ b/homeassistant/components/hangouts/strings.json @@ -15,14 +15,12 @@ "email": "E-Mail Address", "password": "Password" }, - "description": "", "title": "Google Hangouts Login" }, "2fa": { "data": { "2fa": "2FA Pin" }, - "description": "", "title": "2-Factor-Authentication" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json index 6fcfcddd75d6e6..605bb0d250bba7 100644 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Access point is already configured", - "conection_aborted": "Could not connect to HMIP server", "connection_aborted": "Could not connect to HMIP server", "unknown": "Unknown error occurred." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/id.json b/homeassistant/components/homematicip_cloud/.translations/id.json new file mode 100644 index 00000000000000..6598af2f044714 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/id.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Jalur akses sudah dikonfigurasi", + "conection_aborted": "Tidak dapat terhubung ke server HMIP", + "connection_aborted": "Tidak dapat terhubung ke server HMIP", + "unknown": "Kesalahan tidak dikenal terjadi." + }, + "error": { + "invalid_pin": "PIN tidak valid, silakan coba lagi.", + "press_the_button": "Silakan tekan tombol biru.", + "register_failed": "Gagal mendaftar, silakan coba lagi.", + "timeout_button": "Batas waktu tekan tombol biru berakhir, silakan coba lagi." + }, + "step": { + "init": { + "data": { + "hapid": "Titik akses ID (SGTIN)", + "name": "Nama (opsional, digunakan sebagai awalan nama untuk semua perangkat)", + "pin": "Kode Pin (opsional)" + }, + "title": "Pilih HomematicIP Access point" + }, + "link": { + "description": "Tekan tombol biru pada access point dan tombol submit untuk mendaftarkan HomematicIP dengan rumah asisten.\n\n! [Lokasi tombol di bridge] (/ static/images/config_flows/config_homematicip_cloud.png)", + "title": "Tautkan jalur akses" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json index 617b65ff623987..6f5ed1ac92316f 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -22,7 +22,7 @@ "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd" }, "link": { - "description": "Home Assistant\uc5d0 HomematicIP\ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc5d0 \uc5f0\uacb0" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/nn.json b/homeassistant/components/homematicip_cloud/.translations/nn.json new file mode 100644 index 00000000000000..3275a3b09d4deb --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/nn.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Tilgangspunktet er allereie konfigurert", + "conection_aborted": "Kunne ikkje kople til HMIP-serveren", + "connection_aborted": "Kunne ikkje kople til HMIP-serveren", + "unknown": "Det hende ein ukjent feil." + }, + "error": { + "invalid_pin": "Ugyldig PIN. Pr\u00f8v igjen.", + "press_the_button": "Ver vennleg og trykk p\u00e5 den bl\u00e5 knappen.", + "register_failed": "Kunne ikkje registrere. Pr\u00f8v igjen.", + "timeout_button": "TIda gjekk ut for \u00e5 trykke p\u00e5 den bl\u00e5 knappen. Ver vennleg og pr\u00f8v igjen." + }, + "step": { + "init": { + "data": { + "hapid": "TilgangspunktID (SGTIN)", + "name": "Namn (valfrii. Brukt som namnprefiks for alle einingar)", + "pin": "Pinkode (valfritt)" + }, + "title": "Vel HomematicIP tilgangspunkt" + }, + "link": { + "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og sendknappen for \u00e5 registrere HomematicIP med Home Assitant.\n\n ! [Plassering av knapp p\u00e5 bro] (/ static / images / config_flows / config_homematicip_cloud.png)", + "title": "Link tilgangspunk" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/id.json b/homeassistant/components/hue/.translations/id.json new file mode 100644 index 00000000000000..bf5557436ce1de --- /dev/null +++ b/homeassistant/components/hue/.translations/id.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Semua Philips Hue bridges sudah dikonfigurasi", + "already_configured": "Bridge sudah dikonfigurasi", + "cannot_connect": "Tidak dapat terhubung ke bridge", + "discover_timeout": "Tidak dapat menemukan Hue Bridges.", + "no_bridges": "Bridge Philips Hue tidak ditemukan", + "unknown": "Kesalahan tidak dikenal terjadi." + }, + "error": { + "linking": "Terjadi kesalahan tautan tidak dikenal.", + "register_failed": "Gagal mendaftar, silakan coba lagi." + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Pilih Hue bridge" + }, + "link": { + "description": "Tekan tombol di bridge untuk mendaftar Philips Hue dengan Home Assistant.\n\n![Lokasi tombol di bridge] (/static/images/config_philips_hue.jpg)", + "title": "Tautan Hub" + } + }, + "title": "Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/nn.json b/homeassistant/components/hue/.translations/nn.json new file mode 100644 index 00000000000000..45d6bc89d72dfc --- /dev/null +++ b/homeassistant/components/hue/.translations/nn.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Alle Philips Hue-bruer er allereie konfiguert", + "already_configured": "Brua er allereie konfiguert", + "cannot_connect": "Klarte ikkje \u00e5 kople til brua", + "discover_timeout": "Klarte ikkje \u00e5 oppdage Hue-bruer", + "no_bridges": "Oppdaga ingen Philips Hue-bruer", + "unknown": "Ukjent feil oppstod" + }, + "error": { + "linking": "Ukjent linkefeil oppstod.", + "register_failed": "Kunne ikkje registrere, pr\u00f8v igjen" + }, + "step": { + "init": { + "data": { + "host": "Vert" + }, + "title": "Vel Hue bru" + }, + "link": { + "description": "Trykk p\u00e5 knappen p\u00e5 brua, for \u00e5 registrere Philips Hue med Home Assistant.\n\n![Lokasjon til knappen p\u00e5 brua]\n(/statisk/bilete/konfiguer_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/de.json b/homeassistant/components/ios/.translations/de.json new file mode 100644 index 00000000000000..e9e592d18c298f --- /dev/null +++ b/homeassistant/components/ios/.translations/de.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Es wird nur eine Konfiguration von Home Assistant iOS ben\u00f6tigt" + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du die Home Assistant iOS-Komponente einrichten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/id.json b/homeassistant/components/ios/.translations/id.json new file mode 100644 index 00000000000000..5813d9488f0d1c --- /dev/null +++ b/homeassistant/components/ios/.translations/id.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Hanya satu konfigurasi Home Assistant iOS yang diperlukan." + }, + "step": { + "confirm": { + "description": "Apakah Anda ingin mengatur komponen iOS Home Assistant?", + "title": "Home Asisten iOS" + } + }, + "title": "Home Asisten iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/ko.json b/homeassistant/components/ios/.translations/ko.json index 6d69ea3126c794..1496dab05558cf 100644 --- a/homeassistant/components/ios/.translations/ko.json +++ b/homeassistant/components/ios/.translations/ko.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 Home Assistant iOS \uad6c\uc131\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + "single_instance_allowed": "\ud558\ub098\uc758 Home Assistant iOS \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "step": { "confirm": { - "description": "Home Assistant iOS \ucef4\ud3ec\ub10c\ud2b8\uc758 \uc124\uc815\uc744 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Home Assistant iOS \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Home Assistant iOS" } }, diff --git a/homeassistant/components/ios/.translations/lb.json b/homeassistant/components/ios/.translations/lb.json new file mode 100644 index 00000000000000..731371cada9e4f --- /dev/null +++ b/homeassistant/components/ios/.translations/lb.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Home Assistant iOS ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "W\u00ebllt dir d'Home Assistant iOS Komponent ariichten?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/nn.json b/homeassistant/components/ios/.translations/nn.json new file mode 100644 index 00000000000000..9d2cf6920064d3 --- /dev/null +++ b/homeassistant/components/ios/.translations/nn.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Du treng berre \u00e9in Home Assistant iOS-konfigurasjon." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Home Assistant iOS-komponenten?", + "title": "Home Assistant Ios" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/no.json b/homeassistant/components/ios/.translations/no.json new file mode 100644 index 00000000000000..a125b96a070ac5 --- /dev/null +++ b/homeassistant/components/ios/.translations/no.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Kun en enkelt konfigurasjon av Home Assistant iOS er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 konfigurere Home Assistant iOS-komponenten?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/pt-BR.json b/homeassistant/components/ios/.translations/pt-BR.json new file mode 100644 index 00000000000000..77efc04b817fde --- /dev/null +++ b/homeassistant/components/ios/.translations/pt-BR.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do Home Assistant iOS \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o componente iOS do Home Assistant?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/pt.json b/homeassistant/components/ios/.translations/pt.json new file mode 100644 index 00000000000000..6752606d9f5aa7 --- /dev/null +++ b/homeassistant/components/ios/.translations/pt.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do componente iOS do Home Assistante \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o componente iOS do Home Assistant?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/de.json b/homeassistant/components/mqtt/.translations/de.json new file mode 100644 index 00000000000000..15b6b3b9731e93 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "broker": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte gib die Verbindungsinformationen deines MQTT-Brokers ein.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/id.json b/homeassistant/components/mqtt/.translations/id.json new file mode 100644 index 00000000000000..7a9bf8639e248d --- /dev/null +++ b/homeassistant/components/mqtt/.translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Hanya satu konfigurasi MQTT yang diizinkan." + }, + "error": { + "cannot_connect": "Tidak dapat terhubung ke broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "Kata sandi", + "port": "Port", + "username": "Nama pengguna" + }, + "description": "Harap masukkan informasi koneksi dari broker MQTT Anda.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index a1e130c59cfa11..3775c8328d1b5e 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "\ud558\ub098\uc758 MQTT \ube0c\ub85c\ucee4\ub9cc \uad6c\uc131\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4." + "single_instance_allowed": "\ud558\ub098\uc758 MQTT \ube0c\ub85c\ucee4\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "error": { "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." @@ -14,7 +14,7 @@ "port": "\ud3ec\ud2b8", "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" }, - "description": "MQTT \ube0c\ub85c\ucee4\uc640\uc758 \uc5f0\uacb0 \uc815\ubcf4\ub97c \uc785\ub825\ud558\uc138\uc694.", + "description": "MQTT \ube0c\ub85c\ucee4\uc640\uc758 \uc5f0\uacb0 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "title": "MQTT" } }, diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json new file mode 100644 index 00000000000000..82a10194667b85 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vum MQTT ass erlaabt" + }, + "error": { + "cannot_connect": "Kann sech net mam Broker verbannen." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "Passwuert", + "port": "Port", + "username": "Benotzernumm" + }, + "description": "Gitt Verbindungs Informatioune vun \u00e4rem MQTT Broker an.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/nl.json b/homeassistant/components/mqtt/.translations/nl.json new file mode 100644 index 00000000000000..b375f353810159 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "broker": { + "data": { + "broker": "Broker", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "description": "MQTT", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/nn.json b/homeassistant/components/mqtt/.translations/nn.json new file mode 100644 index 00000000000000..fb650bc76767d8 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/nn.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Det er berre lov \u00e5 ha \u00e9in MQTT-konfigurasjon" + }, + "error": { + "cannot_connect": "Klarte ikkje \u00e5 kople til meglaren." + }, + "step": { + "broker": { + "data": { + "broker": "Meglar", + "password": "Passord", + "port": "Port", + "username": "Brukarnamn" + }, + "description": "Ver vennleg \u00e5 skriv inn tilkoplingsinformasjonen for MQTT-meglaren din", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/no.json b/homeassistant/components/mqtt/.translations/no.json new file mode 100644 index 00000000000000..a9c60fb0c7ffcf --- /dev/null +++ b/homeassistant/components/mqtt/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Kun en enkelt konfigurasjon av MQTT er tillatt." + }, + "error": { + "cannot_connect": "Kan ikke koble til megleren." + }, + "step": { + "broker": { + "data": { + "broker": "Megler", + "password": "Passord", + "port": "Port", + "username": "Brukernavn" + }, + "description": "Vennligst oppgi tilkoblingsinformasjonen for din MQTT megler.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/pt-BR.json b/homeassistant/components/mqtt/.translations/pt-BR.json new file mode 100644 index 00000000000000..e73e8b155ec62c --- /dev/null +++ b/homeassistant/components/mqtt/.translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do MQTT \u00e9 permitida." + }, + "step": { + "broker": { + "data": { + "password": "Senha", + "port": "Porta", + "username": "Nome de usu\u00e1rio" + }, + "description": "Por favor, insira as informa\u00e7\u00f5es de conex\u00e3o do seu agente MQTT.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/pt.json b/homeassistant/components/mqtt/.translations/pt.json new file mode 100644 index 00000000000000..42f7c7f5ad2ad6 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/pt.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do MQTT \u00e9 permitida." + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel ligar ao broker." + }, + "step": { + "broker": { + "data": { + "broker": "", + "password": "Palavra-passe", + "port": "Porto", + "username": "Utilizador" + }, + "description": "Por favor, insira os detalhes de liga\u00e7\u00e3o ao seu broker MQTT.", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/ca.json b/homeassistant/components/nest/.translations/ca.json index 2fb17916aee81b..e15d0106da8ced 100644 --- a/homeassistant/components/nest/.translations/ca.json +++ b/homeassistant/components/nest/.translations/ca.json @@ -9,7 +9,7 @@ "error": { "internal_error": "Error intern al validar el codi", "invalid_code": "Codi inv\u00e0lid", - "timeout": "Temps d'espera de validaci\u00f3 del codi esgotat", + "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi.", "unknown": "Error desconegut al validar el codi" }, "step": { diff --git a/homeassistant/components/nest/.translations/id.json b/homeassistant/components/nest/.translations/id.json new file mode 100644 index 00000000000000..58f86f5474ee1d --- /dev/null +++ b/homeassistant/components/nest/.translations/id.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Anda hanya dapat mengonfigurasi satu akun Nest.", + "authorize_url_fail": "Kesalahan tidak dikenal terjadi ketika menghasilkan URL otorisasi.", + "authorize_url_timeout": "Waktu tunggu menghasilkan otorisasi url telah habis.", + "no_flows": "Anda harus mengonfigurasi Nest sebelum dapat mengautentikasi dengan Nest. [Silakan baca instruksi] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Kesalahan Internal memvalidasi kode", + "invalid_code": "Kode salah", + "timeout": "Waktu tunggu memvalidasi kode telah habis.", + "unknown": "Error tidak diketahui saat memvalidasi kode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Penyedia" + }, + "description": "Pilih melalui penyedia autentikasi mana yang ingin Anda autentikasi dengan Nest.", + "title": "Penyedia Otentikasi" + }, + "link": { + "data": { + "code": "Kode PIN" + }, + "description": "Untuk menautkan akun Nest Anda, [beri kuasa akun Anda] ( {url} ). \n\n Setelah otorisasi, salin-tempel kode pin yang disediakan di bawah ini.", + "title": "Hubungkan Akun Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/nn.json b/homeassistant/components/nest/.translations/nn.json new file mode 100644 index 00000000000000..be3915c464f3ad --- /dev/null +++ b/homeassistant/components/nest/.translations/nn.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan berre konfiguere \u00e9in Nest-brukar.", + "authorize_url_fail": "Ukjent feil ved generering av autentiserings-URL", + "authorize_url_timeout": "Tida gjekk ut for generert autentikasjons-URL", + "no_flows": "Du m\u00e5 konfiguere Nest f\u00f8r du kan autentisere den. (Les instruksjonane) (https://www.home-assistant.io/components/nest/)" + }, + "error": { + "internal_error": "Intern feil ved validering av kode", + "invalid_code": "Ugyldig kode", + "timeout": "Tida gjekk ut for validering av kode", + "unknown": "Det hende ein ukjent feil ved validering av kode." + }, + "step": { + "init": { + "data": { + "flow_impl": "Leverand\u00f8r" + }, + "description": "Vel kva for ein autentiseringsleverand\u00f8r du vil godkjenne med Nest.", + "title": "Autentiseringsleverand\u00f8r" + }, + "link": { + "data": { + "code": "Pinkode" + }, + "description": "For \u00e5 linke Nestkontoen din, [autoriser kontoen din]{url}.\nEtter autentiseringa, kopier-lim inn koda du fekk under her.", + "title": "Link Nestkonto" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/id.json b/homeassistant/components/openuv/.translations/id.json new file mode 100644 index 00000000000000..beb7c839eb9036 --- /dev/null +++ b/homeassistant/components/openuv/.translations/id.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Koordinat sudah terdaftar", + "invalid_api_key": "Kunci API tidak valid" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API OpenUV", + "elevation": "Ketinggian", + "latitude": "Lintang", + "longitude": "Garis bujur" + }, + "title": "Isi informasi Anda" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/lb.json b/homeassistant/components/openuv/.translations/lb.json new file mode 100644 index 00000000000000..86e558cc807506 --- /dev/null +++ b/homeassistant/components/openuv/.translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Koordinate si scho\u00a0registr\u00e9iert", + "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API Schl\u00ebssel", + "elevation": "H\u00e9icht", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad" + }, + "title": "F\u00ebllt \u00e4r Informatiounen aus" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/nn.json b/homeassistant/components/openuv/.translations/nn.json new file mode 100644 index 00000000000000..135e26cede3e18 --- /dev/null +++ b/homeassistant/components/openuv/.translations/nn.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Koordinata er allereie registrerte", + "invalid_api_key": "Ugyldig API-n\u00f8kkel" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API-n\u00f8kkel", + "elevation": "H\u00f8gde", + "latitude": "Breiddegrad", + "longitude": "Lengdegrad" + }, + "title": "Fyll ut informasjonen din" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/pt-BR.json b/homeassistant/components/openuv/.translations/pt-BR.json new file mode 100644 index 00000000000000..905fdbacab8b75 --- /dev/null +++ b/homeassistant/components/openuv/.translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordenadas j\u00e1 cadastradas", + "invalid_api_key": "Chave de API inv\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Chave de API do OpenUV", + "elevation": "Eleva\u00e7\u00e3o", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "title": "Preencha suas informa\u00e7\u00f5es" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/pt.json b/homeassistant/components/openuv/.translations/pt.json new file mode 100644 index 00000000000000..36f875efc0065d --- /dev/null +++ b/homeassistant/components/openuv/.translations/pt.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordenadas j\u00e1 registadas", + "invalid_api_key": "Chave de API inv\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Chave de API do OpenUV", + "elevation": "Eleva\u00e7\u00e3o", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "title": "Preencha com as suas informa\u00e7\u00f5es" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.id.json b/homeassistant/components/sensor/.translations/moon.id.json new file mode 100644 index 00000000000000..3ce14204fb5f8f --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.id.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Babak pertama", + "full_moon": "Bulan purnama", + "last_quarter": "Kuartal terakhir", + "new_moon": "Bulan baru", + "waning_crescent": "Waning crescent", + "waning_gibbous": "Waning gibbous", + "waxing_crescent": "Waxing crescent", + "waxing_gibbous": "Waxing gibbous" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.nn.json b/homeassistant/components/sensor/.translations/moon.nn.json new file mode 100644 index 00000000000000..7c516bcce50849 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.nn.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Fyrste kvartal", + "full_moon": "Fullm\u00e5ne", + "last_quarter": "Siste kvartal", + "new_moon": "Nym\u00e5ne", + "waning_crescent": "Minkande halvm\u00e5ne", + "waning_gibbous": "Minkande m\u00e5ne", + "waxing_crescent": "Veksande halvm\u00e5ne", + "waxing_gibbous": "Veksande m\u00e5ne" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.id.json b/homeassistant/components/sensor/.translations/season.id.json new file mode 100644 index 00000000000000..ed0666aee36896 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.id.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Musim gugur", + "spring": "Musim semi", + "summer": "Musim panas", + "winter": "Musim dingin" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.nn.json b/homeassistant/components/sensor/.translations/season.nn.json new file mode 100644 index 00000000000000..dbcff7ef81970a --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.nn.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Haust", + "spring": "V\u00e5r", + "summer": "Sommar", + "winter": "Vinter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/id.json b/homeassistant/components/sonos/.translations/id.json new file mode 100644 index 00000000000000..dc810d9773c537 --- /dev/null +++ b/homeassistant/components/sonos/.translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Tidak ada perangkat Sonos yang ditemukan pada jaringan.", + "single_instance_allowed": "Hanya satu konfigurasi Sonos yang diperlukan." + }, + "step": { + "confirm": { + "description": "Apakah Anda ingin mengatur Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/nn.json b/homeassistant/components/sonos/.translations/nn.json new file mode 100644 index 00000000000000..f2451efaff42cd --- /dev/null +++ b/homeassistant/components/sonos/.translations/nn.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Det vart ikkje funne noko Sonoseiningar p\u00e5 nettverket.", + "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Sonos-konfigurasjon." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/ca.json b/homeassistant/components/tradfri/.translations/ca.json new file mode 100644 index 00000000000000..acbbb275fc3135 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "No es pot connectar amb la passarel\u00b7la d'enlla\u00e7", + "invalid_key": "Ha fallat el registre amb la clau proporcionada. Si aix\u00f2 continua passant, intenteu reiniciar la passarel\u00b7la d'enlla\u00e7.", + "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi." + }, + "step": { + "auth": { + "data": { + "host": "Amfitri\u00f3", + "security_code": "Codi de seguretat" + }, + "description": "Podeu trobar el codi de seguretat a la part posterior de la vostra passarel\u00b7la d'enlla\u00e7.", + "title": "Introdu\u00efu el codi de seguretat" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/fr.json b/homeassistant/components/tradfri/.translations/fr.json new file mode 100644 index 00000000000000..3c22885fe817e6 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Le pont est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "Impossible de se connecter \u00e0 la passerelle.", + "invalid_key": "\u00c9chec de l'enregistrement avec la cl\u00e9 fournie. Si cela se reproduit, essayez de red\u00e9marrer la passerelle.", + "timeout": "D\u00e9lai d'attente de la validation du code expir\u00e9" + }, + "step": { + "auth": { + "data": { + "host": "H\u00f4te", + "security_code": "Code de s\u00e9curit\u00e9" + }, + "description": "Vous pouvez trouver le code de s\u00e9curit\u00e9 au dos de votre passerelle.", + "title": "Entrer le code de s\u00e9curit\u00e9" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/hu.json b/homeassistant/components/tradfri/.translations/hu.json new file mode 100644 index 00000000000000..0844e6d709528a --- /dev/null +++ b/homeassistant/components/tradfri/.translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "host": "Hoszt", + "security_code": "Biztons\u00e1gi K\u00f3d" + } + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/id.json b/homeassistant/components/tradfri/.translations/id.json new file mode 100644 index 00000000000000..5e1439c8d7d0d1 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Tidak dapat terhubung ke gateway.", + "invalid_key": "Gagal mendaftar dengan kunci yang disediakan. Jika ini terus terjadi, coba mulai ulang gateway.", + "timeout": "Waktu tunggu memvalidasi kode telah habis." + }, + "step": { + "auth": { + "data": { + "host": "Host", + "security_code": "Kode keamanan" + }, + "description": "Anda dapat menemukan kode keamanan di belakang gateway Anda.", + "title": "Masukkan kode keamanan" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/ko.json b/homeassistant/components/tradfri/.translations/ko.json new file mode 100644 index 00000000000000..b901a1fd508ecf --- /dev/null +++ b/homeassistant/components/tradfri/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "invalid_key": "\uc81c\uacf5\ub41c \ud0a4\ub85c \ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \uc774 \ubb38\uc81c\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud574\ubcf4\uc138\uc694.", + "timeout": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "auth": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "security_code": "\ubcf4\uc548 \ucf54\ub4dc" + }, + "description": "\uac8c\uc774\ud2b8\uc6e8\uc774 \ub4b7\uba74\uc5d0\uc11c \ubcf4\uc548 \ucf54\ub4dc\ub97c \ucc3e\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "\ubcf4\uc548 \ucf54\ub4dc \uc785\ub825" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/lb.json b/homeassistant/components/tradfri/.translations/lb.json new file mode 100644 index 00000000000000..8a623929d23d11 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ass schon konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Keng Verbindung mat der Gateway m\u00e9iglech.", + "invalid_key": "Konnt sech net mam ugebuedenem Schl\u00ebssel registr\u00e9ieren. Falls d\u00ebst widderhuelt optr\u00ebtt, prob\u00e9iert de Gateway fr\u00ebsch ze starten.", + "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code" + }, + "step": { + "auth": { + "data": { + "host": "Apparat", + "security_code": "S\u00e9cherheets Code" + }, + "description": "Dir fannt de S\u00e9cherheets Code op der R\u00e9cks\u00e4it vun \u00e4rem Gateway.", + "title": "Gitt de S\u00e9cherheets Code an" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/nl.json b/homeassistant/components/tradfri/.translations/nl.json new file mode 100644 index 00000000000000..1a681933b0bfa3 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met bridge", + "invalid_key": "Mislukt om te registreren met de meegeleverde sleutel. Als dit blijft gebeuren, probeer dan de gateway opnieuw op te starten.", + "timeout": "Time-out bij validatie van code" + }, + "step": { + "auth": { + "data": { + "host": "Host", + "security_code": "Beveiligingscode" + }, + "description": "U vindt de beveiligingscode op de achterkant van uw gateway.", + "title": "Voer beveiligingscode in" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/nn.json b/homeassistant/components/tradfri/.translations/nn.json new file mode 100644 index 00000000000000..b9c68668dacca4 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/nn.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Brua er allereie konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikkje \u00e5 kople til gatewayen.", + "invalid_key": "Kunne ikkje registrere med den brukte n\u00f8kkelen. Dersom dette held fram, pr\u00f8v \u00e5 starte gatewayen p\u00e5 nytt. ", + "timeout": "Tida gjekk ut for validering av kode" + }, + "step": { + "auth": { + "data": { + "host": "Vert", + "security_code": "Sikkerheitskode" + }, + "description": "Du finn sikkerheitskoda p\u00e5 baksida av gatewayen din.", + "title": "Skriv inn sikkerheitskode" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/no.json b/homeassistant/components/tradfri/.translations/no.json new file mode 100644 index 00000000000000..7244648b4e759a --- /dev/null +++ b/homeassistant/components/tradfri/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge er allerede konfigurert" + }, + "error": { + "cannot_connect": "Kan ikke koble til gatewayen.", + "invalid_key": "Kunne ikke registrere med gitt n\u00f8kkel. Hvis dette fortsetter, pr\u00f8v \u00e5 starte gatewayen p\u00e5 nytt.", + "timeout": "Tidsavbrudd ved validering av kode." + }, + "step": { + "auth": { + "data": { + "host": "Vert", + "security_code": "Sikkerhetskode" + }, + "description": "Du finner sikkerhetskoden p\u00e5 baksiden av gatewayen din.", + "title": "Skriv inn sikkerhetskode" + } + }, + "title": "Ikea Tr\u00e5dfri" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/pt-BR.json b/homeassistant/components/tradfri/.translations/pt-BR.json new file mode 100644 index 00000000000000..d5ad6b96670dd7 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge j\u00e1 est\u00e1 configurado" + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao gateway.", + "timeout": "Excedido tempo limite para validar c\u00f3digo" + }, + "step": { + "auth": { + "data": { + "host": "Hospedeiro", + "security_code": "C\u00f3digo de seguran\u00e7a" + }, + "description": "Voc\u00ea pode encontrar o c\u00f3digo de seguran\u00e7a na parte de tr\u00e1s do seu gateway.", + "title": "Digite o c\u00f3digo de seguran\u00e7a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/pt.json b/homeassistant/components/tradfri/.translations/pt.json new file mode 100644 index 00000000000000..05d3cbb57fe044 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/pt.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge j\u00e1 est\u00e1 configurada" + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel ligar ao gateway.", + "invalid_key": "Falha ao registrar-se com a chave fornecida. Se o problema persistir, tente reiniciar o gateway.", + "timeout": "Tempo excedido a validar o c\u00f3digo." + }, + "step": { + "auth": { + "data": { + "host": "Servidor", + "security_code": "C\u00f3digo de Seguran\u00e7a" + }, + "description": "Encontra o c\u00f3digo de seguran\u00e7a na base da gateway.", + "title": "Introduzir c\u00f3digo de seguran\u00e7a" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/ru.json b/homeassistant/components/tradfri/.translations/ru.json new file mode 100644 index 00000000000000..c7fcfd50b56ca5 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0428\u043b\u044e\u0437 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", + "invalid_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0441 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c \u043a\u043b\u044e\u0447\u043e\u043c. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0448\u043b\u044e\u0437.", + "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430." + }, + "step": { + "auth": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "security_code": "\u041a\u043e\u0434 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438" + }, + "description": "\u041a\u043e\u0434 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0439\u0442\u0438 \u043d\u0430 \u0437\u0430\u0434\u043d\u0435\u0439 \u043f\u0430\u043d\u0435\u043b\u0438 \u0448\u043b\u044e\u0437\u0430.", + "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 " + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/zh-Hans.json b/homeassistant/components/tradfri/.translations/zh-Hans.json new file mode 100644 index 00000000000000..28a9c676f2ec04 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "\u6865\u63a5\u5668\u5df2\u914d\u7f6e\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u7f51\u5173\u3002", + "invalid_key": "\u65e0\u6cd5\u7528\u63d0\u4f9b\u7684\u5bc6\u94a5\u6ce8\u518c\u3002\u5982\u679c\u9519\u8bef\u6301\u7eed\u53d1\u751f\uff0c\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u542f\u52a8\u7f51\u5173\u3002", + "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/zh-Hant.json b/homeassistant/components/tradfri/.translations/zh-Hant.json new file mode 100644 index 00000000000000..b295bba056467f --- /dev/null +++ b/homeassistant/components/tradfri/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u9598\u9053\u5668\u3002", + "invalid_key": "\u63d0\u4f9b\u4e4b\u5b89\u5168\u78bc\u8a3b\u518a\u5931\u6557\u3002\u5047\u5982\u6b64\u60c5\u6cc1\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5617\u8a66\u91cd\u555f\u9598\u9053\u5668\u3002", + "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642\u3002" + }, + "step": { + "auth": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "security_code": "\u5b89\u5168\u78bc" + }, + "description": "\u60a8\u53ef\u4ee5\u65bc\u9598\u9053\u5668\u80cc\u9762\u627e\u5230\u5b89\u5168\u78bc\u3002", + "title": "\u8f38\u5165\u5b89\u5168\u78bc" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/id.json b/homeassistant/components/zone/.translations/id.json new file mode 100644 index 00000000000000..b84710dc408bd6 --- /dev/null +++ b/homeassistant/components/zone/.translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Nama sudah ada" + }, + "step": { + "init": { + "data": { + "icon": "Ikon", + "latitude": "Lintang", + "longitude": "Garis bujur", + "name": "Nama", + "passive": "Pasif", + "radius": "Radius" + }, + "title": "Tentukan parameter zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/nn.json b/homeassistant/components/zone/.translations/nn.json new file mode 100644 index 00000000000000..39161f98c82bb2 --- /dev/null +++ b/homeassistant/components/zone/.translations/nn.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Namnet eksisterar allereie" + }, + "step": { + "init": { + "data": { + "icon": "Ikon", + "latitude": "Breiddegrad", + "longitude": "Lengdegrad", + "name": "Namn", + "passive": "Passiv", + "radius": "Radius" + }, + "title": "Definer soneparameterar" + } + }, + "title": "Sone" + } +} \ No newline at end of file From 3e2a9afff0d54e96ed4acfbe34968f19eba1b868 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 12:01:34 +0200 Subject: [PATCH 157/178] Another update translations --- homeassistant/components/homematicip_cloud/.translations/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json index 605bb0d250bba7..6fcfcddd75d6e6 100644 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Access point is already configured", + "conection_aborted": "Could not connect to HMIP server", "connection_aborted": "Could not connect to HMIP server", "unknown": "Unknown error occurred." }, From 0bd94d8b566a61220ba8640f1881efecf3a87c70 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 12:02:26 +0200 Subject: [PATCH 158/178] Remove unused translation key --- homeassistant/components/homematicip_cloud/.translations/ca.json | 1 - homeassistant/components/homematicip_cloud/.translations/cs.json | 1 - homeassistant/components/homematicip_cloud/.translations/de.json | 1 - homeassistant/components/homematicip_cloud/.translations/en.json | 1 - .../components/homematicip_cloud/.translations/es-419.json | 1 - homeassistant/components/homematicip_cloud/.translations/fr.json | 1 - homeassistant/components/homematicip_cloud/.translations/he.json | 1 - homeassistant/components/homematicip_cloud/.translations/id.json | 1 - homeassistant/components/homematicip_cloud/.translations/ja.json | 1 - homeassistant/components/homematicip_cloud/.translations/ko.json | 1 - homeassistant/components/homematicip_cloud/.translations/lb.json | 1 - homeassistant/components/homematicip_cloud/.translations/nl.json | 1 - homeassistant/components/homematicip_cloud/.translations/nn.json | 1 - homeassistant/components/homematicip_cloud/.translations/no.json | 1 - homeassistant/components/homematicip_cloud/.translations/pl.json | 1 - .../components/homematicip_cloud/.translations/pt-BR.json | 1 - homeassistant/components/homematicip_cloud/.translations/pt.json | 1 - homeassistant/components/homematicip_cloud/.translations/ru.json | 1 - homeassistant/components/homematicip_cloud/.translations/sl.json | 1 - homeassistant/components/homematicip_cloud/.translations/sv.json | 1 - .../components/homematicip_cloud/.translations/zh-Hans.json | 1 - .../components/homematicip_cloud/.translations/zh-Hant.json | 1 - 22 files changed, 22 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json index aab974ba13783b..7cc5943b830781 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "El punt d'acc\u00e9s ja est\u00e0 configurat", - "conection_aborted": "No s'ha pogut connectar al servidor HMIP", "connection_aborted": "No s'ha pogut connectar al servidor HMIP", "unknown": "S'ha produ\u00eft un error desconegut." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/cs.json b/homeassistant/components/homematicip_cloud/.translations/cs.json index 4030450e51c9fe..fa98029f6b0c8e 100644 --- a/homeassistant/components/homematicip_cloud/.translations/cs.json +++ b/homeassistant/components/homematicip_cloud/.translations/cs.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "P\u0159\u00edstupov\u00fd bod je ji\u017e nakonfigurov\u00e1n", - "conection_aborted": "Nelze se p\u0159ipojit k serveru HMIP", "connection_aborted": "Nelze se p\u0159ipojit k HMIP serveru", "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json index fdccac0d2294a6..6b3b9b09480fef 100644 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Der Accesspoint ist bereits konfiguriert", - "conection_aborted": "Keine Verbindung zum HMIP-Server m\u00f6glich", "connection_aborted": "Konnte nicht mit HMIP Server verbinden", "unknown": "Ein unbekannter Fehler ist aufgetreten." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json index 6fcfcddd75d6e6..605bb0d250bba7 100644 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Access point is already configured", - "conection_aborted": "Could not connect to HMIP server", "connection_aborted": "Could not connect to HMIP server", "unknown": "Unknown error occurred." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/es-419.json b/homeassistant/components/homematicip_cloud/.translations/es-419.json index e15d0dbae648a4..8675d6e12b119c 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es-419.json +++ b/homeassistant/components/homematicip_cloud/.translations/es-419.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspoint ya est\u00e1 configurado", - "conection_aborted": "No se pudo conectar al servidor HMIP", "connection_aborted": "No se pudo conectar al servidor HMIP", "unknown": "Se produjo un error desconocido." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/fr.json b/homeassistant/components/homematicip_cloud/.translations/fr.json index 6cab0993c0156f..0e724d62bbe2a7 100644 --- a/homeassistant/components/homematicip_cloud/.translations/fr.json +++ b/homeassistant/components/homematicip_cloud/.translations/fr.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9", - "conection_aborted": "Impossible de se connecter au serveur HMIP", "connection_aborted": "Impossible de se connecter au serveur HMIP", "unknown": "Une erreur inconnue s'est produite." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/he.json b/homeassistant/components/homematicip_cloud/.translations/he.json index bdf1e436badf15..50d5c61b576a31 100644 --- a/homeassistant/components/homematicip_cloud/.translations/he.json +++ b/homeassistant/components/homematicip_cloud/.translations/he.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d2\u05d9\u05e9\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05ea", - "conection_aborted": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e8\u05ea HMIP", "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." }, "error": { diff --git a/homeassistant/components/homematicip_cloud/.translations/id.json b/homeassistant/components/homematicip_cloud/.translations/id.json index 6598af2f044714..0487434274c589 100644 --- a/homeassistant/components/homematicip_cloud/.translations/id.json +++ b/homeassistant/components/homematicip_cloud/.translations/id.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Jalur akses sudah dikonfigurasi", - "conection_aborted": "Tidak dapat terhubung ke server HMIP", "connection_aborted": "Tidak dapat terhubung ke server HMIP", "unknown": "Kesalahan tidak dikenal terjadi." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ja.json b/homeassistant/components/homematicip_cloud/.translations/ja.json index 105a74157897b8..6a03f3ec76b743 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ja.json +++ b/homeassistant/components/homematicip_cloud/.translations/ja.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", - "conection_aborted": "HMIP\u30b5\u30fc\u30d0\u30fc\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" }, "error": { diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json index 6f5ed1ac92316f..7b8dc8b5087bc2 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "conection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "connection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/lb.json b/homeassistant/components/homematicip_cloud/.translations/lb.json index a21767fc7d9cbb..2cad909a7ee54e 100644 --- a/homeassistant/components/homematicip_cloud/.translations/lb.json +++ b/homeassistant/components/homematicip_cloud/.translations/lb.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Acesspoint ass schon konfigur\u00e9iert", - "conection_aborted": "Konnt sech net mam HMIP Server verbannen", "connection_aborted": "Konnt sech net mam HMIP Server verbannen", "unknown": "Onbekannten Feeler opgetrueden" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/nl.json b/homeassistant/components/homematicip_cloud/.translations/nl.json index 40d1ced5007d25..ff3e2dea2cdeea 100644 --- a/homeassistant/components/homematicip_cloud/.translations/nl.json +++ b/homeassistant/components/homematicip_cloud/.translations/nl.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspoint is al geconfigureerd", - "conection_aborted": "Kon geen verbinding maken met de HMIP-server", "connection_aborted": "Kon geen verbinding maken met de HMIP-server", "unknown": "Er is een onbekende fout opgetreden." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/nn.json b/homeassistant/components/homematicip_cloud/.translations/nn.json index 3275a3b09d4deb..966c827c89d2c4 100644 --- a/homeassistant/components/homematicip_cloud/.translations/nn.json +++ b/homeassistant/components/homematicip_cloud/.translations/nn.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Tilgangspunktet er allereie konfigurert", - "conection_aborted": "Kunne ikkje kople til HMIP-serveren", "connection_aborted": "Kunne ikkje kople til HMIP-serveren", "unknown": "Det hende ein ukjent feil." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/no.json b/homeassistant/components/homematicip_cloud/.translations/no.json index 730f00ae625937..d9e6636c9720fa 100644 --- a/homeassistant/components/homematicip_cloud/.translations/no.json +++ b/homeassistant/components/homematicip_cloud/.translations/no.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Tilgangspunktet er allerede konfigurert", - "conection_aborted": "Kunne ikke koble til HMIP serveren", "connection_aborted": "Kunne ikke koble til HMIP serveren", "unknown": "Ukjent feil oppstod." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/pl.json b/homeassistant/components/homematicip_cloud/.translations/pl.json index 3fcbe7e69d1375..7c8714c2c113fd 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pl.json +++ b/homeassistant/components/homematicip_cloud/.translations/pl.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany", - "conection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", "connection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json index d4ecbe5010725d..82166a1aaaf5dd 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json +++ b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "O Accesspoint j\u00e1 est\u00e1 configurado", - "conection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", "connection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", "unknown": "Ocorreu um erro desconhecido." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/pt.json b/homeassistant/components/homematicip_cloud/.translations/pt.json index 87ee494a8752c5..18377490a5f4b6 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pt.json +++ b/homeassistant/components/homematicip_cloud/.translations/pt.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "O ponto de acesso j\u00e1 se encontra configurado", - "conection_aborted": "N\u00e3o foi poss\u00edvel ligar ao servidor HMIP", "connection_aborted": "N\u00e3o foi poss\u00edvel ligar ao servidor HMIP", "unknown": "Ocorreu um erro desconhecido." }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json index ed42daf19cd3c2..ef2b3be4a6446c 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ru.json +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "\u0422\u043e\u0447\u043a\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430", - "conection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP", "connection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/sl.json b/homeassistant/components/homematicip_cloud/.translations/sl.json index 4c4a00e31e03b7..eabb31ac8335cc 100644 --- a/homeassistant/components/homematicip_cloud/.translations/sl.json +++ b/homeassistant/components/homematicip_cloud/.translations/sl.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Dostopna to\u010dka je \u017ee konfigurirana", - "conection_aborted": "Povezave s stre\u017enikom HMIP ni bila mogo\u010da", "connection_aborted": "Povezava s stre\u017enikom HMIP ni bila mogo\u010da", "unknown": "Pri\u0161lo je do neznane napake" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/sv.json b/homeassistant/components/homematicip_cloud/.translations/sv.json index 4e8aac999de2dc..da6bde77ae305b 100644 --- a/homeassistant/components/homematicip_cloud/.translations/sv.json +++ b/homeassistant/components/homematicip_cloud/.translations/sv.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspunkten \u00e4r redan konfigurerad", - "conection_aborted": "Kunde inte ansluta till HMIP server", "connection_aborted": "Det gick inte att ansluta till HMIP-servern", "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json index 930b649bceb128..629ee4347fe5fe 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "\u63a5\u5165\u70b9\u5df2\u7ecf\u914d\u7f6e\u5b8c\u6210", - "conection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", "connection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" }, diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json index 9340070d9a39cd..d2d334551913ce 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json @@ -2,7 +2,6 @@ "config": { "abort": { "already_configured": "Accesspoint \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "conection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", "connection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" }, From c352b6fa5996298d78fa50e900683631a6e05070 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Sep 2018 12:13:52 +0200 Subject: [PATCH 159/178] Version bump to 0.79.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b9b71734241b7c..307cbf9fe661e8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 79 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 4c32ad3b48313f1720b10f0d3f7f0f6392953a4a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 07:52:10 +0200 Subject: [PATCH 160/178] Don't warn but info when on dev mode (#16831) --- homeassistant/components/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index 0cb22bd98dcd93..4e64e3be2e6a84 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -76,7 +76,7 @@ async def async_setup(hass, config): """Set up the updater component.""" if 'dev' in current_version: # This component only makes sense in release versions - _LOGGER.warning("Running on 'dev', only analytics will be submitted") + _LOGGER.info("Running on 'dev', only analytics will be submitted") config = config.get(DOMAIN, {}) if config.get(CONF_REPORTING): From 0431e38aa2bd758cbbc64fe8b22fb80f99c53c62 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Mon, 24 Sep 2018 22:53:35 -0700 Subject: [PATCH 161/178] Bump zm-py to 0.0.3 (#16835) --- homeassistant/components/zoneminder.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py index 5cfd324448bee5..53d6d8b2536609 100644 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['zm-py==0.0.2'] +REQUIREMENTS = ['zm-py==0.0.3'] CONF_PATH_ZMS = 'path_zms' diff --git a/requirements_all.txt b/requirements_all.txt index b9550d032ab080..e009f2e21a039a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1567,4 +1567,4 @@ zigpy-xbee==0.1.1 zigpy==0.2.0 # homeassistant.components.zoneminder -zm-py==0.0.2 +zm-py==0.0.3 From 356040d50602695896a2a056128201a2abbb5a6f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 11:57:32 +0200 Subject: [PATCH 162/178] Support old tradfri config format (#16841) --- .../components/tradfri/config_flow.py | 4 ++- tests/components/tradfri/test_config_flow.py | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 4de43c79e0c655..8d8f9af79e6387 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -95,7 +95,9 @@ async def async_step_import(self, user_input): try: data = await get_gateway_info( - self.hass, user_input['host'], user_input['identity'], + self.hass, user_input['host'], + # Old config format had a fixed identity + user_input.get('identity', 'homeassistant'), user_input['key']) data[CONF_IMPORT_GROUPS] = user_input[CONF_IMPORT_GROUPS] diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 4650fb5d9bc11e..580e9580d76eef 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -154,3 +154,34 @@ async def test_import_connection(hass, mock_gateway_info, mock_entry_setup): assert len(mock_gateway_info.mock_calls) == 1 assert len(mock_entry_setup.mock_calls) == 1 + + +async def test_import_connection_legacy(hass, mock_gateway_info, + mock_entry_setup): + """Test a connection via import.""" + mock_gateway_info.side_effect = \ + lambda hass, host, identity, key: mock_coro({ + 'host': host, + 'identity': identity, + 'key': key, + 'gateway_id': 'mock-gateway' + }) + + result = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'import'}, data={ + 'host': '123.123.123.123', + 'key': 'mock-key', + 'import_groups': True + }) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['result'].data == { + 'host': '123.123.123.123', + 'gateway_id': 'mock-gateway', + 'identity': 'homeassistant', + 'key': 'mock-key', + 'import_groups': True + } + + assert len(mock_gateway_info.mock_calls) == 1 + assert len(mock_entry_setup.mock_calls) == 1 From b9043ef7a788c469a7d63be1ac33fb1743b8580d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 12:22:14 +0200 Subject: [PATCH 163/178] Allow MQTT discovery (#16842) --- .../components/mqtt/.translations/en.json | 1 + homeassistant/components/mqtt/__init__.py | 35 ++++++++++--------- homeassistant/components/mqtt/config_flow.py | 3 +- homeassistant/components/mqtt/const.py | 2 ++ homeassistant/components/mqtt/strings.json | 3 +- tests/components/mqtt/test_config_flow.py | 5 +++ 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt/.translations/en.json b/homeassistant/components/mqtt/.translations/en.json index 1f0ed341bb6065..c0b83a1323ff6c 100644 --- a/homeassistant/components/mqtt/.translations/en.json +++ b/homeassistant/components/mqtt/.translations/en.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Broker", + "discovery": "Enable discovery", "password": "Password", "port": "Port", "username": "Username" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index abc240a65cb8b6..856d5d01894f8a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -36,7 +36,7 @@ # Loading the config flow file will register the flow from . import config_flow # noqa # pylint: disable=unused-import -from .const import CONF_BROKER +from .const import CONF_BROKER, CONF_DISCOVERY, DEFAULT_DISCOVERY from .server import HBMQTT_CONFIG_SCHEMA REQUIREMENTS = ['paho-mqtt==1.4.0'] @@ -47,13 +47,13 @@ DATA_MQTT = 'mqtt' DATA_MQTT_CONFIG = 'mqtt_config' +DATA_MQTT_HASS_CONFIG = 'mqtt_hass_config' SERVICE_PUBLISH = 'publish' CONF_EMBEDDED = 'embedded' CONF_CLIENT_ID = 'client_id' -CONF_DISCOVERY = 'discovery' CONF_DISCOVERY_PREFIX = 'discovery_prefix' CONF_KEEPALIVE = 'keepalive' CONF_CERTIFICATE = 'certificate' @@ -81,7 +81,6 @@ DEFAULT_QOS = 0 DEFAULT_RETAIN = False DEFAULT_PROTOCOL = PROTOCOL_311 -DEFAULT_DISCOVERY = False DEFAULT_DISCOVERY_PREFIX = 'homeassistant' DEFAULT_TLS_PROTOCOL = 'auto' DEFAULT_PAYLOAD_AVAILABLE = 'online' @@ -321,23 +320,21 @@ async def _async_setup_server(hass: HomeAssistantType, config: ConfigType): return broker_config -async def _async_setup_discovery(hass: HomeAssistantType, - config: ConfigType) -> bool: +async def _async_setup_discovery(hass: HomeAssistantType, conf: ConfigType, + hass_config: ConfigType) -> bool: """Try to start the discovery of MQTT devices. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) # type: ConfigType - discovery = await async_prepare_setup_platform( - hass, config, DOMAIN, 'discovery') + hass, hass_config, DOMAIN, 'discovery') if discovery is None: _LOGGER.error("Unable to load MQTT discovery") return False success = await discovery.async_start( - hass, conf[CONF_DISCOVERY_PREFIX], config) # type: bool + hass, conf[CONF_DISCOVERY_PREFIX], hass_config) # type: bool return success @@ -346,6 +343,11 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Start the MQTT protocol service.""" conf = config.get(DOMAIN) # type: Optional[ConfigType] + # We need this because discovery can cause components to be set up and + # otherwise it will not load the users config. + # This needs a better solution. + hass.data[DATA_MQTT_HASS_CONFIG] = config + if conf is None: # If we have a config entry, setup is done by that config entry. # If there is no config entry, this should fail. @@ -390,13 +392,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: data={} )) - if conf.get(CONF_DISCOVERY): - async def async_setup_discovery(event): - await _async_setup_discovery(hass, config) - - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_setup_discovery) - return True @@ -528,6 +523,14 @@ async def async_publish_service(call: ServiceCall): DOMAIN, SERVICE_PUBLISH, async_publish_service, schema=MQTT_PUBLISH_SCHEMA) + if conf.get(CONF_DISCOVERY): + async def async_setup_discovery(event): + await _async_setup_discovery( + hass, conf, hass.data[DATA_MQTT_HASS_CONFIG]) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_setup_discovery) + return True diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a8987a19742e9a..22072857b03d3a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -7,7 +7,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from .const import CONF_BROKER +from .const import CONF_BROKER, CONF_DISCOVERY, DEFAULT_DISCOVERY @config_entries.HANDLERS.register('mqtt') @@ -44,6 +44,7 @@ async def async_step_broker(self, user_input=None): fields[vol.Required(CONF_PORT, default=1883)] = vol.Coerce(int) fields[vol.Optional(CONF_USERNAME)] = str fields[vol.Optional(CONF_PASSWORD)] = str + fields[vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY)] = bool return self.async_show_form( step_id='broker', data_schema=vol.Schema(fields), errors=errors) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 8f9d938cf881e5..3c22001f91ce46 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,2 +1,4 @@ """Constants used by multiple MQTT modules.""" CONF_BROKER = 'broker' +CONF_DISCOVERY = 'discovery' +DEFAULT_DISCOVERY = False diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index a38983125aed55..0a2cb255cc43a6 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -9,7 +9,8 @@ "broker": "Broker", "port": "Port", "username": "Username", - "password": "Password" + "password": "Password", + "discovery": "Enable discovery" } } }, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 4a4d783940f48f..9f6be60c68b047 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -41,6 +41,11 @@ async def test_user_connection_works(hass, mock_try_connection, ) assert result['type'] == 'create_entry' + assert result['result'].data == { + 'broker': '127.0.0.1', + 'port': 1883, + 'discovery': False, + } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 # Check config entry got setup From 345c886dec98bc6d104a975c7fdb871d19543e06 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 13:47:12 +0200 Subject: [PATCH 164/178] Add unique ID and device info to Nest camera (#16846) * Add unique ID and device info to Nest camera * Remove sw version --- homeassistant/components/camera/nest.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/homeassistant/components/camera/nest.py b/homeassistant/components/camera/nest.py index e1d26371984a82..158123989c021e 100644 --- a/homeassistant/components/camera/nest.py +++ b/homeassistant/components/camera/nest.py @@ -62,6 +62,23 @@ def name(self): """Return the name of the nest, if any.""" return self._name + @property + def unique_id(self): + """Return the serial number.""" + return self.device.device_id + + @property + def device_info(self): + """Return information about the device.""" + return { + 'identifiers': { + (nest.DOMAIN, self.device.device_id) + }, + 'name': self.device.name_long, + 'manufacturer': 'Nest Labs', + 'model': "Camera", + } + @property def should_poll(self): """Nest camera should poll periodically.""" From 61a2d093427beb611eee54afaac529591b8e8988 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Sep 2018 15:39:42 +0200 Subject: [PATCH 165/178] Bumped version to 0.79.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 307cbf9fe661e8..862b1d97a76ac4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 79 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From e375b639020547abe7a8a1f423e0dcef53ed8d2a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 11:19:34 +0200 Subject: [PATCH 166/178] Update frontend to 20180926.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 d3d25255508c27..66232f2155b390 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180924.0'] +REQUIREMENTS = ['home-assistant-frontend==20180926.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index e009f2e21a039a..e79feca7feffc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,7 +454,7 @@ hole==0.3.0 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180924.0 +home-assistant-frontend==20180926.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44baabc7f5d34a..691fc6416076ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ hdate==0.6.3 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180924.0 +home-assistant-frontend==20180926.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 3aaf619fc337e3c438314abd349f3bb4d40a41f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 11:19:50 +0200 Subject: [PATCH 167/178] Update translations --- .../components/auth/.translations/ca.json | 21 ++++++++++- .../components/auth/.translations/de.json | 19 +++++++++- .../components/auth/.translations/fr.json | 7 ++++ .../components/auth/.translations/he.json | 35 +++++++++++++++++++ .../components/auth/.translations/ko.json | 19 ++++++++++ .../components/auth/.translations/lb.json | 19 ++++++++++ .../components/auth/.translations/pl.json | 18 ++++++++++ .../components/auth/.translations/ru.json | 19 ++++++++++ .../components/auth/.translations/sl.json | 19 ++++++++++ .../components/auth/.translations/sv.json | 14 ++++++++ .../auth/.translations/zh-Hans.json | 19 ++++++++++ .../auth/.translations/zh-Hant.json | 19 ++++++++++ .../components/cast/.translations/de.json | 2 +- .../components/deconz/.translations/he.json | 3 +- .../components/hangouts/.translations/he.json | 29 +++++++++++++++ .../components/hangouts/.translations/pl.json | 2 ++ .../homematicip_cloud/.translations/de.json | 10 +++--- .../homematicip_cloud/.translations/he.json | 1 + .../components/ios/.translations/he.json | 14 ++++++++ .../components/ios/.translations/sl.json | 14 ++++++++ .../components/ios/.translations/sv.json | 11 ++++++ .../components/mqtt/.translations/ca.json | 1 + .../components/mqtt/.translations/de.json | 6 ++++ .../components/mqtt/.translations/fr.json | 1 + .../components/mqtt/.translations/he.json | 24 +++++++++++++ .../components/mqtt/.translations/lb.json | 1 + .../components/mqtt/.translations/pl.json | 1 + .../components/mqtt/.translations/ru.json | 1 + .../components/mqtt/.translations/sl.json | 24 +++++++++++++ .../components/mqtt/.translations/sv.json | 13 +++++++ .../mqtt/.translations/zh-Hans.json | 1 + .../mqtt/.translations/zh-Hant.json | 1 + .../components/nest/.translations/de.json | 2 +- .../components/openuv/.translations/de.json | 3 +- .../components/openuv/.translations/he.json | 20 +++++++++++ .../sensor/.translations/moon.he.json | 6 +++- .../components/sonos/.translations/de.json | 2 +- .../components/tradfri/.translations/de.json | 22 ++++++++++++ .../components/tradfri/.translations/he.json | 23 ++++++++++++ .../components/tradfri/.translations/pl.json | 22 ++++++++++++ .../components/tradfri/.translations/sl.json | 23 ++++++++++++ .../components/tradfri/.translations/sv.json | 20 +++++++++++ .../tradfri/.translations/zh-Hans.json | 13 ++++++- 43 files changed, 530 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/auth/.translations/he.json create mode 100644 homeassistant/components/hangouts/.translations/he.json create mode 100644 homeassistant/components/ios/.translations/he.json create mode 100644 homeassistant/components/ios/.translations/sl.json create mode 100644 homeassistant/components/ios/.translations/sv.json create mode 100644 homeassistant/components/mqtt/.translations/he.json create mode 100644 homeassistant/components/mqtt/.translations/sl.json create mode 100644 homeassistant/components/mqtt/.translations/sv.json create mode 100644 homeassistant/components/openuv/.translations/he.json create mode 100644 homeassistant/components/tradfri/.translations/de.json create mode 100644 homeassistant/components/tradfri/.translations/he.json create mode 100644 homeassistant/components/tradfri/.translations/pl.json create mode 100644 homeassistant/components/tradfri/.translations/sl.json create mode 100644 homeassistant/components/tradfri/.translations/sv.json diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json index 1b3b25dbcff66a..f4318a0eb21cd8 100644 --- a/homeassistant/components/auth/.translations/ca.json +++ b/homeassistant/components/auth/.translations/ca.json @@ -1,8 +1,27 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles." + }, + "error": { + "invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho." + }, + "step": { + "init": { + "description": "Seleccioneu un dels serveis de notificaci\u00f3:", + "title": "Configureu una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" + }, + "setup": { + "description": "**notify.{notify_service}** ha enviat una contrasenya d'un sol \u00fas. Introdu\u00efu-la a continuaci\u00f3:", + "title": "Verifiqueu la configuraci\u00f3" + } + }, + "title": "Contrasenya d'un sol \u00fas del servei de notificacions" + }, "totp": { "error": { - "invalid_code": "Codi no v\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa." + "invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa." }, "step": { "init": { diff --git a/homeassistant/components/auth/.translations/de.json b/homeassistant/components/auth/.translations/de.json index 67f948e83406c2..2abc64f5f5d5ec 100644 --- a/homeassistant/components/auth/.translations/de.json +++ b/homeassistant/components/auth/.translations/de.json @@ -1,8 +1,25 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Keine Benachrichtigungsdienste verf\u00fcgbar." + }, + "error": { + "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut." + }, + "step": { + "init": { + "description": "Bitte w\u00e4hle einen der Benachrichtigungsdienste:" + }, + "setup": { + "description": "Ein Einmal-Passwort wurde per ** notify gesendet. {notify_service} **. Bitte gebe es unten ein:", + "title": "\u00dcberpr\u00fcfe das Setup" + } + } + }, "totp": { "error": { - "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn Sie diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist." + "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn du diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist." }, "step": { "init": { diff --git a/homeassistant/components/auth/.translations/fr.json b/homeassistant/components/auth/.translations/fr.json index b8d10dc89d07a6..85540314af06f5 100644 --- a/homeassistant/components/auth/.translations/fr.json +++ b/homeassistant/components/auth/.translations/fr.json @@ -1,5 +1,12 @@ { "mfa_setup": { + "notify": { + "step": { + "setup": { + "description": "Un mot de passe unique a \u00e9t\u00e9 envoy\u00e9 par **notify.{notify_service}**. Veuillez le saisir ci-dessous :" + } + } + }, "totp": { "error": { "invalid_code": "Code invalide. Veuillez essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte." diff --git a/homeassistant/components/auth/.translations/he.json b/homeassistant/components/auth/.translations/he.json new file mode 100644 index 00000000000000..bc1826d4d79757 --- /dev/null +++ b/homeassistant/components/auth/.translations/he.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." + }, + "error": { + "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." + }, + "step": { + "init": { + "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify", + "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify" + }, + "setup": { + "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4" + } + }, + "title": "\u05dc\u05d4\u05d5\u05d3\u05d9\u05e2 \u200b\u200b\u05e2\u05dc \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea" + }, + "totp": { + "error": { + "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05de\u05e7\u05d1\u05dc \u05d0\u05ea \u05d4\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d4\u05d6\u05d5 \u05d1\u05d0\u05d5\u05e4\u05df \u05e2\u05e7\u05d1\u05d9, \u05d5\u05d3\u05d0 \u05e9\u05d4\u05e9\u05e2\u05d5\u05df \u05e9\u05dc \u05de\u05e2\u05e8\u05db\u05ea \u05d4 - Home Assistant \u05e9\u05dc\u05da \u05de\u05d3\u05d5\u05d9\u05e7." + }, + "step": { + "init": { + "description": "\u05db\u05d3\u05d9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05d5\u05ea \u05de\u05d1\u05d5\u05e1\u05e1\u05d5\u05ea \u05d6\u05de\u05df, \u05e1\u05e8\u05d5\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 QR \u05e2\u05dd \u05d9\u05d9\u05e9\u05d5\u05dd \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05da. \u05d0\u05dd \u05d0\u05d9\u05df \u05dc\u05da \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4, \u05d0\u05e0\u05d5 \u05de\u05de\u05dc\u05d9\u05e6\u05d9\u05dd \u05e2\u05dc [Google Authenticator] (https://support.google.com/accounts/answer/1066447) \u05d0\u05d5 [Authy] (https://authy.com/). \n\n {qr_code} \n \n \u05dc\u05d0\u05d7\u05e8 \u05e1\u05e8\u05d9\u05e7\u05ea \u05d4\u05e7\u05d5\u05d3, \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d0\u05de\u05ea \u05d0\u05ea \u05d4\u05d4\u05d2\u05d3\u05e8\u05d4. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05e0\u05ea\u05e7\u05dc \u05d1\u05d1\u05e2\u05d9\u05d5\u05ea \u05d1\u05e1\u05e8\u05d9\u05e7\u05ea \u05e7\u05d5\u05d3 QR, \u05d1\u05e6\u05e2 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d9\u05d3\u05e0\u05d9\u05ea \u05e2\u05dd \u05e7\u05d5\u05d3 **`{code}`**.", + "title": "\u05d4\u05d2\u05d3\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/ko.json b/homeassistant/components/auth/.translations/ko.json index 17fb5c56f57055..e1f26e88bc7459 100644 --- a/homeassistant/components/auth/.translations/ko.json +++ b/homeassistant/components/auth/.translations/ko.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc54c\ub9bc \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "error": { + "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "init": { + "description": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:", + "title": "\uc54c\ub9bc \uad6c\uc131\uc694\uc18c\uac00 \uc81c\uacf5\ud558\ub294 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc124\uc815" + }, + "setup": { + "description": "**notify.{notify_service}** \uc5d0\uc11c \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \ubcf4\ub0c8\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc758 \uacf5\ub780\uc5d0 \uc785\ub825\ud574 \uc8fc\uc138\uc694:", + "title": "\uc124\uc815 \ud655\uc778" + } + }, + "title": "\uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc54c\ub9bc" + }, "totp": { "error": { "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \uc774 \uc624\ub958\uac00 \uc9c0\uc18d\uc801\uc73c\ub85c \ubc1c\uc0dd\ud55c\ub2e4\uba74 Home Assistant \uc758 \uc2dc\uac04\uc124\uc815\uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\ubcf4\uc138\uc694." diff --git a/homeassistant/components/auth/.translations/lb.json b/homeassistant/components/auth/.translations/lb.json index f55ae4b97ba0e6..12ced930446f4e 100644 --- a/homeassistant/components/auth/.translations/lb.json +++ b/homeassistant/components/auth/.translations/lb.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Keen Notifikatioun's D\u00e9ngscht disponibel." + }, + "error": { + "invalid_code": "Ong\u00ebltege Code, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "init": { + "description": "Wielt w.e.g. een Notifikatioun's D\u00e9ngscht aus:", + "title": "Eemolegt Passwuert ariichte wat vun engem Notifikatioun's Komponente versch\u00e9ckt g\u00ebtt" + }, + "setup": { + "description": "Een eemolegt Passwuert ass vun **notify.{notify_service}** gesch\u00e9ckt ginn. Gitt et w.e.g hei \u00ebnnen dr\u00ebnner an:", + "title": "Astellungen iwwerpr\u00e9iwen" + } + }, + "title": "Eemolegt Passwuert Notifikatioun" + }, "totp": { "error": { "invalid_code": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol. Falls d\u00ebse Feeler Message \u00ebmmer er\u00ebm optr\u00ebtt dann iwwerpr\u00e9ift op d'Z\u00e4it vum Home Assistant System richteg ass." diff --git a/homeassistant/components/auth/.translations/pl.json b/homeassistant/components/auth/.translations/pl.json index 78999c34c22339..3e320ba8d62d5b 100644 --- a/homeassistant/components/auth/.translations/pl.json +++ b/homeassistant/components/auth/.translations/pl.json @@ -1,5 +1,23 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Brak dost\u0119pnych us\u0142ug powiadamiania." + }, + "error": { + "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie." + }, + "step": { + "init": { + "description": "Prosz\u0119 wybra\u0107 jedn\u0105 z us\u0142ugi powiadamiania:", + "title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144" + }, + "setup": { + "description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez ** powiadom. {notify_service} **. Wpisz je poni\u017cej:", + "title": "Sprawd\u017a konfiguracj\u0119" + } + } + }, "totp": { "error": { "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie. Je\u015bli b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, upewnij si\u0119, \u017ce czas zegara systemu Home Assistant jest prawid\u0142owy." diff --git a/homeassistant/components/auth/.translations/ru.json b/homeassistant/components/auth/.translations/ru.json index a716425f345fa3..edf136bd7f353e 100644 --- a/homeassistant/components/auth/.translations/ru.json +++ b/homeassistant/components/auth/.translations/ru.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439." + }, + "error": { + "invalid_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043e\u0434, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443." + }, + "step": { + "init": { + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u0443 \u0438\u0437 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439:", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0438 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439 \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439" + }, + "setup": { + "description": "\u041e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d \u0447\u0435\u0440\u0435\u0437 **notify.{notify_service}**. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0435\u0433\u043e \u043d\u0438\u0436\u0435:", + "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443" + } + }, + "title": "\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439" + }, "totp": { "error": { "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0432\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f." diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json index 45b57a772f93d8..2efc23f78f6715 100644 --- a/homeassistant/components/auth/.translations/sl.json +++ b/homeassistant/components/auth/.translations/sl.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ni na voljo storitev obve\u0161\u010danja." + }, + "error": { + "invalid_code": "Neveljavna koda, poskusite znova." + }, + "step": { + "init": { + "description": "Prosimo, izberite eno od storitev obve\u0161\u010danja:", + "title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento" + }, + "setup": { + "description": "Enkratno geslo je poslal **notify.{notify_service} **. Vnesite ga spodaj:", + "title": "Preverite nastavitev" + } + }, + "title": "Obvesti Enkratno Geslo" + }, "totp": { "error": { "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistenta to\u010dna." diff --git a/homeassistant/components/auth/.translations/sv.json b/homeassistant/components/auth/.translations/sv.json index cf8227c09a37db..604ae3c4fe552d 100644 --- a/homeassistant/components/auth/.translations/sv.json +++ b/homeassistant/components/auth/.translations/sv.json @@ -1,5 +1,19 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Inga tillg\u00e4ngliga meddelande tj\u00e4nster." + }, + "error": { + "invalid_code": "Ogiltig kod, var god f\u00f6rs\u00f6k igen." + }, + "step": { + "setup": { + "description": "Ett eng\u00e5ngsl\u00f6senord har skickats av **notify.{notify_service}**. V\u00e4nligen ange det nedan:", + "title": "Verifiera installationen" + } + } + }, "totp": { "error": { "invalid_code": "Ogiltig kod, f\u00f6rs\u00f6k igen. Om du flera g\u00e5nger i rad f\u00e5r detta fel, se till att klockan i din Home Assistant \u00e4r korrekt inst\u00e4lld." diff --git a/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant/components/auth/.translations/zh-Hans.json index c5b397a8e12a60..d2a1b97b9b7813 100644 --- a/homeassistant/components/auth/.translations/zh-Hans.json +++ b/homeassistant/components/auth/.translations/zh-Hans.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u6ca1\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52a1\u3002" + }, + "error": { + "invalid_code": "\u4ee3\u7801\u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u8bf7\u9009\u62e9\u4ee5\u4e0b\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a", + "title": "\u8bbe\u7f6e\u7531\u901a\u77e5\u7ec4\u4ef6\u4f20\u9012\u7684\u4e00\u6b21\u6027\u5bc6\u7801" + }, + "setup": { + "description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}**\u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165:", + "title": "\u9a8c\u8bc1\u8bbe\u7f6e" + } + }, + "title": "\u4e00\u6b21\u6027\u5bc6\u7801\u901a\u77e5" + }, "totp": { "error": { "invalid_code": "\u53e3\u4ee4\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165\u3002\u5982\u679c\u9519\u8bef\u53cd\u590d\u51fa\u73b0\uff0c\u8bf7\u786e\u4fdd Home Assistant \u7cfb\u7edf\u7684\u65f6\u95f4\u51c6\u786e\u65e0\u8bef\u3002" diff --git a/homeassistant/components/auth/.translations/zh-Hant.json b/homeassistant/components/auth/.translations/zh-Hant.json index ef41ea8724811f..e791f20a73860e 100644 --- a/homeassistant/components/auth/.translations/zh-Hant.json +++ b/homeassistant/components/auth/.translations/zh-Hant.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u6c92\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52d9\u3002" + }, + "error": { + "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u8acb\u9078\u64c7\u4e00\u9805\u901a\u77e5\u670d\u52d9\uff1a", + "title": "\u8a2d\u5b9a\u4e00\u6b21\u6027\u5bc6\u78bc\u50b3\u9001\u7d44\u4ef6" + }, + "setup": { + "description": "\u4e00\u6b21\u6027\u5bc6\u78bc\u5df2\u900f\u904e **notify.{notify_service}** \u50b3\u9001\u3002\u8acb\u65bc\u4e0b\u65b9\u8f38\u5165\uff1a", + "title": "\u9a57\u8b49\u8a2d\u5b9a" + } + }, + "title": "\u901a\u77e5\u4e00\u6b21\u6027\u5bc6\u78bc" + }, "totp": { "error": { "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002\u5047\u5982\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5148\u78ba\u5b9a\u60a8\u7684 Home Assistant \u7cfb\u7d71\u4e0a\u7684\u6642\u9593\u8a2d\u5b9a\u6b63\u78ba\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002" diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json index a37dbd6f5b7fc1..ac1ebbeb23653b 100644 --- a/homeassistant/components/cast/.translations/de.json +++ b/homeassistant/components/cast/.translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chten Sie Google Cast einrichten?", + "description": "M\u00f6chtest du Google Cast einrichten?", "title": "Google Cast" } }, diff --git a/homeassistant/components/deconz/.translations/he.json b/homeassistant/components/deconz/.translations/he.json index b4b3d54e07567b..89a2d69950e418 100644 --- a/homeassistant/components/deconz/.translations/he.json +++ b/homeassistant/components/deconz/.translations/he.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", - "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ" + "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ", + "one_instance_only": "\u05d4\u05e8\u05db\u05d9\u05d1 \u05ea\u05d5\u05de\u05da \u05e8\u05e7 \u05d0\u05d7\u05d3 deCONZ \u05dc\u05de\u05e9\u05dc" }, "error": { "no_key": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05d1\u05dc \u05de\u05e4\u05ea\u05d7 API" diff --git a/homeassistant/components/hangouts/.translations/he.json b/homeassistant/components/hangouts/.translations/he.json new file mode 100644 index 00000000000000..28326d97142b94 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." + }, + "error": { + "invalid_2fa": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d1\u05d1\u05e7\u05e9\u05d4 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "invalid_2fa_method": "\u05d3\u05e8\u05da \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea (\u05d0\u05de\u05ea \u05d1\u05d8\u05dc\u05e4\u05d5\u05df).", + "invalid_login": "\u05db\u05e0\u05d9\u05e1\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." + }, + "step": { + "2fa": { + "data": { + "2fa": "\u05e7\u05d5\u05d3 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" + }, + "user": { + "data": { + "email": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc- Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pl.json b/homeassistant/components/hangouts/.translations/pl.json index a8314761f8d39b..5e0ecfa2900602 100644 --- a/homeassistant/components/hangouts/.translations/pl.json +++ b/homeassistant/components/hangouts/.translations/pl.json @@ -14,6 +14,7 @@ "data": { "2fa": "PIN" }, + "description": "Pusty", "title": "Uwierzytelnianie dwusk\u0142adnikowe" }, "user": { @@ -21,6 +22,7 @@ "email": "Adres e-mail", "password": "Has\u0142o" }, + "description": "Pusty", "title": "Logowanie do Google Hangouts" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json index 6b3b9b09480fef..bd600f7d2ef2da 100644 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -6,10 +6,10 @@ "unknown": "Ein unbekannter Fehler ist aufgetreten." }, "error": { - "invalid_pin": "Ung\u00fcltige PIN, bitte versuchen Sie es erneut.", - "press_the_button": "Bitte dr\u00fccken Sie die blaue Taste.", - "register_failed": "Registrierung fehlgeschlagen, bitte versuchen Sie es erneut.", - "timeout_button": "Zeit\u00fcberschreitung beim Dr\u00fccken der blauen Taste. Bitte versuchen Sie es erneut." + "invalid_pin": "Ung\u00fcltige PIN, bitte versuche es erneut.", + "press_the_button": "Bitte dr\u00fccke die blaue Taste.", + "register_failed": "Registrierung fehlgeschlagen, bitte versuche es erneut.", + "timeout_button": "Zeit\u00fcberschreitung beim Dr\u00fccken der blauen Taste. Bitte versuche es erneut." }, "step": { "init": { @@ -21,7 +21,7 @@ "title": "HometicIP Accesspoint ausw\u00e4hlen" }, "link": { - "description": "Dr\u00fccken Sie den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Dr\u00fccke den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Verkn\u00fcpfe den Accesspoint" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/he.json b/homeassistant/components/homematicip_cloud/.translations/he.json index 50d5c61b576a31..c60294e21d5b0b 100644 --- a/homeassistant/components/homematicip_cloud/.translations/he.json +++ b/homeassistant/components/homematicip_cloud/.translations/he.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d2\u05d9\u05e9\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05ea", + "connection_aborted": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e8\u05ea HMIP", "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." }, "error": { diff --git a/homeassistant/components/ios/.translations/he.json b/homeassistant/components/ios/.translations/he.json new file mode 100644 index 00000000000000..e786e5ae843112 --- /dev/null +++ b/homeassistant/components/ios/.translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Home Assistant iOS \u05e0\u05d7\u05d5\u05e6\u05d4." + }, + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/sl.json b/homeassistant/components/ios/.translations/sl.json new file mode 100644 index 00000000000000..28e9102aafd9e7 --- /dev/null +++ b/homeassistant/components/ios/.translations/sl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Potrebna je samo ena konfiguracija Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti komponento za Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/sv.json b/homeassistant/components/ios/.translations/sv.json new file mode 100644 index 00000000000000..6806f9bab905b6 --- /dev/null +++ b/homeassistant/components/ios/.translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Vill du konfigurera Home Assistants iOS komponent?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json index 57e9a83d201e46..b6c73f35f26bbd 100644 --- a/homeassistant/components/mqtt/.translations/ca.json +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Broker", + "discovery": "Activar descobreix automaticament", "password": "Contrasenya", "port": "Port", "username": "Nom d'usuari" diff --git a/homeassistant/components/mqtt/.translations/de.json b/homeassistant/components/mqtt/.translations/de.json index 15b6b3b9731e93..eeff1ca3041b9c 100644 --- a/homeassistant/components/mqtt/.translations/de.json +++ b/homeassistant/components/mqtt/.translations/de.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "Nur eine einzige Konfiguration von MQTT ist zul\u00e4ssig." + }, + "error": { + "cannot_connect": "Es konnte keine Verbindung zum Broker hergestellt werden." + }, "step": { "broker": { "data": { diff --git a/homeassistant/components/mqtt/.translations/fr.json b/homeassistant/components/mqtt/.translations/fr.json index 1870c598e3b7a0..916b4fdaf394a3 100644 --- a/homeassistant/components/mqtt/.translations/fr.json +++ b/homeassistant/components/mqtt/.translations/fr.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Broker", + "discovery": "Activer la d\u00e9couverte automatique", "password": "Mot de passe", "port": "Port", "username": "Nom d'utilisateur" diff --git a/homeassistant/components/mqtt/.translations/he.json b/homeassistant/components/mqtt/.translations/he.json new file mode 100644 index 00000000000000..e1e2ed497487d5 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/he.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc MQTT \u05de\u05d5\u05ea\u05e8\u05ea." + }, + "error": { + "cannot_connect": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05d1\u05e8\u05d5\u05e7\u05e8." + }, + "step": { + "broker": { + "data": { + "broker": "\u05d1\u05e8\u05d5\u05e7\u05e8", + "discovery": "\u05d0\u05e4\u05e9\u05e8 \u05d2\u05d9\u05dc\u05d5\u05d9", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05d5\u05e8\u05d8", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + }, + "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e4\u05e8\u05d8\u05d9 \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e9\u05dc \u05d4\u05d1\u05e8\u05d5\u05e7\u05e8 MQTT \u05e9\u05dc\u05da.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json index 82a10194667b85..166fce9fbfb087 100644 --- a/homeassistant/components/mqtt/.translations/lb.json +++ b/homeassistant/components/mqtt/.translations/lb.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Broker", + "discovery": "Entdeckung aktiv\u00e9ieren", "password": "Passwuert", "port": "Port", "username": "Benotzernumm" diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json index f3ccaf8f37e223..e87e550b98df4b 100644 --- a/homeassistant/components/mqtt/.translations/pl.json +++ b/homeassistant/components/mqtt/.translations/pl.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Po\u015brednik", + "discovery": "W\u0142\u0105cz wykrywanie", "password": "Has\u0142o", "port": "Port", "username": "Nazwa u\u017cytkownika" diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index c9dc3c2fd600c6..f1ff498dd72ee0 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "\u0411\u0440\u043e\u043a\u0435\u0440", + "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" diff --git a/homeassistant/components/mqtt/.translations/sl.json b/homeassistant/components/mqtt/.translations/sl.json new file mode 100644 index 00000000000000..a12498ac4c2115 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dovoljena je samo ena konfiguracija MQTT." + }, + "error": { + "cannot_connect": "Ne morem se povezati na posrednik." + }, + "step": { + "broker": { + "data": { + "broker": "Posrednik", + "discovery": "Omogo\u010di odkrivanje", + "password": "Geslo", + "port": "port", + "username": "Uporabni\u0161ko ime" + }, + "description": "Prosimo vnesite informacije o povezavi va\u0161ega MQTT posrednika.", + "title": "MQTT" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/sv.json b/homeassistant/components/mqtt/.translations/sv.json new file mode 100644 index 00000000000000..7cf6d75b9c1392 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/sv.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "broker": { + "data": { + "password": "L\u00f6senord", + "port": "Port", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/zh-Hans.json b/homeassistant/components/mqtt/.translations/zh-Hans.json index f539bd2a630a49..98a7d9eb4be1b1 100644 --- a/homeassistant/components/mqtt/.translations/zh-Hans.json +++ b/homeassistant/components/mqtt/.translations/zh-Hans.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "\u670d\u52a1\u5668", + "discovery": "\u542f\u7528\u53d1\u73b0", "password": "\u5bc6\u7801", "port": "\u7aef\u53e3", "username": "\u7528\u6237\u540d" diff --git a/homeassistant/components/mqtt/.translations/zh-Hant.json b/homeassistant/components/mqtt/.translations/zh-Hant.json index e6f27a439d5aea..cf87ceb8f9828f 100644 --- a/homeassistant/components/mqtt/.translations/zh-Hant.json +++ b/homeassistant/components/mqtt/.translations/zh-Hant.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Broker", + "discovery": "\u958b\u555f\u63a2\u7d22", "password": "\u4f7f\u7528\u8005\u5bc6\u78bc", "port": "\u901a\u8a0a\u57e0", "username": "\u4f7f\u7528\u8005\u540d\u7a31" diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json index 86b50ab3c10323..975d15e4470b90 100644 --- a/homeassistant/components/nest/.translations/de.json +++ b/homeassistant/components/nest/.translations/de.json @@ -24,7 +24,7 @@ "data": { "code": "PIN Code" }, - "description": "[Autorisieren Sie ihr Konto] ( {url} ), um ihren Nest-Account zu verkn\u00fcpfen.\n\n F\u00fcgen Sie anschlie\u00dfend den erhaltenen PIN Code hier ein.", + "description": "[Autorisiere dein Konto] ( {url} ), um deinen Nest-Account zu verkn\u00fcpfen.\n\n F\u00fcge anschlie\u00dfend den erhaltenen PIN Code hier ein.", "title": "Nest-Konto verkn\u00fcpfen" } }, diff --git a/homeassistant/components/openuv/.translations/de.json b/homeassistant/components/openuv/.translations/de.json index 1f81ac30f5320b..7f8121dd96b76a 100644 --- a/homeassistant/components/openuv/.translations/de.json +++ b/homeassistant/components/openuv/.translations/de.json @@ -11,7 +11,8 @@ "elevation": "H\u00f6he", "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" - } + }, + "title": "Gebe deine Informationen ein" } }, "title": "OpenUV" diff --git a/homeassistant/components/openuv/.translations/he.json b/homeassistant/components/openuv/.translations/he.json new file mode 100644 index 00000000000000..262a3d732a29af --- /dev/null +++ b/homeassistant/components/openuv/.translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "\u05d4\u05e7\u05d5\u05d0\u05d5\u05e8\u05d3\u05d9\u05e0\u05d8\u05d5\u05ea \u05db\u05d1\u05e8 \u05e8\u05e9\u05d5\u05de\u05d5\u05ea", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API \u05e9\u05dc OpenUV", + "elevation": "\u05d2\u05d5\u05d1\u05d4 \u05de\u05e2\u05dc \u05e4\u05e0\u05d9 \u05d4\u05d9\u05dd", + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + }, + "title": "\u05de\u05dc\u05d0 \u05d0\u05ea \u05d4\u05e4\u05e8\u05d8\u05d9\u05dd \u05e9\u05dc\u05da" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.he.json b/homeassistant/components/sensor/.translations/moon.he.json index 60999f83645044..6531d3c82657cc 100644 --- a/homeassistant/components/sensor/.translations/moon.he.json +++ b/homeassistant/components/sensor/.translations/moon.he.json @@ -3,6 +3,10 @@ "first_quarter": "\u05e8\u05d1\u05e2\u05d5\u05df \u05e8\u05d0\u05e9\u05d5\u05df", "full_moon": "\u05d9\u05e8\u05d7 \u05de\u05dc\u05d0", "last_quarter": "\u05e8\u05d1\u05e2\u05d5\u05df \u05d0\u05d7\u05e8\u05d5\u05df", - "new_moon": "\u05e8\u05d0\u05e9 \u05d7\u05d5\u05d3\u05e9" + "new_moon": "\u05e8\u05d0\u05e9 \u05d7\u05d5\u05d3\u05e9", + "waning_crescent": "Waning crescent", + "waning_gibbous": "Waning gibbous", + "waxing_crescent": "Waxing crescent", + "waxing_gibbous": "Waxing gibbous" } } \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/de.json b/homeassistant/components/sonos/.translations/de.json index dd44fca5888474..920d25a3bfa310 100644 --- a/homeassistant/components/sonos/.translations/de.json +++ b/homeassistant/components/sonos/.translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chten Sie Sonos einrichten?", + "description": "M\u00f6chtest du Sonos einrichten?", "title": "Sonos" } }, diff --git a/homeassistant/components/tradfri/.translations/de.json b/homeassistant/components/tradfri/.translations/de.json new file mode 100644 index 00000000000000..5284ae18b6d309 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung zum Gateway nicht m\u00f6glich.", + "invalid_key": "Fehler beim Registrieren mit dem angegebenen Schl\u00fcssel. Wenn dies weiterhin geschieht, versuche, das Gateway neu zu starten.", + "timeout": "Timeout bei der \u00dcberpr\u00fcfung des Codes." + }, + "step": { + "auth": { + "data": { + "security_code": "Sicherheitscode" + }, + "description": "Du findest den Sicherheitscode auf der R\u00fcckseite deines Gateways.", + "title": "Sicherheitscode eingeben" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/he.json b/homeassistant/components/tradfri/.translations/he.json new file mode 100644 index 00000000000000..09af3d09bdcc7b --- /dev/null +++ b/homeassistant/components/tradfri/.translations/he.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + }, + "error": { + "cannot_connect": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05d2\u05e9\u05e8", + "invalid_key": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc \u05e2\u05dd \u05d4\u05de\u05e4\u05ea\u05d7 \u05e9\u05e1\u05d5\u05e4\u05e7. \u05d0\u05dd \u05d6\u05d4 \u05e7\u05d5\u05e8\u05d4 \u05e9\u05d5\u05d1, \u05e0\u05e1\u05d4 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05de\u05d2\u05e9\u05e8.", + "timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3" + }, + "step": { + "auth": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "security_code": "\u05e7\u05d5\u05d3 \u05d0\u05d1\u05d8\u05d7\u05d4" + }, + "description": "\u05ea\u05d5\u05db\u05dc \u05dc\u05de\u05e6\u05d5\u05d0 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d1\u05d8\u05d7\u05d4 \u05d1\u05d2\u05d1 \u05d4\u05de\u05d2\u05e9\u05e8 \u05e9\u05dc\u05da.", + "title": "\u05d4\u05d6\u05df \u05e7\u05d5\u05d3 \u05d0\u05d1\u05d8\u05d7\u05d4" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/pl.json b/homeassistant/components/tradfri/.translations/pl.json new file mode 100644 index 00000000000000..ec253447ef424a --- /dev/null +++ b/homeassistant/components/tradfri/.translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Mostek jest ju\u017c skonfigurowany" + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z bram\u0105.", + "invalid_key": "Rejestracja si\u0119 nie powiod\u0142a z podanym kluczem. Je\u015bli tak si\u0119 stanie, spr\u00f3buj ponownie uruchomi\u0107 bramk\u0119.", + "timeout": "Min\u0105\u0142 limit czasu sprawdzania poprawno\u015bci kodu" + }, + "step": { + "auth": { + "data": { + "host": "Host", + "security_code": "Kod bezpiecze\u0144stwa" + }, + "description": "Mo\u017cesz znale\u017a\u0107 kod bezpiecze\u0144stwa z ty\u0142u bramy.", + "title": "Wprowad\u017a kod bezpiecze\u0144stwa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/sl.json b/homeassistant/components/tradfri/.translations/sl.json new file mode 100644 index 00000000000000..ee2bf7d3d2bbaa --- /dev/null +++ b/homeassistant/components/tradfri/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Most je \u017ee konfiguriran" + }, + "error": { + "cannot_connect": "Povezava s prehodom ni mogo\u010de.", + "invalid_key": "Ni se bilo mogo\u010de registrirati s prilo\u017eenim klju\u010dem. \u010ce se to dogaja, poskusite znova zagnati prehod.", + "timeout": "\u010casovna omejitev za potrditev kode je potekla." + }, + "step": { + "auth": { + "data": { + "host": "Gostitelj", + "security_code": "Varnostna koda" + }, + "description": "Varnostno kodo najdete na hrbtni strani va\u0161ega prehoda.", + "title": "Vnesite varnostno kodo" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/sv.json b/homeassistant/components/tradfri/.translations/sv.json new file mode 100644 index 00000000000000..ffe8bff22b43b7 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Bryggan \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta till gatewayen." + }, + "step": { + "auth": { + "data": { + "security_code": "S\u00e4kerhetskod" + }, + "description": "Du kan hitta s\u00e4kerhetskoden p\u00e5 baksidan av din gateway.", + "title": "Ange s\u00e4kerhetskod" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/zh-Hans.json b/homeassistant/components/tradfri/.translations/zh-Hans.json index 28a9c676f2ec04..4791e46062aaf2 100644 --- a/homeassistant/components/tradfri/.translations/zh-Hans.json +++ b/homeassistant/components/tradfri/.translations/zh-Hans.json @@ -7,6 +7,17 @@ "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u7f51\u5173\u3002", "invalid_key": "\u65e0\u6cd5\u7528\u63d0\u4f9b\u7684\u5bc6\u94a5\u6ce8\u518c\u3002\u5982\u679c\u9519\u8bef\u6301\u7eed\u53d1\u751f\uff0c\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u542f\u52a8\u7f51\u5173\u3002", "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6" - } + }, + "step": { + "auth": { + "data": { + "host": "\u4e3b\u673a", + "security_code": "\u5b89\u5168\u7801" + }, + "description": "\u60a8\u53ef\u4ee5\u5728\u7f51\u5173\u80cc\u9762\u627e\u5230\u5b89\u5168\u7801\u3002", + "title": "\u8f93\u5165\u5b89\u5168\u7801" + } + }, + "title": "IKEA TR\u00c5DFRI" } } \ No newline at end of file From cf0d0fb33e2d9e3cdeab515d192ef51ee8d28f82 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 26 Sep 2018 02:56:23 -0400 Subject: [PATCH 168/178] Device Registry Support for iOS Sensors (#16862) * Add device_info property to iOS sensors for device registry * Remove unused logger import * Fix spacing * lint * Lint --- homeassistant/components/sensor/ios.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/sensor/ios.py b/homeassistant/components/sensor/ios.py index a50d1161676dc0..d206cd1df8785c 100644 --- a/homeassistant/components/sensor/ios.py +++ b/homeassistant/components/sensor/ios.py @@ -46,6 +46,21 @@ def __init__(self, sensor_type, device_name, device): self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + @property + def device_info(self): + """Return information about the device.""" + return { + 'identifiers': { + (ios.DOMAIN, + self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_PERMANENT_ID]), + }, + 'name': self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME], + 'manufacturer': 'Apple', + 'model': self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_TYPE], + 'sw_version': + self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_SYSTEM_VERSION], + } + @property def name(self): """Return the name of the iOS sensor.""" From ff9377d1d91d57a771ac25194f28992a813db305 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 09:38:50 +0200 Subject: [PATCH 169/178] Fix MQTT discovery (#16864) * Fix MQTT discovery * Update __init__.py --- homeassistant/components/mqtt/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 856d5d01894f8a..70f20453633742 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -21,7 +21,7 @@ from homeassistant import config_entries from homeassistant.const import ( CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, - CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import Event, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -524,12 +524,8 @@ async def async_publish_service(call: ServiceCall): schema=MQTT_PUBLISH_SCHEMA) if conf.get(CONF_DISCOVERY): - async def async_setup_discovery(event): - await _async_setup_discovery( - hass, conf, hass.data[DATA_MQTT_HASS_CONFIG]) - - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_setup_discovery) + await _async_setup_discovery( + hass, conf, hass.data[DATA_MQTT_HASS_CONFIG]) return True From 824e59499f26d503583db38a5b255120e68b1404 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 08:52:22 +0200 Subject: [PATCH 170/178] Make ring sync again (#16866) --- homeassistant/components/camera/ring.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py index f629b501819928..d0cb6443fc767d 100644 --- a/homeassistant/components/camera/ring.py +++ b/homeassistant/components/camera/ring.py @@ -39,9 +39,7 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up a Ring Door Bell and StickUp Camera.""" ring = hass.data[DATA_RING] @@ -67,14 +65,14 @@ def async_setup_platform(hass, config, async_add_entities, ''' following cameras: {}.'''.format(cameras) _LOGGER.error(err_msg) - hass.components.persistent_notification.async_create( + hass.components.persistent_notification.create( 'Error: {}
' 'You will need to restart hass after fixing.' ''.format(err_msg), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) - async_add_entities(cams, True) + add_entities(cams, True) return True From 7b68f344e3928ac4884d916a5317b1e70b59a37e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 11:21:22 +0200 Subject: [PATCH 171/178] Bumped version to 0.79.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 862b1d97a76ac4..4b4153ac89c958 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 79 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 71333a15f70b254f0b8548c8ba7b64c6620e98f6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Sep 2018 23:04:03 +0200 Subject: [PATCH 172/178] Bump frontend to 20180927.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 66232f2155b390..9bd13f316b6a86 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180926.0'] +REQUIREMENTS = ['home-assistant-frontend==20180927.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index e79feca7feffc0..61a412951f9806 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,7 +454,7 @@ hole==0.3.0 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180926.0 +home-assistant-frontend==20180927.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 691fc6416076ea..e67da9755cd2b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ hdate==0.6.3 holidays==0.9.7 # homeassistant.components.frontend -home-assistant-frontend==20180926.0 +home-assistant-frontend==20180927.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 51dbc988f92d99c05e0e46f2f6dfa04216a2a300 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Sep 2018 23:04:28 +0200 Subject: [PATCH 173/178] Update translations --- .../components/auth/.translations/de.json | 3 ++- .../components/auth/.translations/en.json | 6 +++--- .../components/auth/.translations/no.json | 19 +++++++++++++++++++ .../auth/.translations/zh-Hans.json | 4 ++-- .../components/deconz/.translations/de.json | 4 ++-- .../components/mqtt/.translations/de.json | 1 + .../components/mqtt/.translations/ko.json | 1 + .../components/mqtt/.translations/no.json | 1 + .../components/nest/.translations/de.json | 6 +++--- .../components/zone/.translations/de.json | 2 +- 10 files changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/auth/.translations/de.json b/homeassistant/components/auth/.translations/de.json index 2abc64f5f5d5ec..21c83290629315 100644 --- a/homeassistant/components/auth/.translations/de.json +++ b/homeassistant/components/auth/.translations/de.json @@ -9,7 +9,8 @@ }, "step": { "init": { - "description": "Bitte w\u00e4hle einen der Benachrichtigungsdienste:" + "description": "Bitte w\u00e4hle einen der Benachrichtigungsdienste:", + "title": "Einmal Passwort f\u00fcr Notify einrichten" }, "setup": { "description": "Ein Einmal-Passwort wurde per ** notify gesendet. {notify_service} **. Bitte gebe es unten ein:", diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json index 21cb45e3050a22..66c0e92d9b5a64 100644 --- a/homeassistant/components/auth/.translations/en.json +++ b/homeassistant/components/auth/.translations/en.json @@ -2,18 +2,18 @@ "mfa_setup": { "notify": { "abort": { - "no_available_service": "No available notify services." + "no_available_service": "No notification services available." }, "error": { "invalid_code": "Invalid code, please try again." }, "step": { "init": { - "description": "Please select one of notify service:", + "description": "Please select one of the notification services:", "title": "Set up one-time password delivered by notify component" }, "setup": { - "description": "A one-time password have sent by **notify.{notify_service}**. Please input it in below:", + "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:", "title": "Verify setup" } }, diff --git a/homeassistant/components/auth/.translations/no.json b/homeassistant/components/auth/.translations/no.json index 43ec497cfb1ff9..48b5db8a3b6063 100644 --- a/homeassistant/components/auth/.translations/no.json +++ b/homeassistant/components/auth/.translations/no.json @@ -1,5 +1,24 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ingen varslingstjenester er tilgjengelig." + }, + "error": { + "invalid_code": "Ugyldig kode, vennligst pr\u00f8v igjen." + }, + "step": { + "init": { + "description": "Vennligst velg en av varslingstjenestene:", + "title": "Sett opp engangspassord levert av varsel komponent" + }, + "setup": { + "description": "Et engangspassord har blitt sendt via **notify.{notify_service}**. Vennligst skriv det inn nedenfor:", + "title": "Bekreft oppsettet" + } + }, + "title": "Varsle engangspassord" + }, "totp": { "error": { "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Hvis du f\u00e5r denne feilen konsekvent, m\u00e5 du s\u00f8rge for at klokken p\u00e5 Home Assistant systemet er riktig." diff --git a/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant/components/auth/.translations/zh-Hans.json index d2a1b97b9b7813..1cb311f016fe6d 100644 --- a/homeassistant/components/auth/.translations/zh-Hans.json +++ b/homeassistant/components/auth/.translations/zh-Hans.json @@ -9,11 +9,11 @@ }, "step": { "init": { - "description": "\u8bf7\u9009\u62e9\u4ee5\u4e0b\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a", + "description": "\u8bf7\u4ece\u4e0b\u9762\u9009\u62e9\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a", "title": "\u8bbe\u7f6e\u7531\u901a\u77e5\u7ec4\u4ef6\u4f20\u9012\u7684\u4e00\u6b21\u6027\u5bc6\u7801" }, "setup": { - "description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}**\u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165:", + "description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}** \u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165\uff1a", "title": "\u9a8c\u8bc1\u8bbe\u7f6e" } }, diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 51b496906a2a08..645daa56f6bf63 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -14,10 +14,10 @@ "host": "Host", "port": "Port (Standartwert : '80')" }, - "title": "Definieren Sie den deCONZ-Gateway" + "title": "Definiere das deCONZ-Gateway" }, "link": { - "description": "Entsperren Sie Ihr deCONZ-Gateway, um sich bei Home Assistant zu registrieren. \n\n 1. Gehen Sie zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccken Sie die Taste \"Gateway entsperren\"", + "description": "Entsperre dein deCONZ-Gateway, um dich bei Home Assistant zu registrieren. \n\n 1. Gehe zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" }, "options": { diff --git a/homeassistant/components/mqtt/.translations/de.json b/homeassistant/components/mqtt/.translations/de.json index eeff1ca3041b9c..2a35e95f559791 100644 --- a/homeassistant/components/mqtt/.translations/de.json +++ b/homeassistant/components/mqtt/.translations/de.json @@ -9,6 +9,7 @@ "step": { "broker": { "data": { + "discovery": "Suche aktivieren", "password": "Passwort", "username": "Benutzername" }, diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index 3775c8328d1b5e..f20658d252c374 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "\ube0c\ub85c\ucee4", + "discovery": "\uae30\uae30 \uac80\uc0c9 \ud65c\uc131\ud654", "password": "\ube44\ubc00\ubc88\ud638", "port": "\ud3ec\ud2b8", "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" diff --git a/homeassistant/components/mqtt/.translations/no.json b/homeassistant/components/mqtt/.translations/no.json index a9c60fb0c7ffcf..412efd3e107fa9 100644 --- a/homeassistant/components/mqtt/.translations/no.json +++ b/homeassistant/components/mqtt/.translations/no.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Megler", + "discovery": "Aktiver oppdagelse", "password": "Passord", "port": "Port", "username": "Brukernavn" diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json index 975d15e4470b90..500862039a282b 100644 --- a/homeassistant/components/nest/.translations/de.json +++ b/homeassistant/components/nest/.translations/de.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_setup": "Sie k\u00f6nnen nur ein einziges Nest-Konto konfigurieren.", + "already_setup": "Du kannst nur ein einziges Nest-Konto konfigurieren.", "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL", - "no_flows": "Sie m\u00fcssen Nest konfigurieren, bevor Sie sich authentifizieren k\u00f6nnen. [Bitte lesen Sie die Anweisungen] (https://www.home-assistant.io/components/nest/)." + "no_flows": "Du musst Nest konfigurieren, bevor du dich authentifizieren kannst. [Bitte lese die Anweisungen] (https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Ein interner Fehler ist aufgetreten", @@ -17,7 +17,7 @@ "data": { "flow_impl": "Anbieter" }, - "description": "W\u00e4hlen Sie, \u00fcber welchen Authentifizierungsanbieter Sie sich bei Nest authentifizieren m\u00f6chten.", + "description": "W\u00e4hlen, \u00fcber welchen Authentifizierungsanbieter du dich bei Nest authentifizieren m\u00f6chtest.", "title": "Authentifizierungsanbieter" }, "link": { diff --git a/homeassistant/components/zone/.translations/de.json b/homeassistant/components/zone/.translations/de.json index fc1e3537f33ec6..483c7f065a329a 100644 --- a/homeassistant/components/zone/.translations/de.json +++ b/homeassistant/components/zone/.translations/de.json @@ -13,7 +13,7 @@ "passive": "Passiv", "radius": "Radius" }, - "title": "Definieren Sie die Zonenparameter" + "title": "Definiere die Zonenparameter" } }, "title": "Zone" From 7597e30efbc32870e08e17bbb12c6fc5808f735d Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 26 Sep 2018 03:19:47 -0400 Subject: [PATCH 174/178] Add unique_id to Nest Sensors (#16869) * Add unique_id * Add device_info * Fix typo * Update __init__.py --- homeassistant/components/nest/__init__.py | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 57111350396e1b..f609c774b129fa 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -309,6 +309,37 @@ def should_poll(self): """Do not need poll thanks using Nest streaming API.""" return False + @property + def unique_id(self): + """Return unique id based on device serial and variable.""" + return "{}-{}".format(self.device.serial, self.variable) + + @property + def device_info(self): + """Return information about the device.""" + if not hasattr(self.device, 'name_long'): + name = self.structure.name + model = "Structure" + else: + name = self.device.name_long + if self.device.is_thermostat: + model = 'Thermostat' + elif self.device.is_camera: + model = 'Camera' + elif self.device.is_smoke_co_alarm: + model = 'Nest Protect' + else: + model = None + + return { + 'identifiers': { + (DOMAIN, self.device.serial) + }, + 'name': name, + 'manufacturer': 'Nest Labs', + 'model': model, + } + def update(self): """Do not use NestSensorDevice directly.""" raise NotImplementedError From b7e03f6973a99657255582a08c8b1b09949cf0e2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Sep 2018 18:03:25 +0200 Subject: [PATCH 175/178] Prevent discovered Tradfri while already configured (#16891) * Prevent discovered Tradfri while already configured * Lint --- homeassistant/components/tradfri/__init__.py | 12 +++- .../components/tradfri/config_flow.py | 6 ++ tests/components/tradfri/test_config_flow.py | 34 ++++++++- tests/components/tradfri/test_init.py | 72 +++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 tests/components/tradfri/test_init.py diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 079381f8b451ef..6e91ab338a3376 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -44,10 +44,16 @@ async def async_setup(hass, config): if conf is None: return True - known_hosts = await hass.async_add_executor_job( + configured_hosts = [entry.data['host'] for entry in + hass.config_entries.async_entries(DOMAIN)] + + legacy_hosts = await hass.async_add_executor_job( load_json, hass.config.path(CONFIG_FILE)) - for host, info in known_hosts.items(): + for host, info in legacy_hosts.items(): + if host in configured_hosts: + continue + info[CONF_HOST] = host info[CONF_IMPORT_GROUPS] = conf[CONF_ALLOW_TRADFRI_GROUPS] @@ -58,7 +64,7 @@ async def async_setup(hass, config): host = conf.get(CONF_HOST) - if host is None or host in known_hosts: + if host is None or host in configured_hosts or host in legacy_hosts: return True hass.async_create_task(hass.config_entries.flow.async_init( diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 8d8f9af79e6387..29aa768dbb5ab8 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -77,6 +77,12 @@ async def async_step_auth(self, user_input=None): async def async_step_discovery(self, user_input): """Handle discovery.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == user_input['host']: + return self.async_abort( + reason='already_configured' + ) + self._host = user_input['host'] return await self.async_step_auth() diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 580e9580d76eef..99566356f61d5d 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -6,7 +6,7 @@ from homeassistant import data_entry_flow from homeassistant.components.tradfri import config_flow -from tests.common import mock_coro +from tests.common import mock_coro, MockConfigEntry @pytest.fixture @@ -185,3 +185,35 @@ async def test_import_connection_legacy(hass, mock_gateway_info, assert len(mock_gateway_info.mock_calls) == 1 assert len(mock_entry_setup.mock_calls) == 1 + + +async def test_discovery_duplicate_aborted(hass): + """Test a duplicate discovery host is ignored.""" + MockConfigEntry( + domain='tradfri', + data={'host': 'some-host'} + ).add_to_hass(hass) + + flow = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'discovery'}, data={ + 'host': 'some-host' + }) + + assert flow['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert flow['reason'] == 'already_configured' + + +async def test_import_duplicate_aborted(hass): + """Test a duplicate discovery host is ignored.""" + MockConfigEntry( + domain='tradfri', + data={'host': 'some-host'} + ).add_to_hass(hass) + + flow = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'import'}, data={ + 'host': 'some-host' + }) + + assert flow['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert flow['reason'] == 'already_configured' diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py new file mode 100644 index 00000000000000..4527e87f605d8a --- /dev/null +++ b/tests/components/tradfri/test_init.py @@ -0,0 +1,72 @@ +"""Tests for Tradfri setup.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_config_yaml_host_not_imported(hass): + """Test that we don't import a configured host.""" + MockConfigEntry( + domain='tradfri', + data={'host': 'mock-host'} + ).add_to_hass(hass) + + with patch('homeassistant.components.tradfri.load_json', + return_value={}), \ + patch.object(hass.config_entries.flow, 'async_init') as mock_init: + assert await async_setup_component(hass, 'tradfri', { + 'tradfri': { + 'host': 'mock-host' + } + }) + + assert len(mock_init.mock_calls) == 0 + + +async def test_config_yaml_host_imported(hass): + """Test that we import a configured host.""" + with patch('homeassistant.components.tradfri.load_json', + return_value={}): + assert await async_setup_component(hass, 'tradfri', { + 'tradfri': { + 'host': 'mock-host' + } + }) + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]['handler'] == 'tradfri' + assert progress[0]['context'] == {'source': 'import'} + + +async def test_config_json_host_not_imported(hass): + """Test that we don't import a configured host.""" + MockConfigEntry( + domain='tradfri', + data={'host': 'mock-host'} + ).add_to_hass(hass) + + with patch('homeassistant.components.tradfri.load_json', + return_value={'mock-host': {'key': 'some-info'}}), \ + patch.object(hass.config_entries.flow, 'async_init') as mock_init: + assert await async_setup_component(hass, 'tradfri', { + 'tradfri': {} + }) + + assert len(mock_init.mock_calls) == 0 + + +async def test_config_json_host_imported(hass): + """Test that we import a configured host.""" + with patch('homeassistant.components.tradfri.load_json', + return_value={'mock-host': {'key': 'some-info'}}): + assert await async_setup_component(hass, 'tradfri', { + 'tradfri': {} + }) + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]['handler'] == 'tradfri' + assert progress[0]['context'] == {'source': 'import'} From fb39641eef7cf2d03304591e83a9de06c767ffc3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Sep 2018 23:10:07 +0200 Subject: [PATCH 176/178] Handle exception handling websocket command (#16927) * Handle exception handling websocket command * lint * Lint2 --- homeassistant/components/websocket_api.py | 9 ++++++++- tests/components/test_websocket_api.py | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 9bd4aac4b6a970..4e7c186facc868 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -40,6 +40,7 @@ ERR_INVALID_FORMAT = 2 ERR_NOT_FOUND = 3 ERR_UNKNOWN_COMMAND = 4 +ERR_UNKNOWN_ERROR = 5 TYPE_AUTH = 'auth' TYPE_AUTH_INVALID = 'auth_invalid' @@ -405,7 +406,13 @@ def handle_hass_stop(event): else: handler, schema = handlers[msg['type']] - handler(self.hass, self, schema(msg)) + try: + handler(self.hass, self, schema(msg)) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling message: %s', msg) + self.to_write.put_nowait(error_message( + cur_id, ERR_UNKNOWN_ERROR, + 'Unknown error.')) last_id = cur_id msg = await wsock.receive_json() diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 199a9d804f83f2..cf74081adb12ba 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -1,6 +1,6 @@ """Tests for the Home Assistant Websocket API.""" import asyncio -from unittest.mock import patch +from unittest.mock import patch, Mock from aiohttp import WSMsgType from async_timeout import timeout @@ -539,3 +539,20 @@ async def test_call_service_context_no_user(hass, aiohttp_client): assert call.service == 'test_service' assert call.data == {'hello': 'world'} assert call.context.user_id is None + + +async def test_handler_failing(hass, websocket_client): + """Test a command that raises.""" + hass.components.websocket_api.async_register_command( + 'bla', Mock(side_effect=TypeError), + wapi.BASE_COMMAND_MESSAGE_SCHEMA.extend({'type': 'bla'})) + await websocket_client.send_json({ + 'id': 5, + 'type': 'bla', + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == wapi.ERR_UNKNOWN_ERROR From 7bfe0e1c00ff0cbf4c80042f70d06282f314615c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Sep 2018 23:10:42 +0200 Subject: [PATCH 177/178] Bumped version to 0.79.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4b4153ac89c958..3afabdb2d3a38c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 79 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From f476d781eca7e3b6228e5bd952c2b082a5095e6c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Sep 2018 15:28:24 +0200 Subject: [PATCH 178/178] Version bump to 0.79.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3afabdb2d3a38c..d3888d2651efa1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 79 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)