From 4f8fec6494b707061acd5a11a4ad40c35ab7433e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 24 Aug 2018 17:03:05 +0200 Subject: [PATCH 01/42] Bumped version to 0.77.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d72bde548d3..b11354e0064 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 77 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From bb6567f84c7bf75f09bcc8ebaa02078a2309fc96 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Aug 2018 11:15:01 +0200 Subject: [PATCH 02/42] Bump frontend to 20180825.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 bfcf7322749..c475ea55974 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180824.0'] +REQUIREMENTS = ['home-assistant-frontend==20180825.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 25480a023ec..4d0e02ac0c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -438,7 +438,7 @@ hole==0.3.0 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180824.0 +home-assistant-frontend==20180825.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71cbc724c59..8b6fc0fcd64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.2 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180824.0 +home-assistant-frontend==20180825.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From e567b2281d83909d5a093b84cf3731f56fd9b933 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 24 Aug 2018 19:37:22 +0200 Subject: [PATCH 03/42] deCONZ - Support device registry (#16115) Add support for device registry in deCONZ component --- .../components/binary_sensor/deconz.py | 19 ++++++- homeassistant/components/deconz/__init__.py | 11 +++- homeassistant/components/deconz/const.py | 1 + homeassistant/components/light/deconz.py | 19 ++++++- homeassistant/components/sensor/deconz.py | 35 ++++++++++++- homeassistant/components/switch/deconz.py | 19 ++++++- homeassistant/helpers/device_registry.py | 13 +++-- homeassistant/helpers/entity_platform.py | 7 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_init.py | 50 +++++++++++++------ tests/helpers/test_device_registry.py | 20 +++++--- 12 files changed, 162 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index d3d27c05333..9aa0c446f2b 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -7,9 +7,10 @@ https://home-assistant.io/components/binary_sensor.deconz/ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.deconz.const import ( ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB) + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['deconz'] @@ -113,3 +114,19 @@ class DeconzBinarySensor(BinarySensorDevice): if self._sensor.type in PRESENCE and self._sensor.dark is not None: attr[ATTR_DARK] = self._sensor.dark return attr + + @property + def device(self): + """Return a device description for device registry.""" + if (self._sensor.uniqueid is None or + self._sensor.uniqueid.count(':') != 7): + return None + serial = self._sensor.uniqueid.split('-', 1)[0] + return { + 'connection': [[CONNECTION_ZIGBEE, serial]], + 'identifiers': [[DECONZ_DOMAIN, serial]], + 'manufacturer': self._sensor.manufacturer, + 'model': self._sensor.modelid, + 'name': self._sensor.name, + 'sw_version': self._sensor.swversion, + } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index cf8d891661e..d435e9e3c04 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_ID, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import EventOrigin, callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from homeassistant.util import slugify @@ -23,7 +24,7 @@ from .const import ( CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -REQUIREMENTS = ['pydeconz==43'] +REQUIREMENTS = ['pydeconz==44'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -119,6 +120,14 @@ async def async_setup_entry(hass, config_entry): deconz.start() + device_registry = await \ + hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + connection=[[CONNECTION_NETWORK_MAC, deconz.config.mac]], + identifiers=[[DOMAIN, deconz.config.bridgeid]], + manufacturer='Dresden Elektronik', model=deconz.config.modelid, + name=deconz.config.name, sw_version=deconz.config.swversion) + async def async_configure(call): """Set attribute of device in deCONZ. diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e7bc5605aee..e629d57f201 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -8,6 +8,7 @@ CONFIG_FILE = 'deconz.conf' DATA_DECONZ_EVENT = 'deconz_events' DATA_DECONZ_ID = 'deconz_entities' DATA_DECONZ_UNSUB = 'deconz_dispatchers' +DECONZ_DOMAIN = 'deconz' CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 6dce6b7fdb8..067f1474f96 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -6,13 +6,14 @@ https://home-assistant.io/components/light.deconz/ """ from homeassistant.components.deconz.const import ( CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB, SWITCH_TYPES) + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, Light) from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util @@ -199,3 +200,19 @@ class DeconzLight(Light): if self._light.type == 'LightGroup': attributes['all_on'] = self._light.all_on return attributes + + @property + def device(self): + """Return a device description for device registry.""" + if (self._light.uniqueid is None or + self._light.uniqueid.count(':') != 7): + return None + serial = self._light.uniqueid.split('-', 1)[0] + return { + 'connection': [[CONNECTION_ZIGBEE, serial]], + 'identifiers': [[DECONZ_DOMAIN, serial]], + 'manufacturer': self._light.manufacturer, + 'model': self._light.modelid, + 'name': self._light.name, + 'sw_version': self._light.swversion, + } diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index a32f1e5e210..45c604a74ee 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -6,10 +6,11 @@ https://home-assistant.io/components/sensor.deconz/ """ from homeassistant.components.deconz.const import ( ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB) + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import slugify @@ -134,6 +135,22 @@ class DeconzSensor(Entity): attr[ATTR_DAYLIGHT] = self._sensor.daylight return attr + @property + def device(self): + """Return a device description for device registry.""" + if (self._sensor.uniqueid is None or + self._sensor.uniqueid.count(':') != 7): + return None + serial = self._sensor.uniqueid.split('-', 1)[0] + return { + 'connection': [[CONNECTION_ZIGBEE, serial]], + 'identifiers': [[DECONZ_DOMAIN, serial]], + 'manufacturer': self._sensor.manufacturer, + 'model': self._sensor.modelid, + 'name': self._sensor.name, + 'sw_version': self._sensor.swversion, + } + class DeconzBattery(Entity): """Battery class for when a device is only represented as an event.""" @@ -192,3 +209,19 @@ class DeconzBattery(Entity): ATTR_EVENT_ID: slugify(self._device.name), } return attr + + @property + def device(self): + """Return a device description for device registry.""" + if (self._device.uniqueid is None or + self._device.uniqueid.count(':') != 7): + return None + serial = self._device.uniqueid.split('-', 1)[0] + return { + 'connection': [[CONNECTION_ZIGBEE, serial]], + 'identifiers': [[DECONZ_DOMAIN, serial]], + 'manufacturer': self._device.manufacturer, + 'model': self._device.modelid, + 'name': self._device.name, + 'sw_version': self._device.swversion, + } diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py index 11f7f42c6c9..7d861e4c29c 100644 --- a/homeassistant/components/switch/deconz.py +++ b/homeassistant/components/switch/deconz.py @@ -6,9 +6,10 @@ https://home-assistant.io/components/switch.deconz/ """ from homeassistant.components.deconz.const import ( DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, - POWER_PLUGS, SIRENS) + DECONZ_DOMAIN, POWER_PLUGS, SIRENS) from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['deconz'] @@ -79,6 +80,22 @@ class DeconzSwitch(SwitchDevice): """No polling needed.""" return False + @property + def device(self): + """Return a device description for device registry.""" + if (self._switch.uniqueid is None or + self._switch.uniqueid.count(':') != 7): + return None + serial = self._switch.uniqueid.split('-', 1)[0] + return { + 'connection': [[CONNECTION_ZIGBEE, serial]], + 'identifiers': [[DECONZ_DOMAIN, serial]], + 'manufacturer': self._switch.manufacturer, + 'model': self._switch.modelid, + 'name': self._switch.name, + 'sw_version': self._switch.swversion, + } + class DeconzPowerPlug(DeconzSwitch): """Representation of power plugs from deCONZ.""" diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 3276763a967..19a6eaa62dc 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -15,15 +15,18 @@ STORAGE_KEY = 'core.device_registry' STORAGE_VERSION = 1 SAVE_DELAY = 10 +CONNECTION_NETWORK_MAC = 'mac' +CONNECTION_ZIGBEE = 'zigbee' + @attr.s(slots=True, frozen=True) class DeviceEntry: """Device Registry Entry.""" + connection = attr.ib(type=list) identifiers = attr.ib(type=list) manufacturer = attr.ib(type=str) model = attr.ib(type=str) - connection = attr.ib(type=list) name = attr.ib(type=str, default=None) sw_version = attr.ib(type=str, default=None) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) @@ -48,8 +51,8 @@ class DeviceRegistry: return None @callback - def async_get_or_create(self, identifiers, manufacturer, model, - connection, *, name=None, sw_version=None): + def async_get_or_create(self, *, connection, identifiers, manufacturer, + model, name=None, sw_version=None): """Get device. Create if it doesn't exist.""" device = self.async_get_device(identifiers, connection) @@ -57,10 +60,10 @@ class DeviceRegistry: return device device = DeviceEntry( + connection=connection, identifiers=identifiers, manufacturer=manufacturer, model=model, - connection=connection, name=name, sw_version=sw_version ) @@ -93,10 +96,10 @@ class DeviceRegistry: data['devices'] = [ { 'id': entry.id, + 'connection': entry.connection, 'identifiers': entry.identifiers, 'manufacturer': entry.manufacturer, 'model': entry.model, - 'connection': entry.connection, 'name': entry.name, 'sw_version': entry.sw_version, } for entry in self.devices diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c65aa5e98c2..ffac68c5f07 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -275,8 +275,11 @@ class EntityPlatform: device = entity.device if device is not None: device = device_registry.async_get_or_create( - device['identifiers'], device['manufacturer'], - device['model'], device['connection'], + connection=device['connection'], + identifiers=device['identifiers'], + manufacturer=device['manufacturer'], + model=device['model'], + name=device.get('name'), sw_version=device.get('sw_version')) device_id = device.id else: diff --git a/requirements_all.txt b/requirements_all.txt index 4d0e02ac0c2..a5cfe9e402a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -810,7 +810,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==43 +pydeconz==44 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b6fc0fcd64..7b9dc1d1eb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -139,7 +139,7 @@ py-canary==0.5.0 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==43 +pydeconz==44 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index c6fc130a4a4..049a3b961b6 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -7,6 +7,16 @@ from homeassistant.components import deconz from tests.common import mock_coro +CONFIG = { + "config": { + "bridgeid": "0123456789ABCDEF", + "mac": "12:34:56:78:90:ab", + "modelid": "deCONZ", + "name": "Phoscon", + "swversion": "2.05.35" + } +} + async def test_config_with_host_passed_to_config_entry(hass): """Test that configured options for a host are loaded via config entry.""" @@ -93,8 +103,11 @@ async def test_setup_entry_successful(hass): entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} with patch.object(hass, 'async_create_task') as mock_add_job, \ patch.object(hass, 'config_entries') as mock_config_entries, \ - patch('pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(True)): + patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(CONFIG)), \ + patch('pydeconz.DeconzSession.start', return_value=True), \ + patch('homeassistant.helpers.device_registry.async_get_registry', + return_value=mock_coro(Mock())): assert await deconz.async_setup_entry(hass, entry) is True assert hass.data[deconz.DOMAIN] assert hass.data[deconz.DATA_DECONZ_ID] == {} @@ -117,10 +130,15 @@ async def test_unload_entry(hass): """Test being able to unload an entry.""" entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} - with patch('pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(True)): + entry.async_unload.return_value = mock_coro(True) + deconzmock = Mock() + deconzmock.async_load_parameters.return_value = mock_coro(True) + deconzmock.sensors = {} + with patch('pydeconz.DeconzSession', return_value=deconzmock): assert await deconz.async_setup_entry(hass, entry) is True + assert deconz.DATA_DECONZ_EVENT in hass.data + hass.data[deconz.DATA_DECONZ_EVENT].append(Mock()) hass.data[deconz.DATA_DECONZ_ID] = {'id': 'deconzid'} assert await deconz.async_unload_entry(hass, entry) @@ -132,6 +150,9 @@ async def test_unload_entry(hass): async def test_add_new_device(hass): """Test adding a new device generates a signal for platforms.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, + 'api_key': '1234567890ABCDEF', 'allow_clip_sensor': False} new_event = { "t": "event", "e": "added", @@ -147,11 +168,10 @@ async def test_add_new_device(hass): "type": "ZHASwitch" } } - entry = Mock() - entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} with patch.object(deconz, 'async_dispatcher_send') as mock_dispatch_send, \ - patch('pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(True)): + patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(CONFIG)), \ + patch('pydeconz.DeconzSession.start', return_value=True): assert await deconz.async_setup_entry(hass, entry) is True hass.data[deconz.DOMAIN].async_event_handler(new_event) await hass.async_block_till_done() @@ -162,15 +182,16 @@ async def test_add_new_device(hass): async def test_add_new_remote(hass): """Test new added device creates a new remote.""" entry = Mock() - entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + entry.data = {'host': '1.2.3.4', 'port': 80, + 'api_key': '1234567890ABCDEF', 'allow_clip_sensor': False} remote = Mock() remote.name = 'name' remote.type = 'ZHASwitch' remote.register_async_callback = Mock() - with patch('pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(True)): + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(CONFIG)), \ + patch('pydeconz.DeconzSession.start', return_value=True): assert await deconz.async_setup_entry(hass, entry) is True - async_dispatcher_send(hass, 'deconz_new_sensor', [remote]) await hass.async_block_till_done() assert len(hass.data[deconz.DATA_DECONZ_EVENT]) == 1 @@ -185,8 +206,9 @@ async def test_do_not_allow_clip_sensor(hass): remote.name = 'name' remote.type = 'CLIPSwitch' remote.register_async_callback = Mock() - with patch('pydeconz.DeconzSession.async_load_parameters', - return_value=mock_coro(True)): + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(CONFIG)), \ + patch('pydeconz.DeconzSession.start', return_value=True): assert await deconz.async_setup_entry(hass, entry) is True async_dispatcher_send(hass, 'deconz_new_sensor', [remote]) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 41e7d39e977..f7792eb5250 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -26,14 +26,17 @@ def registry(hass): async def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create( - [['bridgeid', '0123']], 'manufacturer', 'model', - [['ethernet', '12:34:56:78:90:AB:CD:EF']]) + connection=[['ethernet', '12:34:56:78:90:AB:CD:EF']], + identifiers=[['bridgeid', '0123']], + manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( - [['bridgeid', '0123']], 'manufacturer', 'model', - [['ethernet', '11:22:33:44:55:66:77:88']]) + connection=[['ethernet', '11:22:33:44:55:66:77:88']], + identifiers=[['bridgeid', '0123']], + manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( - [['bridgeid', '1234']], 'manufacturer', 'model', - [['ethernet', '12:34:56:78:90:AB:CD:EF']]) + connection=[['ethernet', '12:34:56:78:90:AB:CD:EF']], + identifiers=[['bridgeid', '1234']], + manufacturer='manufacturer', model='model') assert len(registry.devices) == 1 assert entry is entry2 @@ -73,6 +76,7 @@ async def test_loading_from_storage(hass, hass_storage): registry = await device_registry.async_get_registry(hass) entry = registry.async_get_or_create( - [['serial', '12:34:56:78:90:AB:CD:EF']], 'manufacturer', - 'model', [['Zigbee', '01.23.45.67.89']]) + connection=[['Zigbee', '01.23.45.67.89']], + identifiers=[['serial', '12:34:56:78:90:AB:CD:EF']], + manufacturer='manufacturer', model='model') assert entry.id == 'abcdefghijklm' From 914d90a2bc8d4ad2f7f457c4976dcd9053f8f912 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 24 Aug 2018 10:17:43 -0700 Subject: [PATCH 04/42] Add multi-factor auth module setup flow (#16141) * Add mfa setup flow * Lint * Address code review comment * Fix unit test * Add assertion for WS response ordering * Missed a return * Remove setup_schema from MFA base class * Move auth.util.validate_current_user -> webscoket_api.ws_require_user --- homeassistant/auth/__init__.py | 9 -- homeassistant/auth/mfa_modules/__init__.py | 49 ++++++- .../auth/mfa_modules/insecure_example.py | 15 +- homeassistant/components/auth/__init__.py | 40 ++++-- .../components/auth/mfa_setup_flow.py | 134 ++++++++++++++++++ homeassistant/components/websocket_api.py | 58 +++++++- .../auth/mfa_modules/test_insecure_example.py | 18 +++ tests/components/auth/test_mfa_setup_flow.py | 99 +++++++++++++ 8 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/auth/mfa_setup_flow.py create mode 100644 tests/components/auth/test_mfa_setup_flow.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index b5ba869cdf1..e0b7b377b1f 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -6,8 +6,6 @@ from typing import Any, Dict, List, Optional, Tuple, cast import jwt -import voluptuous as vol - from homeassistant import data_entry_flow from homeassistant.core import callback, HomeAssistant from homeassistant.util import dt as dt_util @@ -235,13 +233,6 @@ class AuthManager: raise ValueError('Unable find multi-factor auth module: {}' .format(mfa_module_id)) - if module.setup_schema is not None: - try: - # pylint: disable=not-callable - data = module.setup_schema(data) - except vol.Invalid as err: - raise ValueError('Data does not match schema: {}'.format(err)) - await module.async_setup_user(user.id, data) async def async_disable_user_mfa(self, user: models.User, diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index d0707c4a745..cb0758e3ef8 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Optional import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant import requirements +from homeassistant import requirements, data_entry_flow from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.util.decorator import Registry @@ -64,15 +64,14 @@ class MultiFactorAuthModule: """Return a voluptuous schema to define mfa auth module's input.""" raise NotImplementedError - @property - def setup_schema(self) -> Optional[vol.Schema]: - """Return a vol schema to validate mfa auth module's setup input. + async def async_setup_flow(self, user_id: str) -> 'SetupFlow': + """Return a data entry flow handler for setup module. - Optional + Mfa module should extend SetupFlow """ - return None + raise NotImplementedError - async def async_setup_user(self, user_id: str, setup_data: Any) -> None: + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: """Set up user for mfa auth module.""" raise NotImplementedError @@ -90,6 +89,42 @@ class MultiFactorAuthModule: raise NotImplementedError +class SetupFlow(data_entry_flow.FlowHandler): + """Handler for the setup flow.""" + + def __init__(self, auth_module: MultiFactorAuthModule, + setup_schema: vol.Schema, + user_id: str) -> None: + """Initialize the setup flow.""" + self._auth_module = auth_module + self._setup_schema = setup_schema + self._user_id = user_id + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the first step of setup flow. + + Return self.async_show_form(step_id='init') if user_input == None. + Return self.async_create_entry(data={'result': result}) if finish. + """ + errors = {} # type: Dict[str, str] + + if user_input: + result = await self._auth_module.async_setup_user( + self._user_id, user_input) + return self.async_create_entry( + title=self._auth_module.name, + data={'result': result} + ) + + return self.async_show_form( + step_id='init', + data_schema=self._setup_schema, + errors=errors + ) + + async def auth_mfa_module_from_config( hass: HomeAssistant, config: Dict[str, Any]) \ -> Optional[MultiFactorAuthModule]: diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 59b3f64d2e0..9c72111ef96 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -1,13 +1,13 @@ """Example auth module.""" import logging -from typing import Any, Dict, Optional +from typing import Any, Dict import voluptuous as vol from homeassistant.core import HomeAssistant from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ - MULTI_FACTOR_AUTH_MODULE_SCHEMA + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ vol.Required('data'): [vol.Schema({ @@ -36,11 +36,18 @@ class InsecureExampleModule(MultiFactorAuthModule): return vol.Schema({'pin': str}) @property - def setup_schema(self) -> Optional[vol.Schema]: + def setup_schema(self) -> vol.Schema: """Validate async_setup_user input data.""" return vol.Schema({'pin': str}) - async def async_setup_user(self, user_id: str, setup_data: Any) -> None: + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + return SetupFlow(self, self.setup_schema, user_id) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: """Set up user to use mfa module.""" # data shall has been validate in caller pin = setup_data['pin'] diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 4251b23e514..a87e646761c 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -68,10 +68,12 @@ from homeassistant.components import websocket_api from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.util import dt as dt_util + from . import indieauth from . import login_flow +from . import mfa_setup_flow DOMAIN = 'auth' DEPENDENCIES = ['http'] @@ -100,6 +102,7 @@ async def async_setup(hass, config): ) await login_flow.async_setup(hass, store_result) + await mfa_setup_flow.async_setup(hass) return True @@ -315,21 +318,28 @@ def _create_auth_code_store(): return store_result, retrieve_result +@websocket_api.ws_require_user() @callback -def websocket_current_user(hass, connection, msg): +def websocket_current_user( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): """Return the current user.""" - user = connection.request.get('hass_user') + async def async_get_current_user(user): + """Get current user.""" + enabled_modules = await hass.auth.async_get_enabled_mfa(user) - if user is None: - connection.to_write.put_nowait(websocket_api.error_message( - msg['id'], 'no_user', 'Not authenticated as a user')) - return + connection.send_message_outside( + websocket_api.result_message(msg['id'], { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'credentials': [{'auth_provider_type': c.auth_provider_type, + 'auth_provider_id': c.auth_provider_id} + for c in user.credentials], + 'mfa_modules': [{ + 'id': module.id, + 'name': module.name, + 'enabled': module.id in enabled_modules, + } for module in hass.auth.auth_mfa_modules], + })) - connection.to_write.put_nowait(websocket_api.result_message(msg['id'], { - 'id': user.id, - 'name': user.name, - 'is_owner': user.is_owner, - 'credentials': [{'auth_provider_type': c.auth_provider_type, - 'auth_provider_id': c.auth_provider_id} - for c in user.credentials] - })) + hass.async_create_task(async_get_current_user(connection.user)) diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py new file mode 100644 index 00000000000..82eb913d890 --- /dev/null +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -0,0 +1,134 @@ +"""Helpers to setup multi-factor auth module.""" +import logging + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components import websocket_api +from homeassistant.core import callback, HomeAssistant + +WS_TYPE_SETUP_MFA = 'auth/setup_mfa' +SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SETUP_MFA, + vol.Exclusive('mfa_module_id', 'module_or_flow_id'): str, + vol.Exclusive('flow_id', 'module_or_flow_id'): str, + vol.Optional('user_input'): object, +}) + +WS_TYPE_DEPOSE_MFA = 'auth/depose_mfa' +SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DEPOSE_MFA, + vol.Required('mfa_module_id'): str, +}) + +DATA_SETUP_FLOW_MGR = 'auth_mfa_setup_flow_manager' + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass): + """Init mfa setup flow manager.""" + async def _async_create_setup_flow(handler, context, data): + """Create a setup flow. hanlder is a mfa module.""" + mfa_module = hass.auth.get_auth_mfa_module(handler) + if mfa_module is None: + raise ValueError('Mfa module {} is not found'.format(handler)) + + user_id = data.pop('user_id') + return await mfa_module.async_setup_flow(user_id) + + async def _async_finish_setup_flow(flow, flow_result): + _LOGGER.debug('flow_result: %s', flow_result) + return flow_result + + hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager( + hass, _async_create_setup_flow, _async_finish_setup_flow) + + hass.components.websocket_api.async_register_command( + WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA) + + hass.components.websocket_api.async_register_command( + WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_setup_mfa( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Return a setup flow for mfa auth module.""" + async def async_setup_flow(msg): + """Return a setup flow for mfa auth module.""" + flow_manager = hass.data[DATA_SETUP_FLOW_MGR] + + flow_id = msg.get('flow_id') + if flow_id is not None: + result = await flow_manager.async_configure( + flow_id, msg.get('user_input')) + connection.send_message_outside( + websocket_api.result_message( + msg['id'], _prepare_result_json(result))) + return + + mfa_module_id = msg.get('mfa_module_id') + mfa_module = hass.auth.get_auth_mfa_module(mfa_module_id) + if mfa_module is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'no_module', + 'MFA module {} is not found'.format(mfa_module_id))) + return + + result = await flow_manager.async_init( + mfa_module_id, data={'user_id': connection.user.id}) + + connection.send_message_outside( + websocket_api.result_message( + msg['id'], _prepare_result_json(result))) + + hass.async_create_task(async_setup_flow(msg)) + + +@callback +@websocket_api.ws_require_user(allow_system_user=False) +def websocket_depose_mfa( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + """Remove user from mfa module.""" + async def async_depose(msg): + """Remove user from mfa auth module.""" + mfa_module_id = msg['mfa_module_id'] + try: + await hass.auth.async_disable_user_mfa( + connection.user, msg['mfa_module_id']) + except ValueError as err: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'disable_failed', + 'Cannot disable MFA Module {}: {}'.format( + mfa_module_id, err))) + return + + connection.send_message_outside( + websocket_api.result_message( + msg['id'], 'done')) + + hass.async_create_task(async_depose(msg)) + + +def _prepare_result_json(result): + """Convert result to JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + return data + + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 1ba0e20d553..0c9ab366534 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -18,7 +18,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import ( MATCH_ALL, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, __version__) -from homeassistant.core import Context, callback +from homeassistant.core import Context, callback, HomeAssistant from homeassistant.loader import bind_hass from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers import config_validation as cv @@ -576,3 +576,59 @@ def handle_ping(hass, connection, msg): Async friendly. """ connection.to_write.put_nowait(pong_message(msg['id'])) + + +def ws_require_user( + only_owner=False, only_system_user=False, allow_system_user=True, + only_active_user=True, only_inactive_user=False): + """Decorate function validating login user exist in current WS connection. + + Will write out error message if not authenticated. + """ + def validator(func): + """Decorate func.""" + @wraps(func) + def check_current_user(hass: HomeAssistant, + connection: ActiveConnection, + msg): + """Check current user.""" + def output_error(message_id, message): + """Output error message.""" + connection.send_message_outside(error_message( + msg['id'], message_id, message)) + + if connection.user is None: + output_error('no_user', 'Not authenticated as a user') + return + + if only_owner and not connection.user.is_owner: + output_error('only_owner', 'Only allowed as owner') + return + + if (only_system_user and + not connection.user.system_generated): + output_error('only_system_user', + 'Only allowed as system user') + return + + if (not allow_system_user + and connection.user.system_generated): + output_error('not_system_user', 'Not allowed as system user') + return + + if (only_active_user and + not connection.user.is_active): + output_error('only_active_user', + 'Only allowed as active user') + return + + if only_inactive_user and connection.user.is_active: + output_error('only_inactive_user', + 'Not allowed as active user') + return + + return func(hass, connection, msg) + + return check_current_user + + return validator diff --git a/tests/auth/mfa_modules/test_insecure_example.py b/tests/auth/mfa_modules/test_insecure_example.py index 9d90532728a..e6f83762cd7 100644 --- a/tests/auth/mfa_modules/test_insecure_example.py +++ b/tests/auth/mfa_modules/test_insecure_example.py @@ -125,3 +125,21 @@ async def test_login(hass): result['flow_id'], {'pin': '123456'}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['data'].id == 'mock-user' + + +async def test_setup_flow(hass): + """Test validating pin.""" + auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'insecure_example', + 'data': [{'user_id': 'test-user', 'pin': '123456'}] + }) + + flow = await auth_module.async_setup_flow('new-user') + + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await flow.async_step_init({'pin': 'abcdefg'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert auth_module._data[1]['user_id'] == 'new-user' + assert auth_module._data[1]['pin'] == 'abcdefg' diff --git a/tests/components/auth/test_mfa_setup_flow.py b/tests/components/auth/test_mfa_setup_flow.py new file mode 100644 index 00000000000..93b5cdf7bb9 --- /dev/null +++ b/tests/components/auth/test_mfa_setup_flow.py @@ -0,0 +1,99 @@ +"""Tests for the mfa setup flow.""" +from homeassistant import data_entry_flow +from homeassistant.auth import auth_manager_from_config +from homeassistant.components.auth import mfa_setup_flow +from homeassistant.setup import async_setup_component + +from tests.common import MockUser, CLIENT_ID, ensure_auth_manager_loaded + + +async def test_ws_setup_depose_mfa(hass, hass_ws_client): + """Test set up mfa module for current user.""" + hass.auth = await auth_manager_from_config( + hass, provider_configs=[{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name', + }] + }], module_configs=[{ + 'type': 'insecure_example', + 'id': 'example_module', + 'data': [{'user_id': 'mock-user', 'pin': '123456'}] + }]) + ensure_auth_manager_loaded(hass.auth) + await async_setup_component(hass, 'auth', {'http': {}}) + + user = MockUser(id='mock-user').add_to_hass(hass) + cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( + {'username': 'test-user'}) + await hass.auth.async_link_user(user, cred) + refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) + + client = await hass_ws_client(hass, access_token) + + await client.send_json({ + 'id': 10, + 'type': mfa_setup_flow.WS_TYPE_SETUP_MFA, + }) + + result = await client.receive_json() + assert result['id'] == 10 + assert result['success'] is False + assert result['error']['code'] == 'no_module' + + await client.send_json({ + 'id': 11, + 'type': mfa_setup_flow.WS_TYPE_SETUP_MFA, + 'mfa_module_id': 'example_module', + }) + + result = await client.receive_json() + assert result['id'] == 11 + assert result['success'] + + flow = result['result'] + assert flow['type'] == data_entry_flow.RESULT_TYPE_FORM + assert flow['handler'] == 'example_module' + assert flow['step_id'] == 'init' + assert flow['data_schema'][0] == {'type': 'string', 'name': 'pin'} + + await client.send_json({ + 'id': 12, + 'type': mfa_setup_flow.WS_TYPE_SETUP_MFA, + 'flow_id': flow['flow_id'], + 'user_input': {'pin': '654321'}, + }) + + result = await client.receive_json() + assert result['id'] == 12 + assert result['success'] + + flow = result['result'] + assert flow['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert flow['handler'] == 'example_module' + assert flow['data']['result'] is None + + await client.send_json({ + 'id': 13, + 'type': mfa_setup_flow.WS_TYPE_DEPOSE_MFA, + 'mfa_module_id': 'invalid_id', + }) + + result = await client.receive_json() + assert result['id'] == 13 + assert result['success'] is False + assert result['error']['code'] == 'disable_failed' + + await client.send_json({ + 'id': 14, + 'type': mfa_setup_flow.WS_TYPE_DEPOSE_MFA, + 'mfa_module_id': 'example_module', + }) + + result = await client.receive_json() + assert result['id'] == 14 + assert result['success'] + assert result['result'] == 'done' From 90b2257347f8736450a6fef780c573dec3e125c0 Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Fri, 24 Aug 2018 17:29:25 -0400 Subject: [PATCH 05/42] Decouple Konnected entity setup from discovery (#16146) * decouple entity setup from discovery * validate that device_id is a full MAC address --- homeassistant/components/konnected.py | 151 ++++++++++--------- homeassistant/components/switch/konnected.py | 17 ++- 2 files changed, 94 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py index 9e85e85818d..3df28586313 100644 --- a/homeassistant/components/konnected.py +++ b/homeassistant/components/konnected.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.components.discovery import SERVICE_KONNECTED from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, + HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, ATTR_ENTITY_ID, ATTR_STATE) @@ -74,7 +74,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_API_HOST): vol.Url(), vol.Required(CONF_DEVICES): [{ - vol.Required(CONF_ID): cv.string, + vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All( @@ -107,12 +107,18 @@ async def async_setup(hass, config): def device_discovered(service, info): """Call when a Konnected device has been discovered.""" - _LOGGER.debug("Discovered a new Konnected device: %s", info) host = info.get(CONF_HOST) port = info.get(CONF_PORT) + discovered = DiscoveredDevice(hass, host, port) + if discovered.is_configured: + discovered.setup() + else: + _LOGGER.warning("Konnected device %s was discovered on the network" + " but not specified in configuration.yaml", + discovered.device_id) - device = KonnectedDevice(hass, host, port, cfg) - device.setup() + for device in cfg.get(CONF_DEVICES): + ConfiguredDevice(hass, device).save_data() discovery.async_listen( hass, @@ -124,98 +130,51 @@ async def async_setup(hass, config): return True -class KonnectedDevice: - """A representation of a single Konnected device.""" +class ConfiguredDevice: + """A representation of a configured Konnected device.""" - def __init__(self, hass, host, port, config): + def __init__(self, hass, config): """Initialize the Konnected device.""" self.hass = hass - self.host = host - self.port = port - self.user_config = config - - import konnected - self.client = konnected.Client(host, str(port)) - self.status = self.client.get_status() - _LOGGER.info('Initialized Konnected device %s', self.device_id) - - def setup(self): - """Set up a newly discovered Konnected device.""" - user_config = self.config() - if user_config: - _LOGGER.debug('Configuring Konnected device %s', self.device_id) - self.save_data() - self.sync_device_config() - discovery.load_platform( - self.hass, 'binary_sensor', - DOMAIN, {'device_id': self.device_id}) - discovery.load_platform( - self.hass, 'switch', DOMAIN, - {'device_id': self.device_id}) + self.config = config @property def device_id(self): """Device id is the MAC address as string with punctuation removed.""" - return self.status['mac'].replace(':', '') - - def config(self): - """Return an object representing the user defined configuration.""" - device_id = self.device_id - valid_keys = [device_id, device_id.upper(), - device_id[6:], device_id.upper()[6:]] - configured_devices = self.user_config[CONF_DEVICES] - return next((device for device in - configured_devices if device[CONF_ID] in valid_keys), - None) + return self.config.get(CONF_ID) def save_data(self): """Save the device configuration to `hass.data`.""" sensors = {} - for entity in self.config().get(CONF_BINARY_SENSORS) or []: + for entity in self.config.get(CONF_BINARY_SENSORS) or []: if CONF_ZONE in entity: pin = ZONE_TO_PIN[entity[CONF_ZONE]] else: pin = entity[CONF_PIN] - sensor_status = next((sensor for sensor in - self.status.get('sensors') if - sensor.get(CONF_PIN) == pin), {}) - if sensor_status.get(ATTR_STATE): - initial_state = bool(int(sensor_status.get(ATTR_STATE))) - else: - initial_state = None - sensors[pin] = { CONF_TYPE: entity[CONF_TYPE], CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( self.device_id[6:], PIN_TO_ZONE[pin])), - ATTR_STATE: initial_state + ATTR_STATE: None } _LOGGER.debug('Set up sensor %s (initial state: %s)', sensors[pin].get('name'), sensors[pin].get(ATTR_STATE)) actuators = [] - for entity in self.config().get(CONF_SWITCHES) or []: + for entity in self.config.get(CONF_SWITCHES) or []: if 'zone' in entity: pin = ZONE_TO_PIN[entity['zone']] else: pin = entity['pin'] - actuator_status = next((actuator for actuator in - self.status.get('actuators') if - actuator.get('pin') == pin), {}) - if actuator_status.get(ATTR_STATE): - initial_state = bool(int(actuator_status.get(ATTR_STATE))) - else: - initial_state = None - act = { CONF_PIN: pin, CONF_NAME: entity.get( CONF_NAME, 'Konnected {} Actuator {}'.format( self.device_id[6:], PIN_TO_ZONE[pin])), - ATTR_STATE: initial_state, + ATTR_STATE: None, CONF_ACTIVATION: entity[CONF_ACTIVATION], CONF_MOMENTARY: entity.get(CONF_MOMENTARY), CONF_PAUSE: entity.get(CONF_PAUSE), @@ -224,23 +183,67 @@ class KonnectedDevice: _LOGGER.debug('Set up actuator %s', act) device_data = { - 'client': self.client, CONF_BINARY_SENSORS: sensors, CONF_SWITCHES: actuators, - CONF_HOST: self.host, - CONF_PORT: self.port, } if CONF_DEVICES not in self.hass.data[DOMAIN]: self.hass.data[DOMAIN][CONF_DEVICES] = {} - _LOGGER.debug('Storing data in hass.data[konnected]: %s', device_data) + _LOGGER.debug('Storing data in hass.data[%s][%s][%s]: %s', + DOMAIN, CONF_DEVICES, self.device_id, device_data) self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data + discovery.load_platform( + self.hass, 'binary_sensor', + DOMAIN, {'device_id': self.device_id}) + discovery.load_platform( + self.hass, 'switch', DOMAIN, + {'device_id': self.device_id}) + + +class DiscoveredDevice: + """A representation of a discovered Konnected device.""" + + def __init__(self, hass, host, port): + """Initialize the Konnected device.""" + self.hass = hass + self.host = host + self.port = port + + import konnected + self.client = konnected.Client(host, str(port)) + self.status = self.client.get_status() + + def setup(self): + """Set up a newly discovered Konnected device.""" + _LOGGER.info('Discovered Konnected device %s. Open http://%s:%s in a ' + 'web browser to view device status.', + self.device_id, self.host, self.port) + self.save_data() + self.update_initial_states() + self.sync_device_config() + + def save_data(self): + """Save the discovery information to `hass.data`.""" + self.stored_configuration['client'] = self.client + self.stored_configuration['host'] = self.host + self.stored_configuration['port'] = self.port + + @property + def device_id(self): + """Device id is the MAC address as string with punctuation removed.""" + return self.status['mac'].replace(':', '') + + @property + def is_configured(self): + """Return true if device_id is specified in the configuration.""" + return bool(self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)) + @property def stored_configuration(self): """Return the configuration stored in `hass.data` for this device.""" - return self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] + return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) def sensor_configuration(self): """Return the configuration map for syncing sensors.""" @@ -254,6 +257,18 @@ class KonnectedDevice: else 1)} for data in self.stored_configuration[CONF_SWITCHES]] + def update_initial_states(self): + """Update the initial state of each sensor from status poll.""" + for sensor in self.status.get('sensors'): + entity_id = self.stored_configuration[CONF_BINARY_SENSORS]. \ + get(sensor.get(CONF_PIN), {}). \ + get(ATTR_ENTITY_ID) + + async_dispatcher_send( + self.hass, + SIGNAL_SENSOR_UPDATE.format(entity_id), + bool(sensor.get(ATTR_STATE))) + def sync_device_config(self): """Sync the new pin configuration to the Konnected device.""" desired_sensor_configuration = self.sensor_configuration() @@ -285,7 +300,7 @@ class KonnectedDevice: if (desired_sensor_configuration != current_sensor_configuration) or \ (current_actuator_config != desired_actuator_config) or \ (current_api_endpoint != desired_api_endpoint): - _LOGGER.debug('pushing settings to device %s', self.device_id) + _LOGGER.info('pushing settings to device %s', self.device_id) self.client.put_settings( desired_sensor_configuration, desired_actuator_config, @@ -340,7 +355,7 @@ class KonnectedView(HomeAssistantView): entity_id = pin_data.get(ATTR_ENTITY_ID) if entity_id is None: return self.json_message('uninitialized sensor/actuator', - status_code=HTTP_INTERNAL_SERVER_ERROR) + status_code=HTTP_NOT_FOUND) async_dispatcher_send( hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) diff --git a/homeassistant/components/switch/konnected.py b/homeassistant/components/switch/konnected.py index c085d0bb0a5..20774accbd5 100644 --- a/homeassistant/components/switch/konnected.py +++ b/homeassistant/components/switch/konnected.py @@ -27,9 +27,8 @@ async def async_setup_platform(hass, config, async_add_entities, data = hass.data[KONNECTED_DOMAIN] device_id = discovery_info['device_id'] - client = data[CONF_DEVICES][device_id]['client'] switches = [ - KonnectedSwitch(device_id, pin_data.get(CONF_PIN), pin_data, client) + KonnectedSwitch(device_id, pin_data.get(CONF_PIN), pin_data) for pin_data in data[CONF_DEVICES][device_id][CONF_SWITCHES]] async_add_entities(switches) @@ -37,7 +36,7 @@ async def async_setup_platform(hass, config, async_add_entities, class KonnectedSwitch(ToggleEntity): """Representation of a Konnected switch.""" - def __init__(self, device_id, pin_num, data, client): + def __init__(self, device_id, pin_num, data): """Initialize the switch.""" self._data = data self._device_id = device_id @@ -50,7 +49,6 @@ class KonnectedSwitch(ToggleEntity): self._name = self._data.get( 'name', 'Konnected {} Actuator {}'.format( device_id, PIN_TO_ZONE[pin_num])) - self._client = client _LOGGER.debug('Created new switch: %s', self._name) @property @@ -63,9 +61,16 @@ class KonnectedSwitch(ToggleEntity): """Return the status of the sensor.""" return self._state + @property + def client(self): + """Return the Konnected HTTP client.""" + return \ + self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id].\ + get('client') + def turn_on(self, **kwargs): """Send a command to turn on the switch.""" - resp = self._client.put_device( + resp = self.client.put_device( self._pin_num, int(self._activation == STATE_HIGH), self._momentary, @@ -82,7 +87,7 @@ class KonnectedSwitch(ToggleEntity): def turn_off(self, **kwargs): """Send a command to turn off the switch.""" - resp = self._client.put_device( + resp = self.client.put_device( self._pin_num, int(self._activation == STATE_LOW)) if resp.get(ATTR_STATE) is not None: From cf8bd92d4d1ab1e2a619c034f4327662a442673c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Aug 2018 10:59:28 +0200 Subject: [PATCH 06/42] Device registry store config entry (#16152) * Allow device registry to optionally store config entries * Connections and identifiers are now sets with tupels * Make config entries mandatory * Fix duplicate keys in test * Rename device to device_info * Entity platform should only create device entries if config_entry_id exists * Fix Soundtouch tests * Revert soundtouch to use self.device * Fix baloobs comments * Correct type in test --- .../components/binary_sensor/deconz.py | 6 +- homeassistant/components/deconz/__init__.py | 5 +- homeassistant/components/light/deconz.py | 6 +- homeassistant/components/media_player/roku.py | 12 +-- .../components/media_player/soundtouch.py | 17 ++-- homeassistant/components/sensor/deconz.py | 12 +-- homeassistant/components/switch/deconz.py | 6 +- homeassistant/helpers/device_registry.py | 42 +++++++--- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/entity_platform.py | 17 ++-- tests/helpers/test_device_registry.py | 78 ++++++++++++++++--- 11 files changed, 142 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 9aa0c446f2b..1fb62124407 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -116,15 +116,15 @@ class DeconzBinarySensor(BinarySensorDevice): return attr @property - def device(self): + def device_info(self): """Return a device description for device registry.""" if (self._sensor.uniqueid is None or self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] return { - 'connection': [[CONNECTION_ZIGBEE, serial]], - 'identifiers': [[DECONZ_DOMAIN, serial]], + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, 'manufacturer': self._sensor.manufacturer, 'model': self._sensor.modelid, 'name': self._sensor.name, diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index d435e9e3c04..a4edc009ea1 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -123,8 +123,9 @@ async def async_setup_entry(hass, config_entry): device_registry = await \ hass.helpers.device_registry.async_get_registry() device_registry.async_get_or_create( - connection=[[CONNECTION_NETWORK_MAC, deconz.config.mac]], - identifiers=[[DOMAIN, deconz.config.bridgeid]], + config_entry=config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, deconz.config.mac)}, + identifiers={(DOMAIN, deconz.config.bridgeid)}, manufacturer='Dresden Elektronik', model=deconz.config.modelid, name=deconz.config.name, sw_version=deconz.config.swversion) diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 067f1474f96..412cf8693e5 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -202,15 +202,15 @@ class DeconzLight(Light): return attributes @property - def device(self): + def device_info(self): """Return a device description for device registry.""" if (self._light.uniqueid is None or self._light.uniqueid.count(':') != 7): return None serial = self._light.uniqueid.split('-', 1)[0] return { - 'connection': [[CONNECTION_ZIGBEE, serial]], - 'identifiers': [[DECONZ_DOMAIN, serial]], + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, 'manufacturer': self._light.manufacturer, 'model': self._light.modelid, 'name': self._light.name, diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index fa1120db98c..fca7b29d2ec 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -87,7 +87,7 @@ class RokuDevice(MediaPlayerDevice): self.ip_address = host self.channels = [] self.current_app = None - self.device_info = {} + self._device_info = {} self.update() @@ -96,7 +96,7 @@ class RokuDevice(MediaPlayerDevice): import requests.exceptions try: - self.device_info = self.roku.device_info + self._device_info = self.roku.device_info self.ip_address = self.roku.host self.channels = self.get_source_list() @@ -121,9 +121,9 @@ class RokuDevice(MediaPlayerDevice): @property def name(self): """Return the name of the device.""" - if self.device_info.userdevicename: - return self.device_info.userdevicename - return "Roku {}".format(self.device_info.sernum) + if self._device_info.userdevicename: + return self._device_info.userdevicename + return "Roku {}".format(self._device_info.sernum) @property def state(self): @@ -149,7 +149,7 @@ class RokuDevice(MediaPlayerDevice): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return self.device_info.sernum + return self._device_info.sernum @property def media_content_type(self): diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index f2ac45a996f..489d028aad4 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -166,6 +166,11 @@ class SoundTouchDevice(MediaPlayerDevice): """Return specific soundtouch configuration.""" return self._config + @property + def device(self): + """Return Soundtouch device.""" + return self._device + def update(self): """Retrieve the latest data.""" self._status = self._device.status() @@ -318,8 +323,8 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.warning("Unable to create zone without slaves") else: _LOGGER.info("Creating zone with master %s", - self._device.config.name) - self._device.create_zone([slave.device for slave in slaves]) + self.device.config.name) + self.device.create_zone([slave.device for slave in slaves]) def remove_zone_slave(self, slaves): """ @@ -336,8 +341,8 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.warning("Unable to find slaves to remove") else: _LOGGER.info("Removing slaves from zone with master %s", - self._device.config.name) - self._device.remove_zone_slave([slave.device for slave in slaves]) + self.device.config.name) + self.device.remove_zone_slave([slave.device for slave in slaves]) def add_zone_slave(self, slaves): """ @@ -352,5 +357,5 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.warning("Unable to find slaves to add") else: _LOGGER.info("Adding slaves to zone with master %s", - self._device.config.name) - self._device.add_zone_slave([slave.device for slave in slaves]) + self.device.config.name) + self.device.add_zone_slave([slave.device for slave in slaves]) diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 45c604a74ee..8cb3915dc46 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -136,15 +136,15 @@ class DeconzSensor(Entity): return attr @property - def device(self): + def device_info(self): """Return a device description for device registry.""" if (self._sensor.uniqueid is None or self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] return { - 'connection': [[CONNECTION_ZIGBEE, serial]], - 'identifiers': [[DECONZ_DOMAIN, serial]], + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, 'manufacturer': self._sensor.manufacturer, 'model': self._sensor.modelid, 'name': self._sensor.name, @@ -211,15 +211,15 @@ class DeconzBattery(Entity): return attr @property - def device(self): + def device_info(self): """Return a device description for device registry.""" if (self._device.uniqueid is None or self._device.uniqueid.count(':') != 7): return None serial = self._device.uniqueid.split('-', 1)[0] return { - 'connection': [[CONNECTION_ZIGBEE, serial]], - 'identifiers': [[DECONZ_DOMAIN, serial]], + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, 'manufacturer': self._device.manufacturer, 'model': self._device.modelid, 'name': self._device.name, diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py index 7d861e4c29c..35dbc3ef782 100644 --- a/homeassistant/components/switch/deconz.py +++ b/homeassistant/components/switch/deconz.py @@ -81,15 +81,15 @@ class DeconzSwitch(SwitchDevice): return False @property - def device(self): + def device_info(self): """Return a device description for device registry.""" if (self._switch.uniqueid is None or self._switch.uniqueid.count(':') != 7): return None serial = self._switch.uniqueid.split('-', 1)[0] return { - 'connection': [[CONNECTION_ZIGBEE, serial]], - 'identifiers': [[DECONZ_DOMAIN, serial]], + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, 'manufacturer': self._switch.manufacturer, 'model': self._switch.modelid, 'name': self._switch.name, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 19a6eaa62dc..31da40134a5 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -23,8 +23,9 @@ CONNECTION_ZIGBEE = 'zigbee' class DeviceEntry: """Device Registry Entry.""" - connection = attr.ib(type=list) - identifiers = attr.ib(type=list) + config_entries = attr.ib(type=set, converter=set) + connections = attr.ib(type=set, converter=set) + identifiers = attr.ib(type=set, converter=set) manufacturer = attr.ib(type=str) model = attr.ib(type=str) name = attr.ib(type=str, default=None) @@ -46,29 +47,36 @@ class DeviceRegistry: """Check if device is registered.""" for device in self.devices: if any(iden in device.identifiers for iden in identifiers) or \ - any(conn in device.connection for conn in connections): + any(conn in device.connections for conn in connections): return device return None @callback - def async_get_or_create(self, *, connection, identifiers, manufacturer, - model, name=None, sw_version=None): + def async_get_or_create(self, *, config_entry, connections, identifiers, + manufacturer, model, name=None, sw_version=None): """Get device. Create if it doesn't exist.""" - device = self.async_get_device(identifiers, connection) + if not identifiers and not connections: + return None + + device = self.async_get_device(identifiers, connections) if device is not None: + if config_entry not in device.config_entries: + device.config_entries.add(config_entry) + self.async_schedule_save() return device device = DeviceEntry( - connection=connection, + config_entries=[config_entry], + connections=connections, identifiers=identifiers, manufacturer=manufacturer, model=model, name=name, sw_version=sw_version ) - self.devices.append(device) + self.async_schedule_save() return device @@ -81,7 +89,16 @@ class DeviceRegistry: self.devices = [] return - self.devices = [DeviceEntry(**device) for device in devices['devices']] + self.devices = [DeviceEntry( + config_entries=device['config_entries'], + connections={tuple(conn) for conn in device['connections']}, + identifiers={tuple(iden) for iden in device['identifiers']}, + manufacturer=device['manufacturer'], + model=device['model'], + name=device['name'], + sw_version=device['sw_version'], + id=device['id'], + ) for device in devices['devices']] @callback def async_schedule_save(self): @@ -95,13 +112,14 @@ class DeviceRegistry: data['devices'] = [ { - 'id': entry.id, - 'connection': entry.connection, - 'identifiers': entry.identifiers, + 'config_entries': list(entry.config_entries), + 'connections': list(entry.connections), + 'identifiers': list(entry.identifiers), 'manufacturer': entry.manufacturer, 'model': entry.model, 'name': entry.name, 'sw_version': entry.sw_version, + 'id': entry.id, } for entry in self.devices ] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 78806e65ef1..695da5bce9c 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -131,7 +131,7 @@ class Entity: return None @property - def device(self): + def device_info(self): """Return device specific attributes. Implemented by platform classes. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ffac68c5f07..083a2946122 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -272,15 +272,16 @@ class EntityPlatform: else: config_entry_id = None - device = entity.device - if device is not None: + device_info = entity.device_info + if config_entry_id is not None and device_info is not None: device = device_registry.async_get_or_create( - connection=device['connection'], - identifiers=device['identifiers'], - manufacturer=device['manufacturer'], - model=device['model'], - name=device.get('name'), - sw_version=device.get('sw_version')) + config_entry=config_entry_id, + connections=device_info.get('connections', []), + identifiers=device_info.get('identifiers', []), + manufacturer=device_info.get('manufacturer'), + model=device_info.get('model'), + name=device_info.get('name'), + sw_version=device_info.get('sw_version')) device_id = device.id else: device_id = None diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index f7792eb5250..b2e73071823 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -26,22 +26,73 @@ def registry(hass): async def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create( - connection=[['ethernet', '12:34:56:78:90:AB:CD:EF']], - identifiers=[['bridgeid', '0123']], + config_entry='1234', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( - connection=[['ethernet', '11:22:33:44:55:66:77:88']], - identifiers=[['bridgeid', '0123']], + config_entry='1234', + connections={('ethernet', '11:22:33:44:55:66:77:88')}, + identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( - connection=[['ethernet', '12:34:56:78:90:AB:CD:EF']], - identifiers=[['bridgeid', '1234']], + config_entry='1234', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '1234')}, manufacturer='manufacturer', model='model') assert len(registry.devices) == 1 assert entry is entry2 assert entry is entry3 - assert entry.identifiers == [['bridgeid', '0123']] + assert entry.identifiers == {('bridgeid', '0123')} + + +async def test_requirement_for_identifier_or_connection(registry): + """Make sure we do require some descriptor of device.""" + entry = registry.async_get_or_create( + config_entry='1234', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers=set(), + manufacturer='manufacturer', model='model') + entry2 = registry.async_get_or_create( + config_entry='1234', + connections=set(), + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + entry3 = registry.async_get_or_create( + config_entry='1234', + connections=set(), + identifiers=set(), + manufacturer='manufacturer', model='model') + + assert len(registry.devices) == 2 + assert entry + assert entry2 + assert entry3 is None + + +async def test_multiple_config_entries(registry): + """Make sure we do not get duplicate entries.""" + entry = registry.async_get_or_create( + config_entry='123', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + entry2 = registry.async_get_or_create( + config_entry='456', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + entry3 = registry.async_get_or_create( + config_entry='123', + connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('bridgeid', '0123')}, + manufacturer='manufacturer', model='model') + + assert len(registry.devices) == 1 + assert entry is entry2 + assert entry is entry3 + assert entry.config_entries == {'123', '456'} async def test_loading_from_storage(hass, hass_storage): @@ -51,7 +102,10 @@ async def test_loading_from_storage(hass, hass_storage): 'data': { 'devices': [ { - 'connection': [ + 'config_entries': [ + '1234' + ], + 'connections': [ [ 'Zigbee', '01.23.45.67.89' @@ -67,7 +121,7 @@ async def test_loading_from_storage(hass, hass_storage): 'manufacturer': 'manufacturer', 'model': 'model', 'name': 'name', - 'sw_version': 'version' + 'sw_version': 'version', } ] } @@ -76,7 +130,9 @@ async def test_loading_from_storage(hass, hass_storage): registry = await device_registry.async_get_registry(hass) entry = registry.async_get_or_create( - connection=[['Zigbee', '01.23.45.67.89']], - identifiers=[['serial', '12:34:56:78:90:AB:CD:EF']], + config_entry='1234', + connections={('Zigbee', '01.23.45.67.89')}, + identifiers={('serial', '12:34:56:78:90:AB:CD:EF')}, manufacturer='manufacturer', model='model') assert entry.id == 'abcdefghijklm' + assert isinstance(entry.config_entries, set) From 4cb9ac72b4ebe09c7c969d8efdf9e8bdcc8166c2 Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Fri, 24 Aug 2018 17:27:12 -0400 Subject: [PATCH 07/42] fix error message for cv.matches_regex (#16175) --- homeassistant/helpers/config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index bbd863b5693..90098a677a1 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -92,7 +92,7 @@ def matches_regex(regex): if not regex.match(value): raise vol.Invalid('value {} does not match regular expression {}' - .format(regex.pattern, value)) + .format(value, regex.pattern)) return value return validator From b8c272258e6f4778327d2f3fbe60846019341b6c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Aug 2018 11:01:32 +0200 Subject: [PATCH 08/42] Fix hangouts (#16180) --- homeassistant/components/hangouts/__init__.py | 4 ++-- homeassistant/components/hangouts/config_flow.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 89649ecb8e1..8ebacc3736b 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -26,8 +26,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up the Hangouts bot component.""" - config = config.get(DOMAIN, []) - hass.data[DOMAIN] = {CONF_COMMANDS: config[CONF_COMMANDS]} + config = config.get(DOMAIN, {}) + hass.data[DOMAIN] = {CONF_COMMANDS: config.get(CONF_COMMANDS, [])} if configured_hangouts(hass) is None: hass.async_add_job(hass.config_entries.flow.async_init( diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py index bd81d5053c8..74eb14b050d 100644 --- a/homeassistant/components/hangouts/config_flow.py +++ b/homeassistant/components/hangouts/config_flow.py @@ -104,4 +104,4 @@ class HangoutsFlowHandler(data_entry_flow.FlowHandler): async def async_step_import(self, _): """Handle a flow import.""" - return self.async_abort(reason='already_configured') + return await self.async_step_user() From c2891b9905687abc483bd179745b74feae56110f Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 24 Aug 2018 22:57:36 -0700 Subject: [PATCH 09/42] Tweak log level for bearer token warning (#16182) --- homeassistant/components/http/auth.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index d01d1b50c5a..7adcc43f4af 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -30,8 +30,10 @@ def setup_auth(app, trusted_networks, use_auth, if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or DATA_API_PASSWORD in request.query): if request.path not in old_auth_warning: - _LOGGER.warning('Please change to use bearer token access %s', - request.path) + _LOGGER.log( + logging.INFO if support_legacy else logging.WARNING, + 'Please change to use bearer token access %s from %s', + request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) legacy_auth = (not use_auth or support_legacy) and api_password From 66a8bede128cd7b1a32114b10c145b2aa31c9128 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sat, 25 Aug 2018 02:09:48 -0700 Subject: [PATCH 10/42] Default load trusted_network auth provider if configured trusted networks (#16184) --- homeassistant/bootstrap.py | 4 +++- homeassistant/config.py | 5 ++++- tests/test_config.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 41fa61964de..c10964e2da3 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -88,10 +88,12 @@ async def async_from_config_dict(config: Dict[str, Any], core_config = config.get(core.DOMAIN, {}) has_api_password = bool((config.get('http') or {}).get('api_password')) + has_trusted_networks = bool((config.get('http') or {}) + .get('trusted_networks')) try: await conf_util.async_process_ha_core_config( - hass, core_config, has_api_password) + hass, core_config, has_api_password, has_trusted_networks) except vol.Invalid as ex: conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) return None diff --git a/homeassistant/config.py b/homeassistant/config.py index 45505bbbc9b..fe8f8ef0f60 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -406,7 +406,8 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: async def async_process_ha_core_config( hass: HomeAssistant, config: Dict, - has_api_password: bool = False) -> None: + has_api_password: bool = False, + has_trusted_networks: bool = False) -> None: """Process the [homeassistant] section from the configuration. This method is a coroutine. @@ -423,6 +424,8 @@ async def async_process_ha_core_config( ] if has_api_password: auth_conf.append({'type': 'legacy_api_password'}) + if has_trusted_networks: + auth_conf.append({'type': 'trusted_networks'}) setattr(hass, 'auth', await auth.auth_manager_from_config( hass, diff --git a/tests/test_config.py b/tests/test_config.py index 77a30fd771b..76ea576ac28 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -856,6 +856,27 @@ async def test_auth_provider_config_default_api_password(hass): assert hass.auth.active is True +async def test_auth_provider_config_default_trusted_networks(hass): + """Test loading default auth provider config with trusted networks.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + } + if hasattr(hass, 'auth'): + del hass.auth + await config_util.async_process_ha_core_config(hass, core_config, + has_trusted_networks=True) + + assert len(hass.auth.auth_providers) == 2 + assert hass.auth.auth_providers[0].type == 'homeassistant' + assert hass.auth.auth_providers[1].type == 'trusted_networks' + assert hass.auth.active is True + + async def test_disallowed_auth_provider_config(hass): """Test loading insecure example auth provider is disallowed.""" core_config = { From 9ffcd2d86a95c2fd502be407f7a9bde0244b2e41 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Aug 2018 11:16:01 +0200 Subject: [PATCH 11/42] Bumped version to 0.77.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b11354e0064..c299b5a2a73 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 77 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 3783d1ce908672000408dc6c5562762e550f36b0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Aug 2018 21:30:14 +0200 Subject: [PATCH 12/42] Update frontend to 20180826.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 c475ea55974..4622f80948e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180825.0'] +REQUIREMENTS = ['home-assistant-frontend==20180826.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index a5cfe9e402a..39deea61056 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -438,7 +438,7 @@ hole==0.3.0 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180825.0 +home-assistant-frontend==20180826.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b9dc1d1eb3..3dbbb1f399c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.2 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180825.0 +home-assistant-frontend==20180826.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From c13e5fcb9289ba01f78ef6a698d01767aaee9721 Mon Sep 17 00:00:00 2001 From: Matt Hamilton Date: Sun, 26 Aug 2018 16:50:31 -0400 Subject: [PATCH 13/42] Replace pbkdf2 with bcrypt (#16071) * Replace pbkdf2 with bcrypt bcrypt isn't inherently better than pbkdf2, but everything "just works" out of the box. * the hash verification routine now only computes one hash per call * a per-user salt is built into the hash as opposed to the current global salt * bcrypt.checkpw() is immune to timing attacks regardless of input * hash strength is a function of real time benchmarks and a "difficulty" level, meaning we won't have to ever update the iteration count * WIP: add hash upgrade mechanism * WIP: clarify decode issue * remove stale testing code * Fix test * Ensure incorrect legacy passwords fail * Add better invalid legacy password test * Lint * Run tests in async scope --- homeassistant/auth/providers/homeassistant.py | 51 +++++++++-- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 1 + setup.py | 1 + tests/auth/providers/test_homeassistant.py | 89 +++++++++++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index ce252497901..c743a5b7f65 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -5,13 +5,16 @@ import hashlib import hmac from typing import Any, Dict, List, Optional, cast +import bcrypt import voluptuous as vol from homeassistant.const import CONF_ID from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.async_ import run_coroutine_threadsafe from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow + from ..models import Credentials, UserMeta from ..util import generate_secret @@ -74,8 +77,7 @@ class Data: Raises InvalidAuth if auth invalid. """ - hashed = self.hash_password(password) - + dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO' found = None # Compare all users to avoid timing attacks. @@ -84,22 +86,55 @@ class Data: found = user if found is None: - # Do one more compare to make timing the same as if user was found. - hmac.compare_digest(hashed, hashed) + # check a hash to make timing the same as if user was found + bcrypt.checkpw(b'foo', + dummy) raise InvalidAuth - if not hmac.compare_digest(hashed, - base64.b64decode(found['password'])): + user_hash = base64.b64decode(found['password']) + + # if the hash is not a bcrypt hash... + # provide a transparant upgrade for old pbkdf2 hash format + if not (user_hash.startswith(b'$2a$') + or user_hash.startswith(b'$2b$') + or user_hash.startswith(b'$2x$') + or user_hash.startswith(b'$2y$')): + # IMPORTANT! validate the login, bail if invalid + hashed = self.legacy_hash_password(password) + if not hmac.compare_digest(hashed, user_hash): + raise InvalidAuth + # then re-hash the valid password with bcrypt + self.change_password(found['username'], password) + run_coroutine_threadsafe( + self.async_save(), self.hass.loop + ).result() + user_hash = base64.b64decode(found['password']) + + # bcrypt.checkpw is timing-safe + if not bcrypt.checkpw(password.encode(), + user_hash): raise InvalidAuth - def hash_password(self, password: str, for_storage: bool = False) -> bytes: - """Encode a password.""" + def legacy_hash_password(self, password: str, + for_storage: bool = False) -> bytes: + """LEGACY password encoding.""" + # We're no longer storing salts in data, but if one exists we + # should be able to retrieve it. salt = self._data['salt'].encode() # type: ignore hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000) if for_storage: hashed = base64.b64encode(hashed) return hashed + # pylint: disable=no-self-use + def hash_password(self, password: str, for_storage: bool = False) -> bytes: + """Encode a password.""" + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) \ + # type: bytes + if for_storage: + hashed = base64.b64encode(hashed) + return hashed + def add_auth(self, username: str, password: str) -> None: """Add a new authenticated user/pass.""" if any(user['username'] == username for user in self.users): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 26628d7fe62..1b9447c32e6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,6 +2,7 @@ aiohttp==3.3.2 astral==1.6.1 async_timeout==3.0.0 attrs==18.1.0 +bcrypt==3.1.4 certifi>=2018.04.16 jinja2>=2.10 PyJWT==1.6.4 diff --git a/requirements_all.txt b/requirements_all.txt index 39deea61056..22fa8219f9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,6 +3,7 @@ aiohttp==3.3.2 astral==1.6.1 async_timeout==3.0.0 attrs==18.1.0 +bcrypt==3.1.4 certifi>=2018.04.16 jinja2>=2.10 PyJWT==1.6.4 diff --git a/setup.py b/setup.py index 7484dc286e6..1f1beaf6f06 100755 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ REQUIRES = [ 'astral==1.6.1', 'async_timeout==3.0.0', 'attrs==18.1.0', + 'bcrypt==3.1.4', 'certifi>=2018.04.16', 'jinja2>=2.10', 'PyJWT==1.6.4', diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index c92f8539b17..935c5e50dd5 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,6 +1,7 @@ """Test the Home Assistant local auth provider.""" from unittest.mock import Mock +import base64 import pytest from homeassistant import data_entry_flow @@ -132,3 +133,91 @@ async def test_new_users_populate_values(hass, data): user = await manager.async_get_or_create_user(credentials) assert user.name == 'hello' assert user.is_active + + +async def test_new_hashes_are_bcrypt(data, hass): + """Test that newly created hashes are using bcrypt.""" + data.add_auth('newuser', 'newpass') + found = None + for user in data.users: + if user['username'] == 'newuser': + found = user + assert found is not None + user_hash = base64.b64decode(found['password']) + assert (user_hash.startswith(b'$2a$') + or user_hash.startswith(b'$2b$') + or user_hash.startswith(b'$2x$') + or user_hash.startswith(b'$2y$')) + + +async def test_pbkdf2_to_bcrypt_hash_upgrade(hass_storage, hass): + """Test migrating user from pbkdf2 hash to bcrypt hash.""" + hass_storage[hass_auth.STORAGE_KEY] = { + 'version': hass_auth.STORAGE_VERSION, + 'key': hass_auth.STORAGE_KEY, + 'data': { + 'salt': '09c52f0b120eaa7dea5f73f9a9b985f3d493b30a08f3f2945ef613' + '0b08e6a3ea', + 'users': [ + { + 'password': 'L5PAbehB8LAQI2Ixu+d+PDNJKmljqLnBcYWYw35onC/8D' + 'BM1SpvT6A8ZFael5+deCt+s+43J08IcztnguouHSw==', + 'username': 'legacyuser' + } + ] + }, + } + data = hass_auth.Data(hass) + await data.async_load() + + # verify the correct (pbkdf2) password successfuly authenticates the user + await hass.async_add_executor_job( + data.validate_login, 'legacyuser', 'beer') + + # ...and that the hashes are now bcrypt hashes + user_hash = base64.b64decode( + hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) + assert (user_hash.startswith(b'$2a$') + or user_hash.startswith(b'$2b$') + or user_hash.startswith(b'$2x$') + or user_hash.startswith(b'$2y$')) + + +async def test_pbkdf2_to_bcrypt_hash_upgrade_with_incorrect_pass(hass_storage, + hass): + """Test migrating user from pbkdf2 hash to bcrypt hash.""" + hass_storage[hass_auth.STORAGE_KEY] = { + 'version': hass_auth.STORAGE_VERSION, + 'key': hass_auth.STORAGE_KEY, + 'data': { + 'salt': '09c52f0b120eaa7dea5f73f9a9b985f3d493b30a08f3f2945ef613' + '0b08e6a3ea', + 'users': [ + { + 'password': 'L5PAbehB8LAQI2Ixu+d+PDNJKmljqLnBcYWYw35onC/8D' + 'BM1SpvT6A8ZFael5+deCt+s+43J08IcztnguouHSw==', + 'username': 'legacyuser' + } + ] + }, + } + data = hass_auth.Data(hass) + await data.async_load() + + orig_user_hash = base64.b64decode( + hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) + + # Make sure invalid legacy passwords fail + with pytest.raises(hass_auth.InvalidAuth): + await hass.async_add_executor_job( + data.validate_login, 'legacyuser', 'wine') + + # Make sure we don't change the password/hash when password is incorrect + with pytest.raises(hass_auth.InvalidAuth): + await hass.async_add_executor_job( + data.validate_login, 'legacyuser', 'wine') + + same_user_hash = base64.b64decode( + hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) + + assert orig_user_hash == same_user_hash From 2ece671bfd77a5a99e7475f957d53356ba76dcdb Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 26 Aug 2018 13:38:52 -0700 Subject: [PATCH 14/42] Add Time-based Onetime Password Multi-factor Authentication Module (#16129) * Add Time-based Onetime Password Multi-factor Auth Add TOTP setup flow, generate QR code * Resolve rebase issue * Use svg instead png for QR code * Lint and typing * Fix translation * Load totp auth module by default * use tag instead markdown image * Update strings * Cleanup --- homeassistant/auth/__init__.py | 8 +- homeassistant/auth/mfa_modules/totp.py | 212 ++++++++++++++++++ homeassistant/auth/providers/__init__.py | 4 +- .../components/auth/.translations/en.json | 16 ++ homeassistant/components/auth/strings.json | 16 ++ homeassistant/config.py | 6 +- requirements_all.txt | 4 + requirements_test_all.txt | 4 + script/gen_requirements_all.py | 1 + tests/auth/mfa_modules/test_totp.py | 130 +++++++++++ tests/test_config.py | 11 +- 11 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 homeassistant/auth/mfa_modules/totp.py create mode 100644 homeassistant/components/auth/.translations/en.json create mode 100644 homeassistant/components/auth/strings.json create mode 100644 tests/auth/mfa_modules/test_totp.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e0b7b377b1f..952bb3b8352 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -249,13 +249,13 @@ class AuthManager: await module.async_depose_user(user.id) - async def async_get_enabled_mfa(self, user: models.User) -> List[str]: + async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]: """List enabled mfa modules for user.""" - module_ids = [] + modules = OrderedDict() # type: Dict[str, str] for module_id, module in self._mfa_modules.items(): if await module.async_is_user_setup(user.id): - module_ids.append(module_id) - return module_ids + modules[module_id] = module.name + return modules async def async_create_refresh_token(self, user: models.User, client_id: Optional[str] = None) \ diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py new file mode 100644 index 00000000000..48531863c1a --- /dev/null +++ b/homeassistant/auth/mfa_modules/totp.py @@ -0,0 +1,212 @@ +"""Time-based One Time Password auth module.""" +import logging +from io import BytesIO +from typing import Any, Dict, Optional, Tuple # noqa: F401 + +import voluptuous as vol + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant + +from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ + MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow + +REQUIREMENTS = ['pyotp==2.2.6', 'PyQRCode==1.2.1'] + +CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_module.totp' +STORAGE_USERS = 'users' +STORAGE_USER_ID = 'user_id' +STORAGE_OTA_SECRET = 'ota_secret' + +INPUT_FIELD_CODE = 'code' + +DUMMY_SECRET = 'FPPTH34D4E3MI2HG' + +_LOGGER = logging.getLogger(__name__) + + +def _generate_qr_code(data: str) -> str: + """Generate a base64 PNG string represent QR Code image of data.""" + import pyqrcode + + qr_code = pyqrcode.create(data) + + with BytesIO() as buffer: + qr_code.svg(file=buffer, scale=4) + return '{}'.format( + buffer.getvalue().decode("ascii").replace('\n', '') + .replace('' + ' Tuple[str, str, str]: + """Generate a secret, url, and QR code.""" + import pyotp + + ota_secret = pyotp.random_base32() + url = pyotp.totp.TOTP(ota_secret).provisioning_uri( + username, issuer_name="Home Assistant") + image = _generate_qr_code(url) + return ota_secret, url, image + + +@MULTI_FACTOR_AUTH_MODULES.register('totp') +class TotpAuthModule(MultiFactorAuthModule): + """Auth module validate time-based one time password.""" + + DEFAULT_TITLE = 'Time-based One Time Password' + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the user data store.""" + super().__init__(hass, config) + self._users = None # type: Optional[Dict[str, str]] + self._user_store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY) + + @property + def input_schema(self) -> vol.Schema: + """Validate login flow input data.""" + return vol.Schema({INPUT_FIELD_CODE: str}) + + async def _async_load(self) -> None: + """Load stored data.""" + data = await self._user_store.async_load() + + if data is None: + data = {STORAGE_USERS: {}} + + self._users = data.get(STORAGE_USERS, {}) + + async def _async_save(self) -> None: + """Save data.""" + await self._user_store.async_save({STORAGE_USERS: self._users}) + + def _add_ota_secret(self, user_id: str, + secret: Optional[str] = None) -> str: + """Create a ota_secret for user.""" + import pyotp + + ota_secret = secret or pyotp.random_base32() # type: str + + self._users[user_id] = ota_secret # type: ignore + return ota_secret + + async def async_setup_flow(self, user_id: str) -> SetupFlow: + """Return a data entry flow handler for setup module. + + Mfa module should extend SetupFlow + """ + user = await self.hass.auth.async_get_user(user_id) # type: ignore + return TotpSetupFlow(self, self.input_schema, user) + + async def async_setup_user(self, user_id: str, setup_data: Any) -> str: + """Set up auth module for user.""" + if self._users is None: + await self._async_load() + + result = await self.hass.async_add_executor_job( + self._add_ota_secret, user_id, setup_data.get('secret')) + + await self._async_save() + return result + + async def async_depose_user(self, user_id: str) -> None: + """Depose auth module for user.""" + if self._users is None: + await self._async_load() + + if self._users.pop(user_id, None): # type: ignore + await self._async_save() + + async def async_is_user_setup(self, user_id: str) -> bool: + """Return whether user is setup.""" + if self._users is None: + await self._async_load() + + return user_id in self._users # type: ignore + + async def async_validation( + self, user_id: str, user_input: Dict[str, Any]) -> bool: + """Return True if validation passed.""" + if self._users is None: + await self._async_load() + + # user_input has been validate in caller + return await self.hass.async_add_executor_job( + self._validate_2fa, user_id, user_input[INPUT_FIELD_CODE]) + + def _validate_2fa(self, user_id: str, code: str) -> bool: + """Validate two factor authentication code.""" + import pyotp + + ota_secret = self._users.get(user_id) # type: ignore + if ota_secret is None: + # even we cannot find user, we still do verify + # to make timing the same as if user was found. + pyotp.TOTP(DUMMY_SECRET).verify(code) + return False + + return bool(pyotp.TOTP(ota_secret).verify(code)) + + +class TotpSetupFlow(SetupFlow): + """Handler for the setup flow.""" + + def __init__(self, auth_module: TotpAuthModule, + setup_schema: vol.Schema, + user: User) -> None: + """Initialize the setup flow.""" + super().__init__(auth_module, setup_schema, user.id) + # to fix typing complaint + self._auth_module = auth_module # type: TotpAuthModule + self._user = user + self._ota_secret = None # type: Optional[str] + self._url = None # type Optional[str] + self._image = None # type Optional[str] + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None) \ + -> Dict[str, Any]: + """Handle the first step of setup flow. + + Return self.async_show_form(step_id='init') if user_input == None. + Return self.async_create_entry(data={'result': result}) if finish. + """ + import pyotp + + errors = {} # type: Dict[str, str] + + if user_input: + verified = await self.hass.async_add_executor_job( # type: ignore + pyotp.TOTP(self._ota_secret).verify, user_input['code']) + if verified: + result = await self._auth_module.async_setup_user( + self._user_id, {'secret': self._ota_secret}) + return self.async_create_entry( + title=self._auth_module.name, + data={'result': result} + ) + + errors['base'] = 'invalid_code' + + else: + hass = self._auth_module.hass + self._ota_secret, self._url, self._image = \ + await hass.async_add_executor_job( # type: ignore + _generate_secret_and_qr_code, str(self._user.name)) + + return self.async_show_form( + step_id='init', + data_schema=self._setup_schema, + description_placeholders={ + 'code': self._ota_secret, + 'url': self._url, + 'qr_code': self._image + }, + errors=errors + ) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index e8ef7cbf3d4..0bcb47d4af9 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -168,7 +168,7 @@ class LoginFlow(data_entry_flow.FlowHandler): self._auth_provider = auth_provider self._auth_module_id = None # type: Optional[str] self._auth_manager = auth_provider.hass.auth # type: ignore - self.available_mfa_modules = [] # type: List + self.available_mfa_modules = {} # type: Dict[str, str] self.created_at = dt_util.utcnow() self.user = None # type: Optional[User] @@ -196,7 +196,7 @@ class LoginFlow(data_entry_flow.FlowHandler): errors['base'] = 'invalid_auth_module' if len(self.available_mfa_modules) == 1: - self._auth_module_id = self.available_mfa_modules[0] + self._auth_module_id = list(self.available_mfa_modules.keys())[0] return await self.async_step_mfa() return self.async_show_form( diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json new file mode 100644 index 00000000000..5c1af67b120 --- /dev/null +++ b/homeassistant/components/auth/.translations/en.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock on Home Assistant system is accurate." + }, + "step": { + "init": { + "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n{qr_code}\n\nEnter the six digi code appeared in your app below to verify the setup:", + "title": "Scan this QR code with your app" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json new file mode 100644 index 00000000000..b0083ab577b --- /dev/null +++ b/homeassistant/components/auth/strings.json @@ -0,0 +1,16 @@ +{ + "mfa_setup":{ + "totp": { + "title": "TOTP", + "step": { + "init": { + "title": "Set up two-factor authentication using TOTP", + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." + } + }, + "error": { + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." + } + } + } +} diff --git a/homeassistant/config.py b/homeassistant/config.py index fe8f8ef0f60..a799094c94d 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -427,10 +427,14 @@ async def async_process_ha_core_config( if has_trusted_networks: auth_conf.append({'type': 'trusted_networks'}) + mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [ + {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'} + ]) + setattr(hass, 'auth', await auth.auth_manager_from_config( hass, auth_conf, - config.get(CONF_AUTH_MFA_MODULES, []))) + mfa_conf)) hac = hass.config diff --git a/requirements_all.txt b/requirements_all.txt index 22fa8219f9d..559bfdf7856 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -47,6 +47,9 @@ PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.auth.mfa_modules.totp +PyQRCode==1.2.1 + # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.0.7 @@ -986,6 +989,7 @@ pyopenuv==1.0.1 # homeassistant.components.iota pyota==2.0.5 +# homeassistant.auth.mfa_modules.totp # homeassistant.components.sensor.otp pyotp==2.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3dbbb1f399c..05d449a5eb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -154,6 +154,10 @@ pymonoprice==0.3 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.auth.mfa_modules.totp +# homeassistant.components.sensor.otp +pyotp==2.2.6 + # homeassistant.components.qwikswitch pyqwikswitch==0.8 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e26393bb800..fe23e638e5b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -78,6 +78,7 @@ TEST_REQUIREMENTS = ( 'pylitejet', 'pymonoprice', 'pynx584', + 'pyotp', 'pyqwikswitch', 'PyRMVtransport', 'python-forecastio', diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py new file mode 100644 index 00000000000..28e6c949bc4 --- /dev/null +++ b/tests/auth/mfa_modules/test_totp.py @@ -0,0 +1,130 @@ +"""Test the Time-based One Time Password (MFA) auth module.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.auth import models as auth_models, auth_manager_from_config +from homeassistant.auth.mfa_modules import auth_mfa_module_from_config +from tests.common import MockUser + +MOCK_CODE = '123456' + + +async def test_validating_mfa(hass): + """Test validating mfa code.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + await totp_auth_module.async_setup_user('test-user', {}) + + with patch('pyotp.TOTP.verify', return_value=True): + assert await totp_auth_module.async_validation( + 'test-user', {'code': MOCK_CODE}) + + +async def test_validating_mfa_invalid_code(hass): + """Test validating an invalid mfa code.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + await totp_auth_module.async_setup_user('test-user', {}) + + with patch('pyotp.TOTP.verify', return_value=False): + assert await totp_auth_module.async_validation( + 'test-user', {'code': MOCK_CODE}) is False + + +async def test_validating_mfa_invalid_user(hass): + """Test validating an mfa code with invalid user.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + await totp_auth_module.async_setup_user('test-user', {}) + + assert await totp_auth_module.async_validation( + 'invalid-user', {'code': MOCK_CODE}) is False + + +async def test_setup_depose_user(hass): + """Test despose user.""" + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + result = await totp_auth_module.async_setup_user('test-user', {}) + assert len(totp_auth_module._users) == 1 + result2 = await totp_auth_module.async_setup_user('test-user', {}) + assert len(totp_auth_module._users) == 1 + assert result != result2 + + await totp_auth_module.async_depose_user('test-user') + assert len(totp_auth_module._users) == 0 + + result = await totp_auth_module.async_setup_user( + 'test-user2', {'secret': 'secret-code'}) + assert result == 'secret-code' + assert len(totp_auth_module._users) == 1 + + +async def test_login_flow_validates_mfa(hass): + """Test login flow with mfa enabled.""" + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{'username': 'test-user', 'password': 'test-pass'}], + }], [{ + 'type': 'totp', + }]) + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(hass.auth) + await hass.auth.async_link_user(user, auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + + await hass.auth.async_enable_user_mfa(user, 'totp', {}) + + provider = hass.auth.auth_providers[0] + + result = await hass.auth.login_flow.async_init( + (provider.type, provider.id)) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'test-user', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await hass.auth.login_flow.async_configure(result['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['data_schema'].schema.get('code') == str + + with patch('pyotp.TOTP.verify', return_value=False): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': 'invalid-code'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'mfa' + assert result['errors']['base'] == 'invalid_auth' + + with patch('pyotp.TOTP.verify', return_value=True): + result = await hass.auth.login_flow.async_configure( + result['flow_id'], {'code': MOCK_CODE}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'].id == 'mock-user' diff --git a/tests/test_config.py b/tests/test_config.py index 76ea576ac28..3cfe67f70b1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, - CONF_AUTH_PROVIDERS) + CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES) from homeassistant.util import location as location_util, dt as dt_util from homeassistant.util.yaml import SECRET_YAML from homeassistant.util.async_ import run_coroutine_threadsafe @@ -805,6 +805,10 @@ async def test_auth_provider_config(hass): CONF_AUTH_PROVIDERS: [ {'type': 'homeassistant'}, {'type': 'legacy_api_password'}, + ], + CONF_AUTH_MFA_MODULES: [ + {'type': 'totp'}, + {'type': 'totp', 'id': 'second'}, ] } if hasattr(hass, 'auth'): @@ -815,6 +819,9 @@ async def test_auth_provider_config(hass): assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' assert hass.auth.active is True + assert len(hass.auth.auth_mfa_modules) == 2 + assert hass.auth.auth_mfa_modules[0].id == 'totp' + assert hass.auth.auth_mfa_modules[1].id == 'second' async def test_auth_provider_config_default(hass): @@ -834,6 +841,8 @@ async def test_auth_provider_config_default(hass): assert len(hass.auth.auth_providers) == 1 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.active is True + assert len(hass.auth.auth_mfa_modules) == 1 + assert hass.auth.auth_mfa_modules[0].id == 'totp' async def test_auth_provider_config_default_api_password(hass): From 6992a6fe6d11aa5799ac5bcadf7a99bf42ae0ae0 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Sun, 26 Aug 2018 12:29:15 -0700 Subject: [PATCH 15/42] Handle exception from pillow (#16190) --- homeassistant/components/camera/proxy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index a19efcfb1af..6c245ffdf43 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -64,7 +64,10 @@ def _resize_image(image, opts): quality = opts.quality or DEFAULT_QUALITY new_width = opts.max_width - img = Image.open(io.BytesIO(image)) + try: + img = Image.open(io.BytesIO(image)) + except IOError: + return image imgfmt = str(img.format) if imgfmt not in ('PNG', 'JPEG'): _LOGGER.debug("Image is of unsupported type: %s", imgfmt) From c8449d8f8a18f1566274a598ca18e4aad9ca1655 Mon Sep 17 00:00:00 2001 From: Marcel Hoppe Date: Sun, 26 Aug 2018 21:28:42 +0200 Subject: [PATCH 16/42] remove hangouts.users state, simplifies hangouts.conversations (#16191) --- .../components/hangouts/hangouts_bot.py | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index d4c5606799d..d9ffb4cbace 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -195,23 +195,15 @@ class HangoutsBot: import hangups self._user_list, self._conversation_list = \ (await hangups.build_user_conversation_list(self._client)) - users = {} conversations = {} - for user in self._user_list.get_all(): - users[str(user.id_.chat_id)] = {'full_name': user.full_name, - 'is_self': user.is_self} - - for conv in self._conversation_list.get_all(): - users_in_conversation = {} + for i, conv in enumerate(self._conversation_list.get_all()): + users_in_conversation = [] for user in conv.users: - users_in_conversation[str(user.id_.chat_id)] = \ - {'full_name': user.full_name, 'is_self': user.is_self} - conversations[str(conv.id_)] = \ - {'name': conv.name, 'users': users_in_conversation} + users_in_conversation.append(user.full_name) + conversations[str(i)] = {'id': str(conv.id_), + 'name': conv.name, + 'users': users_in_conversation} - self.hass.states.async_set("{}.users".format(DOMAIN), - len(self._user_list.get_all()), - attributes=users) self.hass.states.async_set("{}.conversations".format(DOMAIN), len(self._conversation_list.get_all()), attributes=conversations) From 969b15a297f23c1b811697176705fc3f9a6c6f66 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Mon, 27 Aug 2018 03:35:06 +0800 Subject: [PATCH 17/42] Update aiohttp to version 3.4.0. (#16198) --- 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 1b9447c32e6..70fb519eef4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -aiohttp==3.3.2 +aiohttp==3.4.0 astral==1.6.1 async_timeout==3.0.0 attrs==18.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 559bfdf7856..3c505a2f1e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,5 +1,5 @@ # Home Assistant core -aiohttp==3.3.2 +aiohttp==3.4.0 astral==1.6.1 async_timeout==3.0.0 attrs==18.1.0 diff --git a/setup.py b/setup.py index 1f1beaf6f06..b1b0af70319 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ PROJECT_URLS = { PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ - 'aiohttp==3.3.2', + 'aiohttp==3.4.0', 'astral==1.6.1', 'async_timeout==3.0.0', 'attrs==18.1.0', From 2ad938ed4401fbce13ff5d83612916d153eef1c4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 26 Aug 2018 21:25:39 +0200 Subject: [PATCH 18/42] Revert changes to platforms using self.device (#16209) * Revert tank_utility * Fix Soundtouch * Fix Plex * Fix Emby * Fix Radiotherm * Fix Juicenet * Fix Qwikswitch * Fix Xiaomi miio * Fix Nest * Fix Tellduslive * Fix KNX --- homeassistant/components/binary_sensor/knx.py | 10 +-- .../components/binary_sensor/nest.py | 5 +- .../components/binary_sensor/tellduslive.py | 2 +- homeassistant/components/camera/nest.py | 18 +++--- homeassistant/components/climate/knx.py | 30 ++++----- homeassistant/components/climate/nest.py | 44 ++++++------- .../components/climate/radiotherm.py | 32 +++++----- homeassistant/components/cover/knx.py | 34 +++++----- homeassistant/components/cover/tellduslive.py | 8 +-- homeassistant/components/juicenet.py | 12 ++-- homeassistant/components/light/knx.py | 32 +++++----- homeassistant/components/light/qwikswitch.py | 4 +- homeassistant/components/light/tellduslive.py | 8 +-- homeassistant/components/media_player/emby.py | 52 +++++++-------- homeassistant/components/media_player/plex.py | 63 ++++++++++--------- .../components/media_player/soundtouch.py | 12 ++-- homeassistant/components/nest/__init__.py | 6 +- homeassistant/components/notify/knx.py | 8 +-- homeassistant/components/qwikswitch.py | 6 +- .../components/remote/xiaomi_miio.py | 9 ++- homeassistant/components/sensor/juicenet.py | 20 +++--- homeassistant/components/sensor/knx.py | 10 +-- homeassistant/components/sensor/nest.py | 10 +-- .../components/sensor/tank_utility.py | 11 +++- .../components/sensor/tellduslive.py | 2 +- homeassistant/components/switch/knx.py | 12 ++-- .../components/switch/tellduslive.py | 6 +- homeassistant/components/tellduslive.py | 27 ++++---- 28 files changed, 256 insertions(+), 237 deletions(-) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index a7d1d597f67..d0707b0f067 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -105,7 +105,7 @@ class KNXBinarySensor(BinarySensorDevice): def __init__(self, hass, device): """Initialize of KNX binary sensor.""" - self._device = device + self.device = device self.hass = hass self.async_register_callbacks() self.automations = [] @@ -116,12 +116,12 @@ class KNXBinarySensor(BinarySensorDevice): async def after_update_callback(device): """Call after device was updated.""" await self.async_update_ha_state() - self._device.register_device_updated_cb(after_update_callback) + self.device.register_device_updated_cb(after_update_callback) @property def name(self): """Return the name of the KNX device.""" - return self._device.name + return self.device.name @property def available(self): @@ -136,9 +136,9 @@ class KNXBinarySensor(BinarySensorDevice): @property def device_class(self): """Return the class of this sensor.""" - return self._device.device_class + return self.device.device_class @property def is_on(self): """Return true if the binary sensor is on.""" - return self._device.is_on() + return self.device.is_on() diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index c952e7c8987..c60463a8663 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -130,7 +130,7 @@ class NestBinarySensor(NestSensorDevice, BinarySensorDevice): def update(self): """Retrieve latest state.""" - value = getattr(self._device, self.variable) + value = getattr(self.device, self.variable) if self.variable in STRUCTURE_BINARY_TYPES: self._state = bool(STRUCTURE_BINARY_STATE_MAP [self.variable].get(value)) @@ -154,5 +154,4 @@ class NestActivityZoneSensor(NestBinarySensor): def update(self): """Retrieve latest state.""" - self._state = self._device.has_ongoing_motion_in_zone( - self.zone.zone_id) + self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id) diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py index c412ec37e51..450a5e580bd 100644 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -31,4 +31,4 @@ class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice): @property def is_on(self): """Return true if switch is on.""" - return self._device.is_on + return self.device.is_on diff --git a/homeassistant/components/camera/nest.py b/homeassistant/components/camera/nest.py index 175dbcd2267..e1d26371984 100644 --- a/homeassistant/components/camera/nest.py +++ b/homeassistant/components/camera/nest.py @@ -46,7 +46,7 @@ class NestCamera(Camera): """Initialize a Nest Camera.""" super(NestCamera, self).__init__() self.structure = structure - self._device = device + self.device = device self._location = None self._name = None self._online = None @@ -93,7 +93,7 @@ class NestCamera(Camera): # Calling Nest API in is_streaming setter. # device.is_streaming would not immediately change until the process # finished in Nest Cam. - self._device.is_streaming = False + self.device.is_streaming = False def turn_on(self): """Turn on camera.""" @@ -105,15 +105,15 @@ class NestCamera(Camera): # Calling Nest API in is_streaming setter. # device.is_streaming would not immediately change until the process # finished in Nest Cam. - self._device.is_streaming = True + self.device.is_streaming = True def update(self): """Cache value from Python-nest.""" - self._location = self._device.where - self._name = self._device.name - self._online = self._device.online - self._is_streaming = self._device.is_streaming - self._is_video_history_enabled = self._device.is_video_history_enabled + self._location = self.device.where + self._name = self.device.name + self._online = self.device.online + self._is_streaming = self.device.is_streaming + self._is_video_history_enabled = self.device.is_video_history_enabled if self._is_video_history_enabled: # NestAware allowed 10/min @@ -130,7 +130,7 @@ class NestCamera(Camera): """Return a still image response from the camera.""" now = utcnow() if self._ready_for_snapshot(now): - url = self._device.snapshot_url + url = self.device.snapshot_url try: response = requests.get(url) diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index ed197f57ab3..4eada356653 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -118,7 +118,7 @@ class KNXClimate(ClimateDevice): def __init__(self, hass, device): """Initialize of a KNX climate device.""" - self._device = device + self.device = device self.hass = hass self.async_register_callbacks() @@ -126,7 +126,7 @@ class KNXClimate(ClimateDevice): def supported_features(self): """Return the list of supported features.""" support = SUPPORT_TARGET_TEMPERATURE - if self._device.supports_operation_mode: + if self.device.supports_operation_mode: support |= SUPPORT_OPERATION_MODE return support @@ -135,12 +135,12 @@ class KNXClimate(ClimateDevice): async def after_update_callback(device): """Call after device was updated.""" await self.async_update_ha_state() - self._device.register_device_updated_cb(after_update_callback) + self.device.register_device_updated_cb(after_update_callback) @property def name(self): """Return the name of the KNX device.""" - return self._device.name + return self.device.name @property def available(self): @@ -160,41 +160,41 @@ class KNXClimate(ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._device.temperature.value + return self.device.temperature.value @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self._device.setpoint_shift_step + return self.device.setpoint_shift_step @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._device.target_temperature.value + return self.device.target_temperature.value @property def min_temp(self): """Return the minimum temperature.""" - return self._device.target_temperature_min + return self.device.target_temperature_min @property def max_temp(self): """Return the maximum temperature.""" - return self._device.target_temperature_max + return self.device.target_temperature_max async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - await self._device.set_target_temperature(temperature) + await self.device.set_target_temperature(temperature) await self.async_update_ha_state() @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - if self._device.supports_operation_mode: - return self._device.operation_mode.value + if self.device.supports_operation_mode: + return self.device.operation_mode.value return None @property @@ -202,11 +202,11 @@ class KNXClimate(ClimateDevice): """Return the list of available operation modes.""" return [operation_mode.value for operation_mode in - self._device.get_supported_operation_modes()] + self.device.get_supported_operation_modes()] async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - if self._device.supports_operation_mode: + if self.device.supports_operation_mode: from xknx.knx import HVACOperationMode knx_operation_mode = HVACOperationMode(operation_mode) - await self._device.set_operation_mode(knx_operation_mode) + await self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 81c5fb3c2aa..321559f10ee 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -57,7 +57,7 @@ class NestThermostat(ClimateDevice): """Initialize the thermostat.""" self._unit = temp_unit self.structure = structure - self._device = device + self.device = device self._fan_list = [STATE_ON, STATE_AUTO] # Set the default supported features @@ -68,13 +68,13 @@ class NestThermostat(ClimateDevice): self._operation_list = [STATE_OFF] # Add supported nest thermostat features - if self._device.can_heat: + if self.device.can_heat: self._operation_list.append(STATE_HEAT) - if self._device.can_cool: + if self.device.can_cool: self._operation_list.append(STATE_COOL) - if self._device.can_heat and self._device.can_cool: + if self.device.can_heat and self.device.can_cool: self._operation_list.append(STATE_AUTO) self._support_flags = (self._support_flags | SUPPORT_TARGET_TEMPERATURE_HIGH | @@ -83,7 +83,7 @@ class NestThermostat(ClimateDevice): self._operation_list.append(STATE_ECO) # feature of device - self._has_fan = self._device.has_fan + self._has_fan = self.device.has_fan if self._has_fan: self._support_flags = (self._support_flags | SUPPORT_FAN_MODE) @@ -125,7 +125,7 @@ class NestThermostat(ClimateDevice): @property def unique_id(self): """Return unique ID for this device.""" - return self._device.serial + return self.device.serial @property def name(self): @@ -202,7 +202,7 @@ class NestThermostat(ClimateDevice): _LOGGER.debug("Nest set_temperature-output-value=%s", temp) try: if temp is not None: - self._device.target = temp + self.device.target = temp except nest.nest.APIError as api_error: _LOGGER.error("An error occurred while setting temperature: %s", api_error) @@ -220,7 +220,7 @@ class NestThermostat(ClimateDevice): _LOGGER.error( "An error occurred while setting device mode. " "Invalid operation mode: %s", operation_mode) - self._device.mode = device_mode + self.device.mode = device_mode @property def operation_list(self): @@ -254,7 +254,7 @@ class NestThermostat(ClimateDevice): def set_fan_mode(self, fan_mode): """Turn fan on/off.""" if self._has_fan: - self._device.fan = fan_mode.lower() + self.device.fan = fan_mode.lower() @property def min_temp(self): @@ -268,20 +268,20 @@ class NestThermostat(ClimateDevice): def update(self): """Cache value from Python-nest.""" - self._location = self._device.where - self._name = self._device.name - self._humidity = self._device.humidity - self._temperature = self._device.temperature - self._mode = self._device.mode - self._target_temperature = self._device.target - self._fan = self._device.fan + self._location = self.device.where + self._name = self.device.name + self._humidity = self.device.humidity + self._temperature = self.device.temperature + self._mode = self.device.mode + self._target_temperature = self.device.target + self._fan = self.device.fan self._away = self.structure.away == 'away' - self._eco_temperature = self._device.eco_temperature - self._locked_temperature = self._device.locked_temperature - self._min_temperature = self._device.min_temperature - self._max_temperature = self._device.max_temperature - self._is_locked = self._device.is_locked - if self._device.temperature_scale == 'C': + self._eco_temperature = self.device.eco_temperature + self._locked_temperature = self.device.locked_temperature + self._min_temperature = self.device.min_temperature + self._max_temperature = self.device.max_temperature + self._is_locked = self.device.is_locked + if self.device.temperature_scale == 'C': self._temperature_scale = TEMP_CELSIUS else: self._temperature_scale = TEMP_FAHRENHEIT diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index 3d1d8e6a53e..429b544aefc 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -120,7 +120,7 @@ class RadioThermostat(ClimateDevice): def __init__(self, device, hold_temp, away_temps): """Initialize the thermostat.""" - self._device = device + self.device = device self._target_temperature = None self._current_temperature = None self._current_operation = STATE_IDLE @@ -138,7 +138,7 @@ class RadioThermostat(ClimateDevice): # Fan circulate mode is only supported by the CT80 models. import radiotherm self._is_model_ct80 = isinstance( - self._device, radiotherm.thermostat.CT80) + self.device, radiotherm.thermostat.CT80) @property def supported_features(self): @@ -194,7 +194,7 @@ class RadioThermostat(ClimateDevice): """Turn fan on/off.""" code = FAN_MODE_TO_CODE.get(fan_mode, None) if code is not None: - self._device.fmode = code + self.device.fmode = code @property def current_temperature(self): @@ -234,15 +234,15 @@ class RadioThermostat(ClimateDevice): # First time - get the name from the thermostat. This is # normally set in the radio thermostat web app. if self._name is None: - self._name = self._device.name['raw'] + self._name = self.device.name['raw'] # Request the current state from the thermostat. - data = self._device.tstat['raw'] + data = self.device.tstat['raw'] current_temp = data['temp'] if current_temp == -1: _LOGGER.error('%s (%s) was busy (temp == -1)', self._name, - self._device.host) + self.device.host) return # Map thermostat values into various STATE_ flags. @@ -277,30 +277,30 @@ class RadioThermostat(ClimateDevice): temperature = round_temp(temperature) if self._current_operation == STATE_COOL: - self._device.t_cool = temperature + self.device.t_cool = temperature elif self._current_operation == STATE_HEAT: - self._device.t_heat = temperature + self.device.t_heat = temperature elif self._current_operation == STATE_AUTO: if self._tstate == STATE_COOL: - self._device.t_cool = temperature + self.device.t_cool = temperature elif self._tstate == STATE_HEAT: - self._device.t_heat = temperature + self.device.t_heat = temperature # Only change the hold if requested or if hold mode was turned # on and we haven't set it yet. if kwargs.get('hold_changed', False) or not self._hold_set: if self._hold_temp or self._away: - self._device.hold = 1 + self.device.hold = 1 self._hold_set = True else: - self._device.hold = 0 + self.device.hold = 0 def set_time(self): """Set device time.""" # Calling this clears any local temperature override and # reverts to the scheduled temperature. now = datetime.datetime.now() - self._device.time = { + self.device.time = { 'day': now.weekday(), 'hour': now.hour, 'minute': now.minute @@ -309,13 +309,13 @@ class RadioThermostat(ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode (auto, cool, heat, off).""" if operation_mode in (STATE_OFF, STATE_AUTO): - self._device.tmode = TEMP_MODE_TO_CODE[operation_mode] + self.device.tmode = TEMP_MODE_TO_CODE[operation_mode] # Setting t_cool or t_heat automatically changes tmode. elif operation_mode == STATE_COOL: - self._device.t_cool = self._target_temperature + self.device.t_cool = self._target_temperature elif operation_mode == STATE_HEAT: - self._device.t_heat = self._target_temperature + self.device.t_heat = self._target_temperature def turn_away_mode_on(self): """Turn away on. diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 74ac80a476d..43a87fab367 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -96,7 +96,7 @@ class KNXCover(CoverDevice): def __init__(self, hass, device): """Initialize the cover.""" - self._device = device + self.device = device self.hass = hass self.async_register_callbacks() @@ -108,12 +108,12 @@ class KNXCover(CoverDevice): async def after_update_callback(device): """Call after device was updated.""" await self.async_update_ha_state() - self._device.register_device_updated_cb(after_update_callback) + self.device.register_device_updated_cb(after_update_callback) @property def name(self): """Return the name of the KNX device.""" - return self._device.name + return self.device.name @property def available(self): @@ -130,56 +130,56 @@ class KNXCover(CoverDevice): """Flag supported features.""" supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ SUPPORT_SET_POSITION | SUPPORT_STOP - if self._device.supports_angle: + if self.device.supports_angle: supported_features |= SUPPORT_SET_TILT_POSITION return supported_features @property def current_cover_position(self): """Return the current position of the cover.""" - return self._device.current_position() + return self.device.current_position() @property def is_closed(self): """Return if the cover is closed.""" - return self._device.is_closed() + return self.device.is_closed() async def async_close_cover(self, **kwargs): """Close the cover.""" - if not self._device.is_closed(): - await self._device.set_down() + if not self.device.is_closed(): + await self.device.set_down() self.start_auto_updater() async def async_open_cover(self, **kwargs): """Open the cover.""" - if not self._device.is_open(): - await self._device.set_up() + if not self.device.is_open(): + await self.device.set_up() self.start_auto_updater() async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - await self._device.set_position(position) + await self.device.set_position(position) self.start_auto_updater() async def async_stop_cover(self, **kwargs): """Stop the cover.""" - await self._device.stop() + await self.device.stop() self.stop_auto_updater() @property def current_cover_tilt_position(self): """Return current tilt position of cover.""" - if not self._device.supports_angle: + if not self.device.supports_angle: return None - return self._device.current_angle() + return self.device.current_angle() async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: tilt_position = kwargs[ATTR_TILT_POSITION] - await self._device.set_angle(tilt_position) + await self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" @@ -197,7 +197,7 @@ class KNXCover(CoverDevice): def auto_updater_hook(self, now): """Call for the autoupdater.""" self.async_schedule_update_ha_state() - if self._device.position_reached(): + if self.device.position_reached(): self.stop_auto_updater() - self.hass.add_job(self._device.auto_stop_if_necessary()) + self.hass.add_job(self.device.auto_stop_if_necessary()) diff --git a/homeassistant/components/cover/tellduslive.py b/homeassistant/components/cover/tellduslive.py index fc352aa8482..9d292d9e8b5 100644 --- a/homeassistant/components/cover/tellduslive.py +++ b/homeassistant/components/cover/tellduslive.py @@ -28,19 +28,19 @@ class TelldusLiveCover(TelldusLiveEntity, CoverDevice): @property def is_closed(self): """Return the current position of the cover.""" - return self._device.is_down + return self.device.is_down def close_cover(self, **kwargs): """Close the cover.""" - self._device.down() + self.device.down() self.changed() def open_cover(self, **kwargs): """Open the cover.""" - self._device.up() + self.device.up() self.changed() def stop_cover(self, **kwargs): """Stop the cover.""" - self._device.stop() + self.device.stop() self.changed() diff --git a/homeassistant/components/juicenet.py b/homeassistant/components/juicenet.py index 2ed32521f1d..55567d45879 100644 --- a/homeassistant/components/juicenet.py +++ b/homeassistant/components/juicenet.py @@ -46,29 +46,29 @@ class JuicenetDevice(Entity): def __init__(self, device, sensor_type, hass): """Initialise the sensor.""" self.hass = hass - self._device = device + self.device = device self.type = sensor_type @property def name(self): """Return the name of the device.""" - return self._device.name() + return self.device.name() def update(self): """Update state of the device.""" - self._device.update_state() + self.device.update_state() @property def _manufacturer_device_id(self): """Return the manufacturer device id.""" - return self._device.id() + return self.device.id() @property def _token(self): """Return the device API token.""" - return self._device.token() + return self.device.token() @property def unique_id(self): """Return a unique ID.""" - return "{}-{}".format(self._device.id(), self.type) + return "{}-{}".format(self.device.id(), self.type) diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 23929db8626..778d2fac59c 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -79,7 +79,7 @@ class KNXLight(Light): def __init__(self, hass, device): """Initialize of KNX light.""" - self._device = device + self.device = device self.hass = hass self.async_register_callbacks() @@ -89,12 +89,12 @@ class KNXLight(Light): async def after_update_callback(device): """Call after device was updated.""" await self.async_update_ha_state() - self._device.register_device_updated_cb(after_update_callback) + self.device.register_device_updated_cb(after_update_callback) @property def name(self): """Return the name of the KNX device.""" - return self._device.name + return self.device.name @property def available(self): @@ -109,15 +109,15 @@ class KNXLight(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._device.current_brightness \ - if self._device.supports_brightness else \ + return self.device.current_brightness \ + if self.device.supports_brightness else \ None @property def hs_color(self): """Return the HS color value.""" - if self._device.supports_color: - return color_util.color_RGB_to_hs(*self._device.current_color) + if self.device.supports_color: + return color_util.color_RGB_to_hs(*self.device.current_color) return None @property @@ -143,30 +143,30 @@ class KNXLight(Light): @property def is_on(self): """Return true if light is on.""" - return self._device.state + return self.device.state @property def supported_features(self): """Flag supported features.""" flags = 0 - if self._device.supports_brightness: + if self.device.supports_brightness: flags |= SUPPORT_BRIGHTNESS - if self._device.supports_color: + if self.device.supports_color: flags |= SUPPORT_COLOR return flags async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: - if self._device.supports_brightness: - await self._device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + if self.device.supports_brightness: + await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) elif ATTR_HS_COLOR in kwargs: - if self._device.supports_color: - await self._device.set_color(color_util.color_hs_to_RGB( + if self.device.supports_color: + await self.device.set_color(color_util.color_hs_to_RGB( *kwargs[ATTR_HS_COLOR])) else: - await self._device.set_on() + await self.device.set_on() async def async_turn_off(self, **kwargs): """Turn the light off.""" - await self._device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index fa986ff09f2..413358d9cee 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -27,9 +27,9 @@ class QSLight(QSToggleEntity, Light): @property def brightness(self): """Return the brightness of this light (0-255).""" - return self._device.value if self._device.is_dimmer else None + return self.device.value if self.device.is_dimmer else None @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS if self._device.is_dimmer else 0 + return SUPPORT_BRIGHTNESS if self.device.is_dimmer else 0 diff --git a/homeassistant/components/light/tellduslive.py b/homeassistant/components/light/tellduslive.py index 6f39fb3b318..07b5458fa45 100644 --- a/homeassistant/components/light/tellduslive.py +++ b/homeassistant/components/light/tellduslive.py @@ -38,7 +38,7 @@ class TelldusLiveLight(TelldusLiveEntity, Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._device.dim_level + return self.device.dim_level @property def supported_features(self): @@ -48,15 +48,15 @@ class TelldusLiveLight(TelldusLiveEntity, Light): @property def is_on(self): """Return true if light is on.""" - return self._device.is_on + return self.device.is_on def turn_on(self, **kwargs): """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness) - self._device.dim(level=brightness) + self.device.dim(level=brightness) self.changed() def turn_off(self, **kwargs): """Turn the light off.""" - self._device.turn_off() + self.device.turn_off() self.changed() diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index b64aad38b3e..809db228d02 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -133,7 +133,7 @@ class EmbyDevice(MediaPlayerDevice): _LOGGER.debug("New Emby Device initialized with ID: %s", device_id) self.emby = emby self.device_id = device_id - self._device = self.emby.devices[self.device_id] + self.device = self.emby.devices[self.device_id] self._hidden = False self._available = True @@ -151,11 +151,11 @@ class EmbyDevice(MediaPlayerDevice): def async_update_callback(self, msg): """Handle device updates.""" # Check if we should update progress - if self._device.media_position: - if self._device.media_position != self.media_status_last_position: - self.media_status_last_position = self._device.media_position + if self.device.media_position: + if self.device.media_position != self.media_status_last_position: + self.media_status_last_position = self.device.media_position self.media_status_received = dt_util.utcnow() - elif not self._device.is_nowplaying: + elif not self.device.is_nowplaying: # No position, but we have an old value and are still playing self.media_status_last_position = None self.media_status_received = None @@ -188,12 +188,12 @@ class EmbyDevice(MediaPlayerDevice): @property def supports_remote_control(self): """Return control ability.""" - return self._device.supports_remote_control + return self.device.supports_remote_control @property def name(self): """Return the name of the device.""" - return ('Emby - {} - {}'.format(self._device.client, self._device.name) + return ('Emby - {} - {}'.format(self.device.client, self.device.name) or DEVICE_DEFAULT_NAME) @property @@ -204,7 +204,7 @@ class EmbyDevice(MediaPlayerDevice): @property def state(self): """Return the state of the device.""" - state = self._device.state + state = self.device.state if state == 'Paused': return STATE_PAUSED if state == 'Playing': @@ -218,17 +218,17 @@ class EmbyDevice(MediaPlayerDevice): def app_name(self): """Return current user as app_name.""" # Ideally the media_player object would have a user property. - return self._device.username + return self.device.username @property def media_content_id(self): """Content ID of current playing media.""" - return self._device.media_id + return self.device.media_id @property def media_content_type(self): """Content type of current playing media.""" - media_type = self._device.media_type + media_type = self.device.media_type if media_type == 'Episode': return MEDIA_TYPE_TVSHOW if media_type == 'Movie': @@ -246,7 +246,7 @@ class EmbyDevice(MediaPlayerDevice): @property def media_duration(self): """Return the duration of current playing media in seconds.""" - return self._device.media_runtime + return self.device.media_runtime @property def media_position(self): @@ -265,42 +265,42 @@ class EmbyDevice(MediaPlayerDevice): @property def media_image_url(self): """Return the image URL of current playing media.""" - return self._device.media_image_url + return self.device.media_image_url @property def media_title(self): """Return the title of current playing media.""" - return self._device.media_title + return self.device.media_title @property def media_season(self): """Season of current playing media (TV Show only).""" - return self._device.media_season + return self.device.media_season @property def media_series_title(self): """Return the title of the series of current playing media (TV).""" - return self._device.media_series_title + return self.device.media_series_title @property def media_episode(self): """Return the episode of current playing media (TV only).""" - return self._device.media_episode + return self.device.media_episode @property def media_album_name(self): """Return the album name of current playing media (Music only).""" - return self._device.media_album_name + return self.device.media_album_name @property def media_artist(self): """Return the artist of current playing media (Music track only).""" - return self._device.media_artist + return self.device.media_artist @property def media_album_artist(self): """Return the album artist of current playing media (Music only).""" - return self._device.media_album_artist + return self.device.media_album_artist @property def supported_features(self): @@ -314,39 +314,39 @@ class EmbyDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ - return self._device.media_play() + return self.device.media_play() def async_media_pause(self): """Pause the media player. This method must be run in the event loop and returns a coroutine. """ - return self._device.media_pause() + return self.device.media_pause() def async_media_stop(self): """Stop the media player. This method must be run in the event loop and returns a coroutine. """ - return self._device.media_stop() + return self.device.media_stop() def async_media_next_track(self): """Send next track command. This method must be run in the event loop and returns a coroutine. """ - return self._device.media_next() + return self.device.media_next() def async_media_previous_track(self): """Send next track command. This method must be run in the event loop and returns a coroutine. """ - return self._device.media_previous() + return self.device.media_previous() def async_media_seek(self, position): """Send seek command. This method must be run in the event loop and returns a coroutine. """ - return self._device.media_seek(position) + return self.device.media_seek(position) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 3c916860818..35906cf5023 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -454,7 +454,7 @@ class PlexClient(MediaPlayerDevice): elif self._player_state == 'paused': self._is_player_active = True self._state = STATE_PAUSED - elif self._device: + elif self.device: self._is_player_active = False self._state = STATE_IDLE else: @@ -528,6 +528,11 @@ class PlexClient(MediaPlayerDevice): """Return the library name of playing media.""" return self._app_name + @property + def device(self): + """Return the device, if any.""" + return self.device + @property def marked_unavailable(self): """Return time device was marked unavailable.""" @@ -666,7 +671,7 @@ class PlexClient(MediaPlayerDevice): SUPPORT_TURN_OFF) # Not all devices support playback functionality # Playback includes volume, stop/play/pause, etc. - if self._device and 'playback' in self._device_protocol_capabilities: + if self.device and 'playback' in self._device_protocol_capabilities: return (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_STOP | SUPPORT_VOLUME_SET | SUPPORT_PLAY | @@ -676,22 +681,22 @@ class PlexClient(MediaPlayerDevice): def set_volume_level(self, volume): """Set volume level, range 0..1.""" - if self._device and 'playback' in self._device_protocol_capabilities: - self._device.setVolume( + if self.device and 'playback' in self._device_protocol_capabilities: + self.device.setVolume( int(volume * 100), self._active_media_plexapi_type) self._volume_level = volume # store since we can't retrieve @property def volume_level(self): """Return the volume level of the client (0..1).""" - if (self._is_player_active and self._device and + if (self._is_player_active and self.device and 'playback' in self._device_protocol_capabilities): return self._volume_level @property def is_volume_muted(self): """Return boolean if volume is currently muted.""" - if self._is_player_active and self._device: + if self._is_player_active and self.device: return self._volume_muted def mute_volume(self, mute): @@ -701,7 +706,7 @@ class PlexClient(MediaPlayerDevice): - On mute, store volume and set volume to 0 - On unmute, set volume to previously stored volume """ - if not (self._device and + if not (self.device and 'playback' in self._device_protocol_capabilities): return @@ -714,18 +719,18 @@ class PlexClient(MediaPlayerDevice): def media_play(self): """Send play command.""" - if self._device and 'playback' in self._device_protocol_capabilities: - self._device.play(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self.device.play(self._active_media_plexapi_type) def media_pause(self): """Send pause command.""" - if self._device and 'playback' in self._device_protocol_capabilities: - self._device.pause(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self.device.pause(self._active_media_plexapi_type) def media_stop(self): """Send stop command.""" - if self._device and 'playback' in self._device_protocol_capabilities: - self._device.stop(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self.device.stop(self._active_media_plexapi_type) def turn_off(self): """Turn the client off.""" @@ -734,17 +739,17 @@ class PlexClient(MediaPlayerDevice): def media_next_track(self): """Send next track command.""" - if self._device and 'playback' in self._device_protocol_capabilities: - self._device.skipNext(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self.device.skipNext(self._active_media_plexapi_type) def media_previous_track(self): """Send previous track command.""" - if self._device and 'playback' in self._device_protocol_capabilities: - self._device.skipPrevious(self._active_media_plexapi_type) + if self.device and 'playback' in self._device_protocol_capabilities: + self.device.skipPrevious(self._active_media_plexapi_type) def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" - if not (self._device and + if not (self.device and 'playback' in self._device_protocol_capabilities): return @@ -752,7 +757,7 @@ class PlexClient(MediaPlayerDevice): media = None if media_type == 'MUSIC': - media = self._device.server.library.section( + media = self.device.server.library.section( src['library_name']).get(src['artist_name']).album( src['album_name']).get(src['track_name']) elif media_type == 'EPISODE': @@ -760,9 +765,9 @@ class PlexClient(MediaPlayerDevice): src['library_name'], src['show_name'], src['season_number'], src['episode_number']) elif media_type == 'PLAYLIST': - media = self._device.server.playlist(src['playlist_name']) + media = self.device.server.playlist(src['playlist_name']) elif media_type == 'VIDEO': - media = self._device.server.library.section( + media = self.device.server.library.section( src['library_name']).get(src['video_name']) import plexapi.playlist @@ -780,13 +785,13 @@ class PlexClient(MediaPlayerDevice): target_season = None target_episode = None - show = self._device.server.library.section(library_name).get( + show = self.device.server.library.section(library_name).get( show_name) if not season_number: playlist_name = "{} - {} Episodes".format( self.entity_id, show_name) - return self._device.server.createPlaylist( + return self.device.server.createPlaylist( playlist_name, show.episodes()) for season in show.seasons(): @@ -803,7 +808,7 @@ class PlexClient(MediaPlayerDevice): if not episode_number: playlist_name = "{} - {} Season {} Episodes".format( self.entity_id, show_name, str(season_number)) - return self._device.server.createPlaylist( + return self.device.server.createPlaylist( playlist_name, target_season.episodes()) for episode in target_season.episodes(): @@ -821,22 +826,22 @@ class PlexClient(MediaPlayerDevice): def _client_play_media(self, media, delete=False, **params): """Instruct Plex client to play a piece of media.""" - if not (self._device and + if not (self.device and 'playback' in self._device_protocol_capabilities): _LOGGER.error("Client cannot play media: %s", self.entity_id) return import plexapi.playqueue playqueue = plexapi.playqueue.PlayQueue.create( - self._device.server, media, **params) + self.device.server, media, **params) # Delete dynamic playlists used to build playqueue (ex. play tv season) if delete: media.delete() - server_url = self._device.server.baseurl.split(':') - self._device.sendCommand('playback/playMedia', **dict({ - 'machineIdentifier': self._device.server.machineIdentifier, + server_url = self.device.server.baseurl.split(':') + self.device.sendCommand('playback/playMedia', **dict({ + 'machineIdentifier': self.device.server.machineIdentifier, 'address': server_url[1].strip('/'), 'port': server_url[-1], 'key': media.key, diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index 489d028aad4..4e26af9dcc2 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -323,8 +323,8 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.warning("Unable to create zone without slaves") else: _LOGGER.info("Creating zone with master %s", - self.device.config.name) - self.device.create_zone([slave.device for slave in slaves]) + self._device.config.name) + self._device.create_zone([slave.device for slave in slaves]) def remove_zone_slave(self, slaves): """ @@ -341,8 +341,8 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.warning("Unable to find slaves to remove") else: _LOGGER.info("Removing slaves from zone with master %s", - self.device.config.name) - self.device.remove_zone_slave([slave.device for slave in slaves]) + self._device.config.name) + self._device.remove_zone_slave([slave.device for slave in slaves]) def add_zone_slave(self, slaves): """ @@ -357,5 +357,5 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.warning("Unable to find slaves to add") else: _LOGGER.info("Adding slaves to zone with master %s", - self.device.config.name) - self.device.add_zone_slave([slave.device for slave in slaves]) + self._device.config.name) + self._device.add_zone_slave([slave.device for slave in slaves]) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 04163f1ca13..57111350396 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -282,12 +282,12 @@ class NestSensorDevice(Entity): if device is not None: # device specific - self._device = device - self._name = "{} {}".format(self._device.name_long, + self.device = device + self._name = "{} {}".format(self.device.name_long, self.variable.replace('_', ' ')) else: # structure only - self._device = structure + self.device = structure self._name = "{} {}".format(self.structure.name, self.variable.replace('_', ' ')) diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py index f9a6a4b25f2..750e3945569 100644 --- a/homeassistant/components/notify/knx.py +++ b/homeassistant/components/notify/knx.py @@ -61,13 +61,13 @@ class KNXNotificationService(BaseNotificationService): def __init__(self, devices): """Initialize the service.""" - self._devices = devices + self.devices = devices @property def targets(self): """Return a dictionary of registered targets.""" ret = {} - for device in self._devices: + for device in self.devices: ret[device.name] = device.name return ret @@ -80,11 +80,11 @@ class KNXNotificationService(BaseNotificationService): async def _async_send_to_all_devices(self, message): """Send a notification to knx bus to all connected devices.""" - for device in self._devices: + for device in self.devices: await device.set(message) async def _async_send_to_device(self, message, names): """Send a notification to knx bus to device with given names.""" - for device in self._devices: + for device in self.devices: if device.name in names: await device.set(message) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 8af0e8db28d..63e30a9491e 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -98,13 +98,13 @@ class QSToggleEntity(QSEntity): def __init__(self, qsid, qsusb): """Initialize the ToggleEntity.""" - self._device = qsusb.devices[qsid] - super().__init__(qsid, self._device.name) + self.device = qsusb.devices[qsid] + super().__init__(qsid, self.device.name) @property def is_on(self): """Check if device is on (non-zero).""" - return self._device.value > 0 + return self.device.value > 0 async def async_turn_on(self, **kwargs): """Turn the device on.""" diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 7fbcba5a26e..723f575ba34 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -188,6 +188,11 @@ class XiaomiMiioRemote(RemoteDevice): """Return the name of the remote.""" return self._name + @property + def device(self): + """Return the remote object.""" + return self._device + @property def hidden(self): """Return if we should hide entity.""" @@ -208,7 +213,7 @@ class XiaomiMiioRemote(RemoteDevice): """Return False if device is unreachable, else True.""" from miio import DeviceException try: - self._device.info() + self.device.info() return True except DeviceException: return False @@ -243,7 +248,7 @@ class XiaomiMiioRemote(RemoteDevice): _LOGGER.debug("Sending payload: '%s'", payload) try: - self._device.play(payload) + self.device.play(payload) except DeviceException as ex: _LOGGER.error( "Transmit of IR command failed, %s, exception: %s", diff --git a/homeassistant/components/sensor/juicenet.py b/homeassistant/components/sensor/juicenet.py index b8ef38981e8..18725394a1f 100644 --- a/homeassistant/components/sensor/juicenet.py +++ b/homeassistant/components/sensor/juicenet.py @@ -49,14 +49,14 @@ class JuicenetSensorDevice(JuicenetDevice, Entity): @property def name(self): """Return the name of the device.""" - return '{} {}'.format(self._device.name(), self._name) + return '{} {}'.format(self.device.name(), self._name) @property def icon(self): """Return the icon of the sensor.""" icon = None if self.type == 'status': - status = self._device.getStatus() + status = self.device.getStatus() if status == 'standby': icon = 'mdi:power-plug-off' elif status == 'plugged': @@ -87,19 +87,19 @@ class JuicenetSensorDevice(JuicenetDevice, Entity): """Return the state.""" state = None if self.type == 'status': - state = self._device.getStatus() + state = self.device.getStatus() elif self.type == 'temperature': - state = self._device.getTemperature() + state = self.device.getTemperature() elif self.type == 'voltage': - state = self._device.getVoltage() + state = self.device.getVoltage() elif self.type == 'amps': - state = self._device.getAmps() + state = self.device.getAmps() elif self.type == 'watts': - state = self._device.getWatts() + state = self.device.getWatts() elif self.type == 'charge_time': - state = self._device.getChargeTime() + state = self.device.getChargeTime() elif self.type == 'energy_added': - state = self._device.getEnergyAdded() + state = self.device.getEnergyAdded() else: state = 'Unknown' return state @@ -109,7 +109,7 @@ class JuicenetSensorDevice(JuicenetDevice, Entity): """Return the state attributes.""" attributes = {} if self.type == 'status': - man_dev_id = self._device.id() + man_dev_id = self.device.id() if man_dev_id: attributes["manufacturer_device_id"] = man_dev_id return attributes diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index b8b55a1cc7c..ec506189c12 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -64,7 +64,7 @@ class KNXSensor(Entity): def __init__(self, hass, device): """Initialize of a KNX sensor.""" - self._device = device + self.device = device self.hass = hass self.async_register_callbacks() @@ -74,12 +74,12 @@ class KNXSensor(Entity): async def after_update_callback(device): """Call after device was updated.""" await self.async_update_ha_state() - self._device.register_device_updated_cb(after_update_callback) + self.device.register_device_updated_cb(after_update_callback) @property def name(self): """Return the name of the KNX device.""" - return self._device.name + return self.device.name @property def available(self): @@ -94,12 +94,12 @@ class KNXSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self._device.resolve_state() + return self.device.resolve_state() @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return self._device.unit_of_measurement() + return self.device.unit_of_measurement() @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index d51b0ab4053..738bc53d880 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -140,15 +140,15 @@ class NestBasicSensor(NestSensorDevice): self._unit = SENSOR_UNITS.get(self.variable) if self.variable in VARIABLE_NAME_MAPPING: - self._state = getattr(self._device, + self._state = getattr(self.device, VARIABLE_NAME_MAPPING[self.variable]) elif self.variable in PROTECT_SENSOR_TYPES \ and self.variable != 'color_status': # keep backward compatibility - state = getattr(self._device, self.variable) + state = getattr(self.device, self.variable) self._state = state.capitalize() if state is not None else None else: - self._state = getattr(self._device, self.variable) + self._state = getattr(self.device, self.variable) class NestTempSensor(NestSensorDevice): @@ -166,12 +166,12 @@ class NestTempSensor(NestSensorDevice): def update(self): """Retrieve latest state.""" - if self._device.temperature_scale == 'C': + if self.device.temperature_scale == 'C': self._unit = TEMP_CELSIUS else: self._unit = TEMP_FAHRENHEIT - temp = getattr(self._device, self.variable) + temp = getattr(self.device, self.variable) if temp is None: self._state = None diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py index c3cc75dac0c..55928a80f13 100644 --- a/homeassistant/components/sensor/tank_utility.py +++ b/homeassistant/components/sensor/tank_utility.py @@ -79,10 +79,15 @@ class TankUtilitySensor(Entity): self._token = token self._device = device self._state = STATE_UNKNOWN - self._name = "Tank Utility " + self._device + self._name = "Tank Utility " + self.device self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT self._attributes = {} + @property + def device(self): + """Return the device identifier.""" + return self._device + @property def state(self): """Return the state of the device.""" @@ -112,14 +117,14 @@ class TankUtilitySensor(Entity): from tank_utility import auth, device data = {} try: - data = device.get_device_data(self._token, self._device) + data = device.get_device_data(self._token, self.device) except requests.exceptions.HTTPError as http_error: if (http_error.response.status_code == requests.codes.unauthorized): # pylint: disable=no-member _LOGGER.info("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) - data = device.get_device_data(self._token, self._device) + data = device.get_device_data(self._token, self.device) else: raise http_error data.update(data.pop("device", {})) diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 34908595951..4676e08a247 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -67,7 +67,7 @@ class TelldusLiveSensor(TelldusLiveEntity): @property def _value(self): """Return value of the sensor.""" - return self._device.value(*self._id[1:]) + return self.device.value(*self._id[1:]) @property def _value_as_temperature(self): diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index af60cee127a..678a8d4775f 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -63,7 +63,7 @@ class KNXSwitch(SwitchDevice): def __init__(self, hass, device): """Initialize of KNX switch.""" - self._device = device + self.device = device self.hass = hass self.async_register_callbacks() @@ -73,12 +73,12 @@ class KNXSwitch(SwitchDevice): async def after_update_callback(device): """Call after device was updated.""" await self.async_update_ha_state() - self._device.register_device_updated_cb(after_update_callback) + self.device.register_device_updated_cb(after_update_callback) @property def name(self): """Return the name of the KNX device.""" - return self._device.name + return self.device.name @property def available(self): @@ -93,12 +93,12 @@ class KNXSwitch(SwitchDevice): @property def is_on(self): """Return true if device is on.""" - return self._device.state + return self.device.state async def async_turn_on(self, **kwargs): """Turn the device on.""" - await self._device.set_on() + await self.device.set_on() async def async_turn_off(self, **kwargs): """Turn the device off.""" - await self._device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/switch/tellduslive.py b/homeassistant/components/switch/tellduslive.py index c1134fc21c1..0263dfd8198 100644 --- a/homeassistant/components/switch/tellduslive.py +++ b/homeassistant/components/switch/tellduslive.py @@ -28,14 +28,14 @@ class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): @property def is_on(self): """Return true if switch is on.""" - return self._device.is_on + return self.device.is_on def turn_on(self, **kwargs): """Turn the switch on.""" - self._device.turn_on() + self.device.turn_on() self.changed() def turn_off(self, **kwargs): """Turn the switch off.""" - self._device.turn_off() + self.device.turn_off() self.changed() diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 58be267bbbc..693499510ad 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -287,14 +287,14 @@ class TelldusLiveEntity(Entity): self._id = device_id self._client = hass.data[DOMAIN] self._client.entities.append(self) - self._device = self._client.device(device_id) - self._name = self._device.name + self.device = self._client.device(device_id) + self._name = self.device.name _LOGGER.debug('Created device %s', self) def changed(self): """Return the property of the device might have changed.""" - if self._device.name: - self._name = self._device.name + if self.device.name: + self._name = self.device.name self.schedule_update_ha_state() @property @@ -302,10 +302,15 @@ class TelldusLiveEntity(Entity): """Return the id of the device.""" return self._id + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + @property def _state(self): """Return the state of the device.""" - return self._device.state + return self.device.state @property def should_poll(self): @@ -343,16 +348,16 @@ class TelldusLiveEntity(Entity): from tellduslive import (BATTERY_LOW, BATTERY_UNKNOWN, BATTERY_OK) - if self._device.battery == BATTERY_LOW: + if self.device.battery == BATTERY_LOW: return 1 - if self._device.battery == BATTERY_UNKNOWN: + if self.device.battery == BATTERY_UNKNOWN: return None - if self._device.battery == BATTERY_OK: + if self.device.battery == BATTERY_OK: return 100 - return self._device.battery # Percentage + return self.device.battery # Percentage @property def _last_updated(self): """Return the last update of a device.""" - return str(datetime.fromtimestamp(self._device.lastUpdated)) \ - if self._device.lastUpdated else None + return str(datetime.fromtimestamp(self.device.lastUpdated)) \ + if self.device.lastUpdated else None From 16ad9c2ae64780104529ba95dee23c2730a3f1da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Aug 2018 22:52:21 +0200 Subject: [PATCH 19/42] Update translations --- homeassistant/components/hangouts/.translations/en.json | 2 +- homeassistant/components/hangouts/.translations/pl.json | 2 +- homeassistant/components/hangouts/.translations/ru.json | 5 +++++ .../components/homematicip_cloud/.translations/pt-BR.json | 1 + .../components/homematicip_cloud/.translations/zh-Hant.json | 1 + homeassistant/components/hue/.translations/pt-BR.json | 2 +- 6 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hangouts/.translations/en.json b/homeassistant/components/hangouts/.translations/en.json index eb278afaf7f..6e70a1f4310 100644 --- a/homeassistant/components/hangouts/.translations/en.json +++ b/homeassistant/components/hangouts/.translations/en.json @@ -6,7 +6,7 @@ }, "error": { "invalid_2fa": "Invalid 2 Factor Authorization, please try again.", - "invalid_2fa_method": "Invalig 2FA Method (Verify on Phone).", + "invalid_2fa_method": "Invalid 2FA Method (Verify on Phone).", "invalid_login": "Invalid Login, please try again." }, "step": { diff --git a/homeassistant/components/hangouts/.translations/pl.json b/homeassistant/components/hangouts/.translations/pl.json index 9cbc02f126e..a8314761f8d 100644 --- a/homeassistant/components/hangouts/.translations/pl.json +++ b/homeassistant/components/hangouts/.translations/pl.json @@ -21,7 +21,7 @@ "email": "Adres e-mail", "password": "Has\u0142o" }, - "title": "Login Google Hangouts" + "title": "Logowanie do Google Hangouts" } }, "title": "Google Hangouts" diff --git a/homeassistant/components/hangouts/.translations/ru.json b/homeassistant/components/hangouts/.translations/ru.json index 730d9404837..c3363215201 100644 --- a/homeassistant/components/hangouts/.translations/ru.json +++ b/homeassistant/components/hangouts/.translations/ru.json @@ -5,10 +5,15 @@ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, "error": { + "invalid_2fa": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", + "invalid_2fa_method": "\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439 \u0441\u043f\u043e\u0441\u043e\u0431 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 (\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0435).", "invalid_login": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430." }, "step": { "2fa": { + "data": { + "2fa": "\u041f\u0438\u043d-\u043a\u043e\u0434 \u0434\u043b\u044f \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + }, "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "user": { diff --git a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json index 6e5af1c26cc..d4ecbe50107 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json +++ b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "O Accesspoint j\u00e1 est\u00e1 configurado", "conection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", + "connection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", "unknown": "Ocorreu um erro desconhecido." }, "error": { diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json index d8c6cff9b0c..9340070d9a3 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Accesspoint \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "conection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", + "connection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" }, "error": { diff --git a/homeassistant/components/hue/.translations/pt-BR.json b/homeassistant/components/hue/.translations/pt-BR.json index 5c6e409245c..b30764c9239 100644 --- a/homeassistant/components/hue/.translations/pt-BR.json +++ b/homeassistant/components/hue/.translations/pt-BR.json @@ -24,6 +24,6 @@ "title": "Hub de links" } }, - "title": "Philips Hue" + "title": "" } } \ No newline at end of file From 8fb66c351e9c9edcf75eccfcc6aecbdb2ea6382a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Aug 2018 22:52:58 +0200 Subject: [PATCH 20/42] Add new translations --- .../components/hangouts/.translations/it.json | 5 +++ .../components/hangouts/.translations/no.json | 13 ++++++++ .../hangouts/.translations/pt-BR.json | 28 +++++++++++++++++ .../hangouts/.translations/zh-Hant.json | 31 +++++++++++++++++++ .../homematicip_cloud/.translations/it.json | 11 +++++++ .../sensor/.translations/moon.it.json | 8 +++++ .../sensor/.translations/moon.pt-BR.json | 12 +++++++ 7 files changed, 108 insertions(+) create mode 100644 homeassistant/components/hangouts/.translations/it.json create mode 100644 homeassistant/components/hangouts/.translations/no.json create mode 100644 homeassistant/components/hangouts/.translations/pt-BR.json create mode 100644 homeassistant/components/hangouts/.translations/zh-Hant.json create mode 100644 homeassistant/components/homematicip_cloud/.translations/it.json create mode 100644 homeassistant/components/sensor/.translations/moon.it.json create mode 100644 homeassistant/components/sensor/.translations/moon.pt-BR.json diff --git a/homeassistant/components/hangouts/.translations/it.json b/homeassistant/components/hangouts/.translations/it.json new file mode 100644 index 00000000000..0c609b3430a --- /dev/null +++ b/homeassistant/components/hangouts/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/no.json b/homeassistant/components/hangouts/.translations/no.json new file mode 100644 index 00000000000..7ea074470c7 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "E-postadresse", + "password": "Passord" + } + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/pt-BR.json b/homeassistant/components/hangouts/.translations/pt-BR.json new file mode 100644 index 00000000000..4dffe492c4d --- /dev/null +++ b/homeassistant/components/hangouts/.translations/pt-BR.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Hangouts do Google j\u00e1 est\u00e1 configurado.", + "unknown": "Ocorreu um erro desconhecido." + }, + "error": { + "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).", + "invalid_login": "Login inv\u00e1lido, por favor, tente novamente." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "title": "" + }, + "user": { + "data": { + "email": "Endere\u00e7o de e-mail", + "password": "Senha" + }, + "title": "Login do Hangouts do Google" + } + }, + "title": "Hangouts do Google" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/zh-Hant.json b/homeassistant/components/hangouts/.translations/zh-Hant.json new file mode 100644 index 00000000000..0920e0325d2 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u5df2\u7d93\u8a2d\u5b9a", + "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "error": { + "invalid_2fa": "\u5169\u968e\u6bb5\u9a57\u8b49\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_2fa_method": "\u8a8d\u8b49\u65b9\u5f0f\u7121\u6548\uff08\u65bc\u96fb\u8a71\u4e0a\u9a57\u8b49\uff09\u3002", + "invalid_login": "\u767b\u5165\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "2fa": { + "data": { + "2fa": "\u8a8d\u8b49\u78bc" + }, + "description": "\u7a7a\u767d", + "title": "\u5169\u968e\u6bb5\u8a8d\u8b49" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "description": "\u7a7a\u767d", + "title": "\u767b\u5165 Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/it.json b/homeassistant/components/homematicip_cloud/.translations/it.json new file mode 100644 index 00000000000..2566eb25570 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/it.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "pin": "Codice Pin (opzionale)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.it.json b/homeassistant/components/sensor/.translations/moon.it.json new file mode 100644 index 00000000000..fce5152b3f9 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.it.json @@ -0,0 +1,8 @@ +{ + "state": { + "first_quarter": "Primo quarto", + "full_moon": "Luna piena", + "last_quarter": "Ultimo quarto", + "new_moon": "Nuova luna" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.pt-BR.json b/homeassistant/components/sensor/.translations/moon.pt-BR.json new file mode 100644 index 00000000000..af4cefff6e5 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.pt-BR.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Quarto crescente", + "full_moon": "Cheia", + "last_quarter": "Quarto minguante", + "new_moon": "Nova", + "waning_crescent": "Minguante", + "waning_gibbous": "Minguante gibosa", + "waxing_crescent": "Crescente", + "waxing_gibbous": "Crescente gibosa" + } +} \ No newline at end of file From 84131011489def5f55924f396561d07033d380c7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Aug 2018 22:53:20 +0200 Subject: [PATCH 21/42] Bumped version to 0.77.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c299b5a2a73..cc0c9294506 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 77 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From adb5579690366cd80943a1744ac0266f52cc3cf9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Aug 2018 10:16:59 +0200 Subject: [PATCH 22/42] Update translations --- .../components/auth/.translations/ca.json | 16 ++++++++++++++++ .../components/auth/.translations/en.json | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/auth/.translations/ca.json diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json new file mode 100644 index 00000000000..1b3b25dbcff --- /dev/null +++ b/homeassistant/components/auth/.translations/ca.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Codi no v\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa." + }, + "step": { + "init": { + "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escanegeu el codi QR amb la vostre aplicaci\u00f3 de verificaci\u00f3. Si no en teniu cap, us recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdu\u00efu el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si teniu problemes per escanejar el codi QR, feu una configuraci\u00f3 manual amb el codi **`{code}`**.", + "title": "Configureu la verificaci\u00f3 en dos passos utilitzant TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json index 5c1af67b120..a0fd20e9d08 100644 --- a/homeassistant/components/auth/.translations/en.json +++ b/homeassistant/components/auth/.translations/en.json @@ -2,12 +2,12 @@ "mfa_setup": { "totp": { "error": { - "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock on Home Assistant system is accurate." + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." }, "step": { "init": { - "description": "Scan the QR code with your authentication app, such as **Google Authenticator** or **Authy**. If you have problem to scan the QR code, using **`{code}`** to manual setup. \n\n{qr_code}\n\nEnter the six digi code appeared in your app below to verify the setup:", - "title": "Scan this QR code with your app" + "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.", + "title": "Set up two-factor authentication using TOTP" } }, "title": "TOTP" From 914436f3d5149d4f3542c6df8c308745e5db0938 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Aug 2018 22:28:17 +0200 Subject: [PATCH 23/42] Bump frontend to 20180827.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 4622f80948e..f0976c78224 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180826.0'] +REQUIREMENTS = ['home-assistant-frontend==20180827.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 3c505a2f1e3..fe3e64cd62e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -442,7 +442,7 @@ hole==0.3.0 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180826.0 +home-assistant-frontend==20180827.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05d449a5eb2..9f732f84b55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.2 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180826.0 +home-assistant-frontend==20180827.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 9c7d4381a1b337cb11dd2305643b034044f26a2f Mon Sep 17 00:00:00 2001 From: Dan Klaffenbach Date: Sun, 26 Aug 2018 12:00:20 +0200 Subject: [PATCH 24/42] homematic: Make device avilable again when UNREACH becomes False (#16202) --- homeassistant/components/homematic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 527b8c8f018..53c8e267016 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -869,7 +869,7 @@ class HMDevice(Entity): # Availability has changed if attribute == 'UNREACH': - self._available = bool(value) + self._available = not bool(value) has_changed = True elif not self.available: self._available = False From 2744702f9bc15b9a23e7982f40e55eee476264d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Aug 2018 10:37:03 +0200 Subject: [PATCH 25/42] Change auth warning (#16216) --- homeassistant/components/http/__init__.py | 16 ++++------------ homeassistant/components/http/auth.py | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index ac08c26229c..6909a0e4664 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -200,18 +200,10 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(hass, app, login_threshold) - if hass.auth.active: - if hass.auth.support_legacy: - _LOGGER.warning("Experimental auth api enabled and " - "legacy_api_password support enabled. Please " - "use access_token instead api_password, " - "although you can still use legacy " - "api_password") - else: - _LOGGER.warning("Experimental auth api enabled. Please use " - "access_token instead api_password.") - elif api_password is None: - _LOGGER.warning("You have been advised to set http.api_password.") + if hass.auth.active and hass.auth.support_legacy: + _LOGGER.warning( + "legacy_api_password support has been enabled. If you don't" + "require it, remove the 'api_password' from your http config.") setup_auth(app, trusted_networks, hass.auth.active, support_legacy=hass.auth.support_legacy, diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7adcc43f4af..a18b4de7a10 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -32,7 +32,7 @@ def setup_auth(app, trusted_networks, use_auth, if request.path not in old_auth_warning: _LOGGER.log( logging.INFO if support_legacy else logging.WARNING, - 'Please change to use bearer token access %s from %s', + 'You need to use a bearer token to access %s from %s', request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) From a953601abdfe6ab1fc5b1026ec9fb72530201fcd Mon Sep 17 00:00:00 2001 From: Marcel Hoppe Date: Tue, 28 Aug 2018 00:20:12 +0200 Subject: [PATCH 26/42] rewrite hangouts to use intents instead of commands (#16220) * rewrite hangouts to use intents instead of commands * small fixes * remove configured_hangouts check and CONFIG_SCHEMA * Lint * add import from .config_flow --- .../components/conversation/__init__.py | 38 +--- homeassistant/components/conversation/util.py | 35 ++++ homeassistant/components/hangouts/__init__.py | 53 +++++- homeassistant/components/hangouts/const.py | 26 +-- .../components/hangouts/hangouts_bot.py | 174 ++++++++++-------- tests/components/test_conversation.py | 12 +- 6 files changed, 196 insertions(+), 142 deletions(-) create mode 100644 homeassistant/components/conversation/util.py diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9cb00a84583..d8d386f5ca0 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import http +from homeassistant.components.conversation.util import create_matcher from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components.cover import (INTENT_OPEN_COVER, @@ -74,7 +75,7 @@ def async_register(hass, intent_type, utterances): if isinstance(utterance, REGEX_TYPE): conf.append(utterance) else: - conf.append(_create_matcher(utterance)) + conf.append(create_matcher(utterance)) async def async_setup(hass, config): @@ -91,7 +92,7 @@ async def async_setup(hass, config): if conf is None: conf = intents[intent_type] = [] - conf.extend(_create_matcher(utterance) for utterance in utterances) + conf.extend(create_matcher(utterance) for utterance in utterances) async def process(service): """Parse text into commands.""" @@ -146,39 +147,6 @@ async def async_setup(hass, config): return True -def _create_matcher(utterance): - """Create a regex that matches the utterance.""" - # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL - # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} - parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) - # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r'{(\w+)}') - # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r'\[([\w ]+)\] *') - - pattern = ['^'] - for part in parts: - group_match = group_matcher.match(part) - optional_match = optional_matcher.match(part) - - # Normal part - if group_match is None and optional_match is None: - pattern.append(part) - continue - - # Group part - if group_match is not None: - pattern.append( - r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) - - # Optional part - elif optional_match is not None: - pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) - - pattern.append('$') - return re.compile(''.join(pattern), re.I) - - async def _process(hass, text): """Process a line of text.""" intents = hass.data.get(DOMAIN, {}) diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 00000000000..60d861afdbe --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,35 @@ +"""Util for Conversation.""" +import re + + +def create_matcher(utterance): + """Create a regex that matches the utterance.""" + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} + group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') + + pattern = ['^'] + for part in parts: + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) + + # Normal part + if group_match is None and optional_match is None: + pattern.append(part) + continue + + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) + + pattern.append('$') + return re.compile(''.join(pattern), re.I) diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 8ebacc3736b..72a7e015a22 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -11,28 +11,56 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import dispatcher +import homeassistant.helpers.config_validation as cv -from .config_flow import configured_hangouts from .const import ( - CONF_BOT, CONF_COMMANDS, CONF_REFRESH_TOKEN, DOMAIN, + CONF_BOT, CONF_INTENTS, CONF_REFRESH_TOKEN, DOMAIN, EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, MESSAGE_SCHEMA, SERVICE_SEND_MESSAGE, - SERVICE_UPDATE) + SERVICE_UPDATE, CONF_SENTENCES, CONF_MATCHERS, + CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA) + +# We need an import from .config_flow, without it .config_flow is never loaded. +from .config_flow import HangoutsFlowHandler # noqa: F401 + REQUIREMENTS = ['hangups==0.4.5'] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_INTENTS, default={}): vol.Schema({ + cv.string: INTENT_SCHEMA + }), + vol.Optional(CONF_ERROR_SUPPRESSED_CONVERSATIONS, default=[]): + [TARGETS_SCHEMA] + }) +}, extra=vol.ALLOW_EXTRA) + async def async_setup(hass, config): """Set up the Hangouts bot component.""" - config = config.get(DOMAIN, {}) - hass.data[DOMAIN] = {CONF_COMMANDS: config.get(CONF_COMMANDS, [])} + from homeassistant.components.conversation import create_matcher - if configured_hangouts(hass) is None: - hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT} - )) + config = config.get(DOMAIN) + if config is None: + return True + + hass.data[DOMAIN] = {CONF_INTENTS: config.get(CONF_INTENTS), + CONF_ERROR_SUPPRESSED_CONVERSATIONS: + config.get(CONF_ERROR_SUPPRESSED_CONVERSATIONS)} + + for data in hass.data[DOMAIN][CONF_INTENTS].values(): + matchers = [] + for sentence in data[CONF_SENTENCES]: + matchers.append(create_matcher(sentence)) + + data[CONF_MATCHERS] = matchers + + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT} + )) return True @@ -47,7 +75,8 @@ async def async_setup_entry(hass, config): bot = HangoutsBot( hass, config.data.get(CONF_REFRESH_TOKEN), - hass.data[DOMAIN][CONF_COMMANDS]) + hass.data[DOMAIN][CONF_INTENTS], + hass.data[DOMAIN][CONF_ERROR_SUPPRESSED_CONVERSATIONS]) hass.data[DOMAIN][CONF_BOT] = bot except GoogleAuthError as exception: _LOGGER.error("Hangouts failed to log in: %s", str(exception)) @@ -62,6 +91,10 @@ async def async_setup_entry(hass, config): hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, bot.async_update_conversation_commands) + dispatcher.async_dispatcher_connect( + hass, + EVENT_HANGOUTS_CONVERSATIONS_CHANGED, + bot.async_handle_update_error_suppressed_conversations) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, bot.async_handle_hass_stop) diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index 7083307f3e2..3b96edf93a2 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -4,7 +4,6 @@ import logging import voluptuous as vol from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TARGET -from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger('homeassistant.components.hangouts') @@ -18,17 +17,18 @@ CONF_BOT = 'bot' CONF_CONVERSATIONS = 'conversations' CONF_DEFAULT_CONVERSATIONS = 'default_conversations' +CONF_ERROR_SUPPRESSED_CONVERSATIONS = 'error_suppressed_conversations' -CONF_COMMANDS = 'commands' -CONF_WORD = 'word' -CONF_EXPRESSION = 'expression' - -EVENT_HANGOUTS_COMMAND = 'hangouts_command' +CONF_INTENTS = 'intents' +CONF_INTENT_TYPE = 'intent_type' +CONF_SENTENCES = 'sentences' +CONF_MATCHERS = 'matchers' EVENT_HANGOUTS_CONNECTED = 'hangouts_connected' EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected' EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed' EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed' +EVENT_HANGOUTS_MESSAGE_RECEIVED = 'hangouts_message_received' CONF_CONVERSATION_ID = 'id' CONF_CONVERSATION_NAME = 'name' @@ -59,20 +59,10 @@ MESSAGE_SCHEMA = vol.Schema({ vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA] }) -COMMAND_SCHEMA = vol.All( +INTENT_SCHEMA = vol.All( # Basic Schema vol.Schema({ - vol.Exclusive(CONF_WORD, 'trigger'): cv.string, - vol.Exclusive(CONF_EXPRESSION, 'trigger'): cv.is_regex, - vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SENTENCES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CONVERSATIONS): [TARGETS_SCHEMA] }), - # Make sure it's either a word or an expression command - cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION) ) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA] - }) -}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index d9ffb4cbace..15f4156d374 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -1,13 +1,14 @@ """The Hangouts Bot.""" import logging -import re -from homeassistant.helpers import dispatcher +from homeassistant.helpers import dispatcher, intent from .const import ( - ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATIONS, CONF_EXPRESSION, CONF_NAME, - CONF_WORD, DOMAIN, EVENT_HANGOUTS_COMMAND, EVENT_HANGOUTS_CONNECTED, - EVENT_HANGOUTS_CONVERSATIONS_CHANGED, EVENT_HANGOUTS_DISCONNECTED) + ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATIONS, DOMAIN, + EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, + EVENT_HANGOUTS_DISCONNECTED, EVENT_HANGOUTS_MESSAGE_RECEIVED, + CONF_MATCHERS, CONF_CONVERSATION_ID, + CONF_CONVERSATION_NAME) _LOGGER = logging.getLogger(__name__) @@ -15,20 +16,34 @@ _LOGGER = logging.getLogger(__name__) class HangoutsBot: """The Hangouts Bot.""" - def __init__(self, hass, refresh_token, commands): + def __init__(self, hass, refresh_token, intents, error_suppressed_convs): """Set up the client.""" self.hass = hass self._connected = False self._refresh_token = refresh_token - self._commands = commands + self._intents = intents + self._conversation_intents = None - self._word_commands = None - self._expression_commands = None self._client = None self._user_list = None self._conversation_list = None + self._error_suppressed_convs = error_suppressed_convs + self._error_suppressed_conv_ids = None + + dispatcher.async_dispatcher_connect( + self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, + self._async_handle_conversation_message) + + def _resolve_conversation_id(self, obj): + if CONF_CONVERSATION_ID in obj: + return obj[CONF_CONVERSATION_ID] + if CONF_CONVERSATION_NAME in obj: + conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME]) + if conv is not None: + return conv.id_ + return None def _resolve_conversation_name(self, name): for conv in self._conversation_list.get_all(): @@ -38,89 +53,100 @@ class HangoutsBot: def async_update_conversation_commands(self, _): """Refresh the commands for every conversation.""" - self._word_commands = {} - self._expression_commands = {} + self._conversation_intents = {} - for command in self._commands: - if command.get(CONF_CONVERSATIONS): + for intent_type, data in self._intents.items(): + if data.get(CONF_CONVERSATIONS): conversations = [] - for conversation in command.get(CONF_CONVERSATIONS): - if 'id' in conversation: - conversations.append(conversation['id']) - elif 'name' in conversation: - conversations.append(self._resolve_conversation_name( - conversation['name']).id_) - command['_' + CONF_CONVERSATIONS] = conversations + for conversation in data.get(CONF_CONVERSATIONS): + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + conversations.append(conv_id) + data['_' + CONF_CONVERSATIONS] = conversations else: - command['_' + CONF_CONVERSATIONS] = \ + data['_' + CONF_CONVERSATIONS] = \ [conv.id_ for conv in self._conversation_list.get_all()] - if command.get(CONF_WORD): - for conv_id in command['_' + CONF_CONVERSATIONS]: - if conv_id not in self._word_commands: - self._word_commands[conv_id] = {} - word = command[CONF_WORD].lower() - self._word_commands[conv_id][word] = command - elif command.get(CONF_EXPRESSION): - command['_' + CONF_EXPRESSION] = re.compile( - command.get(CONF_EXPRESSION)) + for conv_id in data['_' + CONF_CONVERSATIONS]: + if conv_id not in self._conversation_intents: + self._conversation_intents[conv_id] = {} - for conv_id in command['_' + CONF_CONVERSATIONS]: - if conv_id not in self._expression_commands: - self._expression_commands[conv_id] = [] - self._expression_commands[conv_id].append(command) + self._conversation_intents[conv_id][intent_type] = data try: self._conversation_list.on_event.remove_observer( - self._handle_conversation_event) + self._async_handle_conversation_event) except ValueError: pass self._conversation_list.on_event.add_observer( - self._handle_conversation_event) + self._async_handle_conversation_event) - def _handle_conversation_event(self, event): + def async_handle_update_error_suppressed_conversations(self, _): + """Resolve the list of error suppressed conversations.""" + self._error_suppressed_conv_ids = [] + for conversation in self._error_suppressed_convs: + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + self._error_suppressed_conv_ids.append(conv_id) + + async def _async_handle_conversation_event(self, event): from hangups import ChatMessageEvent - if event.__class__ is ChatMessageEvent: - self._handle_conversation_message( - event.conversation_id, event.user_id, event) + if isinstance(event, ChatMessageEvent): + dispatcher.async_dispatcher_send(self.hass, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + event.conversation_id, + event.user_id, event) - def _handle_conversation_message(self, conv_id, user_id, event): + async def _async_handle_conversation_message(self, + conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return + message = event.text _LOGGER.debug("Handling message '%s' from %s", - event.text, user.full_name) + message, user.full_name) - event_data = None + intents = self._conversation_intents.get(conv_id) + if intents is not None: + is_error = False + try: + intent_result = await self._async_process(intents, message) + except (intent.UnknownIntent, intent.IntentHandleError) as err: + is_error = True + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) + + if intent_result is None: + is_error = True + intent_result = intent.IntentResponse() + intent_result.async_set_speech( + "Sorry, I didn't understand that") + + message = intent_result.as_dict().get('speech', {})\ + .get('plain', {}).get('speech') + + if (message is not None) and not ( + is_error and conv_id in self._error_suppressed_conv_ids): + await self._async_send_message( + [{'text': message, 'parse_str': True}], + [{CONF_CONVERSATION_ID: conv_id}]) + + async def _async_process(self, intents, text): + """Detect a matching intent.""" + for intent_type, data in intents.items(): + for matcher in data.get(CONF_MATCHERS, []): + match = matcher.match(text) - pieces = event.text.split(' ') - cmd = pieces[0].lower() - command = self._word_commands.get(conv_id, {}).get(cmd) - if command: - event_data = { - 'command': command[CONF_NAME], - 'conversation_id': conv_id, - 'user_id': user_id, - 'user_name': user.full_name, - 'data': pieces[1:] - } - else: - # After single-word commands, check all regex commands in the room - for command in self._expression_commands.get(conv_id, []): - match = command['_' + CONF_EXPRESSION].match(event.text) if not match: continue - event_data = { - 'command': command[CONF_NAME], - 'conversation_id': conv_id, - 'user_id': user_id, - 'user_name': user.full_name, - 'data': match.groupdict() - } - if event_data is not None: - self.hass.bus.fire(EVENT_HANGOUTS_COMMAND, event_data) + + response = await self.hass.helpers.intent.async_handle( + DOMAIN, intent_type, + {key: {'value': value} for key, value + in match.groupdict().items()}, text) + return response async def async_connect(self): """Login to the Google Hangouts.""" @@ -163,10 +189,12 @@ class HangoutsBot: conversations = [] for target in targets: conversation = None - if 'id' in target: - conversation = self._conversation_list.get(target['id']) - elif 'name' in target: - conversation = self._resolve_conversation_name(target['name']) + if CONF_CONVERSATION_ID in target: + conversation = self._conversation_list.get( + target[CONF_CONVERSATION_ID]) + elif CONF_CONVERSATION_NAME in target: + conversation = self._resolve_conversation_name( + target[CONF_CONVERSATION_NAME]) if conversation is not None: conversations.append(conversation) @@ -200,8 +228,8 @@ class HangoutsBot: users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) - conversations[str(i)] = {'id': str(conv.id_), - 'name': conv.name, + conversations[str(i)] = {CONF_CONVERSATION_ID: str(conv.id_), + CONF_CONVERSATION_NAME: conv.name, 'users': users_in_conversation} self.hass.states.async_set("{}.conversations".format(DOMAIN), diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 6a1d5a55c47..61247b5bdde 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -290,11 +290,11 @@ async def test_http_api_wrong_data(hass, aiohttp_client): def test_create_matcher(): """Test the create matcher method.""" # Basic sentence - pattern = conversation._create_matcher('Hello world') + pattern = conversation.create_matcher('Hello world') assert pattern.match('Hello world') is not None # Match a part - pattern = conversation._create_matcher('Hello {name}') + pattern = conversation.create_matcher('Hello {name}') match = pattern.match('hello world') assert match is not None assert match.groupdict()['name'] == 'world' @@ -302,7 +302,7 @@ def test_create_matcher(): assert no_match is None # Optional and matching part - pattern = conversation._create_matcher('Turn on [the] {name}') + pattern = conversation.create_matcher('Turn on [the] {name}') match = pattern.match('turn on the kitchen lights') assert match is not None assert match.groupdict()['name'] == 'kitchen lights' @@ -313,7 +313,7 @@ def test_create_matcher(): assert match is None # Two different optional parts, 1 matching part - pattern = conversation._create_matcher('Turn on [the] [a] {name}') + pattern = conversation.create_matcher('Turn on [the] [a] {name}') match = pattern.match('turn on the kitchen lights') assert match is not None assert match.groupdict()['name'] == 'kitchen lights' @@ -325,13 +325,13 @@ def test_create_matcher(): assert match.groupdict()['name'] == 'kitchen light' # Strip plural - pattern = conversation._create_matcher('Turn {name}[s] on') + pattern = conversation.create_matcher('Turn {name}[s] on') match = pattern.match('turn kitchen lights on') assert match is not None assert match.groupdict()['name'] == 'kitchen light' # Optional 2 words - pattern = conversation._create_matcher('Turn [the great] {name} on') + pattern = conversation.create_matcher('Turn [the great] {name} on') match = pattern.match('turn the great kitchen lights on') assert match is not None assert match.groupdict()['name'] == 'kitchen lights' From 3e65009ea9b8bb38a1a8da55240afba91fad422c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Aug 2018 21:56:28 +0200 Subject: [PATCH 27/42] Fix device telldus (#16224) --- homeassistant/components/tellduslive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 693499510ad..c2b7ba9ba0f 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -287,7 +287,6 @@ class TelldusLiveEntity(Entity): self._id = device_id self._client = hass.data[DOMAIN] self._client.entities.append(self) - self.device = self._client.device(device_id) self._name = self.device.name _LOGGER.debug('Created device %s', self) From 9b01972b414add949755fb93618264ee88d24470 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 00:37:15 +0200 Subject: [PATCH 28/42] Update trusted networks flow (#16227) * Update the trusted networks flow * Fix tests * Remove errors --- .../auth/providers/trusted_networks.py | 20 ++++--------------- tests/auth/providers/test_trusted_networks.py | 13 ++++-------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 37e032e58d7..8a7e1d67c6d 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -111,31 +111,19 @@ class TrustedNetworksLoginFlow(LoginFlow): self, user_input: Optional[Dict[str, str]] = None) \ -> Dict[str, Any]: """Handle the step of the form.""" - errors = {} try: cast(TrustedNetworksAuthProvider, self._auth_provider)\ .async_validate_access(self._ip_address) except InvalidAuthError: - errors['base'] = 'invalid_auth' - return self.async_show_form( - step_id='init', - data_schema=None, - errors=errors, + return self.async_abort( + reason='not_whitelisted' ) if user_input is not None: - user_id = user_input['user'] - if user_id not in self._available_users: - errors['base'] = 'invalid_auth' - - if not errors: - return await self.async_finish(user_input) - - schema = {'user': vol.In(self._available_users)} + return await self.async_finish(user_input) return self.async_show_form( step_id='init', - data_schema=vol.Schema(schema), - errors=errors, + data_schema=vol.Schema({'user': vol.In(self._available_users)}), ) diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index 4839c72a86a..0ca302f8273 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -74,16 +74,16 @@ async def test_login_flow(manager, provider): # trusted network didn't loaded flow = await provider.async_login_flow({'ip_address': '127.0.0.1'}) step = await flow.async_step_init() - assert step['step_id'] == 'init' - assert step['errors']['base'] == 'invalid_auth' + assert step['type'] == 'abort' + assert step['reason'] == 'not_whitelisted' provider.hass.http = Mock(trusted_networks=['192.168.0.1']) # not from trusted network flow = await provider.async_login_flow({'ip_address': '127.0.0.1'}) step = await flow.async_step_init() - assert step['step_id'] == 'init' - assert step['errors']['base'] == 'invalid_auth' + assert step['type'] == 'abort' + assert step['reason'] == 'not_whitelisted' # from trusted network, list users flow = await provider.async_login_flow({'ip_address': '192.168.0.1'}) @@ -95,11 +95,6 @@ async def test_login_flow(manager, provider): with pytest.raises(vol.Invalid): assert schema({'user': 'invalid-user'}) - # login with invalid user - step = await flow.async_step_init({'user': 'invalid-user'}) - assert step['step_id'] == 'init' - assert step['errors']['base'] == 'invalid_auth' - # login with valid user step = await flow.async_step_init({'user': user.id}) assert step['type'] == 'create_entry' From e9cc359abeff6224311057fe22fa19a1bcf942ce Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 00:38:23 +0200 Subject: [PATCH 29/42] Bumped version to 0.77.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cc0c9294506..be0bd814775 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 77 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 11fcffda4cfde5b1a170921681b33dca698352cb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 20:55:58 +0200 Subject: [PATCH 30/42] Update translations --- .../components/auth/.translations/lb.json | 16 ++++++++++ .../auth/.translations/zh-Hans.json | 16 ++++++++++ .../auth/.translations/zh-Hant.json | 16 ++++++++++ .../components/hangouts/.translations/lb.json | 31 +++++++++++++++++++ .../hangouts/.translations/zh-Hans.json | 29 +++++++++++++++++ .../hangouts/.translations/zh-Hant.json | 4 +-- .../.translations/zh-Hans.json | 1 + 7 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/auth/.translations/lb.json create mode 100644 homeassistant/components/auth/.translations/zh-Hans.json create mode 100644 homeassistant/components/auth/.translations/zh-Hant.json create mode 100644 homeassistant/components/hangouts/.translations/lb.json create mode 100644 homeassistant/components/hangouts/.translations/zh-Hans.json diff --git a/homeassistant/components/auth/.translations/lb.json b/homeassistant/components/auth/.translations/lb.json new file mode 100644 index 00000000000..f55ae4b97ba --- /dev/null +++ b/homeassistant/components/auth/.translations/lb.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol. Falls d\u00ebse Feeler Message \u00ebmmer er\u00ebm optr\u00ebtt dann iwwerpr\u00e9ift op d'Z\u00e4it vum Home Assistant System richteg ass." + }, + "step": { + "init": { + "description": "Fir d'Zwee-Faktor-Authentifikatioun m\u00ebttels engem Z\u00e4it bas\u00e9ierten eemolege Passwuert z'aktiv\u00e9ieren, scannt de QR Code mat enger Authentifikatioun's App.\nFalls dir keng hutt, recommand\u00e9iere mir entweder [Google Authenticator](https://support.google.com/accounts/answer/1066447) oder [Authy](https://authy.com/).\n\n{qr_code}\n\nNodeems de Code gescannt ass, gitt de sechs stellege Code vun der App a fir d'Konfiguratioun z'iwwerpr\u00e9iwen. Am Fall vu Problemer fir de QR Code ze scannen, gitt de folgende Code **`{code}`** a fir ee manuelle Setup.", + "title": "Zwee Faktor Authentifikatioun mat TOTP konfigur\u00e9ieren" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant/components/auth/.translations/zh-Hans.json new file mode 100644 index 00000000000..c5b397a8e12 --- /dev/null +++ b/homeassistant/components/auth/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "\u53e3\u4ee4\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165\u3002\u5982\u679c\u9519\u8bef\u53cd\u590d\u51fa\u73b0\uff0c\u8bf7\u786e\u4fdd Home Assistant \u7cfb\u7edf\u7684\u65f6\u95f4\u51c6\u786e\u65e0\u8bef\u3002" + }, + "step": { + "init": { + "description": "\u8981\u6fc0\u6d3b\u57fa\u4e8e\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u7684\u53cc\u91cd\u8ba4\u8bc1\uff0c\u8bf7\u7528\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\u626b\u63cf\u4ee5\u4e0b\u4e8c\u7ef4\u7801\u3002\u5982\u679c\u60a8\u8fd8\u6ca1\u6709\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\uff0c\u63a8\u8350\u4f7f\u7528 [Google \u8eab\u4efd\u9a8c\u8bc1\u5668](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u626b\u63cf\u4e8c\u7ef4\u7801\u4ee5\u540e\uff0c\u8f93\u5165\u5e94\u7528\u4e0a\u7684\u516d\u4f4d\u6570\u5b57\u53e3\u4ee4\u6765\u9a8c\u8bc1\u914d\u7f6e\u3002\u5982\u679c\u5728\u626b\u63cf\u4e8c\u7ef4\u7801\u65f6\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u4f7f\u7528\u4ee3\u7801 **`{code}`** \u624b\u52a8\u914d\u7f6e\u3002", + "title": "\u7528\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u8bbe\u7f6e\u53cc\u91cd\u8ba4\u8bc1" + } + }, + "title": "\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/zh-Hant.json b/homeassistant/components/auth/.translations/zh-Hant.json new file mode 100644 index 00000000000..ef41ea87248 --- /dev/null +++ b/homeassistant/components/auth/.translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002\u5047\u5982\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5148\u78ba\u5b9a\u60a8\u7684 Home Assistant \u7cfb\u7d71\u4e0a\u7684\u6642\u9593\u8a2d\u5b9a\u6b63\u78ba\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "description": "\u6b32\u555f\u7528\u4e00\u6b21\u6027\u4e14\u5177\u6642\u6548\u6027\u7684\u5bc6\u78bc\u4e4b\u5169\u6b65\u9a5f\u9a57\u8b49\u529f\u80fd\uff0c\u8acb\u4f7f\u7528\u60a8\u7684\u9a57\u8b49 App \u6383\u7784\u4e0b\u65b9\u7684 QR code \u3002\u5018\u82e5\u60a8\u5c1a\u672a\u5b89\u88dd\u4efb\u4f55 App\uff0c\u63a8\u85a6\u60a8\u4f7f\u7528 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u65bc\u6383\u63cf\u4e4b\u5f8c\uff0c\u8f38\u5165 App \u4e2d\u7684\u516d\u4f4d\u6578\u5b57\u9032\u884c\u8a2d\u5b9a\u9a57\u8b49\u3002\u5047\u5982\u6383\u63cf\u51fa\u73fe\u554f\u984c\uff0c\u8acb\u624b\u52d5\u8f38\u5165\u4ee5\u4e0b\u9a57\u8b49\u78bc **`{code}`**\u3002", + "title": "\u4f7f\u7528 TOTP \u8a2d\u5b9a\u5169\u6b65\u9a5f\u9a57\u8b49" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/lb.json b/homeassistant/components/hangouts/.translations/lb.json new file mode 100644 index 00000000000..426ab689626 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/lb.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ass scho konfigur\u00e9iert", + "unknown": "Onbekannten Fehler opgetrueden" + }, + "error": { + "invalid_2fa": "Ong\u00eblteg 2-Faktor Authentifikatioun, prob\u00e9iert w.e.g. nach emol.", + "invalid_2fa_method": "Ong\u00eblteg 2FA Methode (Iwwerpr\u00e9ift et um Telefon)", + "invalid_login": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "description": "Eidel", + "title": "2-Faktor-Authentifikatioun" + }, + "user": { + "data": { + "email": "E-Mail Adress", + "password": "Passwuert" + }, + "description": "Eidel", + "title": "Google Hangouts Login" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/zh-Hans.json b/homeassistant/components/hangouts/.translations/zh-Hans.json new file mode 100644 index 00000000000..bee6bf753db --- /dev/null +++ b/homeassistant/components/hangouts/.translations/zh-Hans.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u5df2\u914d\u7f6e\u5b8c\u6210", + "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" + }, + "error": { + "invalid_2fa": "\u53cc\u91cd\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002", + "invalid_2fa_method": "\u65e0\u6548\u7684\u53cc\u91cd\u8ba4\u8bc1\u65b9\u6cd5\uff08\u7535\u8bdd\u9a8c\u8bc1\uff09\u3002", + "invalid_login": "\u767b\u9646\u5931\u8d25\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + }, + "title": "\u53cc\u91cd\u8ba4\u8bc1" + }, + "user": { + "data": { + "email": "\u7535\u5b50\u90ae\u4ef6\u5730\u5740", + "password": "\u5bc6\u7801" + }, + "title": "\u767b\u5f55 Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/zh-Hant.json b/homeassistant/components/hangouts/.translations/zh-Hant.json index 0920e0325d2..16234acb193 100644 --- a/homeassistant/components/hangouts/.translations/zh-Hant.json +++ b/homeassistant/components/hangouts/.translations/zh-Hant.json @@ -5,7 +5,7 @@ "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" }, "error": { - "invalid_2fa": "\u5169\u968e\u6bb5\u9a57\u8b49\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_2fa": "\u5169\u6b65\u9a5f\u9a57\u8b49\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", "invalid_2fa_method": "\u8a8d\u8b49\u65b9\u5f0f\u7121\u6548\uff08\u65bc\u96fb\u8a71\u4e0a\u9a57\u8b49\uff09\u3002", "invalid_login": "\u767b\u5165\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" }, @@ -15,7 +15,7 @@ "2fa": "\u8a8d\u8b49\u78bc" }, "description": "\u7a7a\u767d", - "title": "\u5169\u968e\u6bb5\u8a8d\u8b49" + "title": "\u5169\u6b65\u9a5f\u9a57\u8b49" }, "user": { "data": { diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json index 38970e4a97c..930b649bceb 100644 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "\u63a5\u5165\u70b9\u5df2\u7ecf\u914d\u7f6e\u5b8c\u6210", "conection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", + "connection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" }, "error": { From c50a7deb92097437e763425d5d7ad350a8586a63 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 15:44:06 +0200 Subject: [PATCH 31/42] Fix hangouts (#16232) --- homeassistant/components/hangouts/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 72a7e015a22..ebadff57be3 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -45,11 +45,17 @@ async def async_setup(hass, config): config = config.get(DOMAIN) if config is None: + hass.data[DOMAIN] = { + CONF_INTENTS: {}, + CONF_ERROR_SUPPRESSED_CONVERSATIONS: [], + } return True - hass.data[DOMAIN] = {CONF_INTENTS: config.get(CONF_INTENTS), - CONF_ERROR_SUPPRESSED_CONVERSATIONS: - config.get(CONF_ERROR_SUPPRESSED_CONVERSATIONS)} + hass.data[DOMAIN] = { + CONF_INTENTS: config[CONF_INTENTS], + CONF_ERROR_SUPPRESSED_CONVERSATIONS: + config[CONF_ERROR_SUPPRESSED_CONVERSATIONS], + } for data in hass.data[DOMAIN][CONF_INTENTS].values(): matchers = [] @@ -58,7 +64,7 @@ async def async_setup(hass, config): data[CONF_MATCHERS] = matchers - hass.async_add_job(hass.config_entries.flow.async_init( + hass.async_create_task(hass.config_entries.flow.async_init( DOMAIN, context={'source': config_entries.SOURCE_IMPORT} )) From f708292015d6fbc33e53f3bd80742ab42871eaed Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 09:32:50 +0200 Subject: [PATCH 32/42] Warning missed a space (#16233) --- homeassistant/components/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 6909a0e4664..1b22f8e62d4 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -202,7 +202,7 @@ class HomeAssistantHTTP: if hass.auth.active and hass.auth.support_legacy: _LOGGER.warning( - "legacy_api_password support has been enabled. If you don't" + "legacy_api_password support has been enabled. If you don't " "require it, remove the 'api_password' from your http config.") setup_auth(app, trusted_networks, hass.auth.active, From 667f9c6fe432ba908635e964e146849f0ba70194 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 10:53:12 +0200 Subject: [PATCH 33/42] Package loadable: compare case insensitive (#16234) --- homeassistant/util/package.py | 4 +++- tests/util/test_package.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index feefa65c0f6..3f12fc223b8 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -73,11 +73,13 @@ def package_loadable(package: str) -> bool: # This is a zip file req = pkg_resources.Requirement.parse(urlparse(package).fragment) + req_proj_name = req.project_name.lower() + for path in sys.path: for dist in pkg_resources.find_distributions(path): # If the project name is the same, it will be the one that is # loaded when we import it. - if dist.project_name == req.project_name: + if dist.project_name.lower() == req_proj_name: return dist in req return False diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 19e85a094ee..1e93a078bd9 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -239,3 +239,6 @@ def test_package_loadable_installed_twice(): with patch('pkg_resources.find_distributions', side_effect=[[v2]]): assert package.package_loadable('hello==2.0.0') + + with patch('pkg_resources.find_distributions', side_effect=[[v2]]): + assert package.package_loadable('Hello==2.0.0') From 573f5de14873780a072bb63bd0333163a6118e99 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 12:49:50 +0200 Subject: [PATCH 34/42] Avoid insecure pycryptodome (#16238) --- homeassistant/package_constraints.txt | 2 ++ script/gen_requirements_all.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 70fb519eef4..3e9a763181a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,6 +13,8 @@ pyyaml>=3.13,<4 requests==2.19.1 voluptuous==0.11.5 +pycryptodome>=3.6.6 + # Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fe23e638e5b..4b694ec7ec0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -124,6 +124,8 @@ URL_PIN = ('https://home-assistant.io/developers/code_review_platform/' CONSTRAINT_PATH = os.path.join(os.path.dirname(__file__), '../homeassistant/package_constraints.txt') CONSTRAINT_BASE = """ +pycryptodome>=3.6.6 + # Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 From d994d6bfad7167ef639e8683a70b8e0892bbc1fd Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 28 Aug 2018 02:23:58 -0700 Subject: [PATCH 35/42] Change log level to error when auth provider failed loading (#16235) --- homeassistant/auth/mfa_modules/__init__.py | 4 ++-- homeassistant/auth/providers/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index cb0758e3ef8..a669f8bb5f0 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -152,8 +152,8 @@ async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ try: module = importlib.import_module(module_path) - except ImportError: - _LOGGER.warning('Unable to find %s', module_path) + except ImportError as err: + _LOGGER.error('Unable to load mfa module %s: %s', module_name, err) return None if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 0bcb47d4af9..d8ec04e9072 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -134,8 +134,8 @@ async def load_auth_provider_module( try: module = importlib.import_module( 'homeassistant.auth.providers.{}'.format(provider)) - except ImportError: - _LOGGER.warning('Unable to find auth provider %s', provider) + except ImportError as err: + _LOGGER.error('Unable to load auth provider %s: %s', provider, err) return None if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): From 8d38016b0cc02b28d4af2621205edd2d0b35c8de Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 28 Aug 2018 11:54:01 -0700 Subject: [PATCH 36/42] Blow up startup if init auth providers or modules failed (#16240) * Blow up startup if init auth providers or modules failed * Delete core.entity_registry --- homeassistant/auth/__init__.py | 25 ++------ homeassistant/auth/mfa_modules/__init__.py | 17 +++--- homeassistant/auth/providers/__init__.py | 17 +++--- homeassistant/bootstrap.py | 12 ++-- homeassistant/config.py | 56 +++++++++++++++-- tests/auth/providers/test_homeassistant.py | 11 ++-- tests/auth/test_init.py | 58 +++++++++++------- tests/test_config.py | 70 +++++++++++++++++++++- 8 files changed, 194 insertions(+), 72 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 952bb3b8352..4ef8440de62 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -24,7 +24,11 @@ async def auth_manager_from_config( hass: HomeAssistant, provider_configs: List[Dict[str, Any]], module_configs: List[Dict[str, Any]]) -> 'AuthManager': - """Initialize an auth manager from config.""" + """Initialize an auth manager from config. + + CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or + mfa modules exist in configs. + """ store = auth_store.AuthStore(hass) if provider_configs: providers = await asyncio.gather( @@ -35,17 +39,7 @@ async def auth_manager_from_config( # So returned auth providers are in same order as config provider_hash = OrderedDict() # type: _ProviderDict for provider in providers: - if provider is None: - continue - key = (provider.type, provider.id) - - if key in provider_hash: - _LOGGER.error( - 'Found duplicate provider: %s. Please add unique IDs if you ' - 'want to have the same provider twice.', key) - continue - provider_hash[key] = provider if module_configs: @@ -57,15 +51,6 @@ async def auth_manager_from_config( # So returned auth modules are in same order as config module_hash = OrderedDict() # type: _MfaModuleDict for module in modules: - if module is None: - continue - - if module.id in module_hash: - _LOGGER.error( - 'Found duplicate multi-factor module: %s. Please add unique ' - 'IDs if you want to have the same module twice.', module.id) - continue - module_hash[module.id] = module manager = AuthManager(hass, store, provider_hash, module_hash) diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index a669f8bb5f0..603ca6ff3b1 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -11,6 +11,7 @@ from voluptuous.humanize import humanize_error from homeassistant import requirements, data_entry_flow from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util.decorator import Registry MULTI_FACTOR_AUTH_MODULES = Registry() @@ -127,26 +128,23 @@ class SetupFlow(data_entry_flow.FlowHandler): async def auth_mfa_module_from_config( hass: HomeAssistant, config: Dict[str, Any]) \ - -> Optional[MultiFactorAuthModule]: + -> MultiFactorAuthModule: """Initialize an auth module from a config.""" module_name = config[CONF_TYPE] module = await _load_mfa_module(hass, module_name) - if module is None: - return None - try: config = module.CONFIG_SCHEMA(config) # type: ignore except vol.Invalid as err: _LOGGER.error('Invalid configuration for multi-factor module %s: %s', module_name, humanize_error(config, err)) - return None + raise return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ - -> Optional[types.ModuleType]: + -> types.ModuleType: """Load an mfa auth module.""" module_path = 'homeassistant.auth.mfa_modules.{}'.format(module_name) @@ -154,7 +152,8 @@ async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ module = importlib.import_module(module_path) except ImportError as err: _LOGGER.error('Unable to load mfa module %s: %s', module_name, err) - return None + raise HomeAssistantError('Unable to load mfa module {}: {}'.format( + module_name, err)) if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): return module @@ -170,7 +169,9 @@ async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ hass, module_path, module.REQUIREMENTS) # type: ignore if not req_success: - return None + raise HomeAssistantError( + 'Unable to process requirements of mfa module {}'.format( + module_name)) processed.add(module_name) return module diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index d8ec04e9072..370391d57cd 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -10,6 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant import data_entry_flow, requirements from homeassistant.core import callback, HomeAssistant from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from homeassistant.util.decorator import Registry @@ -110,33 +111,31 @@ class AuthProvider: async def auth_provider_from_config( hass: HomeAssistant, store: AuthStore, - config: Dict[str, Any]) -> Optional[AuthProvider]: + config: Dict[str, Any]) -> AuthProvider: """Initialize an auth provider from a config.""" provider_name = config[CONF_TYPE] module = await load_auth_provider_module(hass, provider_name) - if module is None: - return None - try: config = module.CONFIG_SCHEMA(config) # type: ignore except vol.Invalid as err: _LOGGER.error('Invalid configuration for auth provider %s: %s', provider_name, humanize_error(config, err)) - return None + raise return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore async def load_auth_provider_module( - hass: HomeAssistant, provider: str) -> Optional[types.ModuleType]: + hass: HomeAssistant, provider: str) -> types.ModuleType: """Load an auth provider.""" try: module = importlib.import_module( 'homeassistant.auth.providers.{}'.format(provider)) except ImportError as err: _LOGGER.error('Unable to load auth provider %s: %s', provider, err) - return None + raise HomeAssistantError('Unable to load auth provider {}: {}'.format( + provider, err)) if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): return module @@ -154,7 +153,9 @@ async def load_auth_provider_module( hass, 'auth provider {}'.format(provider), reqs) if not req_success: - return None + raise HomeAssistantError( + 'Unable to process requirements of auth provider {}'.format( + provider)) processed.add(provider) return module diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c10964e2da3..2051359c0ba 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -61,7 +61,6 @@ def from_config_dict(config: Dict[str, Any], config, hass, config_dir, enable_log, verbose, skip_pip, log_rotate_days, log_file, log_no_color) ) - return hass @@ -94,8 +93,13 @@ async def async_from_config_dict(config: Dict[str, Any], try: await conf_util.async_process_ha_core_config( hass, core_config, has_api_password, has_trusted_networks) - except vol.Invalid as ex: - conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) + except vol.Invalid as config_err: + conf_util.async_log_exception( + config_err, 'homeassistant', core_config, hass) + return None + except HomeAssistantError: + _LOGGER.error("Home Assistant core failed to initialize. " + "Further initialization aborted") return None await hass.async_add_executor_job( @@ -130,7 +134,7 @@ async def async_from_config_dict(config: Dict[str, Any], res = await core_components.async_setup(hass, config) if not res: _LOGGER.error("Home Assistant core failed to initialize. " - "further initialization aborted") + "Further initialization aborted") return hass await persistent_notification.async_setup(hass, config) diff --git a/homeassistant/config.py b/homeassistant/config.py index a799094c94d..d742e62660b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -8,7 +8,7 @@ import re import shutil # pylint: disable=unused-import from typing import ( # noqa: F401 - Any, Tuple, Optional, Dict, List, Union, Callable) + Any, Tuple, Optional, Dict, List, Union, Callable, Sequence, Set) from types import ModuleType import voluptuous as vol from voluptuous.humanize import humanize_error @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES, - CONF_TYPE) + CONF_TYPE, CONF_ID) from homeassistant.core import callback, DOMAIN as CONF_CORE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform @@ -128,6 +128,48 @@ some_password: welcome """ +def _no_duplicate_auth_provider(configs: Sequence[Dict[str, Any]]) \ + -> Sequence[Dict[str, Any]]: + """No duplicate auth provider config allowed in a list. + + Each type of auth provider can only have one config without optional id. + Unique id is required if same type of auth provider used multiple times. + """ + config_keys = set() # type: Set[Tuple[str, Optional[str]]] + for config in configs: + key = (config[CONF_TYPE], config.get(CONF_ID)) + if key in config_keys: + raise vol.Invalid( + 'Duplicate auth provider {} found. Please add unique IDs if ' + 'you want to have the same auth provider twice'.format( + config[CONF_TYPE] + )) + config_keys.add(key) + return configs + + +def _no_duplicate_auth_mfa_module(configs: Sequence[Dict[str, Any]]) \ + -> Sequence[Dict[str, Any]]: + """No duplicate auth mfa module item allowed in a list. + + Each type of mfa module can only have one config without optional id. + A global unique id is required if same type of mfa module used multiple + times. + Note: this is different than auth provider + """ + config_keys = set() # type: Set[str] + for config in configs: + key = config.get(CONF_ID, config[CONF_TYPE]) + if key in config_keys: + raise vol.Invalid( + 'Duplicate mfa module {} found. Please add unique IDs if ' + 'you want to have the same mfa module twice'.format( + config[CONF_TYPE] + )) + config_keys.add(key) + return configs + + PACKAGES_CONFIG_SCHEMA = vol.Schema({ cv.slug: vol.Schema( # Package names are slugs {cv.slug: vol.Any(dict, list, None)}) # Only slugs for component names @@ -166,10 +208,16 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ CONF_TYPE: vol.NotIn(['insecure_example'], 'The insecure_example auth provider' ' is for testing only.') - })]), + })], + _no_duplicate_auth_provider), vol.Optional(CONF_AUTH_MFA_MODULES): vol.All(cv.ensure_list, - [auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA]), + [auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ + CONF_TYPE: vol.NotIn(['insecure_example'], + 'The insecure_example mfa module' + ' is for testing only.') + })], + _no_duplicate_auth_mfa_module), }) diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 935c5e50dd5..84beb8cdd3f 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import base64 import pytest +import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.auth import auth_manager_from_config, auth_store @@ -111,11 +112,11 @@ async def test_saving_loading(data, hass): async def test_not_allow_set_id(): """Test we are not allowed to set an ID in config.""" hass = Mock() - provider = await auth_provider_from_config(hass, None, { - 'type': 'homeassistant', - 'id': 'invalid', - }) - assert provider is None + with pytest.raises(vol.Invalid): + await auth_provider_from_config(hass, None, { + 'type': 'homeassistant', + 'id': 'invalid', + }) async def test_new_users_populate_values(hass, data): diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index f724b40a71f..d9e7a50410f 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import Mock, patch import pytest +import voluptuous as vol from homeassistant import auth, data_entry_flow from homeassistant.auth import ( @@ -21,33 +22,36 @@ def mock_hass(loop): return hass -async def test_auth_manager_from_config_validates_config_and_id(mock_hass): +async def test_auth_manager_from_config_validates_config(mock_hass): """Test get auth providers.""" + with pytest.raises(vol.Invalid): + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'name': 'Test Name', + 'type': 'insecure_example', + 'users': [], + }, { + 'name': 'Invalid config because no users', + 'type': 'insecure_example', + 'id': 'invalid_config', + }], []) + manager = await auth.auth_manager_from_config(mock_hass, [{ 'name': 'Test Name', 'type': 'insecure_example', 'users': [], - }, { - 'name': 'Invalid config because no users', - 'type': 'insecure_example', - 'id': 'invalid_config', }, { 'name': 'Test Name 2', 'type': 'insecure_example', 'id': 'another', 'users': [], - }, { - 'name': 'Wrong because duplicate ID', - 'type': 'insecure_example', - 'id': 'another', - 'users': [], }], []) providers = [{ - 'name': provider.name, - 'id': provider.id, - 'type': provider.type, - } for provider in manager.auth_providers] + 'name': provider.name, + 'id': provider.id, + 'type': provider.type, + } for provider in manager.auth_providers] + assert providers == [{ 'name': 'Test Name', 'type': 'insecure_example', @@ -61,6 +65,26 @@ async def test_auth_manager_from_config_validates_config_and_id(mock_hass): async def test_auth_manager_from_config_auth_modules(mock_hass): """Test get auth modules.""" + with pytest.raises(vol.Invalid): + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'name': 'Test Name', + 'type': 'insecure_example', + 'users': [], + }, { + 'name': 'Test Name 2', + 'type': 'insecure_example', + 'id': 'another', + 'users': [], + }], [{ + 'name': 'Module 1', + 'type': 'insecure_example', + 'data': [], + }, { + 'name': 'Invalid config because no data', + 'type': 'insecure_example', + 'id': 'another', + }]) + manager = await auth.auth_manager_from_config(mock_hass, [{ 'name': 'Test Name', 'type': 'insecure_example', @@ -79,13 +103,7 @@ async def test_auth_manager_from_config_auth_modules(mock_hass): 'type': 'insecure_example', 'id': 'another', 'data': [], - }, { - 'name': 'Duplicate ID', - 'type': 'insecure_example', - 'id': 'another', - 'data': [], }]) - providers = [{ 'name': provider.name, 'type': provider.type, diff --git a/tests/test_config.py b/tests/test_config.py index 3cfe67f70b1..e4a6798093f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -895,9 +895,73 @@ async def test_disallowed_auth_provider_config(hass): 'name': 'Huis', CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, 'time_zone': 'GMT', - CONF_AUTH_PROVIDERS: [ - {'type': 'insecure_example'}, - ] + CONF_AUTH_PROVIDERS: [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }], + }] + } + with pytest.raises(Invalid): + await config_util.async_process_ha_core_config(hass, core_config) + + +async def test_disallowed_duplicated_auth_provider_config(hass): + """Test loading insecure example auth provider is disallowed.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [{ + 'type': 'homeassistant', + }, { + 'type': 'homeassistant', + }] + } + with pytest.raises(Invalid): + await config_util.async_process_ha_core_config(hass, core_config) + + +async def test_disallowed_auth_mfa_module_config(hass): + """Test loading insecure example auth mfa module is disallowed.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_MFA_MODULES: [{ + 'type': 'insecure_example', + 'data': [{ + 'user_id': 'mock-user', + 'pin': 'test-pin' + }] + }] + } + with pytest.raises(Invalid): + await config_util.async_process_ha_core_config(hass, core_config) + + +async def test_disallowed_duplicated_auth_mfa_module_config(hass): + """Test loading insecure example auth mfa module is disallowed.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_MFA_MODULES: [{ + 'type': 'totp', + }, { + 'type': 'totp', + }] } with pytest.raises(Invalid): await config_util.async_process_ha_core_config(hass, core_config) From 2ea2bcab7747a5482947f0d35c32c0222233cbe6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Aug 2018 20:59:38 +0200 Subject: [PATCH 37/42] Bumped version to 0.77.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index be0bd814775..ad39aa1ab39 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 77 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0b4' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 8ec109d255ed3fc44a93ca4c462231996ba02a2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Aug 2018 10:27:34 +0200 Subject: [PATCH 38/42] Bump frontend to 20180829.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 f0976c78224..0156a8b2cd6 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180827.0'] +REQUIREMENTS = ['home-assistant-frontend==20180829.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index fe3e64cd62e..98f8a336cfc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -442,7 +442,7 @@ hole==0.3.0 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180827.0 +home-assistant-frontend==20180829.0 # homeassistant.components.homekit_controller # homekit==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f732f84b55..31c43319ea7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ hbmqtt==0.9.2 holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180827.0 +home-assistant-frontend==20180829.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 9238261e17681154a38206a33996e2661b480eba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Aug 2018 10:28:34 +0200 Subject: [PATCH 39/42] Update translations --- .../components/auth/.translations/ko.json | 16 ++++++++++ .../components/auth/.translations/ru.json | 16 ++++++++++ .../components/hangouts/.translations/ko.json | 31 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 homeassistant/components/auth/.translations/ko.json create mode 100644 homeassistant/components/auth/.translations/ru.json create mode 100644 homeassistant/components/hangouts/.translations/ko.json diff --git a/homeassistant/components/auth/.translations/ko.json b/homeassistant/components/auth/.translations/ko.json new file mode 100644 index 00000000000..726fa6a6cd1 --- /dev/null +++ b/homeassistant/components/auth/.translations/ko.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \uc774 \uc624\ub958\uac00 \uc9c0\uc18d\uc801\uc73c\ub85c \ubc1c\uc0dd\ud55c\ub2e4\uba74 Home Assistant \uc758 \uc2dc\uacc4\uac00 \uc815\ud655\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uc138\uc694." + }, + "step": { + "init": { + "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574 \uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \ub098 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.", + "title": "TOTP \ub97c \uc0ac\uc6a9\ud558\uc5ec 2 \ub2e8\uacc4 \uc778\uc99d \uad6c\uc131" + } + }, + "title": "TOTP (\uc2dc\uac04 \uae30\ubc18 OTP)" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/ru.json b/homeassistant/components/auth/.translations/ru.json new file mode 100644 index 00000000000..b4b5b58f9fa --- /dev/null +++ b/homeassistant/components/auth/.translations/ru.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0432\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f." + }, + "step": { + "init": { + "description": "\u0427\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043e\u0442\u0441\u043a\u0430\u043d\u0438\u0440\u0443\u0439\u0442\u0435 QR-\u043a\u043e\u0434 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0433\u043e \u043d\u0435\u0442, \u043c\u044b \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043b\u0438\u0431\u043e [Google Authenticator] (https://support.google.com/accounts/answer/1066447), \u043b\u0438\u0431\u043e [Authy] (https://authy.com/). \n\n {qr_code} \n \n \u041f\u043e\u0441\u043b\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f QR-\u043a\u043e\u0434\u0430 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u043a\u043e\u0434 \u0438\u0437 \u0432\u0430\u0448\u0435\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c QR-\u043a\u043e\u0434\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0440\u0443\u0447\u043d\u0443\u044e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0441 \u043a\u043e\u0434\u043e\u043c ** ` {code} ` **.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/ko.json b/homeassistant/components/hangouts/.translations/ko.json new file mode 100644 index 00000000000..aabf977a8cc --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", + "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_2fa": "2\ub2e8\uacc4 \uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574 \uc8fc\uc138\uc694.", + "invalid_2fa_method": "2\ub2e8\uacc4 \uc778\uc99d \ubc29\ubc95\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. (\uc804\ud654\uae30\uc5d0\uc11c \ud655\uc778)", + "invalid_login": "\uc798\ubabb\ub41c \ub85c\uadf8\uc778\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "2fa": { + "data": { + "2fa": "2\ub2e8\uacc4 \uc778\uc99d PIN" + }, + "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.", + "title": "2\ub2e8\uacc4 \uc778\uc99d" + }, + "user": { + "data": { + "email": "\uc774\uba54\uc77c \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638" + }, + "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.", + "title": "Google Hangouts \ub85c\uadf8\uc778" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file From f9b1fb5906d5ac56fb55523bd0368d20a343766e Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 29 Aug 2018 01:16:54 -0700 Subject: [PATCH 40/42] Tweak MFA login flow (#16254) * Tweak MFA login flow * Fix typo --- homeassistant/auth/mfa_modules/totp.py | 3 ++- homeassistant/auth/providers/__init__.py | 20 +++++++++++++------ .../auth/mfa_modules/test_insecure_example.py | 2 +- tests/auth/mfa_modules/test_totp.py | 2 +- tests/auth/test_init.py | 19 +++++------------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 48531863c1a..0914658a655 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -137,8 +137,9 @@ class TotpAuthModule(MultiFactorAuthModule): await self._async_load() # user_input has been validate in caller + # set INPUT_FIELD_CODE as vol.Required is not user friendly return await self.hass.async_add_executor_job( - self._validate_2fa, user_id, user_input[INPUT_FIELD_CODE]) + self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, '')) def _validate_2fa(self, user_id: str, code: str) -> bool: """Validate two factor authentication code.""" diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 370391d57cd..3cb1c6b121e 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -224,19 +224,27 @@ class LoginFlow(data_entry_flow.FlowHandler): if user_input is not None: expires = self.created_at + SESSION_EXPIRATION if dt_util.utcnow() > expires: - errors['base'] = 'login_expired' - else: - result = await auth_module.async_validation( - self.user.id, user_input) # type: ignore - if not result: - errors['base'] = 'invalid_auth' + return self.async_abort( + reason='login_expired' + ) + + result = await auth_module.async_validation( + self.user.id, user_input) # type: ignore + if not result: + errors['base'] = 'invalid_code' if not errors: return await self.async_finish(self.user) + description_placeholders = { + 'mfa_module_name': auth_module.name, + 'mfa_module_id': auth_module.id + } # type: Dict[str, str] + return self.async_show_form( step_id='mfa', data_schema=auth_module.input_schema, + description_placeholders=description_placeholders, errors=errors, ) diff --git a/tests/auth/mfa_modules/test_insecure_example.py b/tests/auth/mfa_modules/test_insecure_example.py index e6f83762cd7..80109627140 100644 --- a/tests/auth/mfa_modules/test_insecure_example.py +++ b/tests/auth/mfa_modules/test_insecure_example.py @@ -119,7 +119,7 @@ async def test_login(hass): result = await hass.auth.login_flow.async_configure( result['flow_id'], {'pin': 'invalid-code'}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['errors']['base'] == 'invalid_auth' + assert result['errors']['base'] == 'invalid_code' result = await hass.auth.login_flow.async_configure( result['flow_id'], {'pin': '123456'}) diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py index 28e6c949bc4..6e3558ec549 100644 --- a/tests/auth/mfa_modules/test_totp.py +++ b/tests/auth/mfa_modules/test_totp.py @@ -121,7 +121,7 @@ async def test_login_flow_validates_mfa(hass): result['flow_id'], {'code': 'invalid-code'}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'mfa' - assert result['errors']['base'] == 'invalid_auth' + assert result['errors']['base'] == 'invalid_code' with patch('pyotp.TOTP.verify', return_value=True): result = await hass.auth.login_flow.async_configure( diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index d9e7a50410f..63b2b4408dd 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -428,10 +428,10 @@ async def test_login_with_auth_module(mock_hass): 'pin': 'invalid-pin', }) - # Invalid auth error + # Invalid code error assert step['type'] == data_entry_flow.RESULT_TYPE_FORM assert step['step_id'] == 'mfa' - assert step['errors'] == {'base': 'invalid_auth'} + assert step['errors'] == {'base': 'invalid_code'} step = await manager.login_flow.async_configure(step['flow_id'], { 'pin': 'test-pin', @@ -571,18 +571,9 @@ async def test_auth_module_expired_session(mock_hass): step = await manager.login_flow.async_configure(step['flow_id'], { 'pin': 'test-pin', }) - # Invalid auth due session timeout - assert step['type'] == data_entry_flow.RESULT_TYPE_FORM - assert step['step_id'] == 'mfa' - assert step['errors']['base'] == 'login_expired' - - # The second try will fail as well - step = await manager.login_flow.async_configure(step['flow_id'], { - 'pin': 'test-pin', - }) - assert step['type'] == data_entry_flow.RESULT_TYPE_FORM - assert step['step_id'] == 'mfa' - assert step['errors']['base'] == 'login_expired' + # login flow abort due session timeout + assert step['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert step['reason'] == 'login_expired' async def test_enable_mfa_for_user(hass, hass_storage): From b5919ce92ca1f96a0506c883c44380bb46db0006 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 29 Aug 2018 10:07:32 +0200 Subject: [PATCH 41/42] def device shouldnt call it self but self._device (#16255) --- homeassistant/components/media_player/plex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 35906cf5023..46dacd98aad 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -531,7 +531,7 @@ class PlexClient(MediaPlayerDevice): @property def device(self): """Return the device, if any.""" - return self.device + return self._device @property def marked_unavailable(self): From f01e1ef0aab4090cda346dd51fb87721d21e6777 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Aug 2018 10:29:51 +0200 Subject: [PATCH 42/42] Version bump to 0.77.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ad39aa1ab39..6587e13b727 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 77 -PATCH_VERSION = '0b4' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)