From c03b13713077b0e264c4b452bef635ab0876c9b1 Mon Sep 17 00:00:00 2001 From: guillaume1410 Date: Sun, 21 Oct 2018 02:08:35 -0400 Subject: [PATCH 01/37] Removing ryobi gdo (#17637) --- homeassistant/components/cover/ryobi_gdo.py | 103 -------------------- requirements_all.txt | 3 - 2 files changed, 106 deletions(-) delete mode 100644 homeassistant/components/cover/ryobi_gdo.py diff --git a/homeassistant/components/cover/ryobi_gdo.py b/homeassistant/components/cover/ryobi_gdo.py deleted file mode 100644 index fec91f843fd..00000000000 --- a/homeassistant/components/cover/ryobi_gdo.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Ryobi platform for the cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.ryobi_gdo/ -""" -import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, STATE_CLOSED) - -REQUIREMENTS = ['py_ryobi_gdo==0.0.10'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DEVICE_ID = 'device_id' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, -}) - -SUPPORTED_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ryobi covers.""" - from py_ryobi_gdo import RyobiGDO as ryobi_door - covers = [] - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - devices = config.get(CONF_DEVICE_ID) - - for device_id in devices: - my_door = ryobi_door(username, password, device_id) - _LOGGER.debug("Getting the API key") - if my_door.get_api_key() is False: - _LOGGER.error("Wrong credentials, no API key retrieved") - return - _LOGGER.debug("Checking if the device ID is present") - if my_door.check_device_id() is False: - _LOGGER.error("%s not in your device list", device_id) - return - _LOGGER.debug("Adding device %s to covers", device_id) - covers.append(RyobiCover(hass, my_door)) - if covers: - _LOGGER.debug("Adding covers") - add_entities(covers, True) - - -class RyobiCover(CoverDevice): - """Representation of a ryobi cover.""" - - def __init__(self, hass, ryobi_door): - """Initialize the cover.""" - self.ryobi_door = ryobi_door - self._name = 'ryobi_gdo_{}'.format(ryobi_door.get_device_id()) - self._door_state = None - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self._door_state == STATE_UNKNOWN: - return False - return self._door_state == STATE_CLOSED - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_FEATURES - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("Closing garage door") - self.ryobi_door.close_device() - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("Opening garage door") - self.ryobi_door.open_device() - - def update(self): - """Update status from the door.""" - _LOGGER.debug("Updating RyobiGDO status") - self.ryobi_door.update() - self._door_state = self.ryobi_door.get_door_status() diff --git a/requirements_all.txt b/requirements_all.txt index 8c6848a4519..aeff66892c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -790,9 +790,6 @@ pyW215==0.6.0 # homeassistant.components.sensor.noaa_tides # py_noaa==0.3.0 -# homeassistant.components.cover.ryobi_gdo -py_ryobi_gdo==0.0.10 - # homeassistant.components.ads pyads==2.2.6 From 7e3d0f070064b8f1b13ea6d4d3aaa69fc61feea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 21 Oct 2018 11:21:09 +0200 Subject: [PATCH 02/37] Remove ryobi from .coveragerc (#17647) --- .coveragerc | 1 - 1 file changed, 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 04299609bbb..4b65b28597e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -458,7 +458,6 @@ omit = homeassistant/components/cover/myq.py homeassistant/components/cover/opengarage.py homeassistant/components/cover/rpi_gpio.py - homeassistant/components/cover/ryobi_gdo.py homeassistant/components/cover/scsgate.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py From bdfd473aaa22052a786d31d459038fc68e8e6255 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 21 Oct 2018 12:16:24 +0200 Subject: [PATCH 03/37] Reconnect if sub info comes in that is valid again (#17651) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/auth_api.py | 18 +++++ homeassistant/components/cloud/http_api.py | 25 +++++-- tests/components/cloud/test_http_api.py | 86 +++++++++++++++++++--- tests/components/cloud/test_init.py | 4 +- 5 files changed, 117 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 54a221565b4..3bfc5909b0b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -162,7 +162,7 @@ class Cloud: @property def subscription_expired(self): """Return a boolean if the subscription has expired.""" - return dt_util.utcnow() > self.expiration_date + timedelta(days=3) + return dt_util.utcnow() > self.expiration_date + timedelta(days=7) @property def expiration_date(self): diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index dcf7567482a..042b90bf9cb 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -113,6 +113,24 @@ def check_token(cloud): raise _map_aws_exception(err) +def renew_access_token(cloud): + """Renew access token.""" + from botocore.exceptions import ClientError + + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) + + try: + cognito.renew_access_token() + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) + + def _authenticate(cloud, email, password): """Log in and return an authenticated Cognito instance.""" from botocore.exceptions import ClientError diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 720ca00cf52..0df4a39406e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT -from .iot import STATE_DISCONNECTED +from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -249,13 +249,28 @@ async def websocket_subscription(hass, connection, msg): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): response = await cloud.fetch_subscription_info() - if response.status == 200: - connection.send_message(websocket_api.result_message( - msg['id'], await response.json())) - else: + if response.status != 200: connection.send_message(websocket_api.error_message( msg['id'], 'request_failed', 'Failed to request subscription')) + data = await response.json() + + # Check if a user is subscribed but local info is outdated + # In that case, let's refresh and reconnect + if data.get('provider') and cloud.iot.state != STATE_CONNECTED: + _LOGGER.debug( + "Found disconnected account with valid subscriotion, connecting") + await hass.async_add_executor_job( + auth_api.renew_access_token, cloud) + + # Cancel reconnect in progress + if cloud.iot.state != STATE_DISCONNECTED: + await cloud.iot.disconnect() + + hass.async_create_task(cloud.iot.connect()) + + connection.send_message(websocket_api.result_message(msg['id'], data)) + @websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5d4b356b9b2..e27760bd6ed 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,6 +7,7 @@ from jose import jwt from homeassistant.components.cloud import ( DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) +from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -352,24 +353,89 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): } -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'}) +async def test_websocket_subscription_reconnect( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and connecting because valid account.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': dt_util.utcnow().date().isoformat() + }, 'test') + client = await hass_ws_client(hass) + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'provider': 'stripe' + } + assert len(mock_renew.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 + + +async def test_websocket_subscription_no_reconnect_if_connected( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and not reconnecting because still expired.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) + hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': dt_util.utcnow().date().isoformat() + }, 'test') + client = await hass_ws_client(hass) + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'provider': 'stripe' + } + assert len(mock_renew.mock_calls) == 0 + assert len(mock_connect.mock_calls) == 0 + + +async def test_websocket_subscription_no_reconnect_if_expired( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and not reconnecting because still expired.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) 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() + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() assert response['result'] == { - 'return': 'value' + 'provider': 'stripe' } + assert len(mock_renew.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 async def test_websocket_subscription_fail(hass, hass_ws_client, diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 8695830eae9..61518f0f0e8 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -155,14 +155,14 @@ def test_subscription_expired(hass): with patch.object(cl, '_decode_claims', return_value=token_val), \ patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace( - year=2017, month=11, day=15, hour=23, minute=59, + year=2017, month=11, day=19, hour=23, minute=59, second=59)): assert not cl.subscription_expired with patch.object(cl, '_decode_claims', return_value=token_val), \ patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace( - year=2017, month=11, day=16, hour=0, minute=0, + year=2017, month=11, day=20, hour=0, minute=0, second=0)): assert cl.subscription_expired From 9982867d667b698794ccb51531280bc4d5dbd7c4 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 21 Oct 2018 13:05:02 +0200 Subject: [PATCH 04/37] Very minor cleanup of RFLink components (#17649) --- homeassistant/components/light/rflink.py | 13 +++++++------ homeassistant/components/sensor/rflink.py | 5 ++++- homeassistant/components/switch/rflink.py | 7 ++++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index 885239a51c3..3b60280c582 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -6,16 +6,18 @@ https://home-assistant.io/components/light.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.light import ( ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, - CONF_GROUP_ALIASSES, CONF_IGNORE_DEVICES, CONF_NOGROUP_ALIASES, - CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, - DEVICE_DEFAULTS_SCHEMA, - EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, cv, - remove_deprecated, vol) + CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, + CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, DEVICE_DEFAULTS_SCHEMA, + EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, + remove_deprecated) +import homeassistant.helpers.config_validation as cv from homeassistant.const import (CONF_NAME, CONF_TYPE) DEPENDENCIES = ['rflink'] @@ -28,7 +30,6 @@ TYPE_HYBRID = 'hybrid' TYPE_TOGGLE = 'toggle' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_IGNORE_DEVICES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): DEVICE_DEFAULTS_SCHEMA, vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, diff --git a/homeassistant/components/sensor/rflink.py b/homeassistant/components/sensor/rflink.py index 4065e0a439f..f3ec776fda8 100644 --- a/homeassistant/components/sensor/rflink.py +++ b/homeassistant/components/sensor/rflink.py @@ -6,13 +6,16 @@ https://home-assistant.io/components/sensor.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICES, DATA_DEVICE_REGISTER, DATA_ENTITY_LOOKUP, EVENT_KEY_ID, - EVENT_KEY_SENSOR, EVENT_KEY_UNIT, RflinkDevice, cv, remove_deprecated, vol, + EVENT_KEY_SENSOR, EVENT_KEY_UNIT, RflinkDevice, remove_deprecated, SIGNAL_AVAILABILITY, SIGNAL_HANDLE_EVENT, TMP_ENTITY) from homeassistant.components.sensor import ( PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.dispatcher import (async_dispatcher_connect) diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 1f217b1c39c..51bf5543584 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -6,15 +6,16 @@ https://home-assistant.io/components/switch.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, - DEVICE_DEFAULTS_SCHEMA, SwitchableRflinkDevice, cv, - remove_deprecated, vol) + DEVICE_DEFAULTS_SCHEMA, SwitchableRflinkDevice, remove_deprecated) from homeassistant.components.switch import ( PLATFORM_SCHEMA, SwitchDevice) - +import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_NAME DEPENDENCIES = ['rflink'] From ef93d48d50bb25b0405cb8adaf044b3ac7268888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 21 Oct 2018 13:41:27 +0200 Subject: [PATCH 05/37] available to switchmate (#17640) --- homeassistant/components/switch/switchmate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index 2ec77a38267..7f00964cd20 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -52,6 +52,11 @@ class Switchmate(SwitchDevice): """Return a unique, HASS-friendly identifier for this entity.""" return self._mac.replace(':', '') + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._device.available + @property def name(self) -> str: """Return the name of the switch.""" From cf2468702402bf7e550600a7021efb969a6a2104 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 21 Oct 2018 14:13:30 +0200 Subject: [PATCH 06/37] Upgrade async_timeout to 3.0.1 (#17655) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9217e3b3961..fa0d675f4b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ aiohttp==3.4.4 astral==1.6.1 -async_timeout==3.0.0 +async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 diff --git a/requirements_all.txt b/requirements_all.txt index aeff66892c2..00f263ca04e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,7 +1,7 @@ # Home Assistant core aiohttp==3.4.4 astral==1.6.1 -async_timeout==3.0.0 +async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 diff --git a/setup.py b/setup.py index 727badb1d94..90f2e8357fd 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ 'aiohttp==3.4.4', 'astral==1.6.1', - 'async_timeout==3.0.0', + 'async_timeout==3.0.1', 'attrs==18.2.0', 'bcrypt==3.1.4', 'certifi>=2018.04.16', From 731753b604ad5121b803572f632ef4ac71fe5b89 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 21 Oct 2018 15:07:44 +0200 Subject: [PATCH 07/37] Upgrade holidays to 0.9.8 (#17656) --- homeassistant/components/binary_sensor/workday.py | 9 +++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 82b5e66629a..fc8207f83b7 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -14,15 +14,16 @@ from homeassistant.const import CONF_NAME, WEEKDAYS from homeassistant.components.binary_sensor import BinarySensorDevice import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['holidays==0.9.7'] +REQUIREMENTS = ['holidays==0.9.8'] _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', 'Belarus', 'BY' - 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', + 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', + 'Brazil', 'BR', 'Belarus', 'BY', 'Belgium', 'BE', + 'Canada', 'CA', 'Colombia', 'CO', 'Croatia', 'HR', 'Czech', 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', 'India', 'IND', 'Ireland', 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', @@ -30,7 +31,7 @@ ALL_COUNTRIES = [ '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', + 'Ukraine', 'UA', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', ] ALLOWED_DAYS = WEEKDAYS + ['holiday'] diff --git a/requirements_all.txt b/requirements_all.txt index 00f263ca04e..bfee5976a65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -463,7 +463,7 @@ hipnotify==1.0.8 hole==0.3.0 # homeassistant.components.binary_sensor.workday -holidays==0.9.7 +holidays==0.9.8 # homeassistant.components.frontend home-assistant-frontend==20181018.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 911403245ed..302db00f706 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ hbmqtt==0.9.4 hdate==0.6.5 # homeassistant.components.binary_sensor.workday -holidays==0.9.7 +holidays==0.9.8 # homeassistant.components.frontend home-assistant-frontend==20181018.0 From b6d3a199ce25740f72cac686f7efa8be17aa7a95 Mon Sep 17 00:00:00 2001 From: Oscar Tin Lai Date: Mon, 22 Oct 2018 02:35:07 +1100 Subject: [PATCH 08/37] Add support for Dyson Hot+Cool Fan as a climate device (#14598) * Added support for dyson hot+cool fan as climate device * Removed decimal place in kelvin units conversion Minor edits to be consistent with Dyson's internal conversion of temperature from kelvin to celsius. It does not include decimal place to convert between kelvin and celsius. * made changes according to comments * Refactored target temp logics, fixed enum issues * changed name of component to entity * removed temperature conversion for min/max property * changed back to 644 permission * added extra tests for almost-all coverage * changed assert method to avoid lack of certain method in py35 * added test_setup_component * shorten line length * fixed mock spec and added checking of message listener is called * added doc string and debug msg * shorten line length * removed pending target temp --- homeassistant/components/climate/dyson.py | 176 +++++++++++ homeassistant/components/dyson.py | 1 + tests/components/climate/test_dyson.py | 358 ++++++++++++++++++++++ tests/components/test_dyson.py | 4 +- 4 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/climate/dyson.py create mode 100644 tests/components/climate/test_dyson.py diff --git a/homeassistant/components/climate/dyson.py b/homeassistant/components/climate/dyson.py new file mode 100644 index 00000000000..0b09ec7f0b4 --- /dev/null +++ b/homeassistant/components/climate/dyson.py @@ -0,0 +1,176 @@ +""" +Support for Dyson Pure Hot+Cool link fan. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.dyson/ +""" +import logging + +from homeassistant.components.dyson import DYSON_DEVICES +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE + +_LOGGER = logging.getLogger(__name__) + +STATE_DIFFUSE = "Diffuse Mode" +STATE_FOCUS = "Focus Mode" +FAN_LIST = [STATE_FOCUS, STATE_DIFFUSE] +OPERATION_LIST = [STATE_HEAT, STATE_COOL] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dyson fan components.""" + if discovery_info is None: + return + + from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink + # Get Dyson Devices from parent component. + add_devices( + [DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink)] + ) + + +class DysonPureHotCoolLinkDevice(ClimateDevice): + """Representation of a Dyson climate fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + self._current_temp = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, + self.on_message) + + def on_message(self, message): + """Call when new messages received from the climate.""" + from libpurecoollink.dyson_pure_state import DysonPureHotCoolState + + if isinstance(message, DysonPureHotCoolState): + _LOGGER.debug("Message received for climate device %s : %s", + self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the display name of this climate.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._device.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin != 0: + self._current_temp = float("{0:.1f}".format( + temperature_kelvin - 273)) + return self._current_temp + + @property + def target_temperature(self): + """Return the target temperature.""" + heat_target = int(self._device.state.heat_target) / 10 + return int(heat_target - 273) + + @property + def current_humidity(self): + """Return the current humidity.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return None + return self._device.environmental_state.humidity + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + from libpurecoollink.const import HeatMode, HeatState + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return STATE_HEAT + return STATE_IDLE + return STATE_COOL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + @property + def current_fan_mode(self): + """Return the fan setting.""" + from libpurecoollink.const import FocusMode + if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: + return STATE_FOCUS + return STATE_DIFFUSE + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return FAN_LIST + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + target_temp = int(target_temp) + _LOGGER.debug("Set %s temperature %s", self.name, target_temp) + # Limit the target temperature into acceptable range. + target_temp = min(self.max_temp, target_temp) + target_temp = max(self.min_temp, target_temp) + from libpurecoollink.const import HeatTarget, HeatMode + self._device.set_configuration( + heat_target=HeatTarget.celsius(target_temp), + heat_mode=HeatMode.HEAT_ON) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) + from libpurecoollink.const import FocusMode + if fan_mode == STATE_FOCUS: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) + elif fan_mode == STATE_DIFFUSE: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, operation_mode) + from libpurecoollink.const import HeatMode + if operation_mode == STATE_HEAT: + self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) + elif operation_mode == STATE_COOL: + self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 37 diff --git a/homeassistant/components/dyson.py b/homeassistant/components/dyson.py index 3989c0bbe3e..791f990d9ad 100644 --- a/homeassistant/components/dyson.py +++ b/homeassistant/components/dyson.py @@ -102,5 +102,6 @@ def setup(hass, config): discovery.load_platform(hass, "sensor", DOMAIN, {}, config) discovery.load_platform(hass, "fan", DOMAIN, {}, config) discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + discovery.load_platform(hass, "climate", DOMAIN, {}, config) return True diff --git a/tests/components/climate/test_dyson.py b/tests/components/climate/test_dyson.py new file mode 100644 index 00000000000..6e8b63d64c4 --- /dev/null +++ b/tests/components/climate/test_dyson.py @@ -0,0 +1,358 @@ +"""Test the Dyson fan component.""" +import unittest +from unittest import mock + +from libpurecoollink.const import (FocusMode, HeatMode, HeatState, HeatTarget, + TiltState) +from libpurecoollink.dyson_pure_state import DysonPureHotCoolState +from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink +from homeassistant.components.climate import dyson +from homeassistant.components import dyson as dyson_parent +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +class MockDysonState(DysonPureHotCoolState): + """Mock Dyson state.""" + + def __init__(self): + """Create new Mock Dyson State.""" + pass + + +def _get_device_with_no_state(): + """Return a device with no state.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = None + device.environmental_state = None + return device + + +def _get_device_off(): + """Return a device with state off.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.environmental_state = mock.Mock() + return device + + +def _get_device_focus(): + """Return a device with fan state of focus mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_ON.value + return device + + +def _get_device_diffuse(): + """Return a device with fan state of diffuse mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_OFF.value + return device + + +def _get_device_cool(): + """Return a device with state of cooling.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_OFF.value + device.state.heat_target = HeatTarget.celsius(12) + device.state.heat_mode = HeatMode.HEAT_OFF.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 288 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_off(): + """Return a device with state of heat reached target.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(20) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 293 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_on(): + """Return a device with state of heating.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(23) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_ON.value + device.environmental_state.temperature = 289 + device.environmental_state.humidity = 53 + return device + + +class DysonTest(unittest.TestCase): + """Dyson Climate component test class.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_heat_on(), _get_device_cool()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_setup_component_with_parent_discovery(self, mocked_login, + mocked_devices): + """Test setup_component using discovery.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 2) + self.hass.block_till_done() + for m in mocked_devices.return_value: + assert m.add_message_listener.called + + def test_setup_component_without_devices(self): + """Test setup component with no devices.""" + self.hass.data[dyson.DYSON_DEVICES] = [] + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices) + add_devices.assert_not_called() + + def test_setup_component_with_devices(self): + """Test setup component with valid devices.""" + devices = [ + _get_device_with_no_state(), + _get_device_off(), + _get_device_heat_on() + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + self.assertTrue(add_devices.called) + + def test_setup_component_with_invalid_devices(self): + """Test setup component with invalid devices.""" + devices = [ + None, + "foo_bar" + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + add_devices.assert_called_with([]) + + def test_setup_component(self): + """Test setup component with devices.""" + device_fan = _get_device_heat_on() + device_non_fan = _get_device_off() + + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Device_name" + + self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan] + dyson.setup_platform(self.hass, None, _add_device) + + def test_dyson_set_temperature(self): + """Test set climate temperature.""" + device = _get_device_heat_on() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + # Without target temp. + kwargs = {} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_not_called() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + # Should clip the target temperature between 1 and 37 inclusive. + kwargs = {ATTR_TEMPERATURE: 50} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(37)) + + kwargs = {ATTR_TEMPERATURE: -5} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(1)) + + def test_dyson_set_temperature_when_cooling_mode(self): + """Test set climate temperature when heating is off.""" + device = _get_device_cool() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + def test_dyson_set_fan_mode(self): + """Test set fan mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_fan_mode(dyson.STATE_FOCUS) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_ON) + + entity.set_fan_mode(dyson.STATE_DIFFUSE) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_OFF) + + def test_dyson_fan_list(self): + """Test get fan list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.fan_list), 2) + self.assertTrue(dyson.STATE_FOCUS in entity.fan_list) + self.assertTrue(dyson.STATE_DIFFUSE in entity.fan_list) + + def test_dyson_fan_mode_focus(self): + """Test fan focus mode.""" + device = _get_device_focus() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_FOCUS) + + def test_dyson_fan_mode_diffuse(self): + """Test fan diffuse mode.""" + device = _get_device_diffuse() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_DIFFUSE) + + def test_dyson_set_operation_mode(self): + """Test set operation mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_operation_list(self): + """Test get operation list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.operation_list), 2) + self.assertTrue(dyson.STATE_HEAT in entity.operation_list) + self.assertTrue(dyson.STATE_COOL in entity.operation_list) + + def test_dyson_heat_off(self): + """Test turn off heat.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_heat_on(self): + """Test turn on heat.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + def test_dyson_heat_value_on(self): + """Test get heat value on.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_HEAT) + + def test_dyson_heat_value_off(self): + """Test get heat value off.""" + device = _get_device_cool() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_COOL) + + def test_dyson_heat_value_idle(self): + """Test get heat value idle.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_IDLE) + + def test_on_message(self): + """Test when message is received.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + entity.on_message(MockDysonState()) + entity.schedule_update_ha_state.assert_called_with() + + def test_general_properties(self): + """Test properties of entity.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.should_poll, False) + self.assertEqual(entity.supported_features, dyson.SUPPORT_FLAGS) + self.assertEqual(entity.temperature_unit, TEMP_CELSIUS) + + def test_property_current_humidity(self): + """Test properties of current humidity.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, 53) + + def test_property_current_humidity_with_invalid_env_state(self): + """Test properties of current humidity with invalid env state.""" + device = _get_device_off() + device.environmental_state.humidity = 0 + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_humidity_without_env_state(self): + """Test properties of current humidity without env state.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_temperature(self): + """Test properties of current temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + # Result should be in celsius, hence then subtraction of 273. + self.assertEqual(entity.current_temperature, 289 - 273) + + def test_property_target_temperature(self): + """Test properties of target temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.target_temperature, 23) diff --git a/tests/components/test_dyson.py b/tests/components/test_dyson.py index 19c39754eb2..0352551aec9 100644 --- a/tests/components/test_dyson.py +++ b/tests/components/test_dyson.py @@ -87,7 +87,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) @@ -172,7 +172,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) From 2d980f2a9240fa029fcc121bcbe419e3c4bdcb26 Mon Sep 17 00:00:00 2001 From: Mathieu Velten Date: Sun, 21 Oct 2018 19:54:01 +0200 Subject: [PATCH 09/37] Update pynetgear to 0.5.0 (#17652) --- homeassistant/components/device_tracker/netgear.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 2e1b96dffad..12d026a35cd 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_DEVICES, CONF_EXCLUDE) -REQUIREMENTS = ['pynetgear==0.4.2'] +REQUIREMENTS = ['pynetgear==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bfee5976a65..607cabeebbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1005,7 +1005,7 @@ pymysensors==0.17.0 pynello==1.5.1 # homeassistant.components.device_tracker.netgear -pynetgear==0.4.2 +pynetgear==0.5.0 # homeassistant.components.switch.netio pynetio==0.1.9.1 From 95371fe4a6316724e5e96284e5dff7c8c3bb5b6a Mon Sep 17 00:00:00 2001 From: Luke Fritz Date: Sun, 21 Oct 2018 12:54:51 -0500 Subject: [PATCH 10/37] Bump pyarlo==0.2.2 (#17673) * Bump pyarlo to 0.2.2, fixes #17427 * Increase log level for refresh message to clear up logs --- homeassistant/components/arlo.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 015e1e0d1fc..f7d9f012f65 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.2.0'] +REQUIREMENTS = ['pyarlo==0.2.2'] _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ def setup(hass, config): def hub_refresh(event_time): """Call ArloHub to refresh information.""" - _LOGGER.info("Updating Arlo Hub component") + _LOGGER.debug("Updating Arlo Hub component") hass.data[DATA_ARLO].update(update_cameras=True, update_base_station=True) dispatcher_send(hass, SIGNAL_UPDATE_ARLO) diff --git a/requirements_all.txt b/requirements_all.txt index 607cabeebbe..82ba4de9139 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -800,7 +800,7 @@ pyairvisual==2.0.1 pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.2.0 +pyarlo==0.2.2 # homeassistant.components.netatmo pyatmo==1.2 From b2faa67ab7c52f9492de9286ef468dfb6f896c81 Mon Sep 17 00:00:00 2001 From: Richard Patel Date: Sun, 21 Oct 2018 20:12:51 +0200 Subject: [PATCH 11/37] Add new rtorrent sensor (#17421) * New rtorrent sensor * Fix lint issue * Fix another lint issue * Fix pylint issue * how many python linters do you guys use * Cleanup code * python linting * newline --- .coveragerc | 1 + homeassistant/components/sensor/rtorrent.py | 127 ++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 homeassistant/components/sensor/rtorrent.py diff --git a/.coveragerc b/.coveragerc index 4b65b28597e..0049349cfff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -757,6 +757,7 @@ omit = homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py + homeassistant/components/sensor/rtorrent.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sense.py homeassistant/components/sensor/sensehat.py diff --git a/homeassistant/components/sensor/rtorrent.py b/homeassistant/components/sensor/rtorrent.py new file mode 100644 index 00000000000..f71b9c6dbdb --- /dev/null +++ b/homeassistant/components/sensor/rtorrent.py @@ -0,0 +1,127 @@ +"""Support for monitoring the rtorrent BitTorrent client API.""" +import logging +import xmlrpc.client + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_URL, CONF_NAME, + CONF_MONITORED_VARIABLES, STATE_IDLE) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPE_CURRENT_STATUS = 'current_status' +SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed' +SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed' + +DEFAULT_NAME = 'rtorrent' +SENSOR_TYPES = { + SENSOR_TYPE_CURRENT_STATUS: ['Status', None], + SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'], + SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the rtorrent sensors.""" + url = config[CONF_URL] + name = config[CONF_NAME] + + try: + rtorrent = xmlrpc.client.ServerProxy(url) + except (xmlrpc.client.ProtocolError, ConnectionRefusedError): + _LOGGER.error("Connection to rtorrent daemon failed") + raise PlatformNotReady + dev = [] + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(RTorrentSensor(variable, rtorrent, name)) + + add_entities(dev) + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +class RTorrentSensor(Entity): + """Representation of an rtorrent sensor.""" + + def __init__(self, sensor_type, rtorrent_client, client_name): + """Initialize the sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = rtorrent_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.data = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return true if device is available.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from rtorrent and updates the state.""" + multicall = xmlrpc.client.MultiCall(self.client) + multicall.throttle.global_up.rate() + multicall.throttle.global_down.rate() + + try: + self.data = multicall() + self._available = True + except (xmlrpc.client.ProtocolError, ConnectionRefusedError): + _LOGGER.error("Connection to rtorrent lost") + self._available = False + return + + upload = self.data[0] + download = self.data[1] + + if self.type == SENSOR_TYPE_CURRENT_STATUS: + if self.data: + if upload > 0 and download > 0: + self._state = 'Up/Down' + elif upload > 0 and download == 0: + self._state = 'Seeding' + elif upload == 0 and download > 0: + self._state = 'Downloading' + else: + self._state = STATE_IDLE + else: + self._state = None + + if self.data: + if self.type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._state = format_speed(download) + elif self.type == SENSOR_TYPE_UPLOAD_SPEED: + self._state = format_speed(upload) From 8f529b20d7b98b1dbed4f11789f9f07453a6a463 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 21 Oct 2018 20:34:12 +0200 Subject: [PATCH 12/37] Bump frontend to 20181021.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 df25803b4e0..36bb3507dda 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181018.0'] +REQUIREMENTS = ['home-assistant-frontend==20181021.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 82ba4de9139..84b8b2d57ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -466,7 +466,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181018.0 +home-assistant-frontend==20181021.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 302db00f706..1568fd95607 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.6.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181018.0 +home-assistant-frontend==20181021.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 355005114b3315694e3a9654249d4a1998d76fcd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 21 Oct 2018 20:34:28 +0200 Subject: [PATCH 13/37] Update translations --- .../components/auth/.translations/ro.json | 34 +++++++++++++++++++ .../components/cast/.translations/ro.json | 15 ++++++++ .../components/hangouts/.translations/hu.json | 2 ++ .../components/hangouts/.translations/ro.json | 28 +++++++++++++++ .../homematicip_cloud/.translations/ro.json | 19 +++++++++++ .../components/hue/.translations/ro.json | 2 ++ .../components/ifttt/.translations/es.json | 11 ++++++ .../components/ifttt/.translations/hu.json | 3 ++ .../components/ifttt/.translations/pt.json | 18 ++++++++++ .../components/ifttt/.translations/ro.json | 4 +++ .../components/ios/.translations/ro.json | 14 ++++++++ .../components/lifx/.translations/es.json | 15 ++++++++ .../components/lifx/.translations/pt.json | 7 ++++ .../components/mqtt/.translations/es.json | 4 +++ .../components/mqtt/.translations/hu.json | 1 + .../components/mqtt/.translations/pt.json | 7 ++++ .../components/mqtt/.translations/ro.json | 31 +++++++++++++++++ .../components/nest/.translations/hu.json | 4 ++- .../components/nest/.translations/ro.json | 13 +++++++ .../components/openuv/.translations/ro.json | 20 +++++++++++ .../sensor/.translations/moon.ro.json | 6 ++++ .../simplisafe/.translations/es.json | 19 +++++++++++ .../simplisafe/.translations/hu.json | 9 +++-- .../simplisafe/.translations/pt.json | 19 +++++++++++ .../simplisafe/.translations/ro.json | 4 ++- .../components/smhi/.translations/es.json | 19 +++++++++++ .../components/smhi/.translations/hu.json | 12 +++++++ .../components/smhi/.translations/pt.json | 13 +++++++ .../components/smhi/.translations/ro.json | 3 +- .../components/sonos/.translations/ro.json | 15 ++++++++ .../components/tradfri/.translations/ro.json | 23 +++++++++++++ .../components/unifi/.translations/es.json | 26 ++++++++++++++ .../components/unifi/.translations/hu.json | 10 +++++- .../components/unifi/.translations/pt.json | 26 ++++++++++++++ .../components/unifi/.translations/ro.json | 24 +++++++++++++ .../components/upnp/.translations/es.json | 23 +++++++++++++ .../components/upnp/.translations/hu.json | 16 +++++++++ .../components/upnp/.translations/pt.json | 27 +++++++++++++++ .../components/zwave/.translations/es.json | 21 ++++++++++++ .../components/zwave/.translations/hu.json | 11 ++++++ .../components/zwave/.translations/pt.json | 22 ++++++++++++ 41 files changed, 594 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/auth/.translations/ro.json create mode 100644 homeassistant/components/cast/.translations/ro.json create mode 100644 homeassistant/components/hangouts/.translations/ro.json create mode 100644 homeassistant/components/homematicip_cloud/.translations/ro.json create mode 100644 homeassistant/components/ifttt/.translations/es.json create mode 100644 homeassistant/components/ifttt/.translations/pt.json create mode 100644 homeassistant/components/ios/.translations/ro.json create mode 100644 homeassistant/components/lifx/.translations/es.json create mode 100644 homeassistant/components/lifx/.translations/pt.json create mode 100644 homeassistant/components/mqtt/.translations/ro.json create mode 100644 homeassistant/components/nest/.translations/ro.json create mode 100644 homeassistant/components/openuv/.translations/ro.json create mode 100644 homeassistant/components/sensor/.translations/moon.ro.json create mode 100644 homeassistant/components/simplisafe/.translations/es.json create mode 100644 homeassistant/components/simplisafe/.translations/pt.json create mode 100644 homeassistant/components/smhi/.translations/es.json create mode 100644 homeassistant/components/smhi/.translations/hu.json create mode 100644 homeassistant/components/smhi/.translations/pt.json create mode 100644 homeassistant/components/sonos/.translations/ro.json create mode 100644 homeassistant/components/tradfri/.translations/ro.json create mode 100644 homeassistant/components/unifi/.translations/es.json create mode 100644 homeassistant/components/unifi/.translations/pt.json create mode 100644 homeassistant/components/unifi/.translations/ro.json create mode 100644 homeassistant/components/upnp/.translations/es.json create mode 100644 homeassistant/components/upnp/.translations/hu.json create mode 100644 homeassistant/components/upnp/.translations/pt.json create mode 100644 homeassistant/components/zwave/.translations/es.json create mode 100644 homeassistant/components/zwave/.translations/pt.json diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/.translations/ro.json new file mode 100644 index 00000000000..19f9ec10c73 --- /dev/null +++ b/homeassistant/components/auth/.translations/ro.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nu sunt disponibile servicii de notificare." + }, + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou." + }, + "step": { + "init": { + "description": "Selecta\u021bi unul dintre serviciile de notificare:", + "title": "Configura\u021bi o parol\u0103 unic\u0103 livrat\u0103 de o component\u0103 de notificare" + }, + "setup": { + "description": "O parol\u0103 unic\u0103 a fost trimis\u0103 prin **notify.{notify_service}**. Introduce\u021bi parola mai jos:", + "title": "Verifica\u021bi configurarea" + } + }, + "title": "Notifica\u021bi o parol\u0103 unic\u0103" + }, + "totp": { + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou. Dac\u0103 primi\u021bi aceast\u0103 eroare \u00een mod consecvent, asigura\u021bi-v\u0103 c\u0103 ceasul sistemului dvs. Home Assistant este corect." + }, + "step": { + "init": { + "title": "Configura\u021bi autentificarea cu doi factori utiliz\u00e2nd TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ro.json b/homeassistant/components/cast/.translations/ro.json new file mode 100644 index 00000000000..8a1d19c0ecf --- /dev/null +++ b/homeassistant/components/cast/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/hu.json b/homeassistant/components/hangouts/.translations/hu.json index 2631843c784..f6e46e25985 100644 --- a/homeassistant/components/hangouts/.translations/hu.json +++ b/homeassistant/components/hangouts/.translations/hu.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" }, "user": { @@ -21,6 +22,7 @@ "email": "E-Mail C\u00edm", "password": "Jelsz\u00f3" }, + "description": "\u00dcres", "title": "Google Hangouts Bejelentkez\u00e9s" } }, diff --git a/homeassistant/components/hangouts/.translations/ro.json b/homeassistant/components/hangouts/.translations/ro.json new file mode 100644 index 00000000000..d1c3ed767ce --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ro.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_2fa_method": "Metoda 2FA invalid\u0103 (Verifica\u021bi pe telefon).", + "invalid_login": "Conectare invalid\u0103, \u00eencerca\u021bi din nou." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + } + }, + "user": { + "data": { + "email": "Adresa de email", + "password": "Parol\u0103" + }, + "description": "Gol", + "title": "Conectare Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/.translations/ro.json new file mode 100644 index 00000000000..a5399e7e68c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ro.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Punctul de acces este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_pin": "Cod PIN invalid, \u00eencerca\u021bi din nou.", + "press_the_button": "V\u0103 rug\u0103m s\u0103 ap\u0103sa\u021bi butonul albastru." + }, + "step": { + "init": { + "data": { + "pin": "Cod PIN (op\u021bional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json index 69cee1198d3..a2ecf8964b6 100644 --- a/homeassistant/components/hue/.translations/ro.json +++ b/homeassistant/components/hue/.translations/ro.json @@ -2,6 +2,8 @@ "config": { "abort": { "all_configured": "Toate pun\u021bile Philips Hue sunt deja configurate", + "already_configured": "Gateway-ul este deja configurat", + "cannot_connect": "Nu se poate conecta la gateway.", "discover_timeout": "Imposibil de descoperit podurile Hue" }, "error": { diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json new file mode 100644 index 00000000000..13240ccefb1 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?", + "title": "Configurar el applet de webhook IFTTT" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/hu.json b/homeassistant/components/ifttt/.translations/hu.json index a131f848d45..6ecf654ff47 100644 --- a/homeassistant/components/ifttt/.translations/hu.json +++ b/homeassistant/components/ifttt/.translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "not_internet_accessible": "A Home Assistant-nek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l az IFTTT \u00fczenetek fogad\u00e1s\u00e1hoz." + }, "step": { "user": { "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az IFTTT-t?", diff --git a/homeassistant/components/ifttt/.translations/pt.json b/homeassistant/components/ifttt/.translations/pt.json new file mode 100644 index 00000000000..34c6496d7b1 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens IFTTT.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistente, precisa de utilizar a a\u00e7\u00e3o \"Make a web request\" no [IFTTT Webhook applet]({applet_url}).\n\nPreencha com a seguinte informa\u00e7\u00e3o:\n\n- URL: `{webhook_url}`\n- Method: POST \n- Content Type: application/json \n\nConsulte [a documenta\u00e7\u00e3o]({docs_url}) sobre como configurar automa\u00e7\u00f5es para lidar com dados de entrada." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o IFTTT?", + "title": "Configurar o IFTTT Webhook Applet" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/ro.json b/homeassistant/components/ifttt/.translations/ro.json index 03c77426671..dd7ae5f72cb 100644 --- a/homeassistant/components/ifttt/.translations/ro.json +++ b/homeassistant/components/ifttt/.translations/ro.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "not_internet_accessible": "Instan\u021ba Home Assistant trebuie s\u0103 fie accesibil\u0103 de pe internet pentru a primi mesaje IFTTT.", + "one_instance_allowed": "Este necesar\u0103 o singur\u0103 instan\u021b\u0103." + }, "step": { "user": { "description": "Sigur dori\u021bi s\u0103 configura\u021bi IFTTT?" diff --git a/homeassistant/components/ios/.translations/ro.json b/homeassistant/components/ios/.translations/ro.json new file mode 100644 index 00000000000..5a83b5cd732 --- /dev/null +++ b/homeassistant/components/ios/.translations/ro.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Este necesar\u0103 numai o singur\u0103 configurare a aplica\u021biei Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi componenta Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/.translations/es.json b/homeassistant/components/lifx/.translations/es.json new file mode 100644 index 00000000000..f897c673432 --- /dev/null +++ b/homeassistant/components/lifx/.translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos LIFX en la red.", + "single_instance_allowed": "S\u00f3lo es posible una \u00fanica configuraci\u00f3n de LIFX." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar LIFX?", + "title": "LIFX" + } + }, + "title": "LIFX" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/.translations/pt.json b/homeassistant/components/lifx/.translations/pt.json new file mode 100644 index 00000000000..5d7fdf356ef --- /dev/null +++ b/homeassistant/components/lifx/.translations/pt.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo LIFX encontrado na rede." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index e9e869ae966..182cce86057 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -2,6 +2,10 @@ "config": { "step": { "hassio_confirm": { + "data": { + "discovery": "Habilitar descubrimiento" + }, + "description": "\u00bfDesea configurar Home Assistant para conectarse al agente MQTT provisto por el complemento hass.io {addon} ?", "title": "MQTT Broker a trav\u00e9s del complemento Hass.io" } } diff --git a/homeassistant/components/mqtt/.translations/hu.json b/homeassistant/components/mqtt/.translations/hu.json index ba08d36d581..f08c601633e 100644 --- a/homeassistant/components/mqtt/.translations/hu.json +++ b/homeassistant/components/mqtt/.translations/hu.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Br\u00f3ker", + "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/mqtt/.translations/pt.json b/homeassistant/components/mqtt/.translations/pt.json index 1b8c3946b7c..3b36345994d 100644 --- a/homeassistant/components/mqtt/.translations/pt.json +++ b/homeassistant/components/mqtt/.translations/pt.json @@ -17,6 +17,13 @@ }, "description": "Por favor, insira os detalhes de liga\u00e7\u00e3o ao seu broker MQTT.", "title": "" + }, + "hassio_confirm": { + "data": { + "discovery": "Ativar descoberta" + }, + "description": "Deseja configurar o Home Assistant para se ligar ao broker MQTT fornecido pelo add-on hass.io {addon}?", + "title": "MQTT Broker atrav\u00e9s do add-on Hass.io" } }, "title": "" diff --git a/homeassistant/components/mqtt/.translations/ro.json b/homeassistant/components/mqtt/.translations/ro.json new file mode 100644 index 00000000000..bcd150e3063 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/ro.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Este permis\u0103 numai o singur\u0103 configura\u021bie de MQTT." + }, + "error": { + "cannot_connect": "Imposibil de conectat la broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Activa\u021bi descoperirea", + "password": "Parol\u0103", + "port": "Port", + "username": "Nume de utilizator" + }, + "description": "Introduce\u021bi informa\u021biile de conectare ale brokerului dvs. MQTT.", + "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Activa\u021bi descoperirea" + }, + "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a v\u0103 conecta la brokerul MQTT furnizat de addon-ul {addon} ?", + "title": "MQTT Broker, prin intermediul Hass.io add-on" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json index 142747a016f..aa99b46e576 100644 --- a/homeassistant/components/nest/.translations/hu.json +++ b/homeassistant/components/nest/.translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Csak egy Nest-fi\u00f3kot konfigur\u00e1lhat.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n." }, "error": { @@ -18,7 +19,8 @@ "link": { "data": { "code": "PIN-k\u00f3d" - } + }, + "title": "Nest fi\u00f3k \u00f6sszekapcsol\u00e1sa" } }, "title": "Nest" diff --git a/homeassistant/components/nest/.translations/ro.json b/homeassistant/components/nest/.translations/ro.json new file mode 100644 index 00000000000..f315cf549fb --- /dev/null +++ b/homeassistant/components/nest/.translations/ro.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "link": { + "data": { + "code": "Cod PIN" + }, + "title": "Leg\u0103tur\u0103 cont Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/ro.json b/homeassistant/components/openuv/.translations/ro.json new file mode 100644 index 00000000000..976221188d3 --- /dev/null +++ b/homeassistant/components/openuv/.translations/ro.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordonatele deja \u00eenregistrate", + "invalid_api_key": "Cheie API invalid\u0103" + }, + "step": { + "user": { + "data": { + "api_key": "Cheie API OpenUV", + "elevation": "Altitudine", + "latitude": "Latitudine", + "longitude": "Longitudine" + }, + "title": "Completa\u021bi informa\u021biile dvs." + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ro.json b/homeassistant/components/sensor/.translations/moon.ro.json new file mode 100644 index 00000000000..6f64e497c74 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ro.json @@ -0,0 +1,6 @@ +{ + "state": { + "full_moon": "Lun\u0103 plin\u0103", + "new_moon": "Lun\u0103 nou\u0103" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json new file mode 100644 index 00000000000..12d0f63356f --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Cuenta ya registrada", + "invalid_credentials": "Credenciales no v\u00e1lidas" + }, + "step": { + "user": { + "data": { + "code": "C\u00f3digo (para Home Assistant)", + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Rellene sus datos" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/hu.json b/homeassistant/components/simplisafe/.translations/hu.json index ff2c2fc87b5..103bf4e18d0 100644 --- a/homeassistant/components/simplisafe/.translations/hu.json +++ b/homeassistant/components/simplisafe/.translations/hu.json @@ -1,10 +1,15 @@ { "config": { + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, "step": { "user": { "data": { - "password": "Jelsz\u00f3" - } + "password": "Jelsz\u00f3", + "username": "Email c\u00edm" + }, + "title": "T\u00f6ltsd ki az adataid" } } } diff --git a/homeassistant/components/simplisafe/.translations/pt.json b/homeassistant/components/simplisafe/.translations/pt.json new file mode 100644 index 00000000000..47929161976 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada", + "invalid_credentials": "Credenciais inv\u00e1lidas" + }, + "step": { + "user": { + "data": { + "code": "C\u00f3digo (para Home Assistant)", + "password": "Palavra-passe", + "username": "Endere\u00e7o de e-mail" + }, + "title": "Preencha as suas informa\u00e7\u00f5es" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ro.json b/homeassistant/components/simplisafe/.translations/ro.json index 7046b0992b1..b7e281a2bc2 100644 --- a/homeassistant/components/simplisafe/.translations/ro.json +++ b/homeassistant/components/simplisafe/.translations/ro.json @@ -7,11 +7,13 @@ "step": { "user": { "data": { + "code": "Cod (pentru Home Assistant)", "password": "Parola", "username": "Adresa de email" }, "title": "Completa\u021bi informa\u021biile dvs." } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/es.json b/homeassistant/components/smhi/.translations/es.json new file mode 100644 index 00000000000..627c534f6dd --- /dev/null +++ b/homeassistant/components/smhi/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "name_exists": "Nombre ya existe", + "wrong_location": "Ubicaci\u00f3n Suecia solamente" + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, + "title": "Ubicaci\u00f3n en Suecia" + } + }, + "title": "Servicio meteorol\u00f3gico sueco (SMHI)" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/hu.json b/homeassistant/components/smhi/.translations/hu.json new file mode 100644 index 00000000000..740fc1a8179 --- /dev/null +++ b/homeassistant/components/smhi/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/pt.json b/homeassistant/components/smhi/.translations/pt.json new file mode 100644 index 00000000000..a5c71885906 --- /dev/null +++ b/homeassistant/components/smhi/.translations/pt.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Nome" + }, + "title": "Localiza\u00e7\u00e3o na Su\u00e9cia" + } + }, + "title": "Servi\u00e7o meteorol\u00f3gico sueco (SMHI)" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/ro.json b/homeassistant/components/smhi/.translations/ro.json index 6fe28787655..6249e49d2d7 100644 --- a/homeassistant/components/smhi/.translations/ro.json +++ b/homeassistant/components/smhi/.translations/ro.json @@ -1,7 +1,8 @@ { "config": { "error": { - "name_exists": "Numele exist\u0103 deja" + "name_exists": "Numele exist\u0103 deja", + "wrong_location": "Loca\u021bia numai \u00een Suedia" }, "step": { "user": { diff --git a/homeassistant/components/sonos/.translations/ro.json b/homeassistant/components/sonos/.translations/ro.json new file mode 100644 index 00000000000..e442ab9504e --- /dev/null +++ b/homeassistant/components/sonos/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu exist\u0103 dispozitive Sonos g\u0103site \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configurare a Sonos." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/ro.json b/homeassistant/components/tradfri/.translations/ro.json new file mode 100644 index 00000000000..cea0e6d938f --- /dev/null +++ b/homeassistant/components/tradfri/.translations/ro.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge-ul este deja configurat" + }, + "error": { + "cannot_connect": "Nu se poate conecta la gateway.", + "invalid_key": "Nu s-a \u00eenregistrat cu cheia furnizat\u0103. Dac\u0103 acest lucru se \u00eent\u00e2mpl\u0103 \u00een continuare, \u00eencerca\u021bi s\u0103 reporni\u021bi gateway-ul.", + "timeout": "Timeout la validarea codului." + }, + "step": { + "auth": { + "data": { + "host": "Gazd\u0103", + "security_code": "Cod de securitate" + }, + "description": "Pute\u021bi g\u0103si codul de securitate pe spatele gateway-ului.", + "title": "Introduce\u021bi codul de securitate" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json new file mode 100644 index 00000000000..4f570fe1386 --- /dev/null +++ b/homeassistant/components/unifi/.translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El sitio del controlador ya est\u00e1 configurado", + "user_privilege": "El usuario debe ser administrador" + }, + "error": { + "faulty_credentials": "Credenciales de usuario incorrectas", + "service_unavailable": "Servicio No disponible" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "site": "ID del sitio", + "username": "Nombre de usuario", + "verify_ssl": "Controlador usando el certificado adecuado" + }, + "title": "Configurar el controlador UniFi" + } + }, + "title": "Controlador UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/hu.json b/homeassistant/components/unifi/.translations/hu.json index f5827c47353..06104c6ed6c 100644 --- a/homeassistant/components/unifi/.translations/hu.json +++ b/homeassistant/components/unifi/.translations/hu.json @@ -1,10 +1,18 @@ { "config": { + "abort": { + "user_privilege": "A felhaszn\u00e1l\u00f3nak rendszergazd\u00e1nak kell lennie" + }, + "error": { + "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok", + "service_unavailable": "Nincs el\u00e9rhet\u0151 szolg\u00e1ltat\u00e1s" + }, "step": { "user": { "data": { "password": "Jelsz\u00f3", - "port": "Port" + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } diff --git a/homeassistant/components/unifi/.translations/pt.json b/homeassistant/components/unifi/.translations/pt.json new file mode 100644 index 00000000000..6730a3d258e --- /dev/null +++ b/homeassistant/components/unifi/.translations/pt.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "O site do controlador j\u00e1 se encontra configurado", + "user_privilege": "Utilizador tem que ser administrador" + }, + "error": { + "faulty_credentials": "Credenciais do utilizador erradas", + "service_unavailable": "Nenhum servi\u00e7o dispon\u00edvel" + }, + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "port": "Porto", + "site": "Site ID", + "username": "Nome do utilizador", + "verify_ssl": "Controlador com certificados adequados" + }, + "title": "Configurar o controlador UniFi" + } + }, + "title": "Controlador UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/ro.json b/homeassistant/components/unifi/.translations/ro.json new file mode 100644 index 00000000000..99b1ac57e0b --- /dev/null +++ b/homeassistant/components/unifi/.translations/ro.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "user_privilege": "Utilizatorul trebuie s\u0103 fie administrator" + }, + "error": { + "faulty_credentials": "Credentiale utilizator invalide", + "service_unavailable": "Nici un serviciu disponibil" + }, + "step": { + "user": { + "data": { + "host": "Gazd\u0103", + "password": "Parol\u0103", + "port": "Port", + "username": "Nume de utilizator", + "verify_ssl": "Controler utiliz\u00e2nd certificatul adecvat" + }, + "title": "Configura\u021bi un controler UniFi" + } + }, + "title": "Controler UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json new file mode 100644 index 00000000000..e4cabf4cd50 --- /dev/null +++ b/homeassistant/components/upnp/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP / IGD ya est\u00e1 configurado", + "no_devices_discovered": "No se descubrieron UPnP / IGDs", + "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos" + }, + "step": { + "init": { + "title": "UPnP / IGD" + }, + "user": { + "data": { + "enable_port_mapping": "Habilitar la asignaci\u00f3n de puertos para Home Assistant", + "enable_sensors": "A\u00f1adir sensores de tr\u00e1fico", + "igd": "UPnP / IGD" + }, + "title": "Opciones de configuraci\u00f3n para UPnP/IGD" + } + }, + "title": "UPnP / IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json new file mode 100644 index 00000000000..a2bf78a7f3e --- /dev/null +++ b/homeassistant/components/upnp/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "init": { + "title": "UPnP/IGD" + }, + "user": { + "data": { + "igd": "UPnP/IGD" + }, + "title": "Az UPnP/IGD be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gei" + } + }, + "title": "UPnP/IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/pt.json b/homeassistant/components/upnp/.translations/pt.json new file mode 100644 index 00000000000..5e9b516d1c2 --- /dev/null +++ b/homeassistant/components/upnp/.translations/pt.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado", + "no_devices_discovered": "Nenhum UPnP/IGDs descoberto", + "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta" + }, + "error": { + "one": "um", + "other": "v\u00e1rios" + }, + "step": { + "init": { + "title": "" + }, + "user": { + "data": { + "enable_port_mapping": "Ativar o mapeamento de porta para o Home Assistant", + "enable_sensors": "Adicionar sensores de tr\u00e1fego", + "igd": "" + }, + "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o para o UPnP/IGD" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json new file mode 100644 index 00000000000..8c287d9a539 --- /dev/null +++ b/homeassistant/components/zwave/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "one_instance_only": "El componente solo admite una instancia de Z-Wave" + }, + "error": { + "option_error": "Z-Wave error de validaci\u00f3n. \u00bfLa ruta de acceso a la memoria USB escorrecta?" + }, + "step": { + "user": { + "data": { + "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)", + "usb_path": "Ruta USB" + }, + "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", + "title": "Configurar Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/hu.json b/homeassistant/components/zwave/.translations/hu.json index 16c25cb7cab..e2acc5f9115 100644 --- a/homeassistant/components/zwave/.translations/hu.json +++ b/homeassistant/components/zwave/.translations/hu.json @@ -1,5 +1,16 @@ { "config": { + "abort": { + "already_configured": "A Z-Wave m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", + "usb_path": "USB el\u00e9r\u00e9si \u00fat" + } + } + }, "title": "Z-Wave" } } \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/pt.json b/homeassistant/components/zwave/.translations/pt.json new file mode 100644 index 00000000000..6962f077498 --- /dev/null +++ b/homeassistant/components/zwave/.translations/pt.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "O Z-Wave j\u00e1 est\u00e1 configurado", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia Z-Wave" + }, + "error": { + "option_error": "A valida\u00e7\u00e3o Z-Wave falhou. O caminho para o stick USB est\u00e1 correto?" + }, + "step": { + "user": { + "data": { + "network_key": "Network Key (deixe em branco para auto-gera\u00e7\u00e3o)", + "usb_path": "Endere\u00e7o USB" + }, + "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obter informa\u00e7\u00f5es sobre as vari\u00e1veis de configura\u00e7\u00e3o", + "title": "Configurar o Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file From 0524c51c1a5e2afe1874801c93e01448395a5558 Mon Sep 17 00:00:00 2001 From: Matt Snyder Date: Mon, 22 Oct 2018 00:04:47 -0500 Subject: [PATCH 14/37] Update flux library version (#17677) --- homeassistant/components/light/flux_led.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index f389d34cd5d..cab6957c265 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['flux_led==0.21'] +REQUIREMENTS = ['flux_led==0.22'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 84b8b2d57ce..f3f5fb4cd6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -376,7 +376,7 @@ fitbit==0.3.0 fixerio==1.0.0a0 # homeassistant.components.light.flux_led -flux_led==0.21 +flux_led==0.22 # homeassistant.components.sensor.foobot foobot_async==0.3.1 From 96105ef6e74e57fd7c02582018639bcdec66a111 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 22 Oct 2018 14:45:13 +0200 Subject: [PATCH 15/37] Add lovelace websocket get and set card (#17600) * Add ws get, set card * lint+fix test * Add test for set * Added more tests, catch unsupported yaml constructors Like !include will now give an error in the frontend. * lint --- homeassistant/components/lovelace/__init__.py | 187 +++++++++++++++++- tests/components/lovelace/test_init.py | 174 +++++++++++++++- 2 files changed, 348 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index e3f4522580b..2c28b52ec6e 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -2,9 +2,10 @@ import logging import uuid import os -from os import O_WRONLY, O_CREAT, O_TRUNC +from os import O_CREAT, O_TRUNC, O_WRONLY from collections import OrderedDict -from typing import Union, List, Dict +from typing import Dict, List, Union + import voluptuous as vol from homeassistant.components import websocket_api @@ -14,21 +15,45 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' REQUIREMENTS = ['ruamel.yaml==0.15.72'] +LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' +JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name + OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' +WS_TYPE_GET_CARD = 'lovelace/config/card/get' +WS_TYPE_SET_CARD = 'lovelace/config/card/set' SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), }) -JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name +SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_CARD, + vol.Required('card_id'): str, + vol.Optional('format', default='yaml'): str, +}) + +SCHEMA_SET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SET_CARD, + vol.Required('card_id'): str, + vol.Required('card_config'): vol.Any(str, Dict), + vol.Optional('format', default='yaml'): str, +}) class WriteError(HomeAssistantError): """Error writing the data.""" +class CardNotFoundError(HomeAssistantError): + """Card not found in data.""" + + +class UnsupportedYamlError(HomeAssistantError): + """Unsupported YAML.""" + + def save_yaml(fname: str, data: JSON_TYPE): """Save a YAML file.""" from ruamel.yaml import YAML @@ -45,7 +70,7 @@ def save_yaml(fname: str, data: JSON_TYPE): _LOGGER.error(str(exc)) raise HomeAssistantError(exc) except OSError as exc: - _LOGGER.exception('Saving YAML file failed: %s', fname) + _LOGGER.exception('Saving YAML file %s failed: %s', fname, exc) raise WriteError(exc) finally: if os.path.exists(tmp_fname): @@ -57,18 +82,29 @@ def save_yaml(fname: str, data: JSON_TYPE): _LOGGER.error("YAML replacement cleanup failed: %s", exc) +def _yaml_unsupported(loader, node): + raise UnsupportedYamlError( + 'Unsupported YAML, you can not use {} in ui-lovelace.yaml' + .format(node.tag)) + + def load_yaml(fname: str) -> JSON_TYPE: """Load a YAML file.""" from ruamel.yaml import YAML + from ruamel.yaml.constructor import RoundTripConstructor from ruamel.yaml.error import YAMLError + + RoundTripConstructor.add_constructor(None, _yaml_unsupported) + yaml = YAML(typ='rt') + try: with open(fname, encoding='utf-8') as conf_file: # If configuration file is empty YAML returns None # We convert that to an empty dict return yaml.load(conf_file) or OrderedDict() except YAMLError as exc: - _LOGGER.error("YAML error: %s", exc) + _LOGGER.error("YAML error in %s: %s", fname, exc) raise HomeAssistantError(exc) except UnicodeDecodeError as exc: _LOGGER.error("Unable to read file %s: %s", fname, exc) @@ -76,21 +112,86 @@ def load_yaml(fname: str) -> JSON_TYPE: def load_config(fname: str) -> JSON_TYPE: - """Load a YAML file and adds id to card if not present.""" + """Load a YAML file and adds id to views and cards if not present.""" config = load_yaml(fname) - # Check if all cards have an ID or else add one + # Check if all views and cards have an id or else add one updated = False + index = 0 for view in config.get('views', []): + if 'id' not in view: + updated = True + view.insert(0, 'id', index, + comment="Automatically created id") for card in view.get('cards', []): if 'id' not in card: updated = True - card['id'] = uuid.uuid4().hex - card.move_to_end('id', last=False) + card.insert(0, 'id', uuid.uuid4().hex, + comment="Automatically created id") + index += 1 if updated: save_yaml(fname, config) return config +def object_to_yaml(data: JSON_TYPE) -> str: + """Create yaml string from object.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + from ruamel.yaml.compat import StringIO + yaml = YAML(typ='rt') + yaml.indent(sequence=4, offset=2) + stream = StringIO() + try: + yaml.dump(data, stream) + return stream.getvalue() + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + + +def yaml_to_object(data: str) -> JSON_TYPE: + """Create object from yaml string.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + yaml = YAML(typ='rt') + try: + return yaml.load(data) + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + + +def get_card(fname: str, card_id: str, data_format: str) -> JSON_TYPE: + """Load a specific card config for id.""" + config = load_yaml(fname) + for view in config.get('views', []): + for card in view.get('cards', []): + if card.get('id') == card_id: + if data_format == 'yaml': + return object_to_yaml(card) + return card + + raise CardNotFoundError( + "Card with ID: {} was not found in {}.".format(card_id, fname)) + + +def set_card(fname: str, card_id: str, card_config: str, data_format: str)\ + -> bool: + """Save a specific card config for id.""" + config = load_yaml(fname) + for view in config.get('views', []): + for card in view.get('cards', []): + if card.get('id') == card_id: + if data_format == 'yaml': + card_config = yaml_to_object(card_config) + card.update(card_config) + save_yaml(fname, config) + return True + + raise CardNotFoundError( + "Card with ID: {} was not found in {}.".format(card_id, fname)) + + async def async_setup(hass, config): """Set up the Lovelace commands.""" # Backwards compat. Added in 0.80. Remove after 0.85 @@ -102,6 +203,14 @@ async def async_setup(hass, config): WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, SCHEMA_GET_LOVELACE_UI) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_CARD, websocket_lovelace_get_card, + SCHEMA_GET_CARD) + + hass.components.websocket_api.async_register_command( + WS_TYPE_SET_CARD, websocket_lovelace_set_card, + SCHEMA_SET_CARD) + return True @@ -111,13 +220,15 @@ async def websocket_lovelace_config(hass, connection, msg): error = None try: config = await hass.async_add_executor_job( - load_config, hass.config.path('ui-lovelace.yaml')) + load_config, hass.config.path(LOVELACE_CONFIG_FILE)) message = websocket_api.result_message( msg['id'], config ) except FileNotFoundError: error = ('file_not_found', 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) except HomeAssistantError as err: error = 'load_error', str(err) @@ -125,3 +236,59 @@ async def websocket_lovelace_config(hass, connection, msg): message = websocket_api.error_message(msg['id'], *error) connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_get_card(hass, connection, msg): + """Send lovelace card config over websocket config.""" + error = None + try: + card = await hass.async_add_executor_job( + get_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id'], + msg.get('format', 'yaml')) + message = websocket_api.result_message( + msg['id'], card + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except CardNotFoundError: + error = ('card_not_found', + 'Could not find card in ui-lovelace.yaml.') + except HomeAssistantError as err: + error = 'load_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_set_card(hass, connection, msg): + """Receive lovelace card config over websocket and save.""" + error = None + try: + result = await hass.async_add_executor_job( + set_card, hass.config.path(LOVELACE_CONFIG_FILE), + msg['card_id'], msg['card_config'], msg.get('format', 'yaml')) + message = websocket_api.result_message( + msg['id'], result + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except CardNotFoundError: + error = ('card_not_found', + 'Could not find card in ui-lovelace.yaml.') + except HomeAssistantError as err: + error = 'save_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 5e4cf2d8037..c637267cc7e 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -9,7 +9,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.components.lovelace import (load_yaml, - save_yaml, load_config) + save_yaml, load_config, + UnsupportedYamlError) TEST_YAML_A = """\ title: My Awesome Home @@ -55,6 +56,8 @@ views: # Title of the view. Will be used as the tooltip for tab icon title: Second view cards: + - id: test + type: entities # Entities card will take a list of entities and show their state. - type: entities # Title of the entities card @@ -79,6 +82,7 @@ TEST_YAML_B = """\ title: Home views: - title: Dashboard + id: dashboard icon: mdi:home cards: - id: testid @@ -102,6 +106,15 @@ views: type: vertical-stack """ +# Test unsupported YAML +TEST_UNSUP_YAML = """\ +title: Home +views: + - title: Dashboard + icon: mdi:home + cards: !include cards.yaml +""" + class TestYAML(unittest.TestCase): """Test lovelace.yaml save and load.""" @@ -147,9 +160,11 @@ class TestYAML(unittest.TestCase): """Test if id is added.""" fname = self._path_for("test6") with patch('homeassistant.components.lovelace.load_yaml', - return_value=self.yaml.load(TEST_YAML_A)): + return_value=self.yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml'): data = load_config(fname) assert 'id' in data['views'][0]['cards'][0] + assert 'id' in data['views'][1] def test_id_not_changed(self): """Test if id is not changed if already exists.""" @@ -256,7 +271,7 @@ async def test_lovelace_ui_not_found(hass, hass_ws_client): async def test_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" + """Test lovelace_ui command load error.""" await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) @@ -272,3 +287,156 @@ async def test_lovelace_ui_load_err(hass, hass_ws_client): assert msg['type'] == TYPE_RESULT assert msg['success'] is False assert msg['error']['code'] == 'load_error' + + +async def test_lovelace_ui_load_json_err(hass, hass_ws_client): + """Test lovelace_ui command load error.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.lovelace.load_config', + side_effect=UnsupportedYamlError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'unsupported_error' + + +async def test_lovelace_get_card(hass, hass_ws_client): + """Test get_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'test', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result'] == 'id: test\ntype: entities\n' + + +async def test_lovelace_get_card_not_found(hass, hass_ws_client): + """Test get_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'not_found', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'card_not_found' + + +async def test_lovelace_get_card_bad_yaml(hass, hass_ws_client): + """Test get_card command bad yaml.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.lovelace.load_yaml', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'testid', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'load_error' + + +async def test_lovelace_set_card(hass, hass_ws_client): + """Test set_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/set', + 'card_id': 'test', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 1, 'cards', 0, 'type'], + list_ok=True) == 'glance' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_set_card_not_found(hass, hass_ws_client): + """Test set_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/set', + 'card_id': 'not_found', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'card_not_found' + + +async def test_lovelace_set_card_bad_yaml(hass, hass_ws_client): + """Test set_card command bad yaml.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.yaml_to_object', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/set', + 'card_id': 'test', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'save_error' From 61a96aecc015f54f28d46a330b28b03820c1c440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 22 Oct 2018 19:32:19 +0200 Subject: [PATCH 16/37] Mill, support more heater types (#17676) * mill, suport more heater types * mill requirements --- homeassistant/components/climate/mill.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 763e239689b..11ad83bdbcc 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.1.2'] +REQUIREMENTS = ['millheater==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -43,6 +43,7 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.error("Failed to connect to Mill") return + await mill_data_connection.update_rooms() await mill_data_connection.update_heaters() dev = [] diff --git a/requirements_all.txt b/requirements_all.txt index f3f5fb4cd6c..095aa107e1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -606,7 +606,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.1.2 +millheater==0.2.0 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From 42a444712bffcf452c559f6c8e5f13aa23c07fa2 Mon Sep 17 00:00:00 2001 From: Tommy Jonsson Date: Mon, 22 Oct 2018 19:36:29 +0200 Subject: [PATCH 17/37] Add missing hangouts data/image to notify service (#17576) * add missing hangouts image_file/url to notify services Missed adding support for hangouts image to notify service * default in schema --- homeassistant/components/notify/hangouts.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/hangouts.py b/homeassistant/components/notify/hangouts.py index eb2880e8a46..01f98146f4c 100644 --- a/homeassistant/components/notify/hangouts.py +++ b/homeassistant/components/notify/hangouts.py @@ -11,10 +11,10 @@ import voluptuous as vol from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA, NOTIFY_SERVICE_SCHEMA, BaseNotificationService, - ATTR_MESSAGE) + ATTR_MESSAGE, ATTR_DATA) from homeassistant.components.hangouts.const \ - import (DOMAIN, SERVICE_SEND_MESSAGE, + import (DOMAIN, SERVICE_SEND_MESSAGE, MESSAGE_DATA_SCHEMA, TARGETS_SCHEMA, CONF_DEFAULT_CONVERSATIONS) _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) NOTIFY_SERVICE_SCHEMA = NOTIFY_SERVICE_SCHEMA.extend({ - vol.Optional(ATTR_TARGET): [TARGETS_SCHEMA] + vol.Optional(ATTR_TARGET): [TARGETS_SCHEMA], + vol.Optional(ATTR_DATA, default={}): MESSAGE_DATA_SCHEMA }) @@ -59,7 +60,8 @@ class HangoutsNotificationService(BaseNotificationService): messages.append({'text': message, 'parse_str': True}) service_data = { ATTR_TARGET: target_conversations, - ATTR_MESSAGE: messages + ATTR_MESSAGE: messages, + ATTR_DATA: kwargs[ATTR_DATA] } return self.hass.services.call( From 75e42acfe752ea0d5d3edf80ff4a51ca9fa79441 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Tue, 23 Oct 2018 05:01:01 +1100 Subject: [PATCH 18/37] Geo location trigger added (#16967) * zone trigger supports entity id pattern * fixed lint error * fixed test code * initial version of new geo_location trigger * revert to original * simplified code and added tests * refactored geo_location trigger to be based on a source defined by the entity * amended test cases * small refactorings --- .../components/automation/geo_location.py | 74 +++++ homeassistant/const.py | 1 + .../automation/test_geo_location.py | 271 ++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 homeassistant/components/automation/geo_location.py create mode 100644 tests/components/automation/test_geo_location.py diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py new file mode 100644 index 00000000000..b2c9a9c093a --- /dev/null +++ b/homeassistant/components/automation/geo_location.py @@ -0,0 +1,74 @@ +""" +Offer geo location automation rules. + +For more details about this automation trigger, please refer to the +documentation at +https://home-assistant.io/docs/automation/trigger/#geo-location-trigger +""" +import voluptuous as vol + +from homeassistant.components.geo_location import DOMAIN +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE, EVENT_STATE_CHANGED) +from homeassistant.helpers import ( + condition, config_validation as cv) +from homeassistant.helpers.config_validation import entity_domain + +EVENT_ENTER = 'enter' +EVENT_LEAVE = 'leave' +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'geo_location', + vol.Required(CONF_SOURCE): cv.string, + vol.Required(CONF_ZONE): entity_domain('zone'), + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): + vol.Any(EVENT_ENTER, EVENT_LEAVE), +}) + + +def source_match(state, source): + """Check if the state matches the provided source.""" + return state and state.attributes.get('source') == source + + +async def async_trigger(hass, config, action): + """Listen for state changes based on configuration.""" + source = config.get(CONF_SOURCE).lower() + zone_entity_id = config.get(CONF_ZONE) + trigger_event = config.get(CONF_EVENT) + + @callback + def state_change_listener(event): + """Handle specific state changes.""" + # Skip if the event is not a geo_location entity. + if not event.data.get('entity_id').startswith(DOMAIN): + return + # Skip if the event's source does not match the trigger's source. + from_state = event.data.get('old_state') + to_state = event.data.get('new_state') + if not source_match(from_state, source) \ + and not source_match(to_state, source): + return + + zone_state = hass.states.get(zone_entity_id) + from_match = condition.zone(hass, zone_state, from_state) + to_match = condition.zone(hass, zone_state, to_state) + + # pylint: disable=too-many-boolean-expressions + if trigger_event == EVENT_ENTER and not from_match and to_match or \ + trigger_event == EVENT_LEAVE and from_match and not to_match: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'geo_location', + 'source': source, + 'entity_id': event.data.get('entity_id'), + 'from_state': from_state, + 'to_state': to_state, + 'zone': zone_state, + 'event': trigger_event, + }, + }, context=event.context)) + + return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4dcc171d35c..b5ca708f1b2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -129,6 +129,7 @@ CONF_SENSOR_TYPE = 'sensor_type' CONF_SENSORS = 'sensors' CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' +CONF_SOURCE = 'source' CONF_SSL = 'ssl' CONF_STATE = 'state' CONF_STATE_TEMPLATE = 'state_template' diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py new file mode 100644 index 00000000000..130cdeef99c --- /dev/null +++ b/tests/components/automation/test_geo_location.py @@ -0,0 +1,271 @@ +"""The tests for the geo location trigger.""" +import unittest + +from homeassistant.components import automation, zone +from homeassistant.core import callback, Context +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant, mock_component +from tests.components.automation import common + + +class TestAutomationGeoLocation(unittest.TestCase): + """Test the geo location trigger.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_component(self.hass, 'group') + assert setup_component(self.hass, zone.DOMAIN, { + 'zone': { + 'name': 'test', + 'latitude': 32.880837, + 'longitude': -117.237561, + 'radius': 250, + } + }) + + self.calls = [] + + @callback + def record_call(service): + """Record calls.""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_if_fires_on_zone_enter(self): + """Test for firing on zone enter.""" + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - hello - hello - test', + self.calls[0].data['some']) + + # Set out of zone again so we can trigger call + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + common.turn_off(self.hass) + self.hass.block_till_done() + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_enter_on_zone_leave(self): + """Test for not firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_leave(self): + """Test for firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_leave_on_zone_enter(self): + """Test for not firing on zone enter.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_appear(self): + """Test for firing if entity appears in zone.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity appears in zone without previously existing outside the zone. + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - - hello - test', + self.calls[0].data['some']) + + def test_if_fires_on_zone_disappear(self): + """Test for firing if entity disappears from zone.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity disappears from zone without new coordinates outside the zone. + self.hass.states.async_remove('geo_location.entity') + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'geo_location - geo_location.entity - hello - - test', + self.calls[0].data['some']) From 399f8a72c34e401b8e0be8429a5b732133c45518 Mon Sep 17 00:00:00 2001 From: Manuel de la Rosa Date: Mon, 22 Oct 2018 13:02:55 -0500 Subject: [PATCH 19/37] Fix Mexican Spanish identifier (#17674) Mexican Spanish identifier is "es-MX" instead of "en-MX". --- homeassistant/components/tts/microsoft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tts/microsoft.py b/homeassistant/components/tts/microsoft.py index 3043e9f418b..3cce7c1a78d 100644 --- a/homeassistant/components/tts/microsoft.py +++ b/homeassistant/components/tts/microsoft.py @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) SUPPORTED_LANGUAGES = [ 'ar-eg', 'ar-sa', 'ca-es', 'cs-cz', 'da-dk', 'de-at', 'de-ch', 'de-de', 'el-gr', 'en-au', 'en-ca', 'en-gb', 'en-ie', 'en-in', 'en-us', 'es-es', - 'en-mx', 'fi-fi', 'fr-ca', 'fr-ch', 'fr-fr', 'he-il', 'hi-in', 'hu-hu', + 'es-mx', 'fi-fi', 'fr-ca', 'fr-ch', 'fr-fr', 'he-il', 'hi-in', 'hu-hu', 'id-id', 'it-it', 'ja-jp', 'ko-kr', 'nb-no', 'nl-nl', 'pl-pl', 'pt-br', 'pt-pt', 'ro-ro', 'ru-ru', 'sk-sk', 'sv-se', 'th-th', 'tr-tr', 'zh-cn', 'zh-hk', 'zh-tw', From fd9370da399fef95eaee4cb3a32e4d029d0f49d3 Mon Sep 17 00:00:00 2001 From: Tom Monck JR Date: Mon, 22 Oct 2018 14:05:00 -0400 Subject: [PATCH 20/37] Add readthedoc.yml file to specify the version of python to run during documentation building. (#17642) --- readthedocs.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 readthedocs.yml diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 00000000000..6a06f655513 --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,8 @@ +# .readthedocs.yml + +build: + image: latest + +python: + version: 3.6 + setup_py_install: true \ No newline at end of file From 4e8cd7281c43d4a6c3f2adc9211b84506092a8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20RAMAGE?= Date: Mon, 22 Oct 2018 20:07:11 +0200 Subject: [PATCH 21/37] All supported domains should be exposed by default (#17579) According to documentation, all supported domains should be exposed by default https://www.home-assistant.io/components/google_assistant/#expose_by_default --- homeassistant/components/google_assistant/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 485b98e8e22..d8ab231c96b 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -14,7 +14,8 @@ CONF_ROOM_HINT = 'room' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ - 'switch', 'light', 'group', 'media_player', 'fan', 'cover', 'climate' + 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', + 'media_player', 'scene', 'script', 'switch' ] CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} From b773a9049c7937327da174ee822318d69bbe4547 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 Oct 2018 14:49:12 -0600 Subject: [PATCH 22/37] Updated simplisafe-python to 3.1.13 (#17696) --- homeassistant/components/simplisafe/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index de6277c2ef1..aaa8e3a19f9 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv from .config_flow import configured_instances from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE -REQUIREMENTS = ['simplisafe-python==3.1.12'] +REQUIREMENTS = ['simplisafe-python==3.1.13'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 095aa107e1c..0d67b5ea9e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1349,7 +1349,7 @@ shodan==1.10.4 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==3.1.12 +simplisafe-python==3.1.13 # homeassistant.components.sisyphus sisyphus-control==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1568fd95607..a6b7f14f06e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ ruamel.yaml==0.15.72 rxv==0.5.1 # homeassistant.components.simplisafe -simplisafe-python==3.1.12 +simplisafe-python==3.1.13 # homeassistant.components.sleepiq sleepyq==0.6 From 301493037186aa7eeba8de767dcd5f291f8142ec Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 23 Oct 2018 07:11:55 +0200 Subject: [PATCH 23/37] Update limitlessled to 1.1.3 (#17703) --- homeassistant/components/light/limitlessled.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index a5aeabba84d..2e2971cfdc2 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -20,7 +20,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.1.2'] +REQUIREMENTS = ['limitlessled==1.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 0d67b5ea9e6..5d7de61eadf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -559,7 +559,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.1.2 +limitlessled==1.1.3 # homeassistant.components.linode linode-api==4.1.9b1 From ad3d0c4e99db3d4248db6ac93092c31592135d8f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 23 Oct 2018 07:12:12 +0200 Subject: [PATCH 24/37] Upgrade Sphinx to 1.8.1 (#17701) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 1a809c2fb85..cd2eb1a0be6 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.7.8 +Sphinx==1.8.1 sphinx-autodoc-typehints==1.3.0 sphinx-autodoc-annotation==1.0.post1 From 324587b2dbd388536c987ce5b6372cc706fc94e9 Mon Sep 17 00:00:00 2001 From: Yegor Vialov Date: Tue, 23 Oct 2018 10:04:47 +0300 Subject: [PATCH 25/37] Away mode temperature fix for generic thermostat (#17641) * Resolves /home-assistant/home-assistant#17433 Away mode temperature issue fix for generic_thermostat * Debug messages removed from generic_thermostat.py * Test for repeat away_mode set Test for fix of generic thermostat issue when away_mode was set several times in a row. * Code style fix in generic_thermostat * Remove blank line in the end of generic_thermostat * Fix style --- .../components/climate/generic_thermostat.py | 4 ++++ .../climate/test_generic_thermostat.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 258699ff90a..ad8875462fd 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -380,6 +380,8 @@ class GenericThermostat(ClimateDevice): async def async_turn_away_mode_on(self): """Turn away mode on by setting it on away hold indefinitely.""" + if self._is_away: + return self._is_away = True self._saved_target_temp = self._target_temp self._target_temp = self._away_temp @@ -388,6 +390,8 @@ class GenericThermostat(ClimateDevice): async def async_turn_away_mode_off(self): """Turn away off.""" + if not self._is_away: + return self._is_away = False self._target_temp = self._saved_target_temp await self._async_control_heating() diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 47ec621aeb5..8bbcbc8f840 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -221,6 +221,24 @@ class TestClimateGenericThermostat(unittest.TestCase): state = self.hass.states.get(ENTITY) self.assertEqual(23, state.attributes.get('temperature')) + def test_set_away_mode_twice_and_restore_prev_temp(self): + """Test the setting away mode twice in a row. + + Verify original temperature is restored. + """ + common.set_temperature(self.hass, 23) + self.hass.block_till_done() + common.set_away_mode(self.hass, True) + self.hass.block_till_done() + common.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(16, state.attributes.get('temperature')) + common.set_away_mode(self.hass, False) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(23, state.attributes.get('temperature')) + def test_sensor_bad_value(self): """Test sensor that have None as state.""" state = self.hass.states.get(ENTITY) From 44e35b7f526cac48fd3799d4538321b30e0980dd Mon Sep 17 00:00:00 2001 From: Jaxom Nutt <40261038+JaxomCS@users.noreply.github.com> Date: Tue, 23 Oct 2018 16:28:49 +0800 Subject: [PATCH 26/37] Bug fix for clicksend (#17713) * Bug fix Current version causes 500 error since it is sending an array of from numbers to ClickSend. Changing the from number to 'hass' identifies all messages as coming from Home Assistant making them more recognisable and removes the bug. * Amendment Changed it to use 'hass' as the default instead of defaulting to the recipient which is the array. Would have worked if users set their own name but users who were using the default were experiencing the issue. * Added DEFAULT_SENDER variable --- homeassistant/components/notify/clicksend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index c028da2c579..5506d6ed6d0 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -22,6 +22,8 @@ _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' +DEFAULT_SENDER = 'hass' + HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} @@ -29,7 +31,7 @@ def validate_sender(config): """Set the optional sender name if sender name is not provided.""" if CONF_SENDER in config: return config - config[CONF_SENDER] = config[CONF_RECIPIENT] + config[CONF_SENDER] = DEFAULT_SENDER return config @@ -61,7 +63,7 @@ class ClicksendNotificationService(BaseNotificationService): self.username = config.get(CONF_USERNAME) self.api_key = config.get(CONF_API_KEY) self.recipients = config.get(CONF_RECIPIENT) - self.sender = config.get(CONF_SENDER, CONF_RECIPIENT) + self.sender = config.get(CONF_SENDER) def send_message(self, message="", **kwargs): """Send a message to a user.""" From 50f0eac7f37c2339a56f77e12da443edae3bdf9c Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Tue, 23 Oct 2018 04:54:03 -0400 Subject: [PATCH 27/37] Fixed issue #16903 re exception with multiple simultanious writes (#17636) Reworked tests/components/emulated_hue/test_init.py to not be dependent on the specific internal implementation of util/jsonn.py --- homeassistant/util/json.py | 14 ++- tests/components/emulated_hue/test_init.py | 122 ++++++++++----------- 2 files changed, 69 insertions(+), 67 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 0a2a2a1edf3..b002c8e3147 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -4,7 +4,7 @@ from typing import Union, List, Dict import json import os -from os import O_WRONLY, O_CREAT, O_TRUNC +import tempfile from homeassistant.exceptions import HomeAssistantError @@ -46,13 +46,17 @@ def save_json(filename: str, data: Union[List, Dict], Returns True on success. """ - tmp_filename = filename + "__TEMP__" + tmp_filename = "" + tmp_path = os.path.split(filename)[0] try: json_data = json.dumps(data, sort_keys=True, indent=4) - mode = 0o600 if private else 0o644 - with open(os.open(tmp_filename, O_WRONLY | O_CREAT | O_TRUNC, mode), - 'w', encoding='utf-8') as fdesc: + # Modern versions of Python tempfile create this file with mode 0o600 + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8', + dir=tmp_path, delete=False) as fdesc: fdesc.write(json_data) + tmp_filename = fdesc.name + if not private: + os.chmod(tmp_filename, 0o644) os.replace(tmp_filename, filename) except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 9b0a5cd9052..3de8e969140 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,7 +1,5 @@ """Test the Emulated Hue component.""" -import json - -from unittest.mock import patch, Mock, mock_open, MagicMock +from unittest.mock import patch, Mock, MagicMock from homeassistant.components.emulated_hue import Config @@ -14,30 +12,30 @@ def test_config_google_home_entity_id_to_number(): 'type': 'google_home' }) - mop = mock_open(read_data=json.dumps({'1': 'light.test2'})) - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={'1': 'light.test2'}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '2' - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '2' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '1': 'light.test2', - '2': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '1': 'light.test2', '2': 'light.test' + } - number = conf.entity_id_to_number('light.test') - assert number == '2' - assert handle.write.call_count == 1 + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '1' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '2' + assert json_saver.call_count == 1 - entity_id = conf.number_to_entity_id('1') - assert entity_id == 'light.test2' + number = conf.entity_id_to_number('light.test2') + assert number == '1' + assert json_saver.call_count == 1 + + entity_id = conf.number_to_entity_id('1') + assert entity_id == 'light.test2' def test_config_google_home_entity_id_to_number_altered(): @@ -48,30 +46,30 @@ def test_config_google_home_entity_id_to_number_altered(): 'type': 'google_home' }) - mop = mock_open(read_data=json.dumps({'21': 'light.test2'})) - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={'21': 'light.test2'}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '22' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '21': 'light.test2', - '22': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '21': 'light.test2', + '22': 'light.test', + } - number = conf.entity_id_to_number('light.test') - assert number == '22' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert json_saver.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '21' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test2') + assert number == '21' + assert json_saver.call_count == 1 - entity_id = conf.number_to_entity_id('21') - assert entity_id == 'light.test2' + entity_id = conf.number_to_entity_id('21') + assert entity_id == 'light.test2' def test_config_google_home_entity_id_to_number_empty(): @@ -82,29 +80,29 @@ def test_config_google_home_entity_id_to_number_empty(): 'type': 'google_home' }) - mop = mock_open(read_data='') - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '1' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '1': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '1': 'light.test', + } - number = conf.entity_id_to_number('light.test') - assert number == '1' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert json_saver.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '2' - assert handle.write.call_count == 2 + number = conf.entity_id_to_number('light.test2') + assert number == '2' + assert json_saver.call_count == 2 - entity_id = conf.number_to_entity_id('2') - assert entity_id == 'light.test2' + entity_id = conf.number_to_entity_id('2') + assert entity_id == 'light.test2' def test_config_alexa_entity_id_to_number(): From 277a9a39955d636b7c828a09d0ad1b94ce3a54b7 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 23 Oct 2018 11:08:11 +0200 Subject: [PATCH 28/37] Async version for asuswrt (#17692) * Testing async data for asuswrt * Moved to lib --- CODEOWNERS | 1 + .../components/device_tracker/asuswrt.py | 336 +----------- requirements_all.txt | 4 +- requirements_test_all.txt | 1 - .../components/device_tracker/test_asuswrt.py | 478 +----------------- 5 files changed, 26 insertions(+), 794 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index c49af4864a9..2bf31378ac3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -58,6 +58,7 @@ homeassistant/components/climate/mill.py @danielhiversen homeassistant/components/climate/sensibo.py @andrey-git homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue +homeassistant/components/device_tracker/asuswrt.py @kennedyshead homeassistant/components/device_tracker/automatic.py @armills homeassistant/components/device_tracker/huawei_router.py @abmantis homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 710a07f77d3..461380b2c8e 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -5,10 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging -import re -import socket -import telnetlib -from collections import namedtuple import voluptuous as vol @@ -19,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, CONF_PROTOCOL) -REQUIREMENTS = ['pexpect==4.6.0'] +REQUIREMENTS = ['aioasuswrt==1.0.0'] _LOGGER = logging.getLogger(__name__) @@ -44,345 +40,55 @@ PLATFORM_SCHEMA = vol.All( })) -_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases' -_LEASES_REGEX = re.compile( - r'\w+\s' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + - r'(?P([^\s]+))') - -# Command to get both 5GHz and 2.4GHz clients -_WL_CMD = 'for dev in `nvram get wl_ifnames`; do wl -i $dev assoclist; done' -_WL_REGEX = re.compile( - r'\w+\s' + - r'(?P(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))') - -_IP_NEIGH_CMD = 'ip neigh' -_IP_NEIGH_REGEX = re.compile( - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3}|' - r'([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){1,7})\s' - r'\w+\s' - r'\w+\s' - r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' - r'\s?(router)?' - r'\s?(nud)?' - r'(?P(\w+))') - -_ARP_CMD = 'arp -n' -_ARP_REGEX = re.compile( - r'.+\s' + - r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' + - r'.+\s' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' + - r'\s' + - r'.*') - - -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" scanner = AsusWrtDeviceScanner(config[DOMAIN]) - + await scanner.async_connect() return scanner if scanner.success_init else None -def _parse_lines(lines, regex): - """Parse the lines using the given regular expression. - - If a line can't be parsed it is logged and skipped in the output. - """ - results = [] - for line in lines: - match = regex.search(line) - if not match: - _LOGGER.debug("Could not parse row: %s", line) - continue - results.append(match.groupdict()) - return results - - -Device = namedtuple('Device', ['mac', 'ip', 'name']) - - class AsusWrtDeviceScanner(DeviceScanner): """This class queries a router running ASUSWRT firmware.""" # Eighth attribute needed for mode (AP mode vs router mode) def __init__(self, config): """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config.get(CONF_PASSWORD, '') - self.ssh_key = config.get('ssh_key', config.get('pub_key', '')) - self.protocol = config[CONF_PROTOCOL] - self.mode = config[CONF_MODE] - self.port = config[CONF_PORT] - self.require_ip = config[CONF_REQUIRE_IP] + from aioasuswrt.asuswrt import AsusWrt - if self.protocol == 'ssh': - self.connection = SshConnection( - self.host, self.port, self.username, self.password, - self.ssh_key) - else: - self.connection = TelnetConnection( - self.host, self.port, self.username, self.password) + self.last_results = {} + self.success_init = False + self.connection = AsusWrt(config[CONF_HOST], config[CONF_PORT], + config[CONF_PROTOCOL] == 'telnet', + config[CONF_USERNAME], + config.get(CONF_PASSWORD, ''), + config.get('ssh_key', + config.get('pub_key', '')), + config[CONF_MODE], config[CONF_REQUIRE_IP]) + async def async_connect(self): + """Initialize connection to the router.""" self.last_results = {} # Test the router is accessible. - data = self.get_asuswrt_data() + data = await self.connection.async_get_connected_devices() self.success_init = data is not None - def scan_devices(self): + async def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() + await self.async_update_info() return list(self.last_results.keys()) - def get_device_name(self, device): + async def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if device not in self.last_results: return None return self.last_results[device].name - def _update_info(self): + async def async_update_info(self): """Ensure the information from the ASUSWRT router is up to date. Return boolean if scanning successful. """ - if not self.success_init: - return False - _LOGGER.info('Checking Devices') - data = self.get_asuswrt_data() - if not data: - return False - self.last_results = data - return True - - def get_asuswrt_data(self): - """Retrieve data from ASUSWRT. - - Calls various commands on the router and returns the superset of all - responses. Some commands will not work on some routers. - """ - devices = {} - devices.update(self._get_wl()) - devices.update(self._get_arp()) - devices.update(self._get_neigh(devices)) - if not self.mode == 'ap': - devices.update(self._get_leases(devices)) - - ret_devices = {} - for key in devices: - if not self.require_ip or devices[key].ip is not None: - ret_devices[key] = devices[key] - return ret_devices - - def _get_wl(self): - lines = self.connection.run_command(_WL_CMD) - if not lines: - return {} - result = _parse_lines(lines, _WL_REGEX) - devices = {} - for device in result: - mac = device['mac'].upper() - devices[mac] = Device(mac, None, None) - return devices - - def _get_leases(self, cur_devices): - lines = self.connection.run_command(_LEASES_CMD) - if not lines: - return {} - lines = [line for line in lines if not line.startswith('duid ')] - result = _parse_lines(lines, _LEASES_REGEX) - devices = {} - for device in result: - # For leases where the client doesn't set a hostname, ensure it - # is blank and not '*', which breaks entity_id down the line. - host = device['host'] - if host == '*': - host = '' - mac = device['mac'].upper() - if mac in cur_devices: - devices[mac] = Device(mac, device['ip'], host) - return devices - - def _get_neigh(self, cur_devices): - lines = self.connection.run_command(_IP_NEIGH_CMD) - if not lines: - return {} - result = _parse_lines(lines, _IP_NEIGH_REGEX) - devices = {} - for device in result: - status = device['status'] - if status is None or status.upper() != 'REACHABLE': - continue - if device['mac'] is not None: - mac = device['mac'].upper() - old_device = cur_devices.get(mac) - old_ip = old_device.ip if old_device else None - devices[mac] = Device(mac, device.get('ip', old_ip), None) - return devices - - def _get_arp(self): - lines = self.connection.run_command(_ARP_CMD) - if not lines: - return {} - result = _parse_lines(lines, _ARP_REGEX) - devices = {} - for device in result: - if device['mac'] is not None: - mac = device['mac'].upper() - devices[mac] = Device(mac, device['ip'], None) - return devices - - -class _Connection: - def __init__(self): - self._connected = False - - @property - def connected(self): - """Return connection state.""" - return self._connected - - def connect(self): - """Mark current connection state as connected.""" - self._connected = True - - def disconnect(self): - """Mark current connection state as disconnected.""" - self._connected = False - - -class SshConnection(_Connection): - """Maintains an SSH connection to an ASUS-WRT router.""" - - def __init__(self, host, port, username, password, ssh_key): - """Initialize the SSH connection properties.""" - super().__init__() - - self._ssh = None - self._host = host - self._port = port - self._username = username - self._password = password - self._ssh_key = ssh_key - - def run_command(self, command): - """Run commands through an SSH connection. - - Connect to the SSH server if not currently connected, otherwise - use the existing connection. - """ - from pexpect import pxssh, exceptions - - try: - if not self.connected: - self.connect() - self._ssh.sendline(command) - self._ssh.prompt() - lines = self._ssh.before.split(b'\n')[1:-1] - return [line.decode('utf-8') for line in lines] - except exceptions.EOF as err: - _LOGGER.error("Connection refused. %s", self._ssh.before) - self.disconnect() - return None - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", err) - self.disconnect() - return None - except AssertionError as err: - _LOGGER.error("Connection to router unavailable: %s", err) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT SSH server.""" - from pexpect import pxssh - - self._ssh = pxssh.pxssh() - if self._ssh_key: - self._ssh.login(self._host, self._username, quiet=False, - ssh_key=self._ssh_key, port=self._port) - else: - self._ssh.login(self._host, self._username, quiet=False, - password=self._password, port=self._port) - - super().connect() - - def disconnect(self): - """Disconnect the current SSH connection.""" - try: - self._ssh.logout() - except Exception: # pylint: disable=broad-except - pass - finally: - self._ssh = None - - super().disconnect() - - -class TelnetConnection(_Connection): - """Maintains a Telnet connection to an ASUS-WRT router.""" - - def __init__(self, host, port, username, password): - """Initialize the Telnet connection properties.""" - super().__init__() - - self._telnet = None - self._host = host - self._port = port - self._username = username - self._password = password - self._prompt_string = None - - def run_command(self, command): - """Run a command through a Telnet connection. - - Connect to the Telnet server if not currently connected, otherwise - use the existing connection. - """ - try: - if not self.connected: - self.connect() - self._telnet.write('{}\n'.format(command).encode('ascii')) - data = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - return [line.decode('utf-8') for line in data] - except EOFError: - _LOGGER.error("Unexpected response from router") - self.disconnect() - return None - except ConnectionRefusedError: - _LOGGER.error("Connection refused by router. Telnet enabled?") - self.disconnect() - return None - except socket.gaierror as exc: - _LOGGER.error("Socket exception: %s", exc) - self.disconnect() - return None - except OSError as exc: - _LOGGER.error("OSError: %s", exc) - self.disconnect() - return None - - def connect(self): - """Connect to the ASUS-WRT Telnet server.""" - self._telnet = telnetlib.Telnet(self._host) - self._telnet.read_until(b'login: ') - self._telnet.write((self._username + '\n').encode('ascii')) - self._telnet.read_until(b'Password: ') - self._telnet.write((self._password + '\n').encode('ascii')) - self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1] - - super().connect() - - def disconnect(self): - """Disconnect the current Telnet connection.""" - try: - self._telnet.write('exit\n'.encode('ascii')) - except Exception: # pylint: disable=broad-except - pass - - super().disconnect() + self.last_results = await self.connection.async_get_connected_devices() diff --git a/requirements_all.txt b/requirements_all.txt index 5d7de61eadf..ffd2be0b3bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -87,6 +87,9 @@ abodepy==0.14.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 +# homeassistant.components.device_tracker.asuswrt +aioasuswrt==1.0.0 + # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -690,7 +693,6 @@ panasonic_viera==0.3.1 pdunehd==1.3 # homeassistant.components.device_tracker.aruba -# homeassistant.components.device_tracker.asuswrt # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6b7f14f06e..fe02cb0fab1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -125,7 +125,6 @@ numpy==1.15.2 paho-mqtt==1.4.0 # homeassistant.components.device_tracker.aruba -# homeassistant.components.device_tracker.asuswrt # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 8c5af618288..d43a7d53969 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -3,9 +3,6 @@ import os from datetime import timedelta import unittest from unittest import mock -import socket - -import voluptuous as vol from homeassistant.setup import setup_component from homeassistant.components import device_tracker @@ -13,9 +10,7 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_NEW_DEVICE_DEFAULTS, CONF_AWAY_HIDE) from homeassistant.components.device_tracker.asuswrt import ( - CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, DOMAIN, _ARP_REGEX, - CONF_PORT, PLATFORM_SCHEMA, Device, get_scanner, AsusWrtDeviceScanner, - _parse_lines, SshConnection, TelnetConnection, CONF_REQUIRE_IP) + CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) @@ -35,85 +30,6 @@ VALID_CONFIG_ROUTER_SSH = {DOMAIN: { CONF_PORT: '22' }} -WL_DATA = [ - 'assoclist 01:02:03:04:06:08\r', - 'assoclist 08:09:10:11:12:14\r', - 'assoclist 08:09:10:11:12:15\r' -] - -WL_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip=None, name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip=None, name=None), - '08:09:10:11:12:15': Device( - mac='08:09:10:11:12:15', ip=None, name=None) -} - -ARP_DATA = [ - '? (123.123.123.125) at 01:02:03:04:06:08 [ether] on eth0\r', - '? (123.123.123.126) at 08:09:10:11:12:14 [ether] on br0\r', - '? (123.123.123.127) at on br0\r', -] - -ARP_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) -} - -NEIGH_DATA = [ - '123.123.123.125 dev eth0 lladdr 01:02:03:04:06:08 REACHABLE\r', - '123.123.123.126 dev br0 lladdr 08:09:10:11:12:14 REACHABLE\r', - '123.123.123.127 dev br0 FAILED\r', - '123.123.123.128 dev br0 lladdr 08:09:15:15:15:15 DELAY\r', - 'fe80::feff:a6ff:feff:12ff dev br0 lladdr fc:ff:a6:ff:12:ff STALE\r', -] - -NEIGH_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) -} - -LEASES_DATA = [ - '51910 01:02:03:04:06:08 123.123.123.125 TV 01:02:03:04:06:08\r', - '79986 01:02:03:04:06:10 123.123.123.127 android 01:02:03:04:06:15\r', - '23523 08:09:10:11:12:14 123.123.123.126 * 08:09:10:11:12:14\r', -] - -LEASES_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name='TV'), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name='') -} - -WAKE_DEVICES = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name='TV'), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name='') -} - -WAKE_DEVICES_AP = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) -} - -WAKE_DEVICES_NO_IP = { - '01:02:03:04:06:08': Device( - mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), - '08:09:10:11:12:14': Device( - mac='08:09:10:11:12:14', ip='123.123.123.126', name=None), - '08:09:10:11:12:15': Device( - mac='08:09:10:11:12:15', ip=None, name=None) -} - def setup_module(): """Set up the test module.""" @@ -150,24 +66,6 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): except FileNotFoundError: pass - def test_parse_lines_wrong_input(self): - """Testing parse lines.""" - output = _parse_lines("asdf asdfdfsafad", _ARP_REGEX) - self.assertEqual(output, []) - - def test_get_device_name(self): - """Test for getting name.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.last_results = WAKE_DEVICES - self.assertEqual('TV', scanner.get_device_name('01:02:03:04:06:08')) - self.assertEqual(None, scanner.get_device_name('01:02:03:04:08:08')) - - def test_scan_devices(self): - """Test for scan devices.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.last_results = WAKE_DEVICES - self.assertEqual(list(WAKE_DEVICES), scanner.scan_devices()) - def test_password_or_pub_key_required(self): """Test creating an AsusWRT scanner without a pass or pubkey.""" with assert_setup_component(0, DOMAIN): @@ -207,377 +105,3 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): conf_dict[DOMAIN][CONF_PORT] = 22 self.assertEqual(asuswrt_mock.call_count, 1) self.assertEqual(asuswrt_mock.call_args, mock.call(conf_dict[DOMAIN])) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', - return_value=mock.MagicMock()) - def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): - """Test creating an AsusWRT scanner with a pubkey and no password.""" - conf_dict = { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PUB_KEY: FAKEFILE, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: { - CONF_TRACK_NEW: True, - CONF_AWAY_HIDE: False - } - } - } - - with assert_setup_component(1, DOMAIN): - assert setup_component(self.hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_MODE] = 'router' - conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh' - conf_dict[DOMAIN][CONF_PORT] = 22 - self.assertEqual(asuswrt_mock.call_count, 1) - self.assertEqual(asuswrt_mock.call_args, mock.call(conf_dict[DOMAIN])) - - def test_ssh_login_with_pub_key(self): - """Test that login is done with pub_key when configured to.""" - ssh = mock.MagicMock() - ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) - ssh_mock.start() - self.addCleanup(ssh_mock.stop) - conf_dict = PLATFORM_SCHEMA({ - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PUB_KEY: FAKEFILE - }) - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.run_command('ls') - self.assertEqual(ssh.login.call_count, 1) - self.assertEqual( - ssh.login.call_args, - mock.call('fake_host', 'fake_user', quiet=False, - ssh_key=FAKEFILE, port=22) - ) - - def test_ssh_login_with_password(self): - """Test that login is done with password when configured to.""" - ssh = mock.MagicMock() - ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) - ssh_mock.start() - self.addCleanup(ssh_mock.stop) - conf_dict = PLATFORM_SCHEMA({ - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass' - }) - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.run_command('ls') - self.assertEqual(ssh.login.call_count, 1) - self.assertEqual( - ssh.login.call_args, - mock.call('fake_host', 'fake_user', quiet=False, - password='fake_pass', port=22) - ) - - def test_ssh_login_without_password_or_pubkey(self): - """Test that login is not called without password or pub_key.""" - ssh = mock.MagicMock() - ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) - ssh_mock.start() - self.addCleanup(ssh_mock.stop) - - conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - } - - with self.assertRaises(vol.Invalid): - conf_dict = PLATFORM_SCHEMA(conf_dict) - - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - - with assert_setup_component(0, DOMAIN): - assert setup_component(self.hass, DOMAIN, - {DOMAIN: conf_dict}) - ssh.login.assert_not_called() - - def test_telnet_login_with_password(self): - """Test that login is done with password when configured to.""" - telnet = mock.MagicMock() - telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) - telnet_mock.start() - self.addCleanup(telnet_mock.stop) - conf_dict = PLATFORM_SCHEMA({ - CONF_PLATFORM: 'asuswrt', - CONF_PROTOCOL: 'telnet', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass' - }) - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.run_command('ls') - self.assertEqual(telnet.read_until.call_count, 4) - self.assertEqual(telnet.write.call_count, 3) - self.assertEqual( - telnet.read_until.call_args_list[0], - mock.call(b'login: ') - ) - self.assertEqual( - telnet.write.call_args_list[0], - mock.call(b'fake_user\n') - ) - self.assertEqual( - telnet.read_until.call_args_list[1], - mock.call(b'Password: ') - ) - self.assertEqual( - telnet.write.call_args_list[1], - mock.call(b'fake_pass\n') - ) - self.assertEqual( - telnet.read_until.call_args_list[2], - mock.call(b'#') - ) - - def test_telnet_login_without_password(self): - """Test that login is not called without password or pub_key.""" - telnet = mock.MagicMock() - telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) - telnet_mock.start() - self.addCleanup(telnet_mock.stop) - - conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_PROTOCOL: 'telnet', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - } - - with self.assertRaises(vol.Invalid): - conf_dict = PLATFORM_SCHEMA(conf_dict) - - update_mock = mock.patch( - 'homeassistant.components.device_tracker.asuswrt.' - 'AsusWrtDeviceScanner.get_asuswrt_data') - update_mock.start() - self.addCleanup(update_mock.stop) - - with assert_setup_component(0, DOMAIN): - assert setup_component(self.hass, DOMAIN, - {DOMAIN: conf_dict}) - telnet.login.assert_not_called() - - def test_get_asuswrt_data(self): - """Test asuswrt data fetch.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner._get_wl = mock.Mock() - scanner._get_arp = mock.Mock() - scanner._get_neigh = mock.Mock() - scanner._get_leases = mock.Mock() - scanner._get_wl.return_value = WL_DEVICES - scanner._get_arp.return_value = ARP_DEVICES - scanner._get_neigh.return_value = NEIGH_DEVICES - scanner._get_leases.return_value = LEASES_DEVICES - self.assertEqual(WAKE_DEVICES, scanner.get_asuswrt_data()) - - def test_get_asuswrt_data_ap(self): - """Test for get asuswrt_data in ap mode.""" - conf = VALID_CONFIG_ROUTER_SSH.copy()[DOMAIN] - conf[CONF_MODE] = 'ap' - scanner = AsusWrtDeviceScanner(conf) - scanner._get_wl = mock.Mock() - scanner._get_arp = mock.Mock() - scanner._get_neigh = mock.Mock() - scanner._get_leases = mock.Mock() - scanner._get_wl.return_value = WL_DEVICES - scanner._get_arp.return_value = ARP_DEVICES - scanner._get_neigh.return_value = NEIGH_DEVICES - scanner._get_leases.return_value = LEASES_DEVICES - self.assertEqual(WAKE_DEVICES_AP, scanner.get_asuswrt_data()) - - def test_get_asuswrt_data_no_ip(self): - """Test for get asuswrt_data and not requiring ip.""" - conf = VALID_CONFIG_ROUTER_SSH.copy()[DOMAIN] - conf[CONF_REQUIRE_IP] = False - scanner = AsusWrtDeviceScanner(conf) - scanner._get_wl = mock.Mock() - scanner._get_arp = mock.Mock() - scanner._get_neigh = mock.Mock() - scanner._get_leases = mock.Mock() - scanner._get_wl.return_value = WL_DEVICES - scanner._get_arp.return_value = ARP_DEVICES - scanner._get_neigh.return_value = NEIGH_DEVICES - scanner._get_leases.return_value = LEASES_DEVICES - self.assertEqual(WAKE_DEVICES_NO_IP, scanner.get_asuswrt_data()) - - def test_update_info(self): - """Test for update info.""" - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.get_asuswrt_data = mock.Mock() - scanner.get_asuswrt_data.return_value = WAKE_DEVICES - self.assertTrue(scanner._update_info()) - self.assertTrue(scanner.last_results, WAKE_DEVICES) - scanner.success_init = False - self.assertFalse(scanner._update_info()) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_wl(self, mocked_ssh): - """Testing wl.""" - mocked_ssh.run_command.return_value = WL_DATA - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual(WL_DEVICES, scanner._get_wl()) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_wl()) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_arp(self, mocked_ssh): - """Testing arp.""" - mocked_ssh.run_command.return_value = ARP_DATA - - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual(ARP_DEVICES, scanner._get_arp()) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_arp()) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_neigh(self, mocked_ssh): - """Testing neigh.""" - mocked_ssh.run_command.return_value = NEIGH_DATA - - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual(NEIGH_DEVICES, scanner._get_neigh(ARP_DEVICES.copy())) - self.assertEqual(NEIGH_DEVICES, scanner._get_neigh({ - 'UN:KN:WN:DE:VI:CE': Device('UN:KN:WN:DE:VI:CE', None, None), - })) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_neigh(ARP_DEVICES.copy())) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.SshConnection') - def test_get_leases(self, mocked_ssh): - """Testing leases.""" - mocked_ssh.run_command.return_value = LEASES_DATA - - scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) - scanner.connection = mocked_ssh - self.assertEqual( - LEASES_DEVICES, scanner._get_leases(NEIGH_DEVICES.copy())) - mocked_ssh.run_command.return_value = '' - self.assertEqual({}, scanner._get_leases(NEIGH_DEVICES.copy())) - - -@pytest.mark.skip( - reason="These tests are performing actual failing network calls. They " - "need to be cleaned up before they are re-enabled. They're frequently " - "failing in Travis.") -class TestSshConnection(unittest.TestCase): - """Testing SshConnection.""" - - def setUp(self): - """Set up test env.""" - self.connection = SshConnection( - 'fake', 'fake', 'fake', 'fake', 'fake') - self.connection._connected = True - - def test_run_command_exception_eof(self): - """Testing exception in run_command.""" - from pexpect import exceptions - self.connection._ssh = mock.Mock() - self.connection._ssh.sendline = mock.Mock() - self.connection._ssh.sendline.side_effect = exceptions.EOF('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - self.assertIsNone(self.connection._ssh) - - def test_run_command_exception_pxssh(self): - """Testing exception in run_command.""" - from pexpect import pxssh - self.connection._ssh = mock.Mock() - self.connection._ssh.sendline = mock.Mock() - self.connection._ssh.sendline.side_effect = pxssh.ExceptionPxssh( - 'except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - self.assertIsNone(self.connection._ssh) - - def test_run_command_assertion_error(self): - """Testing exception in run_command.""" - self.connection._ssh = mock.Mock() - self.connection._ssh.sendline = mock.Mock() - self.connection._ssh.sendline.side_effect = AssertionError('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - self.assertIsNone(self.connection._ssh) - - -@pytest.mark.skip( - reason="These tests are performing actual failing network calls. They " - "need to be cleaned up before they are re-enabled. They're frequently " - "failing in Travis.") -class TestTelnetConnection(unittest.TestCase): - """Testing TelnetConnection.""" - - def setUp(self): - """Set up test env.""" - self.connection = TelnetConnection( - 'fake', 'fake', 'fake', 'fake') - self.connection._connected = True - - def test_run_command_exception_eof(self): - """Testing EOFException in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = EOFError('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - - def test_run_command_exception_connection_refused(self): - """Testing ConnectionRefusedError in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = ConnectionRefusedError( - 'except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - - def test_run_command_exception_gaierror(self): - """Testing socket.gaierror in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = socket.gaierror('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) - - def test_run_command_exception_oserror(self): - """Testing OSError in run_command.""" - self.connection._telnet = mock.Mock() - self.connection._telnet.write = mock.Mock() - self.connection._telnet.write.side_effect = OSError('except') - self.connection.run_command('test') - self.assertFalse(self.connection._connected) From d5a5695411ef65bc2c30b6e5b87f39ee5e388035 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 23 Oct 2018 02:14:46 -0700 Subject: [PATCH 29/37] Migrate Mailgun to use the webhook component (#17464) * Switch mailgun to use webhook api * Generalize webhook_config_entry_flow * Add tests for webhook_config_entry_flow * Add tests for mailgun * Remove old mailgun file from .coveragerc * Refactor WebhookFlowHandler into config_entry_flow * Remove test of helper func from IFTTT * Lint --- .coveragerc | 1 - homeassistant/components/ifttt/__init__.py | 53 ++--------- homeassistant/components/mailgun.py | 50 ----------- .../components/mailgun/.translations/en.json | 18 ++++ homeassistant/components/mailgun/__init__.py | 67 ++++++++++++++ homeassistant/components/mailgun/strings.json | 18 ++++ homeassistant/components/notify/mailgun.py | 5 +- homeassistant/config_entries.py | 1 + homeassistant/helpers/config_entry_flow.py | 58 ++++++++++++ tests/components/ifttt/test_init.py | 12 +-- tests/components/mailgun/__init__.py | 1 + tests/components/mailgun/test_init.py | 39 ++++++++ tests/helpers/test_config_entry_flow.py | 88 ++++++++++++++++--- 13 files changed, 289 insertions(+), 122 deletions(-) delete mode 100644 homeassistant/components/mailgun.py create mode 100644 homeassistant/components/mailgun/.translations/en.json create mode 100644 homeassistant/components/mailgun/__init__.py create mode 100644 homeassistant/components/mailgun/strings.json create mode 100644 tests/components/mailgun/__init__.py create mode 100644 tests/components/mailgun/test_init.py diff --git a/.coveragerc b/.coveragerc index 0049349cfff..25aa405035b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -209,7 +209,6 @@ omit = homeassistant/components/lutron_caseta.py homeassistant/components/*/lutron_caseta.py - homeassistant/components/mailgun.py homeassistant/components/*/mailgun.py homeassistant/components/matrix.py diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 76f01ad0aca..85ee6b9fa1c 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -4,18 +4,15 @@ Support to trigger Maker IFTTT recipes. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ifttt/ """ -from ipaddress import ip_address import json import logging -from urllib.parse import urlparse import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.util.network import is_local +from homeassistant.helpers import config_entry_flow REQUIREMENTS = ['pyfttt==0.3'] DEPENDENCIES = ['webhook'] @@ -100,43 +97,11 @@ async def async_unload_entry(hass, entry): hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) return True - -@config_entries.HANDLERS.register(DOMAIN) -class ConfigFlow(config_entries.ConfigFlow): - """Handle an IFTTT config flow.""" - - async def async_step_user(self, user_input=None): - """Handle a user initiated set up flow.""" - if self._async_current_entries(): - return self.async_abort(reason='one_instance_allowed') - - try: - url_parts = urlparse(self.hass.config.api.base_url) - - if is_local(ip_address(url_parts.hostname)): - return self.async_abort(reason='not_internet_accessible') - except ValueError: - # If it's not an IP address, it's very likely publicly accessible - pass - - if user_input is None: - return self.async_show_form( - step_id='user', - ) - - webhook_id = self.hass.components.webhook.async_generate_id() - webhook_url = \ - self.hass.components.webhook.async_generate_url(webhook_id) - - return self.async_create_entry( - title='IFTTT Webhook', - data={ - CONF_WEBHOOK_ID: webhook_id - }, - description_placeholders={ - 'applet_url': 'https://ifttt.com/maker_webhooks', - 'webhook_url': webhook_url, - 'docs_url': - 'https://www.home-assistant.io/components/ifttt/' - } - ) +config_entry_flow.register_webhook_flow( + DOMAIN, + 'IFTTT Webhook', + { + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'docs_url': 'https://www.home-assistant.io/components/ifttt/' + } +) diff --git a/homeassistant/components/mailgun.py b/homeassistant/components/mailgun.py deleted file mode 100644 index 7cb7ef7151d..00000000000 --- a/homeassistant/components/mailgun.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Support for Mailgun. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/mailgun/ -""" -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_API_KEY, CONF_DOMAIN -from homeassistant.core import callback -from homeassistant.components.http import HomeAssistantView - - -DOMAIN = 'mailgun' -API_PATH = '/api/{}'.format(DOMAIN) -DATA_MAILGUN = DOMAIN -DEPENDENCIES = ['http'] -MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) -CONF_SANDBOX = 'sandbox' -DEFAULT_SANDBOX = False - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_DOMAIN): cv.string, - vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean - }), -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the Mailgun component.""" - hass.data[DATA_MAILGUN] = config[DOMAIN] - hass.http.register_view(MailgunReceiveMessageView()) - return True - - -class MailgunReceiveMessageView(HomeAssistantView): - """Handle data from Mailgun inbound messages.""" - - url = API_PATH - name = 'api:{}'.format(DOMAIN) - - @callback - def post(self, request): # pylint: disable=no-self-use - """Handle Mailgun message POST.""" - hass = request.app['hass'] - data = yield from request.post() - hass.bus.async_fire(MESSAGE_RECEIVED, dict(data)) diff --git a/homeassistant/components/mailgun/.translations/en.json b/homeassistant/components/mailgun/.translations/en.json new file mode 100644 index 00000000000..0e993bef5d4 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Mailgun", + "step": { + "user": { + "title": "Set up the Mailgun Webhook", + "description": "Are you sure you want to set up Mailgun?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + } +} diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py new file mode 100644 index 00000000000..25f697084d3 --- /dev/null +++ b/homeassistant/components/mailgun/__init__.py @@ -0,0 +1,67 @@ +""" +Support for Mailgun. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mailgun/ +""" + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID +from homeassistant.helpers import config_entry_flow + +DOMAIN = 'mailgun' +API_PATH = '/api/{}'.format(DOMAIN) +DEPENDENCIES = ['webhook'] +MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) +CONF_SANDBOX = 'sandbox' +DEFAULT_SANDBOX = False + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Mailgun component.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = config[DOMAIN] + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with Mailgun inbound messages.""" + data = dict(await request.post()) + data['webhook_id'] = webhook_id + hass.bus.async_fire(MESSAGE_RECEIVED, data) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + entry.data[CONF_WEBHOOK_ID], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + return True + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Mailgun Webhook', + { + 'mailgun_url': + 'https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks', + 'docs_url': 'https://www.home-assistant.io/components/mailgun/' + } +) diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json new file mode 100644 index 00000000000..0e993bef5d4 --- /dev/null +++ b/homeassistant/components/mailgun/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Mailgun", + "step": { + "user": { + "title": "Set up the Mailgun Webhook", + "description": "Are you sure you want to set up Mailgun?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + } +} diff --git a/homeassistant/components/notify/mailgun.py b/homeassistant/components/notify/mailgun.py index 1aa403f0ba8..56b0ab7e333 100644 --- a/homeassistant/components/notify/mailgun.py +++ b/homeassistant/components/notify/mailgun.py @@ -8,7 +8,8 @@ import logging import voluptuous as vol -from homeassistant.components.mailgun import CONF_SANDBOX, DATA_MAILGUN +from homeassistant.components.mailgun import ( + CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA) @@ -35,7 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the Mailgun notification service.""" - data = hass.data[DATA_MAILGUN] + data = hass.data[MAILGUN_DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), data.get(CONF_API_KEY), config.get(CONF_SENDER), diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c1c0fbbf775..e00215b8126 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -143,6 +143,7 @@ FLOWS = [ 'ifttt', 'ios', 'lifx', + 'mailgun', 'mqtt', 'nest', 'openuv', diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 569a101b3dd..31d9907d315 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,7 +1,10 @@ """Helpers for data entry flows for config entries.""" from functools import partial +from ipaddress import ip_address +from urllib.parse import urlparse from homeassistant import config_entries +from homeassistant.util.network import is_local def register_discovery_flow(domain, title, discovery_function, @@ -12,6 +15,14 @@ def register_discovery_flow(domain, title, discovery_function, connection_class)) +def register_webhook_flow(domain, title, description_placeholder, + allow_multiple=False): + """Register flow for webhook integrations.""" + config_entries.HANDLERS.register(domain)( + partial(WebhookFlowHandler, domain, title, description_placeholder, + allow_multiple)) + + class DiscoveryFlowHandler(config_entries.ConfigFlow): """Handle a discovery config flow.""" @@ -84,3 +95,50 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): title=self._title, data={}, ) + + +class WebhookFlowHandler(config_entries.ConfigFlow): + """Handle a webhook config flow.""" + + VERSION = 1 + + def __init__(self, domain, title, description_placeholder, + allow_multiple): + """Initialize the discovery config flow.""" + self._domain = domain + self._title = title + self._description_placeholder = description_placeholder + self._allow_multiple = allow_multiple + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create a webhook.""" + if not self._allow_multiple and self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + try: + url_parts = urlparse(self.hass.config.api.base_url) + + if is_local(ip_address(url_parts.hostname)): + return self.async_abort(reason='not_internet_accessible') + except ValueError: + # If it's not an IP address, it's very likely publicly accessible + pass + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + self._description_placeholder['webhook_url'] = webhook_url + + return self.async_create_entry( + title=self._title, + data={ + 'webhook_id': webhook_id + }, + description_placeholders=self._description_placeholder + ) diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 61d6654ba55..21417c99c5b 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -1,5 +1,5 @@ """Test the init file of IFTTT.""" -from unittest.mock import Mock, patch +from unittest.mock import patch from homeassistant import data_entry_flow from homeassistant.core import callback @@ -36,13 +36,3 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): assert len(ifttt_events) == 1 assert ifttt_events[0].data['webhook_id'] == webhook_id assert ifttt_events[0].data['hello'] == 'ifttt' - - -async def test_config_flow_aborts_external_url(hass, aiohttp_client): - """Test setting up IFTTT and sending webhook.""" - hass.config.api = Mock(base_url='http://192.168.1.10') - result = await hass.config_entries.flow.async_init('ifttt', context={ - 'source': 'user' - }) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'not_internet_accessible' diff --git a/tests/components/mailgun/__init__.py b/tests/components/mailgun/__init__.py new file mode 100644 index 00000000000..3999bce717c --- /dev/null +++ b/tests/components/mailgun/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mailgun component.""" diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py new file mode 100644 index 00000000000..312e3e22bfd --- /dev/null +++ b/tests/components/mailgun/test_init.py @@ -0,0 +1,39 @@ +"""Test the init file of Mailgun.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components import mailgun + +from homeassistant.core import callback + + +async def test_config_flow_registers_webhook(hass, aiohttp_client): + """Test setting up Mailgun and sending webhook.""" + with patch('homeassistant.util.get_local_ip', return_value='example.com'): + result = await hass.config_entries.flow.async_init('mailgun', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + webhook_id = result['result'].data['webhook_id'] + + mailgun_events = [] + + @callback + def handle_event(event): + """Handle Mailgun event.""" + mailgun_events.append(event) + + hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), data={ + 'hello': 'mailgun' + }) + + assert len(mailgun_events) == 1 + assert mailgun_events[0].data['webhook_id'] == webhook_id + assert mailgun_events[0].data['hello'] == 'mailgun' diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 9d858e31a06..8e38f76f1c0 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,5 +1,5 @@ """Tests for the Config Entry Flow helper.""" -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest @@ -9,7 +9,7 @@ from tests.common import MockConfigEntry, MockModule @pytest.fixture -def flow_conf(hass): +def discovery_flow_conf(hass): """Register a handler.""" handler_conf = { 'discovered': False, @@ -26,7 +26,18 @@ def flow_conf(hass): yield handler_conf -async def test_single_entry_allowed(hass, flow_conf): +@pytest.fixture +def webhook_flow_conf(hass): + """Register a handler.""" + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_webhook_flow( + 'test_single', 'Test Single', {}, False) + config_entry_flow.register_webhook_flow( + 'test_multiple', 'Test Multiple', {}, True) + yield {} + + +async def test_single_entry_allowed(hass, discovery_flow_conf): """Test only a single entry is allowed.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -38,7 +49,7 @@ async def test_single_entry_allowed(hass, flow_conf): assert result['reason'] == 'single_instance_allowed' -async def test_user_no_devices_found(hass, flow_conf): +async def test_user_no_devices_found(hass, discovery_flow_conf): """Test if no devices found.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -51,18 +62,18 @@ async def test_user_no_devices_found(hass, flow_conf): assert result['reason'] == 'no_devices_found' -async def test_user_has_confirmation(hass, flow_conf): +async def test_user_has_confirmation(hass, discovery_flow_conf): """Test user requires no confirmation to setup.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True result = await flow.async_step_user() assert result['type'] == data_entry_flow.RESULT_TYPE_FORM -async def test_discovery_single_instance(hass, flow_conf): +async def test_discovery_single_instance(hass, discovery_flow_conf): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -74,7 +85,7 @@ async def test_discovery_single_instance(hass, flow_conf): assert result['reason'] == 'single_instance_allowed' -async def test_discovery_confirmation(hass, flow_conf): +async def test_discovery_confirmation(hass, discovery_flow_conf): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass @@ -88,7 +99,7 @@ async def test_discovery_confirmation(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_multiple_discoveries(hass, flow_conf): +async def test_multiple_discoveries(hass, discovery_flow_conf): """Test we only create one instance for multiple discoveries.""" loader.set_component(hass, 'test', MockModule('test')) @@ -102,7 +113,7 @@ async def test_multiple_discoveries(hass, flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT -async def test_only_one_in_progress(hass, flow_conf): +async def test_only_one_in_progress(hass, discovery_flow_conf): """Test a user initialized one will finish and cancel discovered one.""" loader.set_component(hass, 'test', MockModule('test')) @@ -127,22 +138,71 @@ async def test_only_one_in_progress(hass, flow_conf): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_no_confirmation(hass, flow_conf): +async def test_import_no_confirmation(hass, discovery_flow_conf): """Test import requires no confirmation to set up.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True result = await flow.async_step_import(None) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_import_single_instance(hass, flow_conf): +async def test_import_single_instance(hass, discovery_flow_conf): """Test import doesn't create second instance.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - flow_conf['discovered'] = True + discovery_flow_conf['discovered'] = True MockConfigEntry(domain='test').add_to_hass(hass) result = await flow.async_step_import(None) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_webhook_single_entry_allowed(hass, webhook_flow_conf): + """Test only a single entry is allowed.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + MockConfigEntry(domain='test_single').add_to_hass(hass) + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'one_instance_allowed' + + +async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf): + """Test multiple entries are allowed when specified.""" + flow = config_entries.HANDLERS['test_multiple']() + flow.hass = hass + + MockConfigEntry(domain='test_multiple').add_to_hass(hass) + hass.config.api = Mock(base_url='http://example.com') + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_webhook_config_flow_aborts_external_url(hass, + webhook_flow_conf): + """Test configuring a webhook without an external url.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + hass.config.api = Mock(base_url='http://192.168.1.10') + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'not_internet_accessible' + + +async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf): + """Test setting up an entry creates a webhook.""" + flow = config_entries.HANDLERS['test_single']() + flow.hass = hass + + hass.config.api = Mock(base_url='http://example.com') + result = await flow.async_step_user(user_input={}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['webhook_id'] is not None From 7def587c9305646f46ca5f8474bb6d46c7aebfb4 Mon Sep 17 00:00:00 2001 From: Dougal Matthews Date: Tue, 23 Oct 2018 11:33:56 +0100 Subject: [PATCH 30/37] Only strip from the bluetooth name if it isn't None (#17719) This prevents the following traceback that will otherwise occur. Traceback (most recent call last): File "/usr/local/lib/python3.6/concurrent/futures/thread.py", line 56, in run result = self.fn(*self.args, **self.kwargs) File "/usr/local/lib/python3.6/site-packages/homeassistant/components/device_tracker/bluetooth_le_tracker.py", line 107, in update_ble see_device(address, devs[address], new_device=True) File "/usr/local/lib/python3.6/site-packages/homeassistant/components/device_tracker/bluetooth_le_tracker.py", line 47, in see_device see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"), AttributeError: 'NoneType' object has no attribute 'strip' --- .../components/device_tracker/bluetooth_le_tracker.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 47b86ab9ab2..a07fdfdcf81 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -44,7 +44,10 @@ def setup_scanner(hass, config, see, discovery_info=None): new_devices[address] = 1 return - see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"), + if name is not None: + name = name.strip("\x00") + + see(mac=BLE_PREFIX + address, host_name=name, source_type=SOURCE_TYPE_BLUETOOTH_LE) def discover_ble_devices(): From 0723c7f5dc8479dfb7e6d4a59d2bb2a7f9cccc52 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 23 Oct 2018 13:03:01 +0200 Subject: [PATCH 31/37] Just use debug instead of error if the binary_sensor does not get data (#17720) --- 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 a45d9ceb0d6..52cf0ba75d5 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -210,7 +210,7 @@ class OpenUV: if data.get('from_time') and data.get('to_time'): self.data[DATA_PROTECTION_WINDOW] = data else: - _LOGGER.error( + _LOGGER.debug( 'No valid protection window data for this location') self.data[DATA_PROTECTION_WINDOW] = {} From cf0bd6470aa30cdf94b9d6b2f9aa01ea954157be Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 Oct 2018 14:03:38 +0200 Subject: [PATCH 32/37] Update frontend to 20181023.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 36bb3507dda..55aa0700bef 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181021.0'] +REQUIREMENTS = ['home-assistant-frontend==20181023.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index ffd2be0b3bc..26505b2aad2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -469,7 +469,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181021.0 +home-assistant-frontend==20181023.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe02cb0fab1..d19fd5afa87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.6.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181021.0 +home-assistant-frontend==20181023.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 398ea40189c5b685efc7f73b2cf136990fa8b776 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 Oct 2018 14:04:25 +0200 Subject: [PATCH 33/37] Update translations --- .../components/ifttt/.translations/tr.json | 5 +++ .../components/mailgun/.translations/en.json | 32 +++++++++---------- .../components/mailgun/.translations/lb.json | 18 +++++++++++ .../components/mqtt/.translations/tr.json | 11 +++++++ .../simplisafe/.translations/nl.json | 19 +++++++++++ .../simplisafe/.translations/tr.json | 12 +++++++ .../components/smhi/.translations/tr.json | 16 ++++++++++ .../components/unifi/.translations/nl.json | 23 +++++++++++++ .../components/unifi/.translations/tr.json | 12 +++++++ .../components/upnp/.translations/tr.json | 11 +++++++ .../components/zwave/.translations/tr.json | 11 +++++++ 11 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/ifttt/.translations/tr.json create mode 100644 homeassistant/components/mailgun/.translations/lb.json create mode 100644 homeassistant/components/mqtt/.translations/tr.json create mode 100644 homeassistant/components/simplisafe/.translations/nl.json create mode 100644 homeassistant/components/simplisafe/.translations/tr.json create mode 100644 homeassistant/components/smhi/.translations/tr.json create mode 100644 homeassistant/components/unifi/.translations/nl.json create mode 100644 homeassistant/components/unifi/.translations/tr.json create mode 100644 homeassistant/components/upnp/.translations/tr.json create mode 100644 homeassistant/components/zwave/.translations/tr.json diff --git a/homeassistant/components/ifttt/.translations/tr.json b/homeassistant/components/ifttt/.translations/tr.json new file mode 100644 index 00000000000..80188b637f9 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/tr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IFTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/en.json b/homeassistant/components/mailgun/.translations/en.json index 0e993bef5d4..3abb8aba726 100644 --- a/homeassistant/components/mailgun/.translations/en.json +++ b/homeassistant/components/mailgun/.translations/en.json @@ -1,18 +1,18 @@ { - "config": { - "title": "Mailgun", - "step": { - "user": { - "title": "Set up the Mailgun Webhook", - "description": "Are you sure you want to set up Mailgun?" - } - }, - "abort": { - "one_instance_allowed": "Only a single instance is necessary.", - "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages." - }, - "create_entry": { - "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + }, + "step": { + "user": { + "description": "Are you sure you want to set up Mailgun?", + "title": "Set up the Mailgun Webhook" + } + }, + "title": "Mailgun" } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/lb.json b/homeassistant/components/mailgun/.translations/lb.json new file mode 100644 index 00000000000..f84225444d9 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Mailgun Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, mussen [Webhooks mat Mailgun]({mailgun_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) w\u00e9i een Automatiounen ariicht welch eingehend Donn\u00e9\u00eb trait\u00e9ieren." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Mailgun anzeriichten?", + "title": "Mailgun Webhook ariichten" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/tr.json b/homeassistant/components/mqtt/.translations/tr.json new file mode 100644 index 00000000000..1b73b94d5a4 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "data": { + "discovery": "Ke\u015ffetmeyi etkinle\u015ftir" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/nl.json b/homeassistant/components/simplisafe/.translations/nl.json new file mode 100644 index 00000000000..c84593c0b23 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account bestaat al", + "invalid_credentials": "Ongeldige gebruikersgegevens" + }, + "step": { + "user": { + "data": { + "code": "Code (voor Home Assistant)", + "password": "Wachtwoord", + "username": "E-mailadres" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/tr.json b/homeassistant/components/simplisafe/.translations/tr.json new file mode 100644 index 00000000000..ec84b1b7c1c --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "E-posta adresi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/tr.json b/homeassistant/components/smhi/.translations/tr.json new file mode 100644 index 00000000000..bb50f1e2a8d --- /dev/null +++ b/homeassistant/components/smhi/.translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "name_exists": "Bu ad zaten var", + "wrong_location": "Konum sadece \u0130sve\u00e7" + }, + "step": { + "user": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/nl.json b/homeassistant/components/unifi/.translations/nl.json new file mode 100644 index 00000000000..8e87dc4b2a6 --- /dev/null +++ b/homeassistant/components/unifi/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "user_privilege": "Gebruiker moet beheerder zijn" + }, + "error": { + "faulty_credentials": "Foutieve gebruikersgegevens", + "service_unavailable": "Geen service beschikbaar" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "title": "Stel de UniFi-controller in" + } + }, + "title": "UniFi-controller" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/tr.json b/homeassistant/components/unifi/.translations/tr.json new file mode 100644 index 00000000000..667a5e676fb --- /dev/null +++ b/homeassistant/components/unifi/.translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/tr.json b/homeassistant/components/upnp/.translations/tr.json new file mode 100644 index 00000000000..91503c17a07 --- /dev/null +++ b/homeassistant/components/upnp/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "enable_sensors": "Trafik sens\u00f6rleri ekleyin" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/tr.json b/homeassistant/components/zwave/.translations/tr.json new file mode 100644 index 00000000000..c9762784d52 --- /dev/null +++ b/homeassistant/components/zwave/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "network_key": "A\u011f Anajtar\u0131 (otomatik \u00fcretilmesi i\u00e7in bo\u015f b\u0131rak\u0131n\u0131z)" + } + } + } + } +} \ No newline at end of file From 37a667c2dea174a5323f941bf14c1da79c6fc98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 23 Oct 2018 14:06:42 +0200 Subject: [PATCH 34/37] clean up clicksend (#17723) --- homeassistant/components/notify/clicksend.py | 63 +++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 5506d6ed6d0..faf30ac7cc6 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -7,51 +7,41 @@ https://home-assistant.io/components/notify.clicksend/ import json import logging -from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol +from aiohttp.hdrs import CONTENT_TYPE +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import ( CONF_API_KEY, CONF_RECIPIENT, CONF_SENDER, CONF_USERNAME, CONTENT_TYPE_JSON) -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' - DEFAULT_SENDER = 'hass' +TIMEOUT = 5 HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} -def validate_sender(config): - """Set the optional sender name if sender name is not provided.""" - if CONF_SENDER in config: - return config - config[CONF_SENDER] = DEFAULT_SENDER - return config - - PLATFORM_SCHEMA = vol.Schema( vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_RECIPIENT, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_SENDER): cv.string, - }), validate_sender)) + vol.Optional(CONF_SENDER, default=DEFAULT_SENDER): cv.string, + }),)) def get_service(hass, config, discovery_info=None): """Get the ClickSend notification service.""" - print("#### ", config) - if _authenticate(config) is False: - _LOGGER.exception("You are not authorized to access ClickSend") + if not _authenticate(config): + _LOGGER.error("You are not authorized to access ClickSend") return None - return ClicksendNotificationService(config) @@ -60,10 +50,10 @@ class ClicksendNotificationService(BaseNotificationService): def __init__(self, config): """Initialize the service.""" - self.username = config.get(CONF_USERNAME) - self.api_key = config.get(CONF_API_KEY) - self.recipients = config.get(CONF_RECIPIENT) - self.sender = config.get(CONF_SENDER) + self.username = config[CONF_USERNAME] + self.api_key = config[CONF_API_KEY] + self.recipients = config[CONF_RECIPIENT] + self.sender = config[CONF_SENDER] def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -77,28 +67,29 @@ class ClicksendNotificationService(BaseNotificationService): }) api_url = "{}/sms/send".format(BASE_API_URL) - - resp = requests.post( - api_url, data=json.dumps(data), headers=HEADERS, - auth=(self.username, self.api_key), timeout=5) + resp = requests.post(api_url, + data=json.dumps(data), + headers=HEADERS, + auth=(self.username, self.api_key), + timeout=TIMEOUT) + if resp.status_code == 200: + return obj = json.loads(resp.text) - response_msg = obj['response_msg'] - response_code = obj['response_code'] - - if resp.status_code != 200: - _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, - response_msg, response_code) + response_msg = obj.get('response_msg') + response_code = obj.get('response_code') + _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, + response_msg, response_code) def _authenticate(config): """Authenticate with ClickSend.""" api_url = '{}/account'.format(BASE_API_URL) - resp = requests.get( - api_url, headers=HEADERS, auth=(config.get(CONF_USERNAME), - config.get(CONF_API_KEY)), timeout=5) - + resp = requests.get(api_url, + headers=HEADERS, + auth=(config[CONF_USERNAME], + config[CONF_API_KEY]), + timeout=TIMEOUT) if resp.status_code != 200: return False - return True From 4a757b79945584e38983ba7822062ddbd36c82e1 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 23 Oct 2018 06:09:08 -0600 Subject: [PATCH 35/37] Set available property (#17706) Will set the available property to False if unable to communicate with August lock or doorbell. HTTP request errors (i.e. timeout, connection error, HTTP error) will not result in traceback. Instead an error will be logged. --- homeassistant/components/august.py | 51 ++++++++++++++++--- .../components/binary_sensor/august.py | 17 +++++-- homeassistant/components/lock/august.py | 8 +++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index 850d972c373..ce8e3d8de11 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -225,8 +225,17 @@ class AugustData: for doorbell in self._doorbells: _LOGGER.debug("Updating status for %s", doorbell.device_name) - detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( - self._access_token, doorbell.device_id) + try: + detail_by_id[doorbell.device_id] =\ + self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve doorbell" + " status for %s. %s", doorbell.device_name, ex) + detail_by_id[doorbell.device_id] = None + except Exception: + detail_by_id[doorbell.device_id] = None + raise _LOGGER.debug("Completed retrieving doorbell details") self._doorbell_detail_by_id = detail_by_id @@ -260,8 +269,17 @@ class AugustData: for lock in self._locks: _LOGGER.debug("Updating status for %s", lock.device_name) - state_by_id[lock.device_id] = self._api.get_lock_door_status( - self._access_token, lock.device_id) + + try: + state_by_id[lock.device_id] = self._api.get_lock_door_status( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " status for %s. %s", lock.device_name, ex) + state_by_id[lock.device_id] = None + except Exception: + state_by_id[lock.device_id] = None + raise _LOGGER.debug("Completed retrieving door status") self._door_state_by_id = state_by_id @@ -275,10 +293,27 @@ class AugustData: for lock in self._locks: _LOGGER.debug("Updating status for %s", lock.device_name) - status_by_id[lock.device_id] = self._api.get_lock_status( - self._access_token, lock.device_id) - detail_by_id[lock.device_id] = self._api.get_lock_detail( - self._access_token, lock.device_id) + try: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " status for %s. %s", lock.device_name, ex) + status_by_id[lock.device_id] = None + except Exception: + status_by_id[lock.device_id] = None + raise + + try: + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + except RequestException as ex: + _LOGGER.error("Request error trying to retrieve door" + " details for %s. %s", lock.device_name, ex) + detail_by_id[lock.device_id] = None + except Exception: + detail_by_id[lock.device_id] = None + raise _LOGGER.debug("Completed retrieving locks status") self._lock_status_by_id = status_by_id diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py index 55b31a6da5f..4116a791b01 100644 --- a/homeassistant/components/binary_sensor/august.py +++ b/homeassistant/components/binary_sensor/august.py @@ -19,14 +19,15 @@ SCAN_INTERVAL = timedelta(seconds=5) def _retrieve_door_state(data, lock): """Get the latest state of the DoorSense sensor.""" - from august.lock import LockDoorStatus - doorstate = data.get_door_state(lock.device_id) - return doorstate == LockDoorStatus.OPEN + return data.get_door_state(lock.device_id) def _retrieve_online_state(data, doorbell): """Get the latest state of the sensor.""" detail = data.get_doorbell_detail(doorbell.device_id) + if detail is None: + return None + return detail.is_online @@ -138,9 +139,10 @@ class AugustDoorBinarySensor(BinarySensorDevice): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] self._state = state_provider(self._data, self._door) + self._available = self._state is not None from august.lock import LockDoorStatus - self._available = self._state != LockDoorStatus.UNKNOWN + self._state = self._state == LockDoorStatus.OPEN class AugustDoorbellBinarySensor(BinarySensorDevice): @@ -152,6 +154,12 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._sensor_type = sensor_type self._doorbell = doorbell self._state = None + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available @property def is_on(self): @@ -173,3 +181,4 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] self._state = state_provider(self._data, self._doorbell) + self._available = self._state is not None diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py index e8949255ee9..ce6792ceb39 100644 --- a/homeassistant/components/lock/august.py +++ b/homeassistant/components/lock/august.py @@ -40,6 +40,7 @@ class AugustLock(LockDevice): self._lock_status = None self._lock_detail = None self._changed_by = None + self._available = False def lock(self, **kwargs): """Lock the device.""" @@ -52,6 +53,8 @@ class AugustLock(LockDevice): def update(self): """Get the latest state of the sensor.""" self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._available = self._lock_status is not None + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) from august.activity import ActivityType @@ -67,6 +70,11 @@ class AugustLock(LockDevice): """Return the name of this device.""" return self._lock.device_name + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + @property def is_locked(self): """Return true if device is on.""" From f6f549dc3cce6ae7882fde523ddedd68d34d6413 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 23 Oct 2018 14:15:56 +0200 Subject: [PATCH 36/37] Removes re-init (#17724) --- homeassistant/components/device_tracker/asuswrt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 461380b2c8e..2ac3aaee933 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -67,8 +67,6 @@ class AsusWrtDeviceScanner(DeviceScanner): async def async_connect(self): """Initialize connection to the router.""" - self.last_results = {} - # Test the router is accessible. data = await self.connection.async_get_connected_devices() self.success_init = data is not None From 9798ff019f5f8ef4a61fd6eaff086d2d8f4d9a9e Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Tue, 23 Oct 2018 19:21:03 +0700 Subject: [PATCH 37/37] Don't call off_delay_listener if not needed (#17712) Don't call off_delay_listener if 'OFF' is actually received Moved `off_delay_listener` to be defined once --- .../components/binary_sensor/mqtt.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index beaeb9ce21b..db9ad585999 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -131,7 +131,14 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, await MqttDiscoveryUpdate.async_added_to_hass(self) @callback - def state_message_received(topic, payload, qos): + def off_delay_listener(now): + """Switch device off after a delay.""" + self._delay_listener = None + self._state = False + self.async_schedule_update_ha_state() + + @callback + def state_message_received(_topic, payload, _qos): """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( @@ -146,17 +153,10 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, self._name, self._state_topic) return + if self._delay_listener is not None: + self._delay_listener() + if (self._state and self._off_delay is not None): - @callback - def off_delay_listener(now): - """Switch device off after a delay.""" - self._delay_listener = None - self._state = False - self.async_schedule_update_ha_state() - - if self._delay_listener is not None: - self._delay_listener() - self._delay_listener = evt.async_call_later( self.hass, self._off_delay, off_delay_listener)