From b6bb6919e66990dc1a144efb93775c5250639641 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 30 May 2019 09:24:38 +0200 Subject: [PATCH 001/319] Update pysonos to 0.0.14 (#24185) --- homeassistant/components/sonos/manifest.json | 2 +- homeassistant/components/sonos/media_player.py | 4 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 58fa7b49f884c6..b1f4c924fc4d8b 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ - "pysonos==0.0.12" + "pysonos==0.0.14" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index e8004ec84285b0..5f86327e88dfac 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -374,7 +374,9 @@ def _set_basic_information(self): def _set_favorites(self): """Set available favorites.""" - self._favorites = self.soco.music_library.get_sonos_favorites() + favorites = self.soco.music_library.get_sonos_favorites() + # Exclude favorites that are non-playable due to no linked resources + self._favorites = [f for f in favorites if f.reference.resources] def _radio_artwork(self, url): """Return the private URL with artwork for a radio stream.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4f5ada38c441f0..67f4149fbd3255 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1327,7 +1327,7 @@ pysmartthings==0.6.8 pysnmp==4.4.9 # homeassistant.components.sonos -pysonos==0.0.12 +pysonos==0.0.14 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6b2616964cc02..32e9df11469602 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -274,7 +274,7 @@ pysmartapp==0.3.2 pysmartthings==0.6.8 # homeassistant.components.sonos -pysonos==0.0.12 +pysonos==0.0.14 # homeassistant.components.spc pyspcwebgw==0.4.0 From 3eebb9d51d6003de9d56672c2a5069ac8bf57426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 30 May 2019 13:14:50 +0200 Subject: [PATCH 002/319] upgrade broadlink library, Use cryptography instead of pycryptodome (#24186) --- homeassistant/components/broadlink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 125a3a83d21b22..b730413d0ce050 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -3,7 +3,7 @@ "name": "Broadlink", "documentation": "https://www.home-assistant.io/components/broadlink", "requirements": [ - "broadlink==0.10.0" + "broadlink==0.11.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 67f4149fbd3255..e2bdad3480285a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -272,7 +272,7 @@ boto3==1.9.16 braviarc-homeassistant==0.3.7.dev0 # homeassistant.components.broadlink -broadlink==0.10.0 +broadlink==0.11.0 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 From b3d8f8620cc023c49cf8c232d83f6415e88ce149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 30 May 2019 13:16:59 +0200 Subject: [PATCH 003/319] danielhiversen as codeowner for yr (#24189) --- CODEOWNERS | 1 + homeassistant/components/yr/manifest.json | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 59bd8c31af1722..307d9ebc068db0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -275,6 +275,7 @@ homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yessssms/* @flowolf homeassistant/components/yi/* @bachya +homeassistant/components/yr/* @danielhiversen homeassistant/components/zeroconf/* @robbiet480 @Kane610 homeassistant/components/zha/* @dmulcahey @adminiuga homeassistant/components/zone/* @home-assistant/core diff --git a/homeassistant/components/yr/manifest.json b/homeassistant/components/yr/manifest.json index 88daadd35aa1c6..7f06ddddcb5700 100644 --- a/homeassistant/components/yr/manifest.json +++ b/homeassistant/components/yr/manifest.json @@ -6,5 +6,7 @@ "xmltodict==0.12.0" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@danielhiversen" + ] } From 59ce31f44fbf6ee81e2eae5e6d639a814619f42a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 04:33:26 -0700 Subject: [PATCH 004/319] No longer allow invalid slugs or extra keys (#24176) * No longer allow slugs * Lint * Remove HASchema * Lint * Fix tests --- homeassistant/bootstrap.py | 46 -------- homeassistant/helpers/config_validation.py | 120 +------------------- tests/components/mqtt/test_legacy_vacuum.py | 4 - tests/components/mqtt/test_state_vacuum.py | 19 +--- tests/helpers/test_config_validation.py | 28 ----- tests/test_setup.py | 66 ----------- 6 files changed, 8 insertions(+), 275 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 96417c54b127b5..79e5ec248ae128 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -17,7 +17,6 @@ from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -101,51 +100,6 @@ async def async_from_config_dict(config: Dict[str, Any], "upgrade Python.", "Python version", "python_version" ) - # TEMP: warn users for invalid slugs - # Remove after 0.94 or 1.0 - if cv.INVALID_SLUGS_FOUND or cv.INVALID_ENTITY_IDS_FOUND: - msg = [] - - if cv.INVALID_ENTITY_IDS_FOUND: - msg.append( - "Your configuration contains invalid entity ID references. " - "Please find and update the following. " - "This will become a breaking change." - ) - msg.append('\n'.join('- {} -> {}'.format(*item) - for item - in cv.INVALID_ENTITY_IDS_FOUND.items())) - - if cv.INVALID_SLUGS_FOUND: - msg.append( - "Your configuration contains invalid slugs. " - "Please find and update the following. " - "This will become a breaking change." - ) - msg.append('\n'.join('- {} -> {}'.format(*item) - for item in cv.INVALID_SLUGS_FOUND.items())) - - hass.components.persistent_notification.async_create( - '\n\n'.join(msg), "Config Warning", "config_warning" - ) - - # TEMP: warn users of invalid extra keys - # Remove after 0.92 - if cv.INVALID_EXTRA_KEYS_FOUND: - msg = [] - msg.append( - "Your configuration contains extra keys " - "that the platform does not support (but were silently " - "accepted before 0.88). Please find and remove the following." - "This will become a breaking change." - ) - msg.append('\n'.join('- {}'.format(it) - for it in cv.INVALID_EXTRA_KEYS_FOUND)) - - hass.components.persistent_notification.async_create( - '\n\n'.join(msg), "Config Warning", "config_warning" - ) - return hass diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 9282770de1a85c..7ec6d177178245 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,6 +1,5 @@ """Helpers for config validation using voluptuous.""" import inspect -import json import logging import os import re @@ -17,7 +16,7 @@ import homeassistant.util.dt as dt_util from homeassistant.const import ( CONF_ABOVE, CONF_ALIAS, CONF_BELOW, CONF_CONDITION, CONF_ENTITY_ID, - CONF_ENTITY_NAMESPACE, CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL, + CONF_ENTITY_NAMESPACE, CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE, CONF_TIMEOUT, ENTITY_MATCH_ALL, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, TEMP_CELSIUS, TEMP_FAHRENHEIT, WEEKDAYS, __version__) @@ -29,13 +28,6 @@ # pylint: disable=invalid-name TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" -OLD_SLUG_VALIDATION = r'^[a-z0-9_]+$' -OLD_ENTITY_ID_VALIDATION = r"^(\w+)\.(\w+)$" -# Keep track of invalid slugs and entity ids found so we can create a -# persistent notification. Rare temporary exception to use a global. -INVALID_SLUGS_FOUND = {} -INVALID_ENTITY_IDS_FOUND = {} -INVALID_EXTRA_KEYS_FOUND = [] # Home Assistant types @@ -176,17 +168,6 @@ def entity_id(value: Any) -> str: value = string(value).lower() if valid_entity_id(value): return value - if re.match(OLD_ENTITY_ID_VALIDATION, value): - # To ease the breaking change, we allow old slugs for now - # Remove after 0.94 or 1.0 - fixed = '.'.join(util_slugify(part) for part in value.split('.', 1)) - INVALID_ENTITY_IDS_FOUND[value] = fixed - logging.getLogger(__name__).warning( - "Found invalid entity_id %s, please update with %s. This " - "will become a breaking change.", - value, fixed - ) - return value raise vol.Invalid('Entity ID {} is an invalid entity id'.format(value)) @@ -377,21 +358,7 @@ def verify(value: Dict) -> Dict: raise vol.Invalid('expected dictionary') for key in value.keys(): - try: - slug(key) - except vol.Invalid: - # To ease the breaking change, we allow old slugs for now - # Remove after 0.94 or 1.0 - if re.match(OLD_SLUG_VALIDATION, key): - fixed = util_slugify(key) - INVALID_SLUGS_FOUND[key] = fixed - logging.getLogger(__name__).warning( - "Found invalid slug %s, please update with %s. This " - "will be come a breaking change.", - key, fixed - ) - else: - raise + slug(key) return schema(value) return verify @@ -656,88 +623,7 @@ def validator(value): # Schemas -class HASchema(vol.Schema): - """Schema class that allows us to mark PREVENT_EXTRA errors as warnings.""" - - def __call__(self, data): - """Override __call__ to mark PREVENT_EXTRA as warning.""" - try: - return super().__call__(data) - except vol.Invalid as orig_err: - if self.extra != vol.PREVENT_EXTRA: - raise - - # orig_error is of type vol.MultipleInvalid (see super __call__) - assert isinstance(orig_err, vol.MultipleInvalid) - # pylint: disable=no-member - # If it fails with PREVENT_EXTRA, try with ALLOW_EXTRA - self.extra = vol.ALLOW_EXTRA - # In case it still fails the following will raise - try: - validated = super().__call__(data) - finally: - self.extra = vol.PREVENT_EXTRA - - # This is a legacy config, print warning - extra_key_errs = [err.path[-1] for err in orig_err.errors - if err.error_message == 'extra keys not allowed'] - - if not extra_key_errs: - # This should not happen (all errors should be extra key - # errors). Let's raise the original error anyway. - raise orig_err - - WHITELIST = [ - re.compile(CONF_NAME), - re.compile(CONF_PLATFORM), - re.compile('.*_topic'), - ] - - msg = "Your configuration contains extra keys " \ - "that the platform does not support.\n" \ - "Please remove " - submsg = ', '.join('[{}]'.format(err) for err in - extra_key_errs) - submsg += '. ' - - # Add file+line information, if available - if hasattr(data, '__config_file__'): - submsg += " (See {}, line {}). ".format( - data.__config_file__, data.__line__) - - # Add configuration source information, if available - if hasattr(data, '__configuration_source__'): - submsg += "\nConfiguration source: {}. ".format( - data.__configuration_source__) - redacted_data = {} - - # Print configuration causing the error, but filter any potentially - # sensitive data - for k, v in data.items(): - if (any(regex.match(k) for regex in WHITELIST) or - k in extra_key_errs): - redacted_data[k] = v - else: - redacted_data[k] = '' - submsg += "\nOffending data: {}".format( - json.dumps(redacted_data)) - - msg += submsg - logging.getLogger(__name__).warning(msg) - INVALID_EXTRA_KEYS_FOUND.append(submsg) - - # Return legacy validated config - return validated - - def extend(self, schema, required=None, extra=None): - """Extend this schema and convert it to HASchema if necessary.""" - ret = super().extend(schema, required=required, extra=extra) - if extra is not None: - return ret - return HASchema(ret.schema, required=required, extra=self.extra) - - -PLATFORM_SCHEMA = HASchema({ +PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): string, vol.Optional(CONF_ENTITY_NAMESPACE): string, vol.Optional(CONF_SCAN_INTERVAL): time_period diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 8beceb7d6606cb..f8bef17554093e 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -612,7 +612,6 @@ async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -629,7 +628,6 @@ async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -647,7 +645,6 @@ async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -766,7 +763,6 @@ async def test_entity_device_info_update(hass, mqtt_mock): config = { 'platform': 'mqtt', 'name': 'Test 1', - 'state_topic': 'test-topic', 'command_topic': 'test-command-topic', 'device': { 'identifiers': ['helloworld'], diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index ecd63a1dcdc942..588a808ecfb45d 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -331,8 +331,7 @@ async def test_discovery_removal_vacuum(hass, mqtt_mock): data = ( '{ "name": "Beer",' - ' "command_topic": "test_topic",' - ' "component": "state" }' + ' "command_topic": "test_topic"}' ) async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', @@ -357,13 +356,11 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): data1 = ( '{ "name": "Beer",' - ' "command_topic": "test_topic#",' - ' "component": "state" }' + ' "command_topic": "test_topic#"}' ) data2 = ( '{ "name": "Milk",' - ' "command_topic": "test_topic",' - ' "component": "state" }' + ' "command_topic": "test_topic"}' ) async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', @@ -391,13 +388,11 @@ async def test_discovery_update_vacuum(hass, mqtt_mock): data1 = ( '{ "name": "Beer",' - ' "command_topic": "test_topic",' - '"component": "state" }' + ' "command_topic": "test_topic"}' ) data2 = ( '{ "name": "Milk",' - ' "command_topic": "test_topic",' - ' "component": "state"}' + ' "command_topic": "test_topic"}' ) async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', @@ -425,7 +420,6 @@ async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -442,7 +436,6 @@ async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -460,7 +453,6 @@ async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): vacuum.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'test-topic', 'json_attributes_topic': 'attr-topic' } }) @@ -579,7 +571,6 @@ async def test_entity_device_info_update(hass, mqtt_mock): config = { 'platform': 'mqtt', 'name': 'Test 1', - 'state_topic': 'test-topic', 'command_topic': 'test-command-topic', 'device': { 'identifiers': ['helloworld'], diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e0bd509d33045f..4b65904b8b2651 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -943,34 +943,6 @@ def test_comp_entity_ids(): schema(invalid) -def test_schema_with_slug_keys_allows_old_slugs(caplog): - """Test schema with slug keys allowing old slugs.""" - schema = cv.schema_with_slug_keys(str) - - with patch.dict(cv.INVALID_SLUGS_FOUND, clear=True): - for value in ('_world', 'wow__yeah'): - caplog.clear() - # Will raise if not allowing old slugs - schema({value: 'yo'}) - assert "Found invalid slug {}".format(value) in caplog.text - - assert len(cv.INVALID_SLUGS_FOUND) == 2 - - -def test_entity_id_allow_old_validation(caplog): - """Test schema allowing old entity_ids.""" - schema = vol.Schema(cv.entity_id) - - with patch.dict(cv.INVALID_ENTITY_IDS_FOUND, clear=True): - for value in ('hello.__world', 'great.wow__yeah'): - caplog.clear() - # Will raise if not allowing old entity ID - schema(value) - assert "Found invalid entity_id {}".format(value) in caplog.text - - assert len(cv.INVALID_ENTITY_IDS_FOUND) == 2 - - def test_uuid4_hex(caplog): """Test uuid validation.""" schema = vol.Schema(cv.uuid4_hex) diff --git a/tests/test_setup.py b/tests/test_setup.py index 1dae51966beb8e..410d97b288d461 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -108,37 +108,6 @@ def test_validate_platform_config(self, caplog): 'platform_conf.whatever', MockPlatform(platform_schema=platform_schema)) - with assert_setup_component(1): - assert setup.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - 'invalid': 'extra', - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 1 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - with assert_setup_component(2): - assert setup.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - }, - 'platform_conf 2': { - 'platform': 'whatever', - 'invalid': True - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 2 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - with assert_setup_component(0): assert setup.setup_component(self.hass, 'platform_conf', { 'platform_conf': { @@ -206,21 +175,6 @@ def test_validate_platform_config_2(self, caplog): MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(1): - assert setup.setup_component(self.hass, 'platform_conf', { - # fail: no extra keys allowed in platform schema - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - 'invalid': 'extra', - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 1 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { # pass @@ -235,9 +189,6 @@ def test_validate_platform_config_2(self, caplog): } }) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - def test_validate_platform_config_3(self, caplog): """Test fallback to component PLATFORM_SCHEMA.""" component_schema = PLATFORM_SCHEMA_BASE.extend({ @@ -258,20 +209,6 @@ def test_validate_platform_config_3(self, caplog): MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(1): - assert setup.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - 'invalid': 'extra', - } - }) - assert caplog.text.count('Your configuration contains ' - 'extra keys') == 1 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { # pass @@ -286,9 +223,6 @@ def test_validate_platform_config_3(self, caplog): } }) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - def test_validate_platform_config_4(self): """Test entity_namespace in PLATFORM_SCHEMA.""" component_schema = PLATFORM_SCHEMA_BASE From 1a3a38d37011dac2f8891a9232d46312507dd7c3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 04:37:01 -0700 Subject: [PATCH 005/319] Dynamic panels (#24184) * Don't require all panel urls to be registered * Allow removing panels, fire event when panels updated --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/config/__init__.py | 2 +- homeassistant/components/frontend/__init__.py | 96 +++++++++++-------- .../components/hassio/addon_panel.py | 2 +- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/logbook/__init__.py | 2 +- homeassistant/components/lovelace/__init__.py | 2 +- homeassistant/components/mailbox/__init__.py | 2 +- homeassistant/components/map/__init__.py | 2 +- .../components/panel_custom/__init__.py | 2 +- .../components/panel_iframe/__init__.py | 2 +- .../components/shopping_list/__init__.py | 2 +- .../components/websocket_api/permissions.py | 2 + tests/components/frontend/test_init.py | 71 ++++++++++++-- 14 files changed, 133 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 73a779816a3d1c..5a1ce79c18cce1 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -36,7 +36,7 @@ async def async_setup(hass, config): hass.http.register_view(CalendarEventView(component)) # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 - # await hass.components.frontend.async_register_built_in_panel( + # hass.components.frontend.async_register_built_in_panel( # 'calendar', 'calendar', 'hass:calendar') await component.async_setup(config) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 8cd8856c1ec194..0cb76cc8c3baf6 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -30,7 +30,7 @@ async def async_setup(hass, config): """Set up the config component.""" - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'hass:settings', require_admin=True) async def setup_panel(panel_name): diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8d7f7213787a13..8a692d6f27294c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,5 +1,4 @@ """Handle the frontend for Home Assistant.""" -import asyncio import json import logging import os @@ -26,6 +25,7 @@ CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' +EVENT_PANELS_UPDATED = 'panels_updated' DEFAULT_THEME_COLOR = '#03A9F4' @@ -97,6 +97,28 @@ }) +def generate_negative_index_regex(): + """Generate regex for index.""" + skip = [ + # files + "service_worker.js", + "robots.txt", + "onboarding.html", + "manifest.json", + ] + for folder in ( + "static", + "frontend_latest", + "frontend_es5", + "local", + "auth", + "api", + ): + # Regex matching static, static/, static/index.html + skip.append("{}(/|/.+|)".format(folder)) + return r"(?!(" + "|".join(skip) + r")).*" + + class Panel: """Abstract class for panels.""" @@ -128,15 +150,6 @@ def __init__(self, component_name, sidebar_title, sidebar_icon, self.config = config self.require_admin = require_admin - @callback - def async_register_index_routes(self, router, index_view): - """Register routes for panel to be served by index view.""" - router.add_route( - 'get', '/{}'.format(self.frontend_url_path), index_view.get) - router.add_route( - 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), - index_view.get) - @callback def to_response(self): """Panel as dictionary.""" @@ -151,26 +164,36 @@ def to_response(self): @bind_hass -async def async_register_built_in_panel(hass, component_name, - sidebar_title=None, sidebar_icon=None, - frontend_url_path=None, config=None, - require_admin=False): +@callback +def async_register_built_in_panel(hass, component_name, + sidebar_title=None, sidebar_icon=None, + frontend_url_path=None, config=None, + require_admin=False): """Register a built-in panel.""" panel = Panel(component_name, sidebar_title, sidebar_icon, frontend_url_path, config, require_admin) - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} + panels = hass.data.setdefault(DATA_PANELS, {}) if panel.frontend_url_path in panels: _LOGGER.warning("Overwriting component %s", panel.frontend_url_path) - if DATA_FINALIZE_PANEL in hass.data: - hass.data[DATA_FINALIZE_PANEL](panel) - panels[panel.frontend_url_path] = panel + hass.bus.async_fire(EVENT_PANELS_UPDATED) + + +@bind_hass +@callback +def async_remove_panel(hass, frontend_url_path): + """Remove a built-in panel.""" + panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) + + if panel is None: + _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + + hass.bus.async_fire(EVENT_PANELS_UPDATED) + @bind_hass @callback @@ -233,28 +256,14 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path) - hass.http.register_view(index_view) + hass.http.register_view(IndexView(repo_path)) - @callback - def async_finalize_panel(panel): - """Finalize setup of a panel.""" - panel.async_register_index_routes(hass.http.app.router, index_view) - - await asyncio.wait( - [async_register_built_in_panel(hass, panel) for panel in ( - 'kiosk', 'states', 'profile')]) - await asyncio.wait( - [async_register_built_in_panel(hass, panel, require_admin=True) - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt')]) + for panel in ('kiosk', 'states', 'profile'): + async_register_built_in_panel(hass, panel) - hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel - - # Finalize registration of panels that registered before frontend was setup - # This includes the built-in panels from line above. - for panel in hass.data[DATA_PANELS].values(): - async_finalize_panel(panel) + for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt'): + async_register_built_in_panel(hass, panel, require_admin=True) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() @@ -324,6 +333,9 @@ class IndexView(HomeAssistantView): url = '/' name = 'frontend:index' requires_auth = False + extra_urls = [ + "/{extra:%s}" % generate_negative_index_regex() + ] def __init__(self, repo_path): """Initialize the frontend view.""" @@ -349,6 +361,10 @@ async def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] + if (request.path != '/' and + request.url.parts[1] not in hass.data[DATA_PANELS]): + raise web.HTTPNotFound + if not hass.components.onboarding.async_is_onboarded(): return web.Response(status=302, headers={ 'location': '/onboarding.html' diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 7291a87e9544fd..e85c8f12247112 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -61,7 +61,7 @@ async def post(self, request, addon): async def delete(self, request, addon): """Handle remove add-on panel requests.""" - # Currently not supported by backend / frontend + self.hass.components.frontend.async_remove_panel(addon) return web.Response() async def get_panels(self): diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7efe4f2beb20ab..d0dd098638f62f 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -252,7 +252,7 @@ async def async_setup(hass, config): use_include_order = conf.get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'history', 'history', 'hass:poll-box') return True diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 70fe31e64d6808..43fe9cb2d52eaa 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -102,7 +102,7 @@ def log_message(service): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'logbook', 'logbook', 'hass:format-list-bulleted-type') hass.services.async_register( diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index f550f85bcefadf..b1b9cf1a524078 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -53,7 +53,7 @@ async def async_setup(hass, config): # Pass in default to `get` because defaults not set if loaded as dep mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( DOMAIN, config={ 'mode': mode }) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 939cc4a2aa2a23..3b5012ec160e87 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -30,7 +30,7 @@ async def async_setup(hass, config): """Track states and offer events for mailboxes.""" mailboxes = [] - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'mailbox', 'mailbox', 'mdi:mailbox') hass.http.register_view(MailboxPlatformsView(mailboxes)) hass.http.register_view(MailboxMessageView(mailboxes)) diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py index df8ac49a6d5692..ab89ccf23ce369 100644 --- a/homeassistant/components/map/__init__.py +++ b/homeassistant/components/map/__init__.py @@ -4,6 +4,6 @@ async def async_setup(hass, config): """Register the built-in map panel.""" - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'map', 'map', 'hass:tooltip-account') return True diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index f6a4fcdb733930..275d80facf46a8 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -112,7 +112,7 @@ async def async_register_panel( config['_panel_custom'] = custom_panel_config - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( component_name='custom', sidebar_title=sidebar_title, sidebar_icon=sidebar_icon, diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index f4038c82f71880..fca33b1cf98406 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -32,7 +32,7 @@ async def async_setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}, require_admin=info[CONF_REQUIRE_ADMIN]) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index cfcbfdd4224477..6318d8581c33b8 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -117,7 +117,7 @@ def complete_item_service(call): 'What is on my shopping list' ]) - yield from hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'shopping-list', 'shopping_list', 'mdi:cart') hass.components.websocket_api.async_register_command( diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index 753c5688d186e5..887573f4abb523 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -14,11 +14,13 @@ from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.components.frontend import EVENT_PANELS_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. SUBSCRIBE_WHITELIST = { EVENT_COMPONENT_LOADED, + EVENT_PANELS_UPDATED, EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index ee10b986697f99..09628b5d3fc381 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,10 +8,11 @@ from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5) + CONF_EXTRA_HTML_URL_ES5, generate_negative_index_regex, + EVENT_PANELS_UPDATED) from homeassistant.components.websocket_api.const import TYPE_RESULT -from tests.common import mock_coro +from tests.common import mock_coro, async_capture_events CONFIG_THEMES = { @@ -232,12 +233,21 @@ def test_extra_urls(mock_http_client_with_urls, mock_onboarded): assert text.find('href="https://domain.com/my_extra_url.html"') >= 0 -async def test_get_panels(hass, hass_ws_client): +async def test_get_panels(hass, hass_ws_client, mock_http_client): """Test get_panels command.""" - await async_setup_component(hass, 'frontend', {}) - await hass.components.frontend.async_register_built_in_panel( + events = async_capture_events(hass, EVENT_PANELS_UPDATED) + + resp = await mock_http_client.get('/map') + assert resp.status == 404 + + hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:tooltip-account', require_admin=True) + resp = await mock_http_client.get('/map') + assert resp.status == 200 + + assert len(events) == 1 + client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -255,14 +265,21 @@ async def test_get_panels(hass, hass_ws_client): assert msg['result']['map']['title'] == 'Map' assert msg['result']['map']['require_admin'] is True + hass.components.frontend.async_remove_panel('map') + + resp = await mock_http_client.get('/map') + assert resp.status == 404 + + assert len(events) == 2 + async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user): """Test get_panels command.""" hass_admin_user.groups = [] await async_setup_component(hass, 'frontend', {}) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:tooltip-account', require_admin=True) - await hass.components.frontend.async_register_built_in_panel( + hass.components.frontend.async_register_built_in_panel( 'history', 'History', 'mdi:history') client = await hass_ws_client(hass) @@ -331,3 +348,43 @@ async def test_auth_authorize(mock_http_client): resp = await mock_http_client.get(authorizejs.groups(0)[0]) assert resp.status == 200 assert 'public' in resp.headers.get('cache-control') + + +def test_index_regex(): + """Test the index regex.""" + pattern = re.compile('/' + generate_negative_index_regex()) + + for should_match in ( + '/', + '/lovelace', + '/lovelace/default_view', + '/map', + '/config', + ): + assert pattern.match(should_match), should_match + + for should_not_match in ( + '/service_worker.js', + '/manifest.json', + '/onboarding.html', + '/manifest.json', + 'static', + 'static/', + 'static/index.html', + 'frontend_latest', + 'frontend_latest/', + 'frontend_latest/index.html', + 'frontend_es5', + 'frontend_es5/', + 'frontend_es5/index.html', + 'local', + 'local/', + 'local/index.html', + 'auth', + 'auth/', + 'auth/index.html', + '/api', + '/api/', + '/api/logbook', + ): + assert not pattern.match(should_not_match), should_not_match From e7d34913c0a6e601531d2804ebef4a308de280ac Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 30 May 2019 17:35:47 +0200 Subject: [PATCH 006/319] Update azure-pipelines.yml for check version (#24194) --- azure-pipelines.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7a1e6e550d79c3..ce7def09821a67 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -8,7 +8,6 @@ trigger: tags: include: - '*' - variables: - name: versionBuilder value: '3.2' @@ -18,6 +17,7 @@ variables: - group: wheels - group: github + jobs: - job: 'Wheels' @@ -96,8 +96,30 @@ jobs: displayName: 'Run wheels build' -- job: 'Release' +- job: 'VersionValidate' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: | + setup_version="$(python setup.py -V)" + branch_version="$(Build.SourceBranchName)" + + if [ "${setup_version}" != "${branch_version}" ]; then + echo "Version of tag ${branch_version} don't match with ${setup_version}!" + exit 1 + fi + displayName: 'Check version of branch/tag' + + +- job: 'Release' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate') + dependsOn: + - 'VersionValidate' timeoutInMinutes: 120 pool: vmImage: 'ubuntu-16.04' From d1aa4f42e55ef57ff6c95a2509eb7aeda3b14e58 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 09:32:29 -0700 Subject: [PATCH 007/319] Updated frontend to 20190530.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 72885690223818..cb6ce89198edc7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190529.0" + "home-assistant-frontend==20190530.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index e2bdad3480285a..1c4275ec1d3c29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -577,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190529.0 +home-assistant-frontend==20190530.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32e9df11469602..b4c5cc1d37eb8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -148,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190529.0 +home-assistant-frontend==20190530.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From bcee3f95705c90a0b1b5c84394e061248d10aad5 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 30 May 2019 17:40:38 +0100 Subject: [PATCH 008/319] homekit_controller no longer logs with transient network errors causing crypto failures as it will auto recover (#24193) --- homeassistant/components/homekit_controller/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 1fcbddbb400789..f1ddf1faacfc77 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -95,7 +95,8 @@ async def async_update(self): """Obtain a HomeKit device's state.""" # pylint: disable=import-error from homekit.exceptions import ( - AccessoryDisconnectedError, AccessoryNotFoundError) + AccessoryDisconnectedError, AccessoryNotFoundError, + EncryptionError) try: new_values_dict = await self._accessory.get_characteristics( @@ -106,7 +107,7 @@ async def async_update(self): # visible on the network. self._available = False return - except AccessoryDisconnectedError: + except (AccessoryDisconnectedError, EncryptionError): # Temporary connection failure. Device is still available but our # connection was dropped. return From 7c093bd928068ce6178da12be3345634f5d4a11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 30 May 2019 18:41:12 +0200 Subject: [PATCH 009/319] Update Tibber library (#24192) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 6562fa49cde0b5..922362de1d9ccb 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "name": "Tibber", "documentation": "https://www.home-assistant.io/components/tibber", "requirements": [ - "pyTibber==0.10.3" + "pyTibber==0.11.5" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 1c4275ec1d3c29..db440f222d1a5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -964,7 +964,7 @@ pyRFXtrx==0.23 # pySwitchmate==0.4.5 # homeassistant.components.tibber -pyTibber==0.10.3 +pyTibber==0.11.5 # homeassistant.components.dlink pyW215==0.6.0 From 7692cffdbeb834dc6b8ba2a430de582dd1647e92 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 May 2019 18:41:30 +0200 Subject: [PATCH 010/319] :pencil2: Corrects incorrect command in hassfest (#24188) --- homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/ssdp.py | 2 +- homeassistant/generated/zeroconf.py | 2 +- script/hassfest/config_flow.py | 2 +- script/hassfest/ssdp.py | 2 +- script/hassfest/zeroconf.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c9a8c593b27795..32b41610acf02a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -1,6 +1,6 @@ """Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 897f68a6521437..17b777e5cb3ab6 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -1,6 +1,6 @@ """Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 05b0a0247b93cf..5e0888ad8db5ef 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1,6 +1,6 @@ """Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest """ diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 2f204227f25578..dd3c07fefd294b 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -7,7 +7,7 @@ BASE = """ \"\"\"Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest \"\"\" diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index b5c4b9721c0f35..b13bc66a8f02e2 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -8,7 +8,7 @@ BASE = """ \"\"\"Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest \"\"\" diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 1ed9575c95fb42..3b7d6a44f88787 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -8,7 +8,7 @@ BASE = """ \"\"\"Automatically generated by hassfest. -To update, run python3 -m hassfest +To update, run python3 -m script.hassfest \"\"\" From 04c5cda7e51f54a3e5a4e6027f5922ab26e5ca8a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 30 May 2019 18:46:08 +0200 Subject: [PATCH 011/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index ce7def09821a67..2f5792cfea6f16 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -117,7 +117,7 @@ jobs: - job: 'Release' - condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate') + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) dependsOn: - 'VersionValidate' timeoutInMinutes: 120 From 1ce2d97d3dd0ef9fd46a402926f68fb98c4d3048 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Thu, 30 May 2019 18:48:58 +0200 Subject: [PATCH 012/319] Fix ESPHome discovered when already exists (#24187) * Fix ESPHome discovered when already exists * Update .coveragerc --- .coveragerc | 1 + homeassistant/components/esphome/__init__.py | 169 +++--------------- .../components/esphome/config_flow.py | 21 ++- .../components/esphome/entry_data.py | 107 +++++++++++ tests/components/esphome/test_config_flow.py | 29 ++- 5 files changed, 183 insertions(+), 144 deletions(-) create mode 100644 homeassistant/components/esphome/entry_data.py diff --git a/.coveragerc b/.coveragerc index 030c48cd10c560..967c560198c6b4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -172,6 +172,7 @@ omit = homeassistant/components/esphome/camera.py homeassistant/components/esphome/climate.py homeassistant/components/esphome/cover.py + homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/fan.py homeassistant/components/esphome/light.py homeassistant/components/esphome/sensor.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index d42bbb725dd77b..395c145e5df241 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,12 +2,11 @@ import asyncio import logging import math -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional from aioesphomeapi import ( - COMPONENT_TYPE_TO_INFO, APIClient, APIConnectionError, DeviceInfo, - EntityInfo, EntityState, ServiceCall, UserService, UserServiceArgType) -import attr + APIClient, APIConnectionError, DeviceInfo, EntityInfo, EntityState, + ServiceCall, UserService, UserServiceArgType) import voluptuous as vol from homeassistant import const @@ -19,8 +18,7 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.json import JSONEncoder @@ -30,16 +28,14 @@ # Import config flow so that it's added to the registry from .config_flow import EsphomeFlowHandler # noqa +from .entry_data import ( + DATA_KEY, DISPATCHER_ON_DEVICE_UPDATE, DISPATCHER_ON_LIST, + DISPATCHER_ON_STATE, DISPATCHER_REMOVE_ENTITY, DISPATCHER_UPDATE_ENTITY, + RuntimeEntryData) DOMAIN = 'esphome' _LOGGER = logging.getLogger(__name__) -DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}' -DISPATCHER_REMOVE_ENTITY = 'esphome_{entry_id}_remove_{component_key}_{key}' -DISPATCHER_ON_LIST = 'esphome_{entry_id}_on_list' -DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update' -DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state' - STORAGE_KEY = 'esphome.{}' STORAGE_VERSION = 1 @@ -59,95 +55,6 @@ CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) -@attr.s -class RuntimeEntryData: - """Store runtime data for esphome config entries.""" - - entry_id = attr.ib(type=str) - client = attr.ib(type='APIClient') - store = attr.ib(type=Store) - reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) - state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) - info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) - services = attr.ib(type=Dict[int, 'UserService'], factory=dict) - available = attr.ib(type=bool, default=False) - device_info = attr.ib(type='DeviceInfo', default=None) - cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) - disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) - - def async_update_entity(self, hass: HomeAssistantType, component_key: str, - key: int) -> None: - """Schedule the update of an entity.""" - signal = DISPATCHER_UPDATE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key) - async_dispatcher_send(hass, signal) - - def async_remove_entity(self, hass: HomeAssistantType, component_key: str, - key: int) -> None: - """Schedule the removal of an entity.""" - signal = DISPATCHER_REMOVE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key) - async_dispatcher_send(hass, signal) - - def async_update_static_infos(self, hass: HomeAssistantType, - infos: 'List[EntityInfo]') -> None: - """Distribute an update of static infos to all platforms.""" - signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) - async_dispatcher_send(hass, signal, infos) - - def async_update_state(self, hass: HomeAssistantType, - state: 'EntityState') -> None: - """Distribute an update of state information to all platforms.""" - signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) - async_dispatcher_send(hass, signal, state) - - def async_update_device_state(self, hass: HomeAssistantType) -> None: - """Distribute an update of a core device state like availability.""" - signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) - async_dispatcher_send(hass, signal) - - async def async_load_from_store(self) -> Tuple[List['EntityInfo'], - List['UserService']]: - """Load the retained data from store and return de-serialized data.""" - restored = await self.store.async_load() - if restored is None: - return [], [] - - self.device_info = _attr_obj_from_dict(DeviceInfo, - **restored.pop('device_info')) - infos = [] - for comp_type, restored_infos in restored.items(): - if comp_type not in COMPONENT_TYPE_TO_INFO: - continue - for info in restored_infos: - cls = COMPONENT_TYPE_TO_INFO[comp_type] - infos.append(_attr_obj_from_dict(cls, **info)) - services = [] - for service in restored.get('services', []): - services.append(UserService.from_dict(service)) - return infos, services - - async def async_save_to_store(self) -> None: - """Generate dynamic data to store and save it to the filesystem.""" - store_data = { - 'device_info': attr.asdict(self.device_info), - 'services': [] - } - - for comp_type, infos in self.info.items(): - store_data[comp_type] = [attr.asdict(info) - for info in infos.values()] - for service in self.services.values(): - store_data['services'].append(service.to_dict()) - - await self.store.async_save(store_data) - - -def _attr_obj_from_dict(cls, **kwargs): - return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) - if key in kwargs}) - - async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Stub to allow setting up this component. @@ -159,7 +66,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up the esphome component.""" - hass.data.setdefault(DOMAIN, {}) + hass.data.setdefault(DATA_KEY, {}) host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -171,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistantType, # Store client in per-config-entry hass.data store = Store(hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id), encoder=JSONEncoder) - entry_data = hass.data[DOMAIN][entry.entry_id] = RuntimeEntryData( + entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=store, @@ -186,12 +93,12 @@ async def on_stop(event: Event) -> None: ) @callback - def async_on_state(state: 'EntityState') -> None: + def async_on_state(state: EntityState) -> None: """Send dispatcher updates when a new state is received.""" entry_data.async_update_state(hass, state) @callback - def async_on_service_call(service: 'ServiceCall') -> None: + def async_on_service_call(service: ServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" domain, service_name = service.service.split('.', 1) service_data = service.data @@ -253,26 +160,6 @@ async def on_login() -> None: try_connect = await _setup_auto_reconnect_logic(hass, cli, entry, host, on_login) - # This is a bit of a hack: We schedule complete_setup into the - # event loop and return immediately (return True) - # - # Usually, we should avoid that so that HA can track which components - # have been started successfully and which failed to be set up. - # That doesn't work here for two reasons: - # - We have our own re-connect logic - # - Before we do the first try_connect() call, we need to make sure - # all dispatcher event listeners have been connected, so - # async_forward_entry_setup needs to be awaited. However, if we - # would await async_forward_entry_setup() in async_setup_entry(), - # we would end up with a deadlock. - # - # Solution is: complete the setup outside of the async_setup_entry() - # function. HA will wait until the first connection attempt is made - # before starting up (as it should), but if the first connection attempt - # fails we will schedule all next re-connect attempts outside of the - # tracked tasks (hass.loop.create_task). This way HA won't stall startup - # forever until a connection is successful. - async def complete_setup() -> None: """Complete the config entry setup.""" tasks = [] @@ -285,17 +172,16 @@ async def complete_setup() -> None: entry_data.async_update_static_infos(hass, infos) await _setup_services(hass, entry_data, services) - # If first connect fails, the next re-connect will be scheduled - # outside of _pending_task, in order not to delay HA startup - # indefinitely - await try_connect(is_disconnect=False) + # Create connection attempt outside of HA's tracked task in order + # not to delay startup. + hass.loop.create_task(try_connect(is_disconnect=False)) hass.async_create_task(complete_setup()) return True async def _setup_auto_reconnect_logic(hass: HomeAssistantType, - cli: 'APIClient', + cli: APIClient, entry: ConfigEntry, host: str, on_login): """Set up the re-connect logic for the API client.""" async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: @@ -351,7 +237,7 @@ async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: async def _async_setup_device_registry(hass: HomeAssistantType, entry: ConfigEntry, - device_info: 'DeviceInfo'): + device_info: DeviceInfo): """Set up device registry feature for a particular config entry.""" sw_version = device_info.esphome_core_version if device_info.compilation_time: @@ -371,7 +257,7 @@ async def _async_setup_device_registry(hass: HomeAssistantType, async def _register_service(hass: HomeAssistantType, entry_data: RuntimeEntryData, - service: 'UserService'): + service: UserService): service_name = '{}_{}'.format(entry_data.device_info.name, service.name) schema = {} for arg in service.args: @@ -391,7 +277,7 @@ async def execute_service(call): async def _setup_services(hass: HomeAssistantType, entry_data: RuntimeEntryData, - services: List['UserService']): + services: List[UserService]): old_services = entry_data.services.copy() to_unregister = [] to_register = [] @@ -424,7 +310,7 @@ async def _setup_services(hass: HomeAssistantType, async def _cleanup_instance(hass: HomeAssistantType, entry: ConfigEntry) -> None: """Cleanup the esphome client if it exists.""" - data = hass.data[DOMAIN].pop(entry.entry_id) # type: RuntimeEntryData + data = hass.data[DATA_KEY].pop(entry.entry_id) # type: RuntimeEntryData if data.reconnect_task is not None: data.reconnect_task.cancel() for disconnect_cb in data.disconnect_callbacks: @@ -467,7 +353,7 @@ async def platform_async_setup_entry(hass: HomeAssistantType, entry_data.state[component_key] = {} @callback - def async_list_entities(infos: List['EntityInfo']): + def async_list_entities(infos: List[EntityInfo]): """Update entities of this platform when entities are listed.""" old_infos = entry_data.info[component_key] new_infos = {} @@ -498,7 +384,7 @@ def async_list_entities(infos: List['EntityInfo']): ) @callback - def async_entity_state(state: 'EntityState'): + def async_entity_state(state: EntityState): """Notify the appropriate entity of an updated state.""" if not isinstance(state, state_type): return @@ -519,6 +405,7 @@ def esphome_state_property(func): """ @property def _wrapper(self): + # pylint: disable=protected-access if self._state is None: return None val = func(self) @@ -603,22 +490,22 @@ async def async_will_remove_from_hass(self) -> None: @property def _entry_data(self) -> RuntimeEntryData: - return self.hass.data[DOMAIN][self._entry_id] + return self.hass.data[DATA_KEY][self._entry_id] @property - def _static_info(self) -> 'EntityInfo': + def _static_info(self) -> EntityInfo: return self._entry_data.info[self._component_key][self._key] @property - def _device_info(self) -> 'DeviceInfo': + def _device_info(self) -> DeviceInfo: return self._entry_data.device_info @property - def _client(self) -> 'APIClient': + def _client(self) -> APIClient: return self._entry_data.client @property - def _state(self) -> 'Optional[EntityState]': + def _state(self) -> Optional[EntityState]: try: return self._entry_data.state[self._component_key][self._key] except KeyError: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index f2344e40b2a8f4..283d09e7919933 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -7,6 +7,8 @@ from homeassistant import config_entries from homeassistant.helpers import ConfigType +from .entry_data import DATA_KEY, RuntimeEntryData + @config_entries.HANDLERS.register('esphome') class EsphomeFlowHandler(config_entries.ConfigFlow): @@ -76,10 +78,25 @@ async def async_step_discovery_confirm(self, user_input=None): async def async_step_zeroconf(self, user_input: ConfigType): """Handle zeroconf discovery.""" - address = user_input['properties'].get( - 'address', user_input['hostname'][:-1]) + # Hostname is format: livingroom.local. + local_name = user_input['hostname'][:-1] + node_name = local_name[:-len('.local')] + address = user_input['properties'].get('address', local_name) + + # Check if already configured for entry in self._async_current_entries(): + already_configured = False if entry.data['host'] == address: + # Is this address already configured? + already_configured = True + elif entry.entry_id in self.hass.data.get(DATA_KEY, {}): + # Does a config entry with this name already exist? + data = self.hass.data[DATA_KEY][ + entry.entry_id] # type: RuntimeEntryData + # Node names are unique in the network + already_configured = data.device_info.name == node_name + + if already_configured: return self.async_abort( reason='already_configured' ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py new file mode 100644 index 00000000000000..47cadc00653103 --- /dev/null +++ b/homeassistant/components/esphome/entry_data.py @@ -0,0 +1,107 @@ +"""Runtime entry data for ESPHome stored in hass.data.""" +import asyncio +from typing import Any, Callable, Dict, List, Optional, Tuple + +from aioesphomeapi import ( + COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService) +import attr + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType + +DATA_KEY = 'esphome' +DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}' +DISPATCHER_REMOVE_ENTITY = 'esphome_{entry_id}_remove_{component_key}_{key}' +DISPATCHER_ON_LIST = 'esphome_{entry_id}_on_list' +DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update' +DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state' + + +@attr.s +class RuntimeEntryData: + """Store runtime data for esphome config entries.""" + + entry_id = attr.ib(type=str) + client = attr.ib(type='APIClient') + store = attr.ib(type=Store) + reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) + state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + services = attr.ib(type=Dict[int, 'UserService'], factory=dict) + available = attr.ib(type=bool, default=False) + device_info = attr.ib(type=DeviceInfo, default=None) + cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + + def async_update_entity(self, hass: HomeAssistantType, component_key: str, + key: int) -> None: + """Schedule the update of an entity.""" + signal = DISPATCHER_UPDATE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key) + async_dispatcher_send(hass, signal) + + def async_remove_entity(self, hass: HomeAssistantType, component_key: str, + key: int) -> None: + """Schedule the removal of an entity.""" + signal = DISPATCHER_REMOVE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key) + async_dispatcher_send(hass, signal) + + def async_update_static_infos(self, hass: HomeAssistantType, + infos: List[EntityInfo]) -> None: + """Distribute an update of static infos to all platforms.""" + signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, infos) + + def async_update_state(self, hass: HomeAssistantType, + state: EntityState) -> None: + """Distribute an update of state information to all platforms.""" + signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, state) + + def async_update_device_state(self, hass: HomeAssistantType) -> None: + """Distribute an update of a core device state like availability.""" + signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal) + + async def async_load_from_store(self) -> Tuple[List[EntityInfo], + List[UserService]]: + """Load the retained data from store and return de-serialized data.""" + restored = await self.store.async_load() + if restored is None: + return [], [] + + self.device_info = _attr_obj_from_dict(DeviceInfo, + **restored.pop('device_info')) + infos = [] + for comp_type, restored_infos in restored.items(): + if comp_type not in COMPONENT_TYPE_TO_INFO: + continue + for info in restored_infos: + cls = COMPONENT_TYPE_TO_INFO[comp_type] + infos.append(_attr_obj_from_dict(cls, **info)) + services = [] + for service in restored.get('services', []): + services.append(UserService.from_dict(service)) + return infos, services + + async def async_save_to_store(self) -> None: + """Generate dynamic data to store and save it to the filesystem.""" + store_data = { + 'device_info': attr.asdict(self.device_info), + 'services': [] + } + + for comp_type, infos in self.info.items(): + store_data[comp_type] = [attr.asdict(info) + for info in infos.values()] + for service in self.services.values(): + store_data['services'].append(service.to_dict()) + + await self.store.async_save(store_data) + + +def _attr_obj_from_dict(cls, **kwargs): + return cls(**{key: kwargs[key] for key in attr.fields_dict(cls) + if key in kwargs}) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 5aeb9d1c045c81..f991c36c4f000e 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -4,7 +4,7 @@ import pytest -from homeassistant.components.esphome import config_flow +from homeassistant.components.esphome import config_flow, DATA_KEY from tests.common import mock_coro, MockConfigEntry MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"]) @@ -254,3 +254,30 @@ async def test_discovery_already_configured_ip(hass, mock_client): result = await flow.async_step_zeroconf(user_input=service_info) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' + + +async def test_discovery_already_configured_name(hass, mock_client): + """Test discovery aborts if already configured via name.""" + entry = MockConfigEntry( + domain='esphome', + data={'host': '192.168.43.183', 'port': 6053, 'password': ''} + ) + entry.add_to_hass(hass) + mock_entry_data = MagicMock() + mock_entry_data.device_info.name = 'test8266' + hass.data[DATA_KEY] = { + entry.entry_id: mock_entry_data, + } + + flow = _setup_flow_handler(hass) + service_info = { + 'host': '192.168.43.183', + 'port': 6053, + 'hostname': 'test8266.local.', + 'properties': { + "address": "test8266.local" + } + } + result = await flow.async_step_zeroconf(user_input=service_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' From 6fcd56c4624178540a73c6d0edb397e2494e1452 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 09:49:21 -0700 Subject: [PATCH 013/319] Update hass-nabucasa (#24197) --- homeassistant/components/cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 863e3e86da4130..982b51133a51cc 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Cloud", "documentation": "https://www.home-assistant.io/components/cloud", "requirements": [ - "hass-nabucasa==0.12" + "hass-nabucasa==0.13" ], "dependencies": [ "http", diff --git a/requirements_all.txt b/requirements_all.txt index db440f222d1a5a..2bc9e2717f225f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -547,7 +547,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.12 +hass-nabucasa==0.13 # homeassistant.components.mqtt hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4c5cc1d37eb8b..623ab7941f775c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -136,7 +136,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.12 +hass-nabucasa==0.13 # homeassistant.components.mqtt hbmqtt==0.9.4 From f32d1c0dea0c3d570e5aa226390396c1f7379d29 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 14:08:05 -0700 Subject: [PATCH 014/319] Allow discovery flows to be discovered via zeroconf/ssdp (#24199) --- homeassistant/helpers/config_entry_flow.py | 3 +++ tests/helpers/test_config_entry_flow.py | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 6d200a39c85948..7c087a1ee645f2 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -81,6 +81,9 @@ async def async_step_discovery(self, discovery_info): return await self.async_step_confirm() + async_step_zeroconf = async_step_discovery + async_step_ssdp = async_step_discovery + async def async_step_import(self, _): """Handle a flow initialized by import.""" if self._async_in_progress() or self._async_current_entries(): diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 5f8a642333a41f..eda62e1614ce18 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -75,24 +75,26 @@ async def test_user_has_confirmation(hass, discovery_flow_conf): assert result['type'] == data_entry_flow.RESULT_TYPE_FORM -async def test_discovery_single_instance(hass, discovery_flow_conf): - """Test we ask for confirmation via discovery.""" +@pytest.mark.parametrize('source', ['discovery', 'ssdp', 'zeroconf']) +async def test_discovery_single_instance(hass, discovery_flow_conf, source): + """Test we not allow duplicates.""" flow = config_entries.HANDLERS['test']() flow.hass = hass MockConfigEntry(domain='test').add_to_hass(hass) - result = await flow.async_step_discovery({}) + result = await getattr(flow, "async_step_{}".format(source))({}) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'single_instance_allowed' -async def test_discovery_confirmation(hass, discovery_flow_conf): +@pytest.mark.parametrize('source', ['discovery', 'ssdp', 'zeroconf']) +async def test_discovery_confirmation(hass, discovery_flow_conf, source): """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS['test']() flow.hass = hass - result = await flow.async_step_discovery({}) + result = await getattr(flow, "async_step_{}".format(source))({}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'confirm' From 6f299e72451197051879f4a6cfea8e7c28de60e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2019 16:23:42 -0700 Subject: [PATCH 015/319] Improve error handling (#24204) --- homeassistant/components/ssdp/__init__.py | 6 ++--- tests/components/ssdp/test_init.py | 29 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index af24dd22a897bf..aecca614e7320f 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -135,15 +135,15 @@ async def _fetch_description(self, xml_location): if not xml: resp = await session.get(xml_location, timeout=5) xml = await resp.text() - except aiohttp.ClientError as err: + except (aiohttp.ClientError, asyncio.TimeoutError) as err: _LOGGER.debug("Error fetching %s: %s", xml_location, err) - return None + return {} try: tree = ElementTree.fromstring(xml) except ElementTree.ParseError as err: _LOGGER.debug("Error parsing %s: %s", xml_location, err) - return None + return {} return util.etree_to_dict(tree).get('root', {}).get('device', {}) diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 7ded5f1232963b..4b1e27d2dc88ca 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,6 +1,10 @@ """Test the SSDP integration.""" +import asyncio from unittest.mock import patch, Mock +import aiohttp +import pytest + from homeassistant.generated import ssdp as gn_ssdp from homeassistant.components import ssdp @@ -76,3 +80,28 @@ async def test_scan_match_device_type(hass, aioclient_mock): assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == 'mock-domain' assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'} + + +@pytest.mark.parametrize('exc', [asyncio.TimeoutError, aiohttp.ClientError]) +async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): + """Test failing to fetch description.""" + aioclient_mock.get('http://1.1.1.1', exc=exc) + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location='http://1.1.1.1') + ]): + await scanner.async_scan(None) + + +async def test_scan_description_parse_fail(hass, aioclient_mock): + """Test invalid XML.""" + aioclient_mock.get('http://1.1.1.1', text=""" +INVALIDXML + """) + scanner = ssdp.Scanner(hass) + + with patch('netdisco.ssdp.scan', return_value=[ + Mock(st="mock-st", location='http://1.1.1.1') + ]): + await scanner.async_scan(None) From bf91a8c1b3db6dc982bbaddf09d05a048e87d13b Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Thu, 30 May 2019 22:51:04 -0700 Subject: [PATCH 016/319] Fixing tplink issues with offline devices during setup (#23668) * Fixing tplink issues with offline devices during setup. * Fixing circleci errors. * Adding code to defer the creation of entities that are not online. * Addressing code review changes and cleaning up a little. * Fixing tests and static analysis. * Adding test to satisfy coverage requirements. * Resolving merge conflicts. * Fixing issue where lights don't appear in the integration page. * Using pyHS100 properties for most sysinfo. Addressing some PR feedback. * Addressing some PR feedback. * Better testing async_add_entities_retry Testing for static dimmers. Making greater use of conf constants. * Fixing all static analysis issues. * Adding non-blocking call for getting discovering devices. * Address PR feedback --- homeassistant/components/tplink/__init__.py | 118 +++++----- homeassistant/components/tplink/common.py | 202 ++++++++++++++++++ .../components/tplink/config_flow.py | 13 +- homeassistant/components/tplink/light.py | 58 +++-- homeassistant/components/tplink/switch.py | 56 +++-- tests/components/tplink/test_common.py | 97 +++++++++ tests/components/tplink/test_init.py | 140 +++++++++--- 7 files changed, 543 insertions(+), 141 deletions(-) create mode 100644 homeassistant/components/tplink/common.py create mode 100644 tests/components/tplink/test_common.py diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 4173c1aaa60432..794cc6867b96c4 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -6,28 +6,43 @@ from homeassistant.const import CONF_HOST from homeassistant import config_entries import homeassistant.helpers.config_validation as cv -from .config_flow import async_get_devices -from .const import DOMAIN +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .common import ( + async_discover_devices, + get_static_devices, + ATTR_CONFIG, + CONF_DIMMER, + CONF_DISCOVERY, + CONF_LIGHT, + CONF_SWITCH, + SmartDevices +) _LOGGER = logging.getLogger(__name__) +DOMAIN = 'tplink' + TPLINK_HOST_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string }) -CONF_LIGHT = 'light' -CONF_SWITCH = 'switch' -CONF_DISCOVERY = 'discovery' - -ATTR_CONFIG = 'config' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional('light', default=[]): vol.All(cv.ensure_list, - [TPLINK_HOST_SCHEMA]), - vol.Optional('switch', default=[]): vol.All(cv.ensure_list, - [TPLINK_HOST_SCHEMA]), - vol.Optional('discovery', default=True): cv.boolean, + vol.Optional(CONF_LIGHT, default=[]): vol.All( + cv.ensure_list, + [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_SWITCH, default=[]): vol.All( + cv.ensure_list, + [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_DIMMER, default=[]): vol.All( + cv.ensure_list, + [TPLINK_HOST_SCHEMA] + ), + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -46,76 +61,45 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigType): """Set up TPLink from a config entry.""" - from pyHS100 import SmartBulb, SmartPlug, SmartDeviceException - - devices = {} - config_data = hass.data[DOMAIN].get(ATTR_CONFIG) # These will contain the initialized devices lights = hass.data[DOMAIN][CONF_LIGHT] = [] switches = hass.data[DOMAIN][CONF_SWITCH] = [] - # If discovery is defined and not disabled, discover devices - # If initialized from configure integrations, there's no config - # so we default here to True - if config_data is None or config_data[CONF_DISCOVERY]: - devs = await async_get_devices(hass) - _LOGGER.info("Discovered %s TP-Link smart home device(s)", len(devs)) - devices.update(devs) + # Add static devices + static_devices = SmartDevices() + if config_data is not None: + static_devices = get_static_devices( + config_data, + ) - def _device_for_type(host, type_): - dev = None - if type_ == CONF_LIGHT: - dev = SmartBulb(host) - elif type_ == CONF_SWITCH: - dev = SmartPlug(host) + lights.extend(static_devices.lights) + switches.extend(static_devices.switches) - return dev + # Add discovered devices + if config_data is None or config_data[CONF_DISCOVERY]: + discovered_devices = await async_discover_devices(hass, static_devices) - # When arriving from configure integrations, we have no config data. - if config_data is not None: - for type_ in [CONF_LIGHT, CONF_SWITCH]: - for entry in config_data[type_]: - try: - host = entry['host'] - dev = _device_for_type(host, type_) - devices[host] = dev - _LOGGER.debug("Succesfully added %s %s: %s", - type_, host, dev) - except SmartDeviceException as ex: - _LOGGER.error("Unable to initialize %s %s: %s", - type_, host, ex) - - # This is necessary to avoid I/O blocking on is_dimmable - def _fill_device_lists(): - for dev in devices.values(): - if isinstance(dev, SmartPlug): - try: - if dev.is_dimmable: # Dimmers act as lights - lights.append(dev) - else: - switches.append(dev) - except SmartDeviceException as ex: - _LOGGER.error("Unable to connect to device %s: %s", - dev.host, ex) - - elif isinstance(dev, SmartBulb): - lights.append(dev) - else: - _LOGGER.error("Unknown smart device type: %s", type(dev)) - - # Avoid blocking on is_dimmable - await hass.async_add_executor_job(_fill_device_lists) + lights.extend(discovered_devices.lights) + switches.extend(discovered_devices.switches) forward_setup = hass.config_entries.async_forward_entry_setup if lights: - _LOGGER.debug("Got %s lights: %s", len(lights), lights) + _LOGGER.debug( + "Got %s lights: %s", + len(lights), + ", ".join([d.host for d in lights]) + ) hass.async_create_task(forward_setup(config_entry, 'light')) if switches: - _LOGGER.debug("Got %s switches: %s", len(switches), switches) + _LOGGER.debug( + "Got %s switches: %s", + len(switches), + ", ".join([d.host for d in switches]) + ) hass.async_create_task(forward_setup(config_entry, 'switch')) return True diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py new file mode 100644 index 00000000000000..d97ba36afb41b7 --- /dev/null +++ b/homeassistant/components/tplink/common.py @@ -0,0 +1,202 @@ +"""Common code for tplink.""" +import asyncio +import logging +from datetime import timedelta +from typing import Any, Callable, List + +from pyHS100 import ( + SmartBulb, + SmartDevice, + SmartPlug, + SmartDeviceException +) + +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + + +ATTR_CONFIG = 'config' +CONF_DIMMER = 'dimmer' +CONF_DISCOVERY = 'discovery' +CONF_LIGHT = 'light' +CONF_SWITCH = 'switch' + + +class SmartDevices: + """Hold different kinds of devices.""" + + def __init__( + self, + lights: List[SmartDevice] = None, + switches: List[SmartDevice] = None + ): + """Constructor.""" + self._lights = lights or [] + self._switches = switches or [] + + @property + def lights(self): + """Get the lights.""" + return self._lights + + @property + def switches(self): + """Get the switches.""" + return self._switches + + def has_device_with_host(self, host): + """Check if a devices exists with a specific host.""" + for device in self.lights + self.switches: + if device.host == host: + return True + + return False + + +async def async_get_discoverable_devices(hass): + """Return if there are devices that can be discovered.""" + from pyHS100 import Discover + + def discover(): + devs = Discover.discover() + return devs + return await hass.async_add_executor_job(discover) + + +async def async_discover_devices( + hass: HomeAssistantType, + existing_devices: SmartDevices +) -> SmartDevices: + """Get devices through discovery.""" + _LOGGER.debug("Discovering devices") + devices = await async_get_discoverable_devices(hass) + _LOGGER.info( + "Discovered %s TP-Link smart home device(s)", + len(devices) + ) + + lights = [] + switches = [] + + def process_devices(): + for dev in devices.values(): + # If this device already exists, ignore dynamic setup. + if existing_devices.has_device_with_host(dev.host): + continue + + if isinstance(dev, SmartPlug): + try: + if dev.is_dimmable: # Dimmers act as lights + lights.append(dev) + else: + switches.append(dev) + except SmartDeviceException as ex: + _LOGGER.error("Unable to connect to device %s: %s", + dev.host, ex) + + elif isinstance(dev, SmartBulb): + lights.append(dev) + else: + _LOGGER.error("Unknown smart device type: %s", type(dev)) + + await hass.async_add_executor_job(process_devices) + + return SmartDevices(lights, switches) + + +def get_static_devices(config_data) -> SmartDevices: + """Get statically defined devices in the config.""" + _LOGGER.debug("Getting static devices") + lights = [] + switches = [] + + for type_ in [CONF_LIGHT, CONF_SWITCH, CONF_DIMMER]: + for entry in config_data[type_]: + host = entry['host'] + + if type_ == CONF_LIGHT: + lights.append(SmartBulb(host)) + elif type_ == CONF_SWITCH: + switches.append(SmartPlug(host)) + # Dimmers need to be defined as smart plugs to work correctly. + elif type_ == CONF_DIMMER: + lights.append(SmartPlug(host)) + + return SmartDevices( + lights, + switches + ) + + +async def async_add_entities_retry( + hass: HomeAssistantType, + async_add_entities: Callable[[List[Any], bool], None], + objects: List[Any], + callback: Callable[[Any, Callable], None], + interval: timedelta = timedelta(seconds=60) +): + """ + Add entities now and retry later if issues are encountered. + + If the callback throws an exception or returns false, that + object will try again a while later. + This is useful for devices that are not online when hass starts. + :param hass: + :param async_add_entities: The callback provided to a + platform's async_setup. + :param objects: The objects to create as entities. + :param callback: The callback that will perform the add. + :param interval: THe time between attempts to add. + :return: A callback to cancel the retries. + """ + add_objects = objects.copy() + + is_cancelled = False + + def cancel_interval_callback(): + nonlocal is_cancelled + is_cancelled = True + + async def process_objects_loop(delay: int): + if is_cancelled: + return + + await process_objects() + + if not add_objects: + return + + await asyncio.sleep(delay) + + hass.async_create_task(process_objects_loop(delay)) + + async def process_objects(*args): + # Process each object. + for add_object in list(add_objects): + # Call the individual item callback. + try: + _LOGGER.debug( + "Attempting to add object of type %s", + type(add_object) + ) + result = await hass.async_add_job( + callback, + add_object, + async_add_entities + ) + except SmartDeviceException as ex: + _LOGGER.debug( + str(ex) + ) + result = False + + if result is True or result is None: + _LOGGER.debug("Added object.") + add_objects.remove(add_object) + else: + _LOGGER.debug("Failed to add object, will try again later") + + await process_objects_loop(interval.seconds) + + return cancel_interval_callback diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 86b1acf4ff119c..8a058be98ed5da 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -2,19 +2,10 @@ from homeassistant.helpers import config_entry_flow from homeassistant import config_entries from .const import DOMAIN - - -async def async_get_devices(hass): - """Return if there are devices that can be discovered.""" - from pyHS100 import Discover - - def discover(): - devs = Discover.discover() - return devs - return await hass.async_add_executor_job(discover) +from .common import async_get_discoverable_devices config_entry_flow.register_discovery_flow(DOMAIN, 'TP-Link Smart Home', - async_get_devices, + async_get_discoverable_devices, config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index dc2fcce949a5ad..99241e2e9f0e3b 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -2,15 +2,19 @@ import logging import time +from pyHS100 import SmartBulb, SmartDeviceException + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired, color_temperature_mired_to_kelvin as mired_to_kelvin) from . import CONF_LIGHT, DOMAIN as TPLINK_DOMAIN +from .common import async_add_entities_retry PARALLEL_UPDATES = 0 @@ -31,17 +35,35 @@ async def async_setup_platform(hass, config, add_entities, 'convert to use the tplink component.') -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up discovered switches.""" - devs = [] - for dev in hass.data[TPLINK_DOMAIN][CONF_LIGHT]: - devs.append(TPLinkSmartBulb(dev)) - - async_add_entities(devs, True) +async def async_setup_entry( + hass: HomeAssistantType, + config_entry, + async_add_entities +): + """Set up switches.""" + await async_add_entities_retry( + hass, + async_add_entities, + hass.data[TPLINK_DOMAIN][CONF_LIGHT], + add_entity + ) return True +def add_entity(device: SmartBulb, async_add_entities): + """Check if device is online and add the entity.""" + # Attempt to get the sysinfo. If it fails, it will raise an + # exception that is caught by async_add_entities_retry which + # will try again later. + device.get_sysinfo() + + async_add_entities( + [TPLinkSmartBulb(device)], + update_before_add=True + ) + + def brightness_to_percentage(byt): """Convert brightness from absolute 0..255 to percentage.""" return int((byt*100.0)/255.0) @@ -55,7 +77,7 @@ def brightness_from_percentage(percent): class TPLinkSmartBulb(Light): """Representation of a TPLink Smart Bulb.""" - def __init__(self, smartbulb) -> None: + def __init__(self, smartbulb: SmartBulb) -> None: """Initialize the bulb.""" self.smartbulb = smartbulb self._sysinfo = None @@ -69,25 +91,29 @@ def __init__(self, smartbulb) -> None: self._max_mireds = None self._emeter_params = {} + self._mac = None + self._alias = None + self._model = None + @property def unique_id(self): """Return a unique ID.""" - return self._sysinfo["mac"] + return self._mac @property def name(self): """Return the name of the Smart Bulb.""" - return self._sysinfo["alias"] + return self._alias @property def device_info(self): """Return information about the device.""" return { - "name": self.name, - "model": self._sysinfo["model"], + "name": self._alias, + "model": self._model, "manufacturer": 'TP-Link', "connections": { - (dr.CONNECTION_NETWORK_MAC, self._sysinfo["mac"]) + (dr.CONNECTION_NETWORK_MAC, self._mac) }, "sw_version": self._sysinfo["sw_ver"], } @@ -104,7 +130,6 @@ def device_state_attributes(self): def turn_on(self, **kwargs): """Turn the light on.""" - from pyHS100 import SmartBulb self.smartbulb.state = SmartBulb.BULB_STATE_ON if ATTR_COLOR_TEMP in kwargs: @@ -122,7 +147,6 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Turn the light off.""" - from pyHS100 import SmartBulb self.smartbulb.state = SmartBulb.BULB_STATE_OFF @property @@ -157,7 +181,6 @@ def is_on(self): def update(self): """Update the TP-Link Bulb's state.""" - from pyHS100 import SmartDeviceException, SmartBulb try: if self._supported_features is None: self.get_features() @@ -212,6 +235,9 @@ def get_features(self): """Determine all supported features in one go.""" self._sysinfo = self.smartbulb.sys_info self._supported_features = 0 + self._mac = self.smartbulb.mac + self._alias = self.smartbulb.alias + self._model = self.smartbulb.model if self.smartbulb.is_dimmable: self._supported_features += SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index a3d680a0a50186..d09df73fe863e7 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -2,12 +2,16 @@ import logging import time +from pyHS100 import SmartDeviceException, SmartPlug + from homeassistant.components.switch import ( ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH, SwitchDevice) from homeassistant.const import ATTR_VOLTAGE import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType from . import CONF_SWITCH, DOMAIN as TPLINK_DOMAIN +from .common import async_add_entities_retry PARALLEL_UPDATES = 0 @@ -27,13 +31,31 @@ async def async_setup_platform(hass, config, add_entities, 'convert to use the tplink component.') -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up discovered switches.""" - devs = [] - for dev in hass.data[TPLINK_DOMAIN][CONF_SWITCH]: - devs.append(SmartPlugSwitch(dev)) - - async_add_entities(devs, True) +def add_entity(device: SmartPlug, async_add_entities): + """Check if device is online and add the entity.""" + # Attempt to get the sysinfo. If it fails, it will raise an + # exception that is caught by async_add_entities_retry which + # will try again later. + device.get_sysinfo() + + async_add_entities( + [SmartPlugSwitch(device)], + update_before_add=True + ) + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry, + async_add_entities +): + """Set up switches.""" + await async_add_entities_retry( + hass, + async_add_entities, + hass.data[TPLINK_DOMAIN][CONF_SWITCH], + add_entity + ) return True @@ -41,7 +63,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmartPlugSwitch(SwitchDevice): """Representation of a TPLink Smart Plug switch.""" - def __init__(self, smartplug): + def __init__(self, smartplug: SmartPlug): """Initialize the switch.""" self.smartplug = smartplug self._sysinfo = None @@ -50,25 +72,29 @@ def __init__(self, smartplug): # Set up emeter cache self._emeter_params = {} + self._mac = None + self._alias = None + self._model = None + @property def unique_id(self): """Return a unique ID.""" - return self._sysinfo["mac"] + return self._mac @property def name(self): """Return the name of the Smart Plug.""" - return self._sysinfo["alias"] + return self._alias @property def device_info(self): """Return information about the device.""" return { - "name": self.name, - "model": self._sysinfo["model"], + "name": self._alias, + "model": self._model, "manufacturer": 'TP-Link', "connections": { - (dr.CONNECTION_NETWORK_MAC, self._sysinfo["mac"]) + (dr.CONNECTION_NETWORK_MAC, self._mac) }, "sw_version": self._sysinfo["sw_ver"], } @@ -98,10 +124,12 @@ def device_state_attributes(self): def update(self): """Update the TP-Link switch's state.""" - from pyHS100 import SmartDeviceException try: if not self._sysinfo: self._sysinfo = self.smartplug.sys_info + self._mac = self.smartplug.mac + self._alias = self.smartplug.alias + self._model = self.smartplug.model self._state = self.smartplug.state == \ self.smartplug.SWITCH_STATE_ON diff --git a/tests/components/tplink/test_common.py b/tests/components/tplink/test_common.py new file mode 100644 index 00000000000000..6c963dc4617af9 --- /dev/null +++ b/tests/components/tplink/test_common.py @@ -0,0 +1,97 @@ +"""Common code tests.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from pyHS100 import SmartDeviceException + +from homeassistant.components.tplink.common import async_add_entities_retry +from homeassistant.helpers.typing import HomeAssistantType + + +async def test_async_add_entities_retry( + hass: HomeAssistantType +): + """Test interval callback.""" + async_add_entities_callback = MagicMock() + + # The objects that will be passed to async_add_entities_callback. + objects = [ + "Object 1", + "Object 2", + "Object 3", + "Object 4", + ] + + # For each call to async_add_entities_callback, the following side effects + # will be triggered in order. This set of side effects accuratley simulates + # 3 attempts to add all entities while also handling several return types. + # To help understand what's going on, a comment exists describing what the + # object list looks like throughout the iterations. + callback_side_effects = [ + # OB1, OB2, OB3, OB4 + False, + False, + True, # Object 3 + False, + + # OB1, OB2, OB4 + True, # Object 1 + SmartDeviceException("My error"), + False, + + # OB2, OB4 + True, # Object 2 + True, # Object 4 + ] + + callback = MagicMock(side_effect=callback_side_effects) + + await async_add_entities_retry( + hass, + async_add_entities_callback, + objects, + callback, + interval=timedelta(milliseconds=100) + ) + await hass.async_block_till_done() + + assert callback.call_count == len(callback_side_effects) + + +async def test_async_add_entities_retry_cancel( + hass: HomeAssistantType +): + """Test interval callback.""" + async_add_entities_callback = MagicMock() + + callback_side_effects = [ + False, + False, + True, # Object 1 + False, + True, # Object 2 + SmartDeviceException("My error"), + False, + True, # Object 3 + True, # Object 4 + ] + + callback = MagicMock(side_effect=callback_side_effects) + + objects = [ + "Object 1", + "Object 2", + "Object 3", + "Object 4", + ] + cancel = await async_add_entities_retry( + hass, + async_add_entities_callback, + objects, + callback, + interval=timedelta(milliseconds=100) + ) + cancel() + await hass.async_block_till_done() + + assert callback.call_count == 4 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 1b234428c94097..2f8ad8e2960644 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,12 +1,20 @@ """Tests for the TP-Link component.""" -from unittest.mock import patch +from typing import Dict, Any +from unittest.mock import MagicMock, patch import pytest +from pyHS100 import SmartPlug, SmartBulb, SmartDevice, SmartDeviceException from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink +from homeassistant.components.tplink.common import ( + CONF_DISCOVERY, + CONF_DIMMER, + CONF_LIGHT, + CONF_SWITCH, +) +from homeassistant.const import CONF_HOST from homeassistant.setup import async_setup_component -from pyHS100 import SmartPlug, SmartBulb from tests.common import MockDependency, MockConfigEntry, mock_coro MOCK_PYHS100 = MockDependency("pyHS100") @@ -15,8 +23,8 @@ async def test_creating_entry_tries_discover(hass): """Test setting up does discovery.""" with MOCK_PYHS100, patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), + "homeassistant.components.tplink.async_setup_entry", + return_value=mock_coro(True), ) as mock_setup, patch( "pyHS100.Discover.discover", return_value={"host": 1234} ): @@ -41,7 +49,7 @@ async def test_configuring_tplink_causes_discovery(hass): """Test that specifying empty config does discovery.""" with MOCK_PYHS100, patch("pyHS100.Discover.discover") as discover: discover.return_value = {"host": 1234} - await async_setup_component(hass, tplink.DOMAIN, {"tplink": {}}) + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert len(discover.mock_calls) == 1 @@ -58,45 +66,111 @@ async def test_configuring_tplink_causes_discovery(hass): async def test_configuring_device_types(hass, name, cls, platform, count): """Test that light or switch platform list is filled correctly.""" with patch("pyHS100.Discover.discover") as discover, patch( - "pyHS100.SmartDevice._query_helper" + "pyHS100.SmartDevice._query_helper" ): discovery_data = { "123.123.123.{}".format(c): cls("123.123.123.123") for c in range(count) } discover.return_value = discovery_data - await async_setup_component(hass, tplink.DOMAIN, {"tplink": {}}) + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert len(discover.mock_calls) == 1 assert len(hass.data[tplink.DOMAIN][platform]) == count +class UnknownSmartDevice(SmartDevice): + """Dummy class for testing.""" + + @property + def has_emeter(self) -> bool: + """Do nothing.""" + pass + + def turn_off(self) -> None: + """Do nothing.""" + pass + + def turn_on(self) -> None: + """Do nothing.""" + pass + + @property + def is_on(self) -> bool: + """Do nothing.""" + pass + + @property + def state_information(self) -> Dict[str, Any]: + """Do nothing.""" + pass + + +async def test_configuring_devices_from_multiple_sources(hass): + """Test static and discover devices are not duplicated.""" + with patch("pyHS100.Discover.discover") as discover, patch( + "pyHS100.SmartDevice._query_helper" + ): + discover_device_fail = SmartPlug("123.123.123.123") + discover_device_fail.get_sysinfo = MagicMock( + side_effect=SmartDeviceException() + ) + + discover.return_value = { + "123.123.123.1": SmartBulb("123.123.123.1"), + "123.123.123.2": SmartPlug("123.123.123.2"), + "123.123.123.3": SmartBulb("123.123.123.3"), + "123.123.123.4": SmartPlug("123.123.123.4"), + "123.123.123.123": discover_device_fail, + "123.123.123.124": UnknownSmartDevice("123.123.123.124") + } + + await async_setup_component(hass, tplink.DOMAIN, { + tplink.DOMAIN: { + CONF_LIGHT: [ + {CONF_HOST: "123.123.123.1"}, + ], + CONF_SWITCH: [ + {CONF_HOST: "123.123.123.2"}, + ], + CONF_DIMMER: [ + {CONF_HOST: "123.123.123.22"}, + ], + } + }) + await hass.async_block_till_done() + + assert len(discover.mock_calls) == 1 + assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 3 + assert len(hass.data[tplink.DOMAIN][CONF_SWITCH]) == 2 + + async def test_is_dimmable(hass): """Test that is_dimmable switches are correctly added as lights.""" with patch("pyHS100.Discover.discover") as discover, patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), + "homeassistant.components.tplink.light.async_setup_entry", + return_value=mock_coro(True), ) as setup, patch("pyHS100.SmartDevice._query_helper"), patch( "pyHS100.SmartPlug.is_dimmable", True ): dimmable_switch = SmartPlug("123.123.123.123") discover.return_value = {"host": dimmable_switch} - await async_setup_component(hass, tplink.DOMAIN, {"tplink": {}}) + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert len(discover.mock_calls) == 1 assert len(setup.mock_calls) == 1 - assert len(hass.data[tplink.DOMAIN]["light"]) == 1 - assert len(hass.data[tplink.DOMAIN]["switch"]) == 0 + assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 1 + assert not hass.data[tplink.DOMAIN][CONF_SWITCH] async def test_configuring_discovery_disabled(hass): """Test that discover does not get called when disabled.""" with MOCK_PYHS100, patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), + "homeassistant.components.tplink.async_setup_entry", + return_value=mock_coro(True), ) as mock_setup, patch( "pyHS100.Discover.discover", return_value=[] ) as discover: @@ -107,22 +181,22 @@ async def test_configuring_discovery_disabled(hass): ) await hass.async_block_till_done() - assert len(discover.mock_calls) == 0 - assert len(mock_setup.mock_calls) == 1 + assert discover.call_count == 0 + assert mock_setup.call_count == 1 async def test_platforms_are_initialized(hass): """Test that platforms are initialized per configuration array.""" config = { - "tplink": { - "discovery": False, - "light": [{"host": "123.123.123.123"}], - "switch": [{"host": "321.321.321.321"}], + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], + CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], } } with patch("pyHS100.Discover.discover") as discover, patch( - "pyHS100.SmartDevice._query_helper" + "pyHS100.SmartDevice._query_helper" ), patch( "homeassistant.components.tplink.light.async_setup_entry", return_value=mock_coro(True), @@ -136,21 +210,21 @@ async def test_platforms_are_initialized(hass): await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert len(discover.mock_calls) == 0 - assert len(light_setup.mock_calls) == 1 - assert len(switch_setup.mock_calls) == 1 + assert discover.call_count == 0 + assert light_setup.call_count == 1 + assert switch_setup.call_count == 1 async def test_no_config_creates_no_entry(hass): """Test for when there is no tplink in config.""" with MOCK_PYHS100, patch( - "homeassistant.components.tplink.async_setup_entry", - return_value=mock_coro(True), + "homeassistant.components.tplink.async_setup_entry", + return_value=mock_coro(True), ) as mock_setup: await async_setup_component(hass, tplink.DOMAIN, {}) await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 + assert mock_setup.call_count == 0 @pytest.mark.parametrize("platform", ["switch", "light"]) @@ -161,14 +235,14 @@ async def test_unload(hass, platform): entry.add_to_hass(hass) with patch("pyHS100.SmartDevice._query_helper"), patch( - "homeassistant.components.tplink.{}" - ".async_setup_entry".format(platform), - return_value=mock_coro(True), + "homeassistant.components.tplink.{}" + ".async_setup_entry".format(platform), + return_value=mock_coro(True), ) as light_setup: config = { - "tplink": { - platform: [{"host": "123.123.123.123"}], - "discovery": False, + tplink.DOMAIN: { + platform: [{CONF_HOST: "123.123.123.123"}], + CONF_DISCOVERY: False, } } assert await async_setup_component(hass, tplink.DOMAIN, config) From a9c85b99448631858f54ee2b2e1d57e838a31acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Herv=C3=A9?= Date: Fri, 31 May 2019 09:17:50 +0200 Subject: [PATCH 017/319] Bump oauthlib version (#24111) * Bump oauthlib version * Bump fitbit instead * Update requirements --- homeassistant/components/fitbit/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index baf0d8aaed1db4..6a6316d80a3f6a 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -3,7 +3,7 @@ "name": "Fitbit", "documentation": "https://www.home-assistant.io/components/fitbit", "requirements": [ - "fitbit==0.3.0" + "fitbit==0.3.1" ], "dependencies": [ "configurator", diff --git a/requirements_all.txt b/requirements_all.txt index 2bc9e2717f225f..e0eb99c33bda29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ fiblary3==0.1.7 fints==1.0.1 # homeassistant.components.fitbit -fitbit==0.3.0 +fitbit==0.3.1 # homeassistant.components.fixer fixerio==1.0.0a0 From dedc2ef918c81eccde91a58c8ef251f75f7618c0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 10:53:34 +0200 Subject: [PATCH 018/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 56 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2f5792cfea6f16..fc511615b605e3 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -16,6 +16,7 @@ variables: - group: docker - group: wheels - group: github + - group: twine jobs: @@ -24,7 +25,7 @@ jobs: condition: eq(variables['Build.SourceBranchName'], 'dev') timeoutInMinutes: 360 pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: maxParallel: 3 matrix: @@ -114,15 +115,53 @@ jobs: exit 1 fi displayName: 'Check version of branch/tag' + - script: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + jq curl + + release="$(Build.SourceBranchName)" + created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')" + + if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480)$ ]]; then + exit 0 + fi + + echo "${created_by} is not allowed to create an release!" + exit 1 + displayName: 'Check rights' + + +- job: 'ReleasePython' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) + dependsOn: + - 'VersionValidate' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: pip install twine + displayName: 'Install twine' + - script: python setup.py sdist bdist_wheel + displayName: 'Build package' + - script: | + export TWINE_USERNAME="$(twineUser)" + export TWINE_PASSWORD="$(twinePassword)" + + twine upload dist/* --skip-existing + displayName: 'Upload pypi' -- job: 'Release' +- job: 'ReleaseDocker' condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) dependsOn: - 'VersionValidate' timeoutInMinutes: 120 pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: maxParallel: 5 matrix: @@ -167,16 +206,17 @@ jobs: displayName: 'Build Release' -- job: 'ReleasePublish' - condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('Release')) +- job: 'ReleaseHassio' + condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('ReleaseDocker')) dependsOn: - - 'Release' + - 'ReleaseDocker' pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - script: | + sudo apt-get update sudo apt-get install -y --no-install-recommends \ - git jq + git jq curl git config --global user.name "Pascal Vizeli" git config --global user.email "pvizeli@syshack.ch" From 561054151570e7f095f90aa8e2fd9d67dbb24787 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 31 May 2019 11:27:27 +0200 Subject: [PATCH 019/319] Fix ESPHome config flow with invalid config entry (#24213) --- homeassistant/components/esphome/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 283d09e7919933..ad18e681021d56 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -94,7 +94,8 @@ async def async_step_zeroconf(self, user_input: ConfigType): data = self.hass.data[DATA_KEY][ entry.entry_id] # type: RuntimeEntryData # Node names are unique in the network - already_configured = data.device_info.name == node_name + if data.device_info is not None: + already_configured = data.device_info.name == node_name if already_configured: return self.async_abort( From 261f3bcba6567fbfc7f87c2b1ab6eb9a6dac7139 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 14:30:58 +0200 Subject: [PATCH 020/319] Don't follow redirect on ingress itself (#24218) * Don't follow redirect on ingress itself * Fix comment --- homeassistant/components/hassio/ingress.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 824dee86fadaa0..250d50681dce85 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -119,8 +119,12 @@ async def _handle_request( source_header = _init_header(request, token) async with self._websession.request( - request.method, url, headers=source_header, - params=request.query, data=data + request.method, + url, + headers=source_header, + params=request.query, + allow_redirects=False, + data=data ) as result: headers = _response_header(result) From 5fa66ba4a3ebd1cfced30aa073eed2f697bd22d5 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 16:46:50 +0200 Subject: [PATCH 021/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fc511615b605e3..0985f938bb12fd 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -82,6 +82,7 @@ jobs: sed -i "s|# decora|decora|g" requirements_hassio.txt sed -i "s|# PySwitchbot|PySwitchbot|g" requirements_hassio.txt sed -i "s|# pySwitchmate|pySwitchmate|g" requirements_hassio.txt + sed -i "s|# face_recognition|face_recognition|g" requirements_hassio.txt # Disable because of error sed -i "s|insteonplm|# insteonplm|g" requirements_hassio.txt From 8fe1a84db26316d53b613a370dcb46a1d96c2f30 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 18:49:46 +0200 Subject: [PATCH 022/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0985f938bb12fd..eaf04588d27ce1 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -91,7 +91,7 @@ jobs: sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \ homeassistant/$(buildArch)-wheels:$(versionWheels) \ --apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \ - --index https://wheels.hass.io \ + --index https://wheels.home-assistant.io \ --requirement requirements_hassio.txt \ --upload rsync \ --remote wheels@$(wheelsHost):/opt/wheels From 440e4289e4c62d04fdd8e9062a28e8538307f5c0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:26:05 -0700 Subject: [PATCH 023/319] Instantiate lock inside event loop (#24203) --- homeassistant/helpers/entity_platform.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7908440e92b1d4..30868c33f9df60 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -45,7 +45,7 @@ def __init__(self, *, hass, logger, domain, platform_name, platform, self._async_unsub_polling = None # Method to cancel the retry of setup self._async_cancel_retry_setup = None - self._process_updates = asyncio.Lock() + self._process_updates = None # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities @@ -404,6 +404,8 @@ async def _update_entity_states(self, now): This method must be run in the event loop. """ + if self._process_updates is None: + self._process_updates = asyncio.Lock() if self._process_updates.locked(): self.logger.warning( "Updating %s %s took longer than the scheduled update " From 3a0616c680939ff4dbf42ca9c5ed3165ded7c4d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:27:05 -0700 Subject: [PATCH 024/319] Use resource for index routing. (#24223) --- homeassistant/components/frontend/__init__.py | 105 +++++++++++------- tests/components/frontend/test_init.py | 43 +------ 2 files changed, 66 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8a692d6f27294c..a18ed6eb3d1d2e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -4,9 +4,10 @@ import os import pathlib -from aiohttp import web +from aiohttp import web, web_urldispatcher, hdrs import voluptuous as vol import jinja2 +from yarl import URL import homeassistant.helpers.config_validation as cv from homeassistant.components.http.view import HomeAssistantView @@ -50,7 +51,6 @@ 'type': 'image/png' }) -DATA_FINALIZE_PANEL = 'frontend_finalize_panel' DATA_PANELS = 'frontend_panels' DATA_JS_VERSION = 'frontend_js_version' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' @@ -97,28 +97,6 @@ }) -def generate_negative_index_regex(): - """Generate regex for index.""" - skip = [ - # files - "service_worker.js", - "robots.txt", - "onboarding.html", - "manifest.json", - ] - for folder in ( - "static", - "frontend_latest", - "frontend_es5", - "local", - "auth", - "api", - ): - # Regex matching static, static/, static/index.html - skip.append("{}(/|/.+|)".format(folder)) - return r"(?!(" + "|".join(skip) + r")).*" - - class Panel: """Abstract class for panels.""" @@ -256,7 +234,7 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - hass.http.register_view(IndexView(repo_path)) + hass.http.app.router.register_resource(IndexView(repo_path, hass)) for panel in ('kiosk', 'states', 'profile'): async_register_built_in_panel(hass, panel) @@ -327,21 +305,64 @@ def reload_themes(_): hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) -class IndexView(HomeAssistantView): +class IndexView(web_urldispatcher.AbstractResource): """Serve the frontend.""" - url = '/' - name = 'frontend:index' - requires_auth = False - extra_urls = [ - "/{extra:%s}" % generate_negative_index_regex() - ] - - def __init__(self, repo_path): + def __init__(self, repo_path, hass): """Initialize the frontend view.""" + super().__init__(name="frontend:index") self.repo_path = repo_path + self.hass = hass self._template_cache = None + @property + def canonical(self) -> str: + """Return resource's canonical path.""" + return '/' + + @property + def _route(self): + """Return the index route.""" + return web_urldispatcher.ResourceRoute('GET', self.get, self) + + def url_for(self, **kwargs: str) -> URL: + """Construct url for resource with additional params.""" + return URL("/") + + async def resolve(self, request: web.Request): + """Resolve resource. + + Return (UrlMappingMatchInfo, allowed_methods) pair. + """ + if (request.path != '/' and + request.url.parts[1] not in self.hass.data[DATA_PANELS]): + return None, set() + + if request.method != hdrs.METH_GET: + return None, {'GET'} + + return web_urldispatcher.UrlMappingMatchInfo({}, self._route), {'GET'} + + def add_prefix(self, prefix: str) -> None: + """Add a prefix to processed URLs. + + Required for subapplications support. + """ + + def get_info(self): + """Return a dict with additional info useful for introspection.""" + return { + 'panels': list(self.hass.data[DATA_PANELS]) + } + + def freeze(self) -> None: + """Freeze the resource.""" + pass + + def raw_match(self, path: str) -> bool: + """Perform a raw match against path.""" + pass + def get_template(self): """Get template.""" tpl = self._template_cache @@ -357,14 +378,10 @@ def get_template(self): return tpl - async def get(self, request, extra=None): - """Serve the index view.""" + async def get(self, request: web.Request): + """Serve the index page for panel pages.""" hass = request.app['hass'] - if (request.path != '/' and - request.url.parts[1] not in hass.data[DATA_PANELS]): - raise web.HTTPNotFound - if not hass.components.onboarding.async_is_onboarded(): return web.Response(status=302, headers={ 'location': '/onboarding.html' @@ -383,6 +400,14 @@ async def get(self, request, extra=None): content_type='text/html' ) + def __len__(self) -> int: + """Return length of resource.""" + return 1 + + def __iter__(self): + """Iterate over routes.""" + return iter([self._route]) + class ManifestJSONView(HomeAssistantView): """View to return a manifest.json.""" diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 09628b5d3fc381..c362499db152a4 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,8 +8,7 @@ from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5, generate_negative_index_regex, - EVENT_PANELS_UPDATED) + CONF_EXTRA_HTML_URL_ES5, EVENT_PANELS_UPDATED) from homeassistant.components.websocket_api.const import TYPE_RESULT from tests.common import mock_coro, async_capture_events @@ -348,43 +347,3 @@ async def test_auth_authorize(mock_http_client): resp = await mock_http_client.get(authorizejs.groups(0)[0]) assert resp.status == 200 assert 'public' in resp.headers.get('cache-control') - - -def test_index_regex(): - """Test the index regex.""" - pattern = re.compile('/' + generate_negative_index_regex()) - - for should_match in ( - '/', - '/lovelace', - '/lovelace/default_view', - '/map', - '/config', - ): - assert pattern.match(should_match), should_match - - for should_not_match in ( - '/service_worker.js', - '/manifest.json', - '/onboarding.html', - '/manifest.json', - 'static', - 'static/', - 'static/index.html', - 'frontend_latest', - 'frontend_latest/', - 'frontend_latest/index.html', - 'frontend_es5', - 'frontend_es5/', - 'frontend_es5/index.html', - 'local', - 'local/', - 'local/index.html', - 'auth', - 'auth/', - 'auth/index.html', - '/api', - '/api/', - '/api/logbook', - ): - assert not pattern.match(should_not_match), should_not_match From 18286dbf4b238a83044e6af0351466c8c380280c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 31 May 2019 20:34:06 +0200 Subject: [PATCH 025/319] Axis - Handle Vapix error messages (#24215) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 27c108b334cbb3..dc64e90ba9a51a 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -3,7 +3,7 @@ "name": "Axis", "config_flow": true, "documentation": "https://www.home-assistant.io/components/axis", - "requirements": ["axis==23"], + "requirements": ["axis==24"], "dependencies": [], "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] diff --git a/requirements_all.txt b/requirements_all.txt index e0eb99c33bda29..efa99c6aa68794 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==23 +axis==24 # homeassistant.components.azure_event_hub azure-eventhub==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 623ab7941f775c..f0f03cc47ea3b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ apns2==0.3.0 av==6.1.2 # homeassistant.components.axis -axis==23 +axis==24 # homeassistant.components.zha bellows-homeassistant==0.7.3 From 3c1cdecb88d7634b9895c826155a52c45f00ee2d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 11:58:48 -0700 Subject: [PATCH 026/319] Add manifest support for homekit discovery (#24225) * Add manifest support for homekit discovery * Add a space after model check * Update comment --- homeassistant/components/lifx/manifest.json | 5 ++ homeassistant/components/zeroconf/__init__.py | 62 +++++++++++++--- homeassistant/generated/zeroconf.py | 4 ++ homeassistant/helpers/config_entry_flow.py | 1 + script/hassfest/manifest.py | 3 + script/hassfest/zeroconf.py | 72 ++++++++++++++++--- tests/components/zeroconf/test_init.py | 52 +++++++++++--- 7 files changed, 168 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index ca9b578432b4a2..fd74d9831fca0a 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -7,6 +7,11 @@ "aiolifx==0.6.7", "aiolifx_effects==0.2.2" ], + "homekit": { + "models": [ + "LIFX" + ] + }, "dependencies": [], "codeowners": [ "@amelchio" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index fe757b8ae850e9..2f93020b4d5eb4 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -10,7 +10,7 @@ from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) -from homeassistant.generated.zeroconf import ZEROCONF +from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ ATTR_PROPERTIES = 'properties' ZEROCONF_TYPE = '_home-assistant._tcp.local.' +HOMEKIT_TYPE = '_hap._tcp.local.' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({}), @@ -50,21 +51,30 @@ def setup(hass, config): def service_update(zeroconf, service_type, name, state_change): """Service state changed.""" - if state_change is ServiceStateChange.Added: - service_info = zeroconf.get_service_info(service_type, name) - info = info_from_service(service_info) - _LOGGER.debug("Discovered new device %s %s", name, info) - - for domain in ZEROCONF[service_type]: - hass.add_job( - hass.config_entries.flow.async_init( - domain, context={'source': DOMAIN}, data=info - ) + if state_change != ServiceStateChange.Added: + return + + service_info = zeroconf.get_service_info(service_type, name) + info = info_from_service(service_info) + _LOGGER.debug("Discovered new device %s %s", name, info) + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type == HOMEKIT_TYPE and handle_homekit(hass, info): + return + + for domain in ZEROCONF[service_type]: + hass.add_job( + hass.config_entries.flow.async_init( + domain, context={'source': DOMAIN}, data=info ) + ) for service in ZEROCONF: ServiceBrowser(zeroconf, service, handlers=[service_update]) + if HOMEKIT_TYPE not in ZEROCONF: + ServiceBrowser(zeroconf, HOMEKIT_TYPE, handlers=[service_update]) + def stop_zeroconf(_): """Stop Zeroconf.""" zeroconf.unregister_service(info) @@ -75,6 +85,36 @@ def stop_zeroconf(_): return True +def handle_homekit(hass, info) -> bool: + """Handle a HomeKit discovery. + + Return if discovery was forwarded. + """ + model = None + props = info.get('properties', {}) + + for key in props: + if key.lower() == 'md': + model = props[key] + break + + if model is None: + return False + + for test_model in HOMEKIT: + if not model.startswith(test_model): + continue + + hass.add_job( + hass.config_entries.flow.async_init( + HOMEKIT[test_model], context={'source': 'homekit'}, data=info + ) + ) + return True + + return False + + def info_from_service(service): """Return prepared info from mDNS entries.""" properties = {} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 5e0888ad8db5ef..716b212e4c623e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -18,3 +18,7 @@ "homekit_controller" ] } + +HOMEKIT = { + "LIFX ": "lifx" +} diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 7c087a1ee645f2..c3e5195131b083 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -83,6 +83,7 @@ async def async_step_discovery(self, discovery_info): async_step_zeroconf = async_step_discovery async_step_ssdp = async_step_discovery + async_step_homekit = async_step_discovery async def async_step_import(self, _): """Handle a flow initialized by import.""" diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 31181ed76bd469..3e25ab31712c6c 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -17,6 +17,9 @@ vol.Optional('manufacturer'): [str], vol.Optional('device_type'): [str], }), + vol.Optional('homekit'): vol.Schema({ + vol.Optional('models'): [str], + }), vol.Required('documentation'): str, vol.Required('requirements'): [str], vol.Required('dependencies'): [str], diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 3b7d6a44f88787..895ae4ab790d6c 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -1,5 +1,5 @@ """Generate zeroconf file.""" -from collections import OrderedDict +from collections import OrderedDict, defaultdict import json from typing import Dict @@ -13,12 +13,15 @@ ZEROCONF = {} + +HOMEKIT = {} """.strip() def generate_and_validate(integrations: Dict[str, Integration]): """Validate and generate zeroconf data.""" - service_type_dict = {} + service_type_dict = defaultdict(list) + homekit_dict = {} for domain in sorted(integrations): integration = integrations[domain] @@ -26,17 +29,30 @@ def generate_and_validate(integrations: Dict[str, Integration]): if not integration.manifest: continue - service_types = integration.manifest.get('zeroconf') + service_types = integration.manifest.get('zeroconf', []) + homekit = integration.manifest.get('homekit', {}) + homekit_models = homekit.get('models', []) - if not service_types: + if not service_types and not homekit_models: continue try: with open(str(integration.path / "config_flow.py")) as fp: - if ' async_step_zeroconf(' not in fp.read(): + content = fp.read() + uses_discovery_flow = 'register_discovery_flow' in content + + if (service_types and not uses_discovery_flow and + ' async_step_zeroconf(' not in content): integration.add_error( 'zeroconf', 'Config flow has no async_step_zeroconf') continue + + if (homekit_models and not uses_discovery_flow and + ' async_step_homekit(' not in content): + integration.add_error( + 'zeroconf', 'Config flow has no async_step_homekit') + continue + except FileNotFoundError: integration.add_error( 'zeroconf', @@ -45,16 +61,50 @@ def generate_and_validate(integrations: Dict[str, Integration]): continue for service_type in service_types: + service_type_dict[service_type].append(domain) - if service_type not in service_type_dict: - service_type_dict[service_type] = [] + for model in homekit_models: + # We add a space, as we want to test for it to be model + space. + model += " " - service_type_dict[service_type].append(domain) + if model in homekit_dict: + integration.add_error( + 'zeroconf', + 'Integrations {} and {} have overlapping HomeKit ' + 'models'.format(domain, homekit_dict[model])) + break - data = OrderedDict((key, service_type_dict[key]) - for key in sorted(service_type_dict)) + homekit_dict[model] = domain + + # HomeKit models are matched on starting string, make sure none overlap. + warned = set() + for key in homekit_dict: + if key in warned: + continue - return BASE.format(json.dumps(data, indent=4)) + # n^2 yoooo + for key_2 in homekit_dict: + if key == key_2 or key_2 in warned: + continue + + if key.startswith(key_2) or key_2.startswith(key): + integration.add_error( + 'zeroconf', + 'Integrations {} and {} have overlapping HomeKit ' + 'models'.format(homekit_dict[key], homekit_dict[key_2])) + warned.add(key) + warned.add(key_2) + break + + zeroconf = OrderedDict((key, service_type_dict[key]) + for key in sorted(service_type_dict)) + homekit = OrderedDict((key, homekit_dict[key]) + for key in sorted(homekit_dict)) + + return BASE.format( + json.dumps(zeroconf, indent=4), + json.dumps(homekit, indent=4), + ) def validate(integrations: Dict[str, Integration], config: Config): diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index e7d7756fe7cf55..27c1dc757492d9 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,6 +1,7 @@ """Test Zeroconf component setup process.""" from unittest.mock import patch +import pytest from zeroconf import ServiceInfo, ServiceStateChange from homeassistant.generated import zeroconf as zc_gen @@ -8,6 +9,13 @@ from homeassistant.components import zeroconf +@pytest.fixture +def mock_zeroconf(): + """Mock zeroconf.""" + with patch('homeassistant.components.zeroconf.Zeroconf') as mock_zc: + yield mock_zc.return_value + + def service_update_mock(zeroconf, service, handlers): """Call service update handler.""" handlers[0]( @@ -23,18 +31,44 @@ def get_service_info_mock(service_type, name): properties={b'macaddress': b'ABCDEF012345'}) -async def test_setup(hass): - """Test configured options for a device are loaded via config entry.""" - with patch.object(hass.config_entries, 'flow') as mock_config_flow, \ - patch.object(zeroconf, 'ServiceBrowser') as MockServiceBrowser, \ - patch.object(zeroconf.Zeroconf, 'get_service_info') as \ - mock_get_service_info: +def get_homekit_info_mock(service_type, name): + """Return homekit info for get_service_info.""" + return ServiceInfo( + service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, + priority=0, server='name.local.', + properties={b'md': b'LIFX Bulb'}) - MockServiceBrowser.side_effect = service_update_mock - mock_get_service_info.side_effect = get_service_info_mock +async def test_setup(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + with patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_service_info_mock assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) - assert len(MockServiceBrowser.mock_calls) == len(zc_gen.ZEROCONF) + assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF) assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2 + + +async def test_homekit(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + with patch.dict( + zc_gen.ZEROCONF, { + zeroconf.HOMEKIT_TYPE: ["homekit_controller"] + }, clear=True + ), patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = get_homekit_info_mock + assert await async_setup_component( + hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[0][1][0] == 'lifx' From d966e0cfce82c00aeea3bc9221878fd20ec215a9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 31 May 2019 15:41:48 -0500 Subject: [PATCH 027/319] Add control of Amcrest indicator light (#23986) Enable feature by default but allow it to be disabled by "control_light: false" in config. Get brand from camera instead of assuming Amcrest (since this works with other cameras, too.) Retrieve RTSP URL in update method instead of in stream_source property and in handle_async_mjpeg_stream method. Move amcrest imports from methods to global. --- homeassistant/components/amcrest/__init__.py | 11 ++-- .../components/amcrest/binary_sensor.py | 4 +- homeassistant/components/amcrest/camera.py | 60 +++++++++++-------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 6de31caa90e325..58df1d8e504763 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import aiohttp +from amcrest import AmcrestCamera, AmcrestError import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_CONTROL @@ -32,6 +33,7 @@ CONF_RESOLUTION = 'resolution' CONF_STREAM_SOURCE = 'stream_source' CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +CONF_CONTROL_LIGHT = 'control_light' DEFAULT_NAME = 'Amcrest Camera' DEFAULT_PORT = 80 @@ -103,6 +105,7 @@ def _has_unique_names(devices): _deprecated_sensor_values), vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, }), _deprecated_switches ) @@ -114,8 +117,6 @@ def _has_unique_names(devices): def setup(hass, config): """Set up the Amcrest IP Camera component.""" - from amcrest import AmcrestCamera, AmcrestError - hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []}) devices = config[DOMAIN] @@ -149,6 +150,7 @@ def setup(hass, config): sensors = device.get(CONF_SENSORS) switches = device.get(CONF_SWITCHES) stream_source = device[CONF_STREAM_SOURCE] + control_light = device.get(CONF_CONTROL_LIGHT) # currently aiohttp only works with basic authentication # only valid for mjpeg streaming @@ -159,7 +161,7 @@ def setup(hass, config): hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice( api, authentication, ffmpeg_arguments, stream_source, - resolution) + resolution, control_light) discovery.load_platform( hass, CAMERA, DOMAIN, { @@ -245,10 +247,11 @@ class AmcrestDevice: """Representation of a base Amcrest discovery device.""" def __init__(self, api, authentication, ffmpeg_arguments, - stream_source, resolution): + stream_source, resolution, control_light): """Initialize the entity.""" self.api = api self.authentication = authentication self.ffmpeg_arguments = ffmpeg_arguments self.stream_source = stream_source self.resolution = resolution + self.control_light = control_light diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 0eb9e42e707dd7..fe4eb25b3db152 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +from amcrest import AmcrestError + from homeassistant.components.binary_sensor import ( BinarySensorDevice, DEVICE_CLASS_MOTION) from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS @@ -58,8 +60,6 @@ def device_class(self): def update(self): """Update entity.""" - from amcrest import AmcrestError - _LOGGER.debug('Pulling data from %s binary sensor', self._name) try: diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index d75475dbb26c6e..3b8c8f38f8bc57 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -2,6 +2,7 @@ import asyncio import logging +from amcrest import AmcrestError import voluptuous as vol from homeassistant.components.camera import ( @@ -94,19 +95,20 @@ def __init__(self, name, device, ffmpeg): self._stream_source = device.stream_source self._resolution = device.resolution self._token = self._auth = device.authentication + self._control_light = device.control_light self._is_recording = False self._motion_detection_enabled = None + self._brand = None self._model = None self._audio_enabled = None self._motion_recording_enabled = None self._color_bw = None + self._rtsp_url = None self._snapshot_lock = asyncio.Lock() self._unsub_dispatcher = [] async def async_camera_image(self): """Return a still image response from the camera.""" - from amcrest import AmcrestError - if not self.is_on: _LOGGER.error( 'Attempt to take snaphot when %s camera is off', self.name) @@ -143,7 +145,7 @@ async def handle_async_mjpeg_stream(self, request): # streaming via ffmpeg from haffmpeg.camera import CameraMjpeg - streaming_url = self._api.rtsp_url(typeno=self._resolution) + streaming_url = self._rtsp_url stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) await stream.open_camera( streaming_url, extra_cmd=self._ffmpeg_arguments) @@ -191,7 +193,7 @@ def is_recording(self): @property def brand(self): """Return the camera brand.""" - return 'Amcrest' + return self._brand @property def motion_detection_enabled(self): @@ -205,7 +207,7 @@ def model(self): async def stream_source(self): """Return the source of the stream.""" - return self._api.rtsp_url(typeno=self._resolution) + return self._rtsp_url @property def is_on(self): @@ -231,9 +233,19 @@ async def async_will_remove_from_hass(self): def update(self): """Update entity status.""" - from amcrest import AmcrestError - _LOGGER.debug('Pulling data from %s camera', self.name) + if self._brand is None: + try: + resp = self._api.vendor_information.strip() + if resp.startswith('vendor='): + self._brand = resp.split('=')[-1] + else: + self._brand = 'unknown' + except AmcrestError as error: + _LOGGER.error( + 'Could not get %s camera brand due to error: %s', + self.name, error) + self._brand = 'unknwown' if self._model is None: try: self._model = self._api.device_type.split('=')[-1].strip() @@ -241,7 +253,7 @@ def update(self): _LOGGER.error( 'Could not get %s camera model due to error: %s', self.name, error) - self._model = '' + self._model = 'unknown' try: self.is_streaming = self._api.video_enabled self._is_recording = self._api.record_mode == 'Manual' @@ -251,6 +263,7 @@ def update(self): self._motion_recording_enabled = ( self._api.is_record_on_motion_detection()) self._color_bw = _CBW[self._api.day_night_color] + self._rtsp_url = self._api.rtsp_url(typeno=self._resolution) except AmcrestError as error: _LOGGER.error( 'Could not get %s camera attributes due to error: %s', @@ -322,8 +335,6 @@ async def async_stop_tour(self): def _enable_video_stream(self, enable): """Enable or disable camera video stream.""" - from amcrest import AmcrestError - # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # recording on if video stream is being turned off. @@ -338,11 +349,11 @@ def _enable_video_stream(self, enable): else: self.is_streaming = enable self.schedule_update_ha_state() + if self._control_light: + self._enable_light(self._audio_enabled or self.is_streaming) def _enable_recording(self, enable): """Turn recording on or off.""" - from amcrest import AmcrestError - # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # video stream off if recording is being turned on. @@ -362,8 +373,6 @@ def _enable_recording(self, enable): def _enable_motion_detection(self, enable): """Enable or disable motion detection.""" - from amcrest import AmcrestError - try: self._api.motion_detection = str(enable).lower() except AmcrestError as error: @@ -376,8 +385,6 @@ def _enable_motion_detection(self, enable): def _enable_audio(self, enable): """Enable or disable audio stream.""" - from amcrest import AmcrestError - try: self._api.audio_enabled = enable except AmcrestError as error: @@ -387,11 +394,22 @@ def _enable_audio(self, enable): else: self._audio_enabled = enable self.schedule_update_ha_state() + if self._control_light: + self._enable_light(self._audio_enabled or self.is_streaming) + + def _enable_light(self, enable): + """Enable or disable indicator light.""" + try: + self._api.command( + 'configManager.cgi?action=setConfig&LightGlobal[0].Enable={}' + .format(str(enable).lower())) + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera indicator light due to error: %s', + 'enable' if enable else 'disable', self.name, error) def _enable_motion_recording(self, enable): """Enable or disable motion recording.""" - from amcrest import AmcrestError - try: self._api.motion_recording = str(enable).lower() except AmcrestError as error: @@ -404,8 +422,6 @@ def _enable_motion_recording(self, enable): def _goto_preset(self, preset): """Move camera position and zoom to preset.""" - from amcrest import AmcrestError - try: self._api.go_to_preset( action='start', preset_point_number=preset) @@ -416,8 +432,6 @@ def _goto_preset(self, preset): def _set_color_bw(self, cbw): """Set camera color mode.""" - from amcrest import AmcrestError - try: self._api.day_night_color = _CBW.index(cbw) except AmcrestError as error: @@ -430,8 +444,6 @@ def _set_color_bw(self, cbw): def _start_tour(self, start): """Start camera tour.""" - from amcrest import AmcrestError - try: self._api.tour(start=start) except AmcrestError as error: From 1e6b91b05ac5e69f1375a2c1b838e1ad8d115457 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 May 2019 23:10:09 +0200 Subject: [PATCH 028/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index eaf04588d27ce1..0f9919b7507227 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -144,8 +144,8 @@ jobs: displayName: 'Use Python 3.7' inputs: versionSpec: '3.7' - - script: pip install twine - displayName: 'Install twine' + - script: pip install twine wheel + displayName: 'Install tools' - script: python setup.py sdist bdist_wheel displayName: 'Build package' - script: | From 0ba2b4e253a9bc17e62c3f2b81a872de3e12c35b Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 31 May 2019 17:15:27 -0400 Subject: [PATCH 029/319] ZHA requirements version bump. (#24228) * ZHA requirements version bump. * zha-quirks version bump. --- homeassistant/components/zha/core/gateway.py | 5 +++++ homeassistant/components/zha/manifest.json | 8 ++++---- requirements_all.txt | 8 ++++---- requirements_test_all.txt | 4 ++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index daf14297ec18c9..740cd450181654 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -116,6 +116,8 @@ def device_joined(self, device): def raw_device_initialized(self, device): """Handle a device initialization without quirks loaded.""" + if device.nwk == 0x0000: + return endpoint_ids = device.endpoints.keys() ept_id = next((ept_id for ept_id in endpoint_ids if ept_id != 0), None) manufacturer = 'Unknown' @@ -259,6 +261,9 @@ async def async_update_device_storage(self): async def async_device_initialized(self, device, is_new_join): """Handle device joined and basic information discovered (async).""" + if device.nwk == 0x0000: + return + zha_device = self._async_get_or_create_device(device, is_new_join) is_rejoin = False diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 610498e62370c3..d9f17d3f41c241 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,11 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ - "bellows-homeassistant==0.7.3", - "zha-quirks==0.0.13", + "bellows-homeassistant==0.8.0", + "zha-quirks==0.0.14", "zigpy-deconz==0.1.4", - "zigpy-homeassistant==0.3.3", - "zigpy-xbee-homeassistant==0.2.1" + "zigpy-homeassistant==0.4.2", + "zigpy-xbee-homeassistant==0.3.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index efa99c6aa68794..b944f37f33102a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -235,7 +235,7 @@ batinfo==0.4.2 beautifulsoup4==4.7.1 # homeassistant.components.zha -bellows-homeassistant==0.7.3 +bellows-homeassistant==0.8.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.5.3 @@ -1878,7 +1878,7 @@ zengge==0.2 zeroconf==0.22.0 # homeassistant.components.zha -zha-quirks==0.0.13 +zha-quirks==0.0.14 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -1890,10 +1890,10 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.1.4 # homeassistant.components.zha -zigpy-homeassistant==0.3.3 +zigpy-homeassistant==0.4.2 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.2.1 +zigpy-xbee-homeassistant==0.3.0 # homeassistant.components.zoneminder zm-py==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0f03cc47ea3b6..bceb5f3e07faf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -73,7 +73,7 @@ av==6.1.2 axis==24 # homeassistant.components.zha -bellows-homeassistant==0.7.3 +bellows-homeassistant==0.8.0 # homeassistant.components.caldav caldav==0.6.1 @@ -355,4 +355,4 @@ wakeonlan==1.1.6 zeroconf==0.22.0 # homeassistant.components.zha -zigpy-homeassistant==0.3.3 +zigpy-homeassistant==0.4.2 From f43eca248a003aae03c8433320c683d0cf4a9e45 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 1 Jun 2019 00:51:55 +0200 Subject: [PATCH 030/319] Don't allow more than one config flow per discovered Axis device (#24230) --- homeassistant/components/axis/config_flow.py | 7 +++++++ homeassistant/components/axis/strings.json | 1 + 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index fc2051e49257fd..2aa5c4de16e147 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -155,6 +155,13 @@ async def async_step_zeroconf(self, discovery_info): return self.async_abort(reason='link_local_address') serialnumber = discovery_info['properties']['macaddress'] + # pylint: disable=unsupported-assignment-operation + self.context['macaddress'] = serialnumber + + if any(serialnumber == flow['context']['macaddress'] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + device_entries = configured_devices(self.hass) if serialnumber in device_entries: diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 3c528dfbb16112..ebefbecf311284 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -14,6 +14,7 @@ }, "error": { "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", "device_unavailable": "Device is not available", "faulty_credentials": "Bad user credentials" }, From 9f1dc71320c32d033fd89338c9641ea3c6ed5430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Oldag?= Date: Sat, 1 Jun 2019 05:49:52 +0200 Subject: [PATCH 031/319] Bump pychromecast (#24234) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 2d310cdda8f2e6..5699f8764cd1aa 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/cast", "requirements": [ - "pychromecast==3.2.1" + "pychromecast==3.2.2" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index b944f37f33102a..0126cbddaf8d7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1021,7 +1021,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==3.2.1 +pychromecast==3.2.2 # homeassistant.components.cmus pycmus==0.1.1 From 70fe4f22dbbc00604721a9d0672d35347571d11a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 22:59:16 -0700 Subject: [PATCH 032/319] Log HomeKit model (#24229) --- homeassistant/components/homekit_controller/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index c7100f3159e137..2ce8c0db6b785a 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -126,14 +126,16 @@ async def async_step_zeroconf(self, discovery_info): # It changes if a device is factory reset. hkid = properties['id'] model = properties['md'] - + name = discovery_info['name'].replace('._hap._tcp.local.', '') status_flags = int(properties['sf']) paired = not status_flags & 0x01 + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # pylint: disable=unsupported-assignment-operation self.context['hkid'] = hkid self.context['title_placeholders'] = { - 'name': discovery_info['name'].replace('._hap._tcp.local.', ''), + 'name': name, } # If multiple HomekitControllerFlowHandler end up getting created From 3edc58a04e4d311bbeee44394b29c347d333b20c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 22:59:35 -0700 Subject: [PATCH 033/319] Add GPSLogger device_info and unique_id (#24231) --- .../components/gpslogger/device_tracker.py | 14 ++++++++ tests/components/gpslogger/test_init.py | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 81a4fb3e7f8b81..49d421cbc8c851 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -45,6 +45,7 @@ def __init__( self._battery = battery self._location = location self._unsub_dispatcher = None + self._unique_id = device @property def battery_level(self): @@ -81,6 +82,19 @@ def should_poll(self): """No polling needed.""" return False + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._name, + 'identifiers': {(GPL_DOMAIN, self._unique_id)}, + } + @property def source_type(self): """Return the source type, eg gps or router, of the device.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 2cffa86f39358c..dbc283895fcb0c 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -140,6 +140,12 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id): data['device'])).state assert STATE_NOT_HOME == state_name + dev_reg = await hass.helpers.device_registry.async_get_registry() + assert len(dev_reg.devices) == 1 + + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 1 + async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): """Test when additional attributes are present.""" @@ -172,6 +178,33 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): assert state.attributes['provider'] == 'gps' assert state.attributes['activity'] == 'running' + data = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '123', + 'accuracy': 123, + 'battery': 23, + 'speed': 23, + 'direction': 123, + 'altitude': 123, + 'provider': 'gps', + 'activity': 'idle' + } + + req = await gpslogger_client.post(url, data=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])) + assert state.state == STATE_HOME + assert state.attributes['gps_accuracy'] == 123 + assert state.attributes['battery_level'] == 23 + assert state.attributes['speed'] == 23 + assert state.attributes['direction'] == 123 + assert state.attributes['altitude'] == 123 + assert state.attributes['provider'] == 'gps' + assert state.attributes['activity'] == 'idle' + @pytest.mark.xfail( reason='The device_tracker component does not support unloading yet.' From e6a54013dc32593ce342fa4856057253b273742e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 22:59:44 -0700 Subject: [PATCH 034/319] GeoFency unique ID and device info (#24232) --- .../components/geofency/device_tracker.py | 14 ++++++++++++++ tests/components/geofency/test_init.py | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index e340272c966024..0c60d5ef2cee2a 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -43,6 +43,7 @@ def __init__(self, device, gps, location_name, attributes): self._location_name = location_name self._gps = gps self._unsub_dispatcher = None + self._unique_id = device @property def device_state_attributes(self): @@ -74,6 +75,19 @@ def should_poll(self): """No polling needed.""" return False + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._name, + 'identifiers': {(GF_DOMAIN, self._unique_id)}, + } + @property def source_type(self): """Return the source type, eg gps or router, of the device.""" diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 718eb259db5596..18f119a753946f 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -217,6 +217,12 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): 'device_tracker', device_name)).attributes['longitude'] assert NOT_HOME_LONGITUDE == current_longitude + dev_reg = await hass.helpers.device_registry.async_get_registry() + assert len(dev_reg.devices) == 1 + + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 1 + async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" From 3076866ec67a8e5876eadfae43e501e296f30231 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 1 Jun 2019 02:00:10 -0400 Subject: [PATCH 035/319] add a deprecation warning for tplink device_tracker (#24236) * add a deprecation warning for tplink device_tracker * reword the warning a bit --- homeassistant/components/tplink/device_tracker.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/tplink/device_tracker.py b/homeassistant/components/tplink/device_tracker.py index 7b665006a44cf7..b139aed4eea0eb 100644 --- a/homeassistant/components/tplink/device_tracker.py +++ b/homeassistant/components/tplink/device_tracker.py @@ -41,6 +41,12 @@ def get_scanner(hass, config): should be gradually migrated in the pypi package """ + _LOGGER.warning("TP-Link device tracker is unmaintained and will be " + "removed in the future releases if no maintainer is " + "found. If you have interest in this integration, " + "feel free to create a pull request to move this code " + "to a new 'tplink_router' integration and refactoring " + "the device-specific parts to the tplink library") for cls in [ TplinkDeviceScanner, Tplink5DeviceScanner, Tplink4DeviceScanner, Tplink3DeviceScanner, Tplink2DeviceScanner, Tplink1DeviceScanner From b4374c8c4c36007c33ff46de5aafd3b11707110a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 23:01:45 -0700 Subject: [PATCH 036/319] Mobile app to use device tracker config entry (#24238) * Mobile app to use device tracker config entry * Lint * Re-use device_info * Lint --- .../components/mobile_app/__init__.py | 16 +- homeassistant/components/mobile_app/const.py | 2 + .../components/mobile_app/device_tracker.py | 137 ++++++++++++++++++ homeassistant/components/mobile_app/entity.py | 16 +- .../components/mobile_app/helpers.py | 15 +- .../components/mobile_app/manifest.json | 1 - .../components/mobile_app/webhook.py | 49 +------ tests/components/mobile_app/__init__.py | 75 +--------- tests/components/mobile_app/conftest.py | 60 ++++++++ .../mobile_app/test_device_tracker.py | 68 +++++++++ tests/components/mobile_app/test_entity.py | 3 - tests/components/mobile_app/test_http_api.py | 3 +- tests/components/mobile_app/test_webhook.py | 35 ++--- .../mobile_app/test_websocket_api.py | 3 +- 14 files changed, 318 insertions(+), 165 deletions(-) create mode 100644 homeassistant/components/mobile_app/device_tracker.py create mode 100644 tests/components/mobile_app/conftest.py create mode 100644 tests/components/mobile_app/test_device_tracker.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index abb7bcb7628c57..839aa8a6c3b3e7 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -7,13 +7,15 @@ from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, - DATA_DEVICES, DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, - STORAGE_VERSION) + DATA_DEVICES, DATA_DEVICE_TRACKER, DATA_SENSOR, DATA_STORE, + DOMAIN, STORAGE_KEY, STORAGE_VERSION) from .http_api import RegistrationsView from .webhook import handle_webhook from .websocket_api import register_websocket_handlers +PLATFORMS = 'sensor', 'binary_sensor', 'device_tracker' + async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" @@ -24,7 +26,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_BINARY_SENSOR: {}, DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], - DATA_DEVICES: {}, DATA_SENSOR: {} } @@ -33,6 +34,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), DATA_DEVICES: {}, + DATA_DEVICE_TRACKER: {}, DATA_SENSOR: app_config.get(DATA_SENSOR, {}), DATA_STORE: store, } @@ -83,10 +85,8 @@ async def async_setup_entry(hass, entry): webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, - DATA_BINARY_SENSOR)) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, DATA_SENSOR)) + for domain in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, domain)) return True diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 8b33406216e496..8cb5aa12731b52 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,6 +25,7 @@ DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' DATA_DEVICES = 'devices' +DATA_DEVICE_TRACKER = 'device_tracker' DATA_SENSOR = 'sensor' DATA_STORE = 'store' @@ -160,6 +161,7 @@ COMBINED_CLASSES = sorted(set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES)) SIGNAL_SENSOR_UPDATE = DOMAIN + '_sensor_update' +SIGNAL_LOCATION_UPDATE = DOMAIN + '_location_update_{}' REGISTER_SENSOR_SCHEMA = vol.Schema({ vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py new file mode 100644 index 00000000000000..19aade50876225 --- /dev/null +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -0,0 +1,137 @@ +"""Device tracker platform that adds support for OwnTracks over MQTT.""" +import logging + +from homeassistant.core import callback +from homeassistant.components.device_tracker.const import ( + DOMAIN, SOURCE_TYPE_GPS) +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) +from .const import ( + DOMAIN as MA_DOMAIN, + + ATTR_ALTITUDE, + ATTR_BATTERY, + ATTR_COURSE, + ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, + ATTR_GPS_ACCURACY, + ATTR_GPS, + ATTR_LOCATION_NAME, + ATTR_SPEED, + ATTR_VERTICAL_ACCURACY, + + SIGNAL_LOCATION_UPDATE, +) +from .helpers import device_info + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up OwnTracks based off an entry.""" + @callback + def _receive_data(data): + """Receive set location.""" + dev_id = entry.data[ATTR_DEVICE_ID] + device = hass.data[MA_DOMAIN][DOMAIN].get(dev_id) + + if device is not None: + device.update_data(data) + return + + device = hass.data[MA_DOMAIN][DOMAIN][dev_id] = MobileAppEntity( + entry, data + ) + async_add_entities([device]) + + hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_LOCATION_UPDATE.format(entry.entry_id), _receive_data) + return True + + +class MobileAppEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__(self, entry, data): + """Set up OwnTracks entity.""" + self._entry = entry + self._data = data + + @property + def unique_id(self): + """Return the unique ID.""" + return self._entry.data[ATTR_DEVICE_ID] + + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._data.get(ATTR_BATTERY) + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + attrs = {} + for key in (ATTR_ALTITUDE, ATTR_COURSE, + ATTR_SPEED, ATTR_VERTICAL_ACCURACY): + value = self._data.get(key) + if value is not None: + attrs[key] = value + + return attrs + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._data.get(ATTR_GPS_ACCURACY) + + @property + def latitude(self): + """Return latitude value of the device.""" + gps = self._data.get(ATTR_GPS) + + if gps is None: + return None + + return gps[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + gps = self._data.get(ATTR_GPS) + + if gps is None: + return None + + return gps[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._data.get(ATTR_LOCATION_NAME) + + @property + def name(self): + """Return the name of the device.""" + return self._entry.data[ATTR_DEVICE_NAME] + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @property + def device_info(self): + """Return the device info.""" + return device_info(self._entry.data) + + @callback + def update_data(self, data): + """Mark the device as seen.""" + self._data = data + self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index eca9d2b024bd40..8c1747d6f2b4f7 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -6,11 +6,11 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, - ATTR_MODEL, ATTR_OS_VERSION, ATTR_SENSOR_ATTRIBUTES, +from .const import (ATTR_SENSOR_ATTRIBUTES, ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, ATTR_SENSOR_NAME, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, DOMAIN, SIGNAL_SENSOR_UPDATE) +from .helpers import device_info def sensor_id(webhook_id, unique_id): @@ -76,17 +76,7 @@ def unique_id(self): @property def device_info(self): """Return device registry information for this entity.""" - return { - 'identifiers': { - (ATTR_DEVICE_ID, self._registration[ATTR_DEVICE_ID]), - (CONF_WEBHOOK_ID, self._registration[CONF_WEBHOOK_ID]) - }, - 'manufacturer': self._registration[ATTR_MANUFACTURER], - 'model': self._registration[ATTR_MODEL], - 'device_name': self._registration[ATTR_DEVICE_NAME], - 'sw_version': self._registration[ATTR_OS_VERSION], - 'config_entries': self._device.config_entries - } + return device_info(self._registration) async def async_update(self): """Get the latest state of the sensor.""" diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 6aec43074648ad..30c111fe0b4702 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -9,7 +9,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import HomeAssistantType -from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, +from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, ATTR_DEVICE_ID, ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, CONF_SECRET, CONF_USER_ID, DATA_BINARY_SENSOR, @@ -148,3 +148,16 @@ def webhook_response(data, *, registration: Dict, status: int = 200, return Response(text=data, status=status, content_type='application/json', headers=headers) + + +def device_info(registration: Dict) -> Dict: + """Return the device info for this registration.""" + return { + 'identifiers': { + (DOMAIN, registration[ATTR_DEVICE_ID]), + }, + 'manufacturer': registration[ATTR_MANUFACTURER], + 'model': registration[ATTR_MODEL], + 'device_name': registration[ATTR_DEVICE_NAME], + 'sw_version': registration[ATTR_OS_VERSION], + } diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index 969817b62c7292..85c6231daa8839 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -7,7 +7,6 @@ "PyNaCl==1.3.0" ], "dependencies": [ - "device_tracker", "http", "webhook" ], diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 4f867885d4f1e1..40002b5cfec5d4 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -6,10 +6,6 @@ from homeassistant.components.cloud import (async_remote_ui_url, CloudNotAvailable) -from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, - ATTR_DEV_ID, - DOMAIN as DT_DOMAIN, - SERVICE_SEE as DT_SEE) from homeassistant.components.frontend import MANIFEST_JSON from homeassistant.components.zone.const import DOMAIN as ZONE_DOMAIN @@ -24,15 +20,12 @@ from homeassistant.helpers.template import attach from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.util import slugify - -from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, - ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SPEED, + ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, - ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, + ATTR_TEMPLATE_VARIABLES, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, CONF_CLOUDHOOK_URL, CONF_REMOTE_UI_URL, CONF_SECRET, @@ -45,7 +38,7 @@ WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION, - WEBHOOK_TYPE_UPDATE_SENSOR_STATES) + WEBHOOK_TYPE_UPDATE_SENSOR_STATES, SIGNAL_LOCATION_UPDATE) from .helpers import (_decrypt_payload, empty_okay_response, error_response, @@ -151,37 +144,9 @@ async def handle_webhook(hass: HomeAssistantType, webhook_id: str, headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: - see_payload = { - ATTR_DEV_ID: slugify(registration[ATTR_DEVICE_NAME]), - ATTR_GPS: data[ATTR_GPS], - ATTR_GPS_ACCURACY: data[ATTR_GPS_ACCURACY], - } - - for key in (ATTR_LOCATION_NAME, ATTR_BATTERY): - value = data.get(key) - if value is not None: - see_payload[key] = value - - attrs = {} - - for key in (ATTR_ALTITUDE, ATTR_COURSE, - ATTR_SPEED, ATTR_VERTICAL_ACCURACY): - value = data.get(key) - if value is not None: - attrs[key] = value - - if attrs: - see_payload[ATTR_ATTRIBUTES] = attrs - - try: - await hass.services.async_call(DT_DOMAIN, - DT_SEE, see_payload, - blocking=True, context=context) - # noqa: E722 pylint: disable=broad-except - except (vol.Invalid, ServiceNotFound, Exception) as ex: - _LOGGER.error("Error when updating location during mobile_app " - "webhook (device name: %s): %s", - registration[ATTR_DEVICE_NAME], ex) + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_LOCATION_UPDATE.format(config_entry.entry_id), data + ) return empty_okay_response(headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index 98c7a20b059d0e..9b37214d079cdb 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -1,74 +1 @@ -"""Tests for mobile_app component.""" -# pylint: disable=redefined-outer-name,unused-import -import pytest - -from tests.common import mock_device_registry - -from homeassistant.setup import async_setup_component - -from homeassistant.components.mobile_app.const import (DATA_BINARY_SENSOR, - DATA_DELETED_IDS, - DATA_SENSOR, - DOMAIN, - STORAGE_KEY, - STORAGE_VERSION) - -from .const import REGISTER, REGISTER_CLEARTEXT - - -@pytest.fixture -def registry(hass): - """Return a configured device registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -async def create_registrations(authed_api_client): - """Return two new registrations.""" - enc_reg = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER - ) - - assert enc_reg.status == 201 - enc_reg_json = await enc_reg.json() - - clear_reg = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT - ) - - assert clear_reg.status == 201 - clear_reg_json = await clear_reg.json() - - return (enc_reg_json, clear_reg_json) - - -@pytest.fixture -async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): - """mobile_app mock client.""" - hass_storage[STORAGE_KEY] = { - 'version': STORAGE_VERSION, - 'data': { - DATA_BINARY_SENSOR: {}, - DATA_DELETED_IDS: [], - DATA_SENSOR: {} - } - } - - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - return await aiohttp_client(hass.http.app) - - -@pytest.fixture -async def authed_api_client(hass, hass_client): - """Provide an authenticated client for mobile_app to use.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - return await hass_client() - - -@pytest.fixture(autouse=True) -async def setup_ws(hass): - """Configure the websocket_api component.""" - assert await async_setup_component(hass, 'websocket_api', {}) - await hass.async_block_till_done() +"""Tests for the mobile app integration.""" diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py new file mode 100644 index 00000000000000..b20d164e6e69da --- /dev/null +++ b/tests/components/mobile_app/conftest.py @@ -0,0 +1,60 @@ +"""Tests for mobile_app component.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from tests.common import mock_device_registry + +from homeassistant.setup import async_setup_component + +from homeassistant.components.mobile_app.const import DOMAIN + +from .const import REGISTER, REGISTER_CLEARTEXT + + +@pytest.fixture +def registry(hass): + """Return a configured device registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +async def create_registrations(authed_api_client): + """Return two new registrations.""" + enc_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert enc_reg.status == 201 + enc_reg_json = await enc_reg.json() + + clear_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT + ) + + assert clear_reg.status == 201 + clear_reg_json = await clear_reg.json() + + return (enc_reg_json, clear_reg_json) + + +@pytest.fixture +async def webhook_client(hass, aiohttp_client): + """mobile_app mock client.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return await aiohttp_client(hass.http.app) + + +@pytest.fixture +async def authed_api_client(hass, hass_client): + """Provide an authenticated client for mobile_app to use.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return await hass_client() + + +@pytest.fixture(autouse=True) +async def setup_ws(hass): + """Configure the websocket_api component.""" + assert await async_setup_component(hass, 'websocket_api', {}) + await hass.async_block_till_done() diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py new file mode 100644 index 00000000000000..448bd9181c8556 --- /dev/null +++ b/tests/components/mobile_app/test_device_tracker.py @@ -0,0 +1,68 @@ +"""Test mobile app device tracker.""" + + +async def test_sending_location(hass, create_registrations, webhook_client): + """Test sending a location via a webhook.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json={ + 'type': 'update_location', + 'data': { + 'gps': [10, 20], + 'gps_accuracy': 30, + 'battery': 40, + 'altitude': 50, + 'course': 60, + 'speed': 70, + 'vertical_accuracy': 80, + 'location_name': 'bar', + } + } + ) + + assert resp.status == 200 + await hass.async_block_till_done() + state = hass.states.get('device_tracker.test_1') + assert state is not None + assert state.name == 'Test 1' + assert state.state == 'bar' + assert state.attributes['source_type'] == 'gps' + assert state.attributes['latitude'] == 10 + assert state.attributes['longitude'] == 20 + assert state.attributes['gps_accuracy'] == 30 + assert state.attributes['battery_level'] == 40 + assert state.attributes['altitude'] == 50 + assert state.attributes['course'] == 60 + assert state.attributes['speed'] == 70 + assert state.attributes['vertical_accuracy'] == 80 + + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json={ + 'type': 'update_location', + 'data': { + 'gps': [1, 2], + 'gps_accuracy': 3, + 'battery': 4, + 'altitude': 5, + 'course': 6, + 'speed': 7, + 'vertical_accuracy': 8, + } + } + ) + + assert resp.status == 200 + await hass.async_block_till_done() + state = hass.states.get('device_tracker.test_1') + assert state is not None + assert state.state == 'not_home' + assert state.attributes['source_type'] == 'gps' + assert state.attributes['latitude'] == 1 + assert state.attributes['longitude'] == 2 + assert state.attributes['gps_accuracy'] == 3 + assert state.attributes['battery_level'] == 4 + assert state.attributes['altitude'] == 5 + assert state.attributes['course'] == 6 + assert state.attributes['speed'] == 7 + assert state.attributes['vertical_accuracy'] == 8 diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py index e98307468d1f31..750c346cbc31ea 100644 --- a/tests/components/mobile_app/test_entity.py +++ b/tests/components/mobile_app/test_entity.py @@ -2,9 +2,6 @@ # pylint: disable=redefined-outer-name,unused-import import logging -from . import (authed_api_client, create_registrations, # noqa: F401 - webhook_client) # noqa: F401 - _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index dc51b850a16e50..80f01315f705e9 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -7,10 +7,9 @@ from homeassistant.setup import async_setup_component from .const import REGISTER, RENDER_TEMPLATE -from . import authed_api_client # noqa: F401 -async def test_registration(hass, hass_client): # noqa: F811 +async def test_registration(hass, hass_client): """Test that registrations happen.""" try: # pylint: disable=unused-import diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 43eac28ec18421..cd5b0a5bbed619 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -11,17 +11,14 @@ from tests.common import async_mock_service -from . import (authed_api_client, create_registrations, # noqa: F401 - webhook_client) # noqa: F401 - from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE) _LOGGER = logging.getLogger(__name__) -async def test_webhook_handle_render_template(create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_render_template(create_registrations, + webhook_client): """Test that we render templates properly.""" resp = await webhook_client.post( '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), @@ -34,7 +31,7 @@ async def test_webhook_handle_render_template(create_registrations, # noqa: F40 assert json == {'one': 'Hello world'} -async def test_webhook_handle_call_services(hass, create_registrations, # noqa: F401, F811, E501 +async def test_webhook_handle_call_services(hass, create_registrations, webhook_client): # noqa: E501 F811 """Test that we call services properly.""" calls = async_mock_service(hass, 'test', 'mobile_app') @@ -49,8 +46,8 @@ async def test_webhook_handle_call_services(hass, create_registrations, # noqa: assert len(calls) == 1 -async def test_webhook_handle_fire_event(hass, create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_fire_event(hass, create_registrations, + webhook_client): """Test that we can fire events.""" events = [] @@ -76,7 +73,7 @@ def store_event(event): async def test_webhook_update_registration(webhook_client, hass_client): # noqa: E501 F811 """Test that a we can update an existing registration via webhook.""" - authed_api_client = await hass_client() # noqa: F811 + authed_api_client = await hass_client() register_resp = await authed_api_client.post( '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT ) @@ -102,8 +99,8 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa assert CONF_SECRET not in update_json -async def test_webhook_handle_get_zones(hass, create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_get_zones(hass, create_registrations, + webhook_client): """Test that we can get zones properly.""" await async_setup_component(hass, ZONE_DOMAIN, { ZONE_DOMAIN: { @@ -126,8 +123,8 @@ async def test_webhook_handle_get_zones(hass, create_registrations, # noqa: F40 assert json[0]['entity_id'] == 'zone.home' -async def test_webhook_handle_get_config(hass, create_registrations, # noqa: F401, F811, E501 - webhook_client): # noqa: F811 +async def test_webhook_handle_get_config(hass, create_registrations, + webhook_client): """Test that we can get config properly.""" resp = await webhook_client.post( '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), @@ -160,8 +157,8 @@ async def test_webhook_handle_get_config(hass, create_registrations, # noqa: F4 assert expected_dict == json -async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F401, F811, E501 - create_registrations, # noqa: F401, F811, E501 +async def test_webhook_returns_error_incorrect_json(webhook_client, + create_registrations, caplog): # noqa: E501 F811 """Test that an error is returned when JSON is invalid.""" resp = await webhook_client.post( @@ -175,8 +172,8 @@ async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F40 assert 'invalid JSON' in caplog.text -async def test_webhook_handle_decryption(webhook_client, # noqa: F811 - create_registrations): # noqa: F401, F811, E501 +async def test_webhook_handle_decryption(webhook_client, + create_registrations): """Test that we can encrypt/decrypt properly.""" try: # pylint: disable=unused-import @@ -221,8 +218,8 @@ async def test_webhook_handle_decryption(webhook_client, # noqa: F811 assert json.loads(decrypted_data) == {'one': 'Hello world'} -async def test_webhook_requires_encryption(webhook_client, # noqa: F811 - create_registrations): # noqa: F401, F811, E501 +async def test_webhook_requires_encryption(webhook_client, + create_registrations): """Test that encrypted registrations only accept encrypted data.""" resp = await webhook_client.post( '/api/webhook/{}'.format(create_registrations[0]['webhook_id']), diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py index ee656159d2e784..20676731393a77 100644 --- a/tests/components/mobile_app/test_websocket_api.py +++ b/tests/components/mobile_app/test_websocket_api.py @@ -5,7 +5,6 @@ from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.setup import async_setup_component -from . import authed_api_client, setup_ws, webhook_client # noqa: F401 from .const import (CALL_SERVICE, REGISTER) @@ -45,7 +44,7 @@ async def test_webocket_get_user_registrations(hass, aiohttp_client, async def test_webocket_delete_registration(hass, hass_client, - hass_ws_client, webhook_client): # noqa: E501 F811 + hass_ws_client, webhook_client): """Test delete_registration websocket command.""" authed_api_client = await hass_client() # noqa: F811 register_resp = await authed_api_client.post( From b90636f64041045521b56b63c792ade6cec4cd80 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2019 23:03:45 -0700 Subject: [PATCH 037/319] Update home zone when core config updated (#24237) * Update home zone when core config updated * Lint --- homeassistant/components/config/core.py | 2 +- homeassistant/components/zone/__init__.py | 28 +++++++++++++++++------ homeassistant/components/zone/zone.py | 17 ++++++-------- homeassistant/core.py | 12 +++------- tests/components/zone/test_init.py | 21 +++++++++++++++++ tests/test_config.py | 2 +- tests/test_core.py | 6 ++--- 7 files changed, 57 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 31abb832f23d2d..a83516bdc3757f 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -56,7 +56,7 @@ async def websocket_update_config(hass, connection, msg): data.pop('type') try: - await hass.config.update(**data) + await hass.config.async_update(**data) connection.send_result(msg['id']) except ValueError as err: connection.send_error( diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 0340964561c5b4..1ece0dbaaa1bd6 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -3,10 +3,12 @@ import voluptuous as vol +from homeassistant.core import callback from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS, + EVENT_CORE_CONFIG_UPDATE) from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify @@ -90,12 +92,24 @@ async def async_setup(hass, config): hass.async_create_task(zone.async_update_ha_state()) entities.add(zone.entity_id) - if ENTITY_ID_HOME not in entities and HOME_ZONE not in zone_entries: - zone = Zone(hass, hass.config.location_name, - hass.config.latitude, hass.config.longitude, - DEFAULT_RADIUS, ICON_HOME, False) - zone.entity_id = ENTITY_ID_HOME - hass.async_create_task(zone.async_update_ha_state()) + if ENTITY_ID_HOME in entities or HOME_ZONE in zone_entries: + return True + + zone = Zone(hass, hass.config.location_name, + hass.config.latitude, hass.config.longitude, + DEFAULT_RADIUS, ICON_HOME, False) + zone.entity_id = ENTITY_ID_HOME + hass.async_create_task(zone.async_update_ha_state()) + + @callback + def core_config_updated(_): + """Handle core config updated.""" + zone.name = hass.config.location_name + zone.latitude = hass.config.latitude + zone.longitude = hass.config.longitude + zone.async_write_ha_state() + + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated) return True diff --git a/homeassistant/components/zone/zone.py b/homeassistant/components/zone/zone.py index 20155e06311fa0..51e2a623def058 100644 --- a/homeassistant/components/zone/zone.py +++ b/homeassistant/components/zone/zone.py @@ -23,21 +23,18 @@ def in_zone(zone, latitude, longitude, radius=0) -> bool: class Zone(Entity): """Representation of a Zone.""" + name = None + def __init__(self, hass, name, latitude, longitude, radius, icon, passive): """Initialize the zone.""" self.hass = hass - self._name = name - self._latitude = latitude - self._longitude = longitude + self.name = name + self.latitude = latitude + self.longitude = longitude self._radius = radius self._icon = icon self._passive = passive - @property - def name(self): - """Return the name of the zone.""" - return self._name - @property def state(self): """Return the state property really does nothing for a zone.""" @@ -53,8 +50,8 @@ def state_attributes(self): """Return the state attributes of the zone.""" data = { ATTR_HIDDEN: True, - ATTR_LATITUDE: self._latitude, - ATTR_LONGITUDE: self._longitude, + ATTR_LATITUDE: self.latitude, + ATTR_LONGITUDE: self.longitude, ATTR_RADIUS: self._radius, } if self._passive: diff --git a/homeassistant/core.py b/homeassistant/core.py index b732eb0d4b3ee2..ef15a4b11a0e45 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1288,10 +1288,7 @@ def _update(self, *, unit_system: Optional[str] = None, location_name: Optional[str] = None, time_zone: Optional[str] = None) -> None: - """Update the configuration from a dictionary. - - Async friendly. - """ + """Update the configuration from a dictionary.""" self.config_source = source if latitude is not None: self.latitude = latitude @@ -1309,11 +1306,8 @@ def _update(self, *, if time_zone is not None: self.set_time_zone(time_zone) - async def update(self, **kwargs: Any) -> None: - """Update the configuration from a dictionary. - - Async friendly. - """ + async def async_update(self, **kwargs: Any) -> None: + """Update the configuration from a dictionary.""" self._update(source=SOURCE_STORAGE, **kwargs) await self.async_store() self.hass.bus.async_fire( diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 576be0ce03ca3d..11fe9ae5e66f9f 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -221,3 +221,24 @@ def test_in_zone_works_for_passive_zones(self): assert zone.zone.in_zone(self.hass.states.get('zone.passive_zone'), latitude, longitude) + + +async def test_core_config_update(hass): + """Test updating core config will update home zone.""" + assert await setup.async_setup_component(hass, 'zone', {}) + + home = hass.states.get('zone.home') + + await hass.config.async_update( + location_name='Updated Name', + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + home_updated = hass.states.get('zone.home') + + assert home is not home_updated + assert home_updated.name == 'Updated Name' + assert home_updated.attributes['latitude'] == 10 + assert home_updated.attributes['longitude'] == 20 diff --git a/tests/test_config.py b/tests/test_config.py index 5579679937bcdb..a42fc3b809c8ee 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -421,7 +421,7 @@ async def test_updating_configuration(hass, hass_storage): hass_storage["core.config"] = dict(core_data) await config_util.async_process_ha_core_config( hass, {'whitelist_external_dirs': '/tmp'}) - await hass.config.update(latitude=50) + await hass.config.async_update(latitude=50) new_core_data = copy.deepcopy(core_data) new_core_data['data']['latitude'] = 50 diff --git a/tests/test_core.py b/tests/test_core.py index 15ab2baf3a9a8d..00bd4265da7a0d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -955,7 +955,7 @@ def callback(event): assert hass.config.latitude != 12 - await hass.config.update(latitude=12) + await hass.config.async_update(latitude=12) await hass.async_block_till_done() assert hass.config.latitude == 12 @@ -963,10 +963,10 @@ def callback(event): assert events[0].data == {'latitude': 12} -def test_bad_timezone_raises_value_error(hass): +async def test_bad_timezone_raises_value_error(hass): """Test bad timezone raises ValueError.""" with pytest.raises(ValueError): - hass.config.set_time_zone('not_a_timezone') + await hass.config.async_update(time_zone='not_a_timezone') @patch('homeassistant.core.monotonic') From 276ab191b5b7db2f5f411599b619bf8235a0d101 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Jun 2019 01:04:12 -0700 Subject: [PATCH 038/319] Do not use the cache dir for PIP installs (#24233) --- homeassistant/requirements.py | 7 +++++-- homeassistant/util/package.py | 5 ++++- tests/test_requirements.py | 21 ++++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index ca34a4bbae401f..1164eff4eb86c2 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -44,12 +44,15 @@ async def async_process_requirements(hass: HomeAssistant, name: str, def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" + is_docker = pkg_util.is_docker_env() kwargs = { - 'constraints': os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE) + 'constraints': os.path.join(os.path.dirname(__file__), + CONSTRAINT_FILE), + 'no_cache_dir': is_docker, } if 'WHEELS_LINKS' in os.environ: kwargs['find_links'] = os.environ['WHEELS_LINKS'] if not (config_dir is None or pkg_util.is_virtual_env()) and \ - not pkg_util.is_docker_env(): + not is_docker: kwargs['target'] = os.path.join(config_dir, 'deps') return kwargs diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 272a097b24c37f..6f6d03d67b6491 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -49,7 +49,8 @@ def is_installed(package: str) -> bool: def install_package(package: str, upgrade: bool = True, target: Optional[str] = None, constraints: Optional[str] = None, - find_links: Optional[str] = None) -> bool: + find_links: Optional[str] = None, + no_cache_dir: Optional[bool] = False) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. Return boolean if install successful. @@ -58,6 +59,8 @@ def install_package(package: str, upgrade: bool = True, _LOGGER.info('Attempting install of %s', package) env = os.environ.copy() args = [sys.executable, '-m', 'pip', 'install', '--quiet', package] + if no_cache_dir: + args.append('--no-cache-dir') if upgrade: args.append('--upgrade') if constraints is not None: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 35264c2e1b4df9..bbf86278bd20b0 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -30,9 +30,8 @@ def teardown_method(self, method): @patch('homeassistant.util.package.is_docker_env', return_value=False) @patch('homeassistant.util.package.install_package', return_value=True) def test_requirement_installed_in_venv( - self, mock_install, mock_venv, mock_denv, mock_dirname): + self, mock_install, mock_denv, mock_venv, mock_dirname): """Test requirement installed in virtual environment.""" - mock_venv.return_value = True mock_dirname.return_value = 'ha_package_path' self.hass.config.skip_pip = False mock_integration( @@ -42,14 +41,16 @@ def test_requirement_installed_in_venv( assert 'comp' in self.hass.config.components assert mock_install.call_args == call( 'package==0.0.1', - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=False, + ) @patch('os.path.dirname') @patch('homeassistant.util.package.is_virtual_env', return_value=False) @patch('homeassistant.util.package.is_docker_env', return_value=False) @patch('homeassistant.util.package.install_package', return_value=True) def test_requirement_installed_in_deps( - self, mock_install, mock_venv, mock_denv, mock_dirname): + self, mock_install, mock_denv, mock_venv, mock_dirname): """Test requirement installed in deps directory.""" mock_dirname.return_value = 'ha_package_path' self.hass.config.skip_pip = False @@ -60,7 +61,9 @@ def test_requirement_installed_in_deps( assert 'comp' in self.hass.config.components assert mock_install.call_args == call( 'package==0.0.1', target=self.hass.config.path('deps'), - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=False, + ) async def test_install_existing_package(hass): @@ -108,7 +111,9 @@ async def test_install_with_wheels_index(hass): print(mock_inst.call_args) assert mock_inst.call_args == call( 'hello==1.0.0', find_links="https://wheels.hass.io/test", - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=True, + ) async def test_install_on_docker(hass): @@ -135,4 +140,6 @@ async def test_install_on_docker(hass): print(mock_inst.call_args) assert mock_inst.call_args == call( 'hello==1.0.0', - constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), + no_cache_dir=True, + ) From 278b9d0f7178f6e4642de2df91c1cb76079c6ce9 Mon Sep 17 00:00:00 2001 From: Jef D Date: Sat, 1 Jun 2019 17:03:41 +0200 Subject: [PATCH 039/319] Round Awair sensor values (#24093) * Round sensor values * Add code owner * Update code owners * Fix tests --- CODEOWNERS | 1 + homeassistant/components/awair/manifest.json | 4 +++- homeassistant/components/awair/sensor.py | 2 +- tests/components/awair/test_sensor.py | 4 ++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 307d9ebc068db0..4bfe0c342721e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,6 +30,7 @@ homeassistant/components/asuswrt/* @kennedyshead homeassistant/components/auth/* @home-assistant/core homeassistant/components/automatic/* @armills homeassistant/components/automation/* @home-assistant/core +homeassistant/components/awair/* @danielsjf homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @kane610 homeassistant/components/azure_event_hub/* @eavanvalkenburg diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json index cba11e8be1ca00..dfa5bec3c00309 100644 --- a/homeassistant/components/awair/manifest.json +++ b/homeassistant/components/awair/manifest.json @@ -6,5 +6,7 @@ "python_awair==0.0.4" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@danielsjf" + ] } diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 85f18e87d13f86..71b74c7971e404 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -219,6 +219,6 @@ async def _async_update(self): # The air_data_latest call only returns one item, so this should # be safe to only process one entry. for sensor in resp[0][ATTR_SENSORS]: - self.data[sensor[ATTR_COMPONENT]] = sensor[ATTR_VALUE] + self.data[sensor[ATTR_COMPONENT]] = round(sensor[ATTR_VALUE], 1) _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index d251e8fdce8640..d5bb8236a1e1e0 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -178,7 +178,7 @@ async def test_awair_humid(hass): await setup_awair(hass) sensor = hass.states.get("sensor.awair_humidity") - assert sensor.state == "32.73" + assert sensor.state == "32.7" assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY assert sensor.attributes["unit_of_measurement"] == "%" @@ -291,7 +291,7 @@ async def test_async_update(hass): assert score_sensor.state == "79" assert hass.states.get("sensor.awair_temperature").state == "23.4" - assert hass.states.get("sensor.awair_humidity").state == "33.73" + assert hass.states.get("sensor.awair_humidity").state == "33.7" assert hass.states.get("sensor.awair_co2").state == "613" assert hass.states.get("sensor.awair_voc").state == "1013" assert hass.states.get("sensor.awair_pm2_5").state == "7.2" From ef820c312666711fc0e33b09bbad4ee811737523 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 1 Jun 2019 20:07:54 +0200 Subject: [PATCH 040/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0f9919b7507227..2a22db03f23a88 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ variables: - name: versionBuilder value: '3.2' - name: versionWheels - value: '0.3' + value: '0.4' - group: docker - group: wheels - group: github From 5a81ddd4e7277ce82676932f551895cd7ce3881c Mon Sep 17 00:00:00 2001 From: kbickar Date: Sat, 1 Jun 2019 15:15:28 -0400 Subject: [PATCH 041/319] Sense update (#24220) * Changed updates to be done by component and updates dispatched * syntax updates * Added code owner * Removed whitespace * Updated CODEOWNERS * Added subscription undoer --- CODEOWNERS | 1 + homeassistant/components/sense/__init__.py | 19 +++++++++- .../components/sense/binary_sensor.py | 36 ++++++++++++------- homeassistant/components/sense/manifest.json | 2 +- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4bfe0c342721e4..e472d4058b3d28 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,6 +202,7 @@ homeassistant/components/ruter/* @ludeeus homeassistant/components/scene/* @home-assistant/core homeassistant/components/scrape/* @fabaff homeassistant/components/script/* @home-assistant/core +homeassistant/components/sense/* @kbickar homeassistant/components/sensibo/* @andrey-git homeassistant/components/serial/* @fabaff homeassistant/components/seventeentrack/* @bachya diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 7266b2fb1e5ab2..85d5cd90e08c44 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -1,11 +1,14 @@ """Support for monitoring a Sense energy sensor.""" import logging +from datetime import timedelta import voluptuous as vol from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -15,6 +18,7 @@ DOMAIN = 'sense' SENSE_DATA = 'sense_data' +SENSE_DEVICE_UPDATE = 'sense_devices_update' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -27,7 +31,9 @@ async def async_setup(hass, config): """Set up the Sense sensor.""" - from sense_energy import ASyncSenseable, SenseAuthenticationException + from sense_energy import ( + ASyncSenseable, SenseAuthenticationException, + SenseAPITimeoutException) username = config[DOMAIN][CONF_EMAIL] password = config[DOMAIN][CONF_PASSWORD] @@ -45,4 +51,15 @@ async def async_setup(hass, config): async_load_platform(hass, 'sensor', DOMAIN, {}, config)) hass.async_create_task( async_load_platform(hass, 'binary_sensor', DOMAIN, {}, config)) + + async def async_sense_update(now): + """Retrieve latest state.""" + try: + await hass.data[SENSE_DATA].update_realtime() + async_dispatcher_send(hass, SENSE_DEVICE_UPDATE) + except SenseAPITimeoutException: + _LOGGER.error("Timeout retrieving data") + + async_track_time_interval(hass, async_sense_update, + timedelta(seconds=ACTIVE_UPDATE_RATE)) return True diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index a0f65ac555a585..43a2dc79a89b2b 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -2,8 +2,10 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import callback -from . import SENSE_DATA +from . import SENSE_DATA, SENSE_DEVICE_UPDATE _LOGGER = logging.getLogger(__name__) @@ -75,12 +77,12 @@ def __init__(self, data, device): self._id = device['id'] self._icon = sense_to_mdi(device['icon']) self._data = data - self._state = False + self._undo_dispatch_subscription = None @property def is_on(self): """Return true if the binary sensor is on.""" - return self._state + return self._name in self._data.active_devices @property def name(self): @@ -102,12 +104,22 @@ def device_class(self): """Return the device class of the binary sensor.""" return BIN_SENSOR_CLASS - async def async_update(self): - """Retrieve latest state.""" - from sense_energy.sense_api import SenseAPITimeoutException - try: - await self._data.update_realtime() - except SenseAPITimeoutException: - _LOGGER.error("Timeout retrieving data") - return - self._state = self._name in self._data.active_devices + @property + def should_poll(self): + """Return the deviceshould not poll for updates.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, SENSE_DEVICE_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 272a4a58f33835..8763234c5ed64d 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -6,5 +6,5 @@ "sense_energy==0.7.0" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@kbickar"] } From 673290d2e1499be69a0c04c93ecc608f71e3b2e5 Mon Sep 17 00:00:00 2001 From: Emilv2 Date: Sun, 2 Jun 2019 04:08:23 +0800 Subject: [PATCH 042/319] fix gitlab_ci sad icon (#24241) --- homeassistant/components/gitlab_ci/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 54cbf34fdfc2c8..1d59a5e4f21a5e 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -30,7 +30,7 @@ ICON_HAPPY = 'mdi:emoticon-happy' ICON_OTHER = 'mdi:git' -ICON_SAD = 'mdi:emoticon-happy' +ICON_SAD = 'mdi:emoticon-sad' SCAN_INTERVAL = timedelta(seconds=300) From 12d59797a7fac96859970134463cd6491743649e Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sat, 1 Jun 2019 13:12:58 -0700 Subject: [PATCH 043/319] Add details to triggered state for total connect alarms (#24106) * Bump skybellpy to 0.4.0 * Bump skybellpy to 0.4.0 in requirements_all.txt * Added extra states for STATE_ALARM_TRIGGERED to allow users to know if it is a burglar or fire or carbon monoxide so automations can take appropriate actions. Updated TotalConnect component to handle these new states. * Fix const import * Fix const import * Fix const imports * Bump total-connect-client to 0.26. * Catch details of alarm trigger in state attributes. Also bumps total_connect_client to 0.27. * Change state_attributes() to device_state_attributes() --- .../totalconnect/alarm_control_panel.py | 24 ++++++++++++++++--- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 848202d6ce1445..6d4c7a9671a87e 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -9,9 +9,8 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, CONF_NAME, - STATE_ALARM_ARMED_CUSTOM_BYPASS) - + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_ALARM_TRIGGERED, + CONF_NAME, STATE_ALARM_ARMED_CUSTOM_BYPASS) _LOGGER = logging.getLogger(__name__) @@ -46,6 +45,7 @@ def __init__(self, name, username, password): self._username = username self._password = password self._state = None + self._device_state_attributes = {} self._client = TotalConnectClient.TotalConnectClient( username, password) @@ -59,9 +59,15 @@ def state(self): """Return the state of the device.""" return self._state + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._device_state_attributes + def update(self): """Return the state of the device.""" status = self._client.get_armed_status() + attr = {'triggered_source': None, 'triggered_zone': None} if status == self._client.DISARMED: state = STATE_ALARM_DISARMED @@ -77,10 +83,22 @@ def update(self): state = STATE_ALARM_ARMING elif status == self._client.DISARMING: state = STATE_ALARM_DISARMING + elif status == self._client.ALARMING: + state = STATE_ALARM_TRIGGERED + attr['triggered_source'] = 'Police/Medical' + elif status == self._client.ALARMING_FIRE_SMOKE: + state = STATE_ALARM_TRIGGERED + attr['triggered_source'] = 'Fire/Smoke' + elif status == self._client.ALARMING_CARBON_MONOXIDE: + state = STATE_ALARM_TRIGGERED + attr['triggered_source'] = 'Carbon Monoxide' else: + logging.info("Total Connect Client returned unknown " + "status code: %s", status) state = None self._state = state + self._device_state_attributes = attr def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index adb60599ae533c..3ff3b5c5b46436 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -3,7 +3,7 @@ "name": "Totalconnect", "documentation": "https://www.home-assistant.io/components/totalconnect", "requirements": [ - "total_connect_client==0.25" + "total_connect_client==0.27" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 0126cbddaf8d7c..375b4ae9124ba2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1755,7 +1755,7 @@ todoist-python==7.0.17 toonapilib==3.2.2 # homeassistant.components.totalconnect -total_connect_client==0.25 +total_connect_client==0.27 # homeassistant.components.tplink_lte tp-connected==0.0.4 From 4d07448cf8939fd46baec743844767f3c8e9e714 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Sat, 1 Jun 2019 22:15:41 +0200 Subject: [PATCH 044/319] Bump python-velbus version for velbus component (#24226) * Bump python-velbus version * Bump python velbus version --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ff32000d5f0729..c432a2695ff568 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -3,7 +3,7 @@ "name": "Velbus", "documentation": "https://www.home-assistant.io/components/velbus", "requirements": [ - "python-velbus==2.0.24" + "python-velbus==2.0.26" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 375b4ae9124ba2..d56b2a6553e4d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1444,7 +1444,7 @@ python-telegram-bot==11.1.0 python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.24 +python-velbus==2.0.26 # homeassistant.components.vlc python-vlc==1.1.2 From 2823ef84dbca64922eda72013f2b0a24be7cad5d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 1 Jun 2019 22:49:29 +0200 Subject: [PATCH 045/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2a22db03f23a88..fbace26c3af050 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ variables: - name: versionBuilder value: '3.2' - name: versionWheels - value: '0.4' + value: '0.5' - group: docker - group: wheels - group: github From 7be7d3ffacc7218de5353c3e4c20d708da77ef55 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Jun 2019 14:27:25 -0700 Subject: [PATCH 046/319] Updated frontend to 20190601.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cb6ce89198edc7..bd93a0f481ce2f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190530.0" + "home-assistant-frontend==20190601.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index d56b2a6553e4d6..4fb5dd4deab8de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -577,7 +577,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190530.0 +home-assistant-frontend==20190601.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bceb5f3e07faf6..7ad6118362569e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -148,7 +148,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190530.0 +home-assistant-frontend==20190601.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 92202709484abd555b785a9c245cfda9c3a86b5a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 2 Jun 2019 07:13:14 +0200 Subject: [PATCH 047/319] Adds AdGuard Home integration (#24219) * Adds AdGuard Home integration * :shirt: Addresses linting warnings * :ambulance: Fixes typehint in async_setup_entry * :shirt: Take advantage of Python's coalescing operators * :shirt: Use adguard instance from outer scope directly in service calls * :shirt: Use more sensible scan_interval default for sensors * :shirt: Adds specific files to .coveragerc * :umbrella: Added tests and small changes to improve coverage * :hammer: Import adguardhome dependencies at the top * :ambulance: Converted service handlers to be async * :fire: Removed init step from config flow --- .coveragerc | 4 + CODEOWNERS | 1 + .../components/adguard/.translations/en.json | 29 +++ homeassistant/components/adguard/__init__.py | 180 ++++++++++++++ .../components/adguard/config_flow.py | 147 +++++++++++ homeassistant/components/adguard/const.py | 14 ++ .../components/adguard/manifest.json | 13 + homeassistant/components/adguard/sensor.py | 232 +++++++++++++++++ .../components/adguard/services.yaml | 37 +++ homeassistant/components/adguard/strings.json | 29 +++ homeassistant/components/adguard/switch.py | 233 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/adguard/__init__.py | 1 + tests/components/adguard/test_config_flow.py | 167 +++++++++++++ 17 files changed, 1095 insertions(+) create mode 100644 homeassistant/components/adguard/.translations/en.json create mode 100644 homeassistant/components/adguard/__init__.py create mode 100644 homeassistant/components/adguard/config_flow.py create mode 100644 homeassistant/components/adguard/const.py create mode 100644 homeassistant/components/adguard/manifest.json create mode 100644 homeassistant/components/adguard/sensor.py create mode 100644 homeassistant/components/adguard/services.yaml create mode 100644 homeassistant/components/adguard/strings.json create mode 100644 homeassistant/components/adguard/switch.py create mode 100644 tests/components/adguard/__init__.py create mode 100644 tests/components/adguard/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 967c560198c6b4..5480e0f1766180 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,6 +13,10 @@ omit = homeassistant/components/abode/* homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py + homeassistant/components/adguard/__init__.py + homeassistant/components/adguard/const.py + homeassistant/components/adguard/sensor.py + homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aftership/sensor.py homeassistant/components/airvisual/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index e472d4058b3d28..0fa8e54acc0925 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -17,6 +17,7 @@ virtualization/Docker/* @home-assistant/docker homeassistant/scripts/check_config.py @kellerza # Integrations +homeassistant/components/adguard/* @frenck homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell homeassistant/components/alpha_vantage/* @fabaff diff --git a/homeassistant/components/adguard/.translations/en.json b/homeassistant/components/adguard/.translations/en.json new file mode 100644 index 00000000000000..c88f7085e341c0 --- /dev/null +++ b/homeassistant/components/adguard/.translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "AdGuard Home", + "step": { + "user": { + "title": "Link your AdGuard Home.", + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username", + "ssl": "AdGuard Home uses a SSL certificate", + "verify_ssl": "AdGuard Home uses a proper certificate" + } + }, + "hassio_confirm": { + "title": "AdGuard Home via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?" + } + }, + "error": { + "connection_error": "Failed to connect." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py new file mode 100644 index 00000000000000..15b8b9978f6db9 --- /dev/null +++ b/homeassistant/components/adguard/__init__.py @@ -0,0 +1,180 @@ +"""Support for AdGuard Home.""" +import logging +from typing import Any, Dict + +from adguardhome import AdGuardHome, AdGuardHomeError +import voluptuous as vol + +from homeassistant.components.adguard.const import ( + CONF_FORCE, DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN, + SERVICE_ADD_URL, SERVICE_DISABLE_URL, SERVICE_ENABLE_URL, SERVICE_REFRESH, + SERVICE_REMOVE_URL) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_URL, + CONF_USERNAME, CONF_VERIFY_SSL) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_ADD_URL_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} +) +SERVICE_REFRESH_SCHEMA = vol.Schema( + {vol.Optional(CONF_FORCE, default=False): cv.boolean} +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the AdGuard Home components.""" + return True + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry +) -> bool: + """Set up AdGuard Home from a config entry.""" + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) + adguard = AdGuardHome( + entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + loop=hass.loop, + session=session, + ) + + hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard + + for component in 'sensor', 'switch': + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + async def add_url(call) -> None: + """Service call to add a new filter subscription to AdGuard Home.""" + await adguard.filtering.add_url( + call.data.get(CONF_NAME), call.data.get(CONF_URL) + ) + + async def remove_url(call) -> None: + """Service call to remove a filter subscription from AdGuard Home.""" + await adguard.filtering.remove_url(call.data.get(CONF_URL)) + + async def enable_url(call) -> None: + """Service call to enable a filter subscription in AdGuard Home.""" + await adguard.filtering.enable_url(call.data.get(CONF_URL)) + + async def disable_url(call) -> None: + """Service call to disable a filter subscription in AdGuard Home.""" + await adguard.filtering.disable_url(call.data.get(CONF_URL)) + + async def refresh(call) -> None: + """Service call to refresh the filter subscriptions in AdGuard Home.""" + await adguard.filtering.refresh(call.data.get(CONF_FORCE)) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, entry: ConfigType +) -> bool: + """Unload AdGuard Home config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) + hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + + for component in 'sensor', 'switch': + await hass.config_entries.async_forward_entry_unload(entry, component) + + del hass.data[DOMAIN] + + return True + + +class AdGuardHomeEntity(Entity): + """Defines a base AdGuard Home entity.""" + + def __init__(self, adguard, name: str, icon: str) -> None: + """Initialize the AdGuard Home entity.""" + self._name = name + self._icon = icon + self._available = True + self.adguard = adguard + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update AdGuard Home entity.""" + try: + await self._adguard_update() + self._available = True + except AdGuardHomeError: + if self._available: + _LOGGER.debug( + "An error occurred while updating AdGuard Home sensor.", + exc_info=True, + ) + self._available = False + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + raise NotImplementedError() + + +class AdGuardHomeDeviceEntity(AdGuardHomeEntity): + """Defines a AdGuard Home device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this AdGuard Home instance.""" + return { + 'identifiers': { + ( + DOMAIN, + self.adguard.host, + self.adguard.port, + self.adguard.base_path, + ) + }, + 'name': 'AdGuard Home', + 'manufacturer': 'AdGuard Team', + 'sw_version': self.hass.data[DOMAIN].get(DATA_ADGUARD_VERION), + } diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py new file mode 100644 index 00000000000000..7e144a76e222e0 --- /dev/null +++ b/homeassistant/components/adguard/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow to configure the AdGuard Home integration.""" +import logging + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.adguard.const import DOMAIN +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class AdGuardHomeFlowHandler(ConfigFlow): + """Handle a AdGuard Home config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + _hassio_discovery = None + + def __init__(self): + """Initialize AgGuard Home flow.""" + pass + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=3000): vol.Coerce(int), + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=True): bool, + vol.Required(CONF_VERIFY_SSL, default=True): bool, + } + ), + errors=errors or {}, + ) + + async def _show_hassio_form(self, errors=None): + """Show the Hass.io confirmation form to the user.""" + return self.async_show_form( + step_id='hassio_confirm', + description_placeholders={ + 'addon': self._hassio_discovery['addon'] + }, + data_schema=vol.Schema({}), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + if user_input is None: + return await self._show_setup_form(user_input) + + errors = {} + + session = async_get_clientsession( + self.hass, user_input[CONF_VERIFY_SSL] + ) + + adguard = AdGuardHome( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + tls=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], + loop=self.hass.loop, + session=session, + ) + + try: + await adguard.version() + except AdGuardHomeConnectionError: + errors['base'] = 'connection_error' + return await self._show_setup_form(errors) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_PORT: user_input[CONF_PORT], + CONF_SSL: user_input[CONF_SSL], + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + async def async_step_hassio(self, user_input=None): + """Prepare configuration for a Hass.io AdGuard Home add-on. + + This flow is triggered by the discovery component. + """ + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + self._hassio_discovery = user_input + + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm(self, user_input=None): + """Confirm Hass.io discovery.""" + if user_input is None: + return await self._show_hassio_form() + + errors = {} + + session = async_get_clientsession(self.hass, False) + + adguard = AdGuardHome( + self._hassio_discovery[CONF_HOST], + port=self._hassio_discovery[CONF_PORT], + tls=False, + loop=self.hass.loop, + session=session, + ) + + try: + await adguard.version() + except AdGuardHomeConnectionError: + errors['base'] = 'connection_error' + return await self._show_hassio_form(errors) + + return self.async_create_entry( + title=self._hassio_discovery['addon'], + data={ + CONF_HOST: self._hassio_discovery[CONF_HOST], + CONF_PORT: self._hassio_discovery[CONF_PORT], + CONF_PASSWORD: None, + CONF_SSL: False, + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py new file mode 100644 index 00000000000000..6bbabdafaf17c5 --- /dev/null +++ b/homeassistant/components/adguard/const.py @@ -0,0 +1,14 @@ +"""Constants for the AdGuard Home integration.""" + +DOMAIN = 'adguard' + +DATA_ADGUARD_CLIENT = 'adguard_client' +DATA_ADGUARD_VERION = 'adguard_version' + +CONF_FORCE = 'force' + +SERVICE_ADD_URL = 'add_url' +SERVICE_DISABLE_URL = 'disable_url' +SERVICE_ENABLE_URL = 'enable_url' +SERVICE_REFRESH = 'refresh' +SERVICE_REMOVE_URL = 'remove_url' diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json new file mode 100644 index 00000000000000..281a384e21fe94 --- /dev/null +++ b/homeassistant/components/adguard/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adguard", + "name": "AdGuard Home", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/adguard", + "requirements": [ + "adguardhome==0.2.0" + ], + "dependencies": [], + "codeowners": [ + "@frenck" + ] +} \ No newline at end of file diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py new file mode 100644 index 00000000000000..abb5309b449b8d --- /dev/null +++ b/homeassistant/components/adguard/sensor.py @@ -0,0 +1,232 @@ +"""Support for AdGuard Home sensors.""" +from datetime import timedelta +import logging + +from adguardhome import AdGuardHomeConnectionError + +from homeassistant.components.adguard import AdGuardHomeDeviceEntity +from homeassistant.components.adguard.const import ( + DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up AdGuard Home sensor based on a config entry.""" + adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT] + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise PlatformNotReady from exception + + hass.data[DOMAIN][DATA_ADGUARD_VERION] = version + + sensors = [ + AdGuardHomeDNSQueriesSensor(adguard), + AdGuardHomeBlockedFilteringSensor(adguard), + AdGuardHomePercentageBlockedSensor(adguard), + AdGuardHomeReplacedParentalSensor(adguard), + AdGuardHomeReplacedSafeBrowsingSensor(adguard), + AdGuardHomeReplacedSafeSearchSensor(adguard), + AdGuardHomeAverageProcessingTimeSensor(adguard), + AdGuardHomeRulesCountSensor(adguard), + ] + + async_add_entities(sensors, True) + + +class AdGuardHomeSensor(AdGuardHomeDeviceEntity): + """Defines a AdGuard Home sensor.""" + + def __init__( + self, + adguard, + name: str, + icon: str, + measurement: str, + unit_of_measurement: str, + ) -> None: + """Initialize AdGuard Home sensor.""" + self._state = None + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + + super().__init__(adguard, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return '_'.join( + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + 'sensor', + self.measurement, + ] + ) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class AdGuardHomeDNSQueriesSensor(AdGuardHomeSensor): + """Defines a AdGuard Home DNS Queries sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard DNS Queries', + 'mdi:magnify', + 'dns_queries', + 'queries', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.dns_queries() + + +class AdGuardHomeBlockedFilteringSensor(AdGuardHomeSensor): + """Defines a AdGuard Home blocked by filtering sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard DNS Queries Blocked', + 'mdi:magnify-close', + 'blocked_filtering', + 'queries', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.blocked_filtering() + + +class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor): + """Defines a AdGuard Home blocked percentage sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard DNS Queries Blocked Ratio', + 'mdi:magnify-close', + 'blocked_percentage', + '%', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + percentage = await self.adguard.stats.blocked_percentage() + self._state = "{:.2f}".format(percentage) + + +class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by parental control sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Parental Control Blocked', + 'mdi:human-male-girl', + 'blocked_parental', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_parental() + + +class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe browsing sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Safe Browsing Blocked', + 'mdi:shield-half-full', + 'blocked_safebrowsing', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safebrowsing() + + +class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe search sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'Searches Safe Search Enforced', + 'mdi:shield-search', + 'enforced_safesearch', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safesearch() + + +class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor): + """Defines a AdGuard Home average processing time sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Average Processing Speed', + 'mdi:speedometer', + 'average_speed', + 'ms', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + average = await self.adguard.stats.avg_processing_time() + self._state = "{:.2f}".format(average) + + +class AdGuardHomeRulesCountSensor(AdGuardHomeSensor): + """Defines a AdGuard Home rules count sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Rules Count', + 'mdi:counter', + 'rules_count', + 'rules', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.filtering.rules_count() diff --git a/homeassistant/components/adguard/services.yaml b/homeassistant/components/adguard/services.yaml new file mode 100644 index 00000000000000..736acdd923c853 --- /dev/null +++ b/homeassistant/components/adguard/services.yaml @@ -0,0 +1,37 @@ +add_url: + description: Add a new filter subscription to AdGuard Home. + fields: + name: + description: The name of the filter subscription. + example: Example + url: + description: The filter URL to subscribe to, containing the filter rules. + example: https://www.example.com/filter/1.txt + +remove_url: + description: Removes a filter subscription from AdGuard Home. + fields: + url: + description: The filter subscription URL to remove. + example: https://www.example.com/filter/1.txt + +enable_url: + description: Enables a filter subscription in AdGuard Home. + fields: + url: + description: The filter subscription URL to enable. + example: https://www.example.com/filter/1.txt + +disable_url: + description: Disables a filter subscription in AdGuard Home. + fields: + url: + description: The filter subscription URL to disable. + example: https://www.example.com/filter/1.txt + +refresh: + description: Refresh all filter subscriptions in AdGuard Home. + fields: + force: + description: Force update (by passes AdGuard Home throttling). + example: '"true" to force, "false" or omit for a regular refresh.' diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json new file mode 100644 index 00000000000000..c88f7085e341c0 --- /dev/null +++ b/homeassistant/components/adguard/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "AdGuard Home", + "step": { + "user": { + "title": "Link your AdGuard Home.", + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username", + "ssl": "AdGuard Home uses a SSL certificate", + "verify_ssl": "AdGuard Home uses a proper certificate" + } + }, + "hassio_confirm": { + "title": "AdGuard Home via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?" + } + }, + "error": { + "connection_error": "Failed to connect." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py new file mode 100644 index 00000000000000..601bf25b5b06e4 --- /dev/null +++ b/homeassistant/components/adguard/switch.py @@ -0,0 +1,233 @@ +"""Support for AdGuard Home switches.""" +from datetime import timedelta +import logging + +from adguardhome import AdGuardHomeConnectionError, AdGuardHomeError + +from homeassistant.components.adguard import AdGuardHomeDeviceEntity +from homeassistant.components.adguard.const import ( + DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up AdGuard Home switch based on a config entry.""" + adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT] + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise PlatformNotReady from exception + + hass.data[DOMAIN][DATA_ADGUARD_VERION] = version + + switches = [ + AdGuardHomeProtectionSwitch(adguard), + AdGuardHomeFilteringSwitch(adguard), + AdGuardHomeParentalSwitch(adguard), + AdGuardHomeSafeBrowsingSwitch(adguard), + AdGuardHomeSafeSearchSwitch(adguard), + AdGuardHomeQueryLogSwitch(adguard), + ] + async_add_entities(switches, True) + + +class AdGuardHomeSwitch(ToggleEntity, AdGuardHomeDeviceEntity): + """Defines a AdGuard Home switch.""" + + def __init__(self, adguard, name: str, icon: str, key: str): + """Initialize AdGuard Home switch.""" + self._state = False + self._key = key + super().__init__(adguard, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return '_'.join( + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + 'switch', + self._key, + ] + ) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._state + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the switch.""" + try: + await self._adguard_turn_off() + except AdGuardHomeError: + _LOGGER.error( + "An error occurred while turning off AdGuard Home switch." + ) + self._available = False + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs) -> None: + """Turn on the switch.""" + try: + await self._adguard_turn_on() + except AdGuardHomeError: + _LOGGER.error( + "An error occurred while turning on AdGuard Home switch." + ) + self._available = False + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + raise NotImplementedError() + + +class AdGuardHomeProtectionSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home protection switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Protection", 'mdi:shield-check', 'protection' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.disable_protection() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.enable_protection() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.protection_enabled() + + +class AdGuardHomeParentalSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home parental control switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Parental Control", 'mdi:shield-check', 'parental' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.parental.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.parental.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.parental.enabled() + + +class AdGuardHomeSafeSearchSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home safe search switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Safe Search", 'mdi:shield-check', 'safesearch' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.safesearch.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.safesearch.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.safesearch.enabled() + + +class AdGuardHomeSafeBrowsingSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home safe search switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, + "AdGuard Safe Browsing", + 'mdi:shield-check', + 'safebrowsing', + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.safebrowsing.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.safebrowsing.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.safebrowsing.enabled() + + +class AdGuardHomeFilteringSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home filtering switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Filtering", 'mdi:shield-check', 'filtering' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.filtering.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.filtering.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.filtering.enabled() + + +class AdGuardHomeQueryLogSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home query log switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Query Log", 'mdi:shield-check', 'querylog' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.querylog.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.querylog.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.querylog.enabled() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 32b41610acf02a..41b03264c4f5be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -5,6 +5,7 @@ FLOWS = [ + "adguard", "ambiclimate", "ambient_station", "axis", diff --git a/requirements_all.txt b/requirements_all.txt index 4fb5dd4deab8de..6c4421ed2b338a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -107,6 +107,9 @@ adafruit-blinka==1.2.1 # homeassistant.components.mcp23017 adafruit-circuitpython-mcp230xx==1.1.2 +# homeassistant.components.adguard +adguardhome==0.2.0 + # homeassistant.components.frontier_silicon afsapi==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ad6118362569e..4fde162bdd9741 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -35,6 +35,9 @@ PyTransportNSW==0.1.1 # homeassistant.components.yessssms YesssSMS==0.2.3 +# homeassistant.components.adguard +adguardhome==0.2.0 + # homeassistant.components.ambient_station aioambient==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f85758e464ff02..33f27a6702188c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -42,6 +42,7 @@ ) TEST_REQUIREMENTS = ( + 'adguardhome', 'ambiclimate', 'aioambient', 'aioautomatic', diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py new file mode 100644 index 00000000000000..318e881ef2f90b --- /dev/null +++ b/tests/components/adguard/__init__.py @@ -0,0 +1 @@ +"""Tests for the AdGuard Home component.""" diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py new file mode 100644 index 00000000000000..451fd1436d411d --- /dev/null +++ b/tests/components/adguard/test_config_flow.py @@ -0,0 +1,167 @@ +"""Tests for the AdGuard Home config flow.""" +import aiohttp + +from homeassistant import data_entry_flow +from homeassistant.components.adguard import config_flow +from homeassistant.components.adguard.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL) + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_HOST: '127.0.0.1', + CONF_PORT: 3000, + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + CONF_SSL: True, + CONF_VERIFY_SSL: True, +} + + +async def test_show_authenticate_form(hass): + """Test that the setup form is served.""" + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_connection_error(hass, aioclient_mock): + """Test we show user form on AdGuard Home connection error.""" + aioclient_mock.get( + "{}://{}:{}/control/status".format( + 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http', + FIXTURE_USER_INPUT[CONF_HOST], + FIXTURE_USER_INPUT[CONF_PORT], + ), + exc=aiohttp.ClientError, + ) + + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'connection_error'} + + +async def test_full_flow_implementation(hass, aioclient_mock): + """Test registering an integration and finishing flow works.""" + aioclient_mock.get( + "{}://{}:{}/control/status".format( + 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http', + FIXTURE_USER_INPUT[CONF_HOST], + FIXTURE_USER_INPUT[CONF_PORT], + ), + json={'version': '1.0'}, + headers={'Content-Type': 'application/json'}, + ) + + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == FIXTURE_USER_INPUT[CONF_HOST] + assert result['data'][CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST] + assert result['data'][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result['data'][CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT] + assert result['data'][CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL] + assert result['data'][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert ( + result['data'][CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL] + ) + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + assert result['type'] == 'abort' + assert result['reason'] == 'single_instance_allowed' + + +async def test_hassio_single_instance(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain='adguard', data={'host': '1.2.3.4'}).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', context={'source': 'hassio'} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'single_instance_allowed' + + +async def test_hassio_confirm(hass, aioclient_mock): + """Test we can finish a config flow.""" + aioclient_mock.get( + "http://mock-adguard:3000/control/status", + json={'version': '1.0'}, + headers={'Content-Type': 'application/json'}, + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': 3000, + }, + context={'source': 'hassio'}, + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'hassio_confirm' + assert result['description_placeholders'] == { + 'addon': 'AdGuard Home Addon' + } + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'AdGuard Home Addon' + assert result['data'][CONF_HOST] == 'mock-adguard' + assert result['data'][CONF_PASSWORD] is None + assert result['data'][CONF_PORT] == 3000 + assert result['data'][CONF_SSL] is False + assert result['data'][CONF_USERNAME] is None + assert result['data'][CONF_VERIFY_SSL] + + +async def test_hassio_connection_error(hass, aioclient_mock): + """Test we show hassio confirm form on AdGuard Home connection error.""" + aioclient_mock.get( + "http://mock-adguard:3000/control/status", + exc=aiohttp.ClientError, + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': 3000, + }, + context={'source': 'hassio'}, + ) + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {} + ) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'hassio_confirm' + assert result['errors'] == {'base': 'connection_error'} From c486f794f9e5c0ca84f07a94fc8280669d64ddeb Mon Sep 17 00:00:00 2001 From: Emilv2 Date: Sun, 2 Jun 2019 16:07:17 +0800 Subject: [PATCH 048/319] Fix typo in integration component (#24250) --- homeassistant/components/integration/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 3a72c81fa11ac0..6aa0f5ad5f2a0a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -40,7 +40,7 @@ 'h': 60*60, 'd': 24*60*60} -ICON = 'mdi:char-histogram' +ICON = 'mdi:chart-histogram' DEFAULT_ROUND = 3 From 034b0e07d20e69cafe11dd4e031848d12b2d2ae8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 2 Jun 2019 14:26:12 +0200 Subject: [PATCH 049/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 68 +++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fbace26c3af050..fc08af84850b73 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ variables: - name: versionBuilder value: '3.2' - name: versionWheels - value: '0.5' + value: '0.6' - group: docker - group: wheels - group: github @@ -22,7 +22,7 @@ variables: jobs: - job: 'Wheels' - condition: eq(variables['Build.SourceBranchName'], 'dev') + condition: or(eq(variables['Build.SourceBranchName'], 'dev'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) timeoutInMinutes: 360 pool: vmImage: 'ubuntu-latest' @@ -44,7 +44,8 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ qemu-user-static \ - binfmt-support + binfmt-support \ + curl sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc sudo update-binfmts --enable qemu-arm @@ -59,40 +60,47 @@ jobs: - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels) displayName: 'Install wheels builder' - script: | - cp requirements_all.txt requirements_hassio.txt + cp requirements_all.txt requirements_wheels.txt + if [ "$(Build.SourceBranchName)" == "dev" ]; then + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt + else + touch requirements_diff.txt + fi # Enable because we can build it - sed -i "s|# pytradfri|pytradfri|g" requirements_hassio.txt - sed -i "s|# pybluez|pybluez|g" requirements_hassio.txt - sed -i "s|# bluepy|bluepy|g" requirements_hassio.txt - sed -i "s|# beacontools|beacontools|g" requirements_hassio.txt - sed -i "s|# RPi.GPIO|RPi.GPIO|g" requirements_hassio.txt - sed -i "s|# raspihats|raspihats|g" requirements_hassio.txt - sed -i "s|# rpi-rf|rpi-rf|g" requirements_hassio.txt - sed -i "s|# blinkt|blinkt|g" requirements_hassio.txt - sed -i "s|# fritzconnection|fritzconnection|g" requirements_hassio.txt - sed -i "s|# pyuserinput|pyuserinput|g" requirements_hassio.txt - sed -i "s|# evdev|evdev|g" requirements_hassio.txt - sed -i "s|# smbus-cffi|smbus-cffi|g" requirements_hassio.txt - sed -i "s|# i2csense|i2csense|g" requirements_hassio.txt - sed -i "s|# python-eq3bt|python-eq3bt|g" requirements_hassio.txt - sed -i "s|# pycups|pycups|g" requirements_hassio.txt - sed -i "s|# homekit|homekit|g" requirements_hassio.txt - sed -i "s|# decora_wifi|decora_wifi|g" requirements_hassio.txt - sed -i "s|# decora|decora|g" requirements_hassio.txt - sed -i "s|# PySwitchbot|PySwitchbot|g" requirements_hassio.txt - sed -i "s|# pySwitchmate|pySwitchmate|g" requirements_hassio.txt - sed -i "s|# face_recognition|face_recognition|g" requirements_hassio.txt - - # Disable because of error - sed -i "s|insteonplm|# insteonplm|g" requirements_hassio.txt + requirement_files="requirements_wheels.txt requirements_diff.txt" + + for requirement_file in ${requirement_files}; do + sed -i "s|# pytradfri|pytradfri|g" ${requirement_file} + sed -i "s|# pybluez|pybluez|g" ${requirement_file} + sed -i "s|# bluepy|bluepy|g" ${requirement_file} + sed -i "s|# beacontools|beacontools|g" ${requirement_file} + sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file} + sed -i "s|# raspihats|raspihats|g" ${requirement_file} + sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file} + sed -i "s|# blinkt|blinkt|g" ${requirement_file} + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file} + sed -i "s|# i2csense|i2csense|g" ${requirement_file} + sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# decora|decora|g" ${requirement_file} + sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} + sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} + sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} + done displayName: 'Prepare requirements files for Hass.io' - script: | sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \ homeassistant/$(buildArch)-wheels:$(versionWheels) \ --apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \ - --index https://wheels.home-assistant.io \ - --requirement requirements_hassio.txt \ + --index $(wheelsIndex) \ + --requirement requirements_wheels.txt \ + --requirement-diff requirements_diff.txt \ --upload rsync \ --remote wheels@$(wheelsHost):/opt/wheels displayName: 'Run wheels build' From 16a846b1e7019521ebc16c09b8cf8f53acb45efa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 2 Jun 2019 17:09:04 +0200 Subject: [PATCH 050/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fc08af84850b73..dfd738ea485e97 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -22,7 +22,7 @@ variables: jobs: - job: 'Wheels' - condition: or(eq(variables['Build.SourceBranchName'], 'dev'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + condition: eq(variables['Build.SourceBranchName'], 'dev') timeoutInMinutes: 360 pool: vmImage: 'ubuntu-latest' @@ -61,15 +61,9 @@ jobs: displayName: 'Install wheels builder' - script: | cp requirements_all.txt requirements_wheels.txt - if [ "$(Build.SourceBranchName)" == "dev" ]; then - curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt - else - touch requirements_diff.txt - fi + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt - # Enable because we can build it requirement_files="requirements_wheels.txt requirements_diff.txt" - for requirement_file in ${requirement_files}; do sed -i "s|# pytradfri|pytradfri|g" ${requirement_file} sed -i "s|# pybluez|pybluez|g" ${requirement_file} From 4d4fd19f876c9c9bc9729ffc82eb7906c850c655 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 2 Jun 2019 18:24:13 +0200 Subject: [PATCH 051/319] Replace pyunifi with aiounifi in UniFi device tracker (#24149) * Replace pyunifi with aiounifi * Fix tests * Add sslcontext * Fix tests * Fix import order --- homeassistant/components/unifi/config_flow.py | 1 + homeassistant/components/unifi/controller.py | 7 +- .../components/unifi/device_tracker.py | 61 +++++++---- homeassistant/components/unifi/manifest.json | 3 +- requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/unifi/test_device_tracker.py | 102 +++++++++--------- tests/components/unifi/test_init.py | 6 +- 8 files changed, 103 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index b784aaa705ad9d..95af83767736b0 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -84,6 +84,7 @@ async def async_step_site(self, user_input=None): try: desc = user_input.get(CONF_SITE_ID, self.desc) + print(self.sites) for site in self.sites.values(): if desc == site['desc']: if site['role'] != 'admin': diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 2b9aa89fef24a7..5105e33f1d6f23 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -1,5 +1,6 @@ """UniFi Controller abstraction.""" import asyncio +import ssl import async_timeout from aiohttp import CookieJar @@ -81,15 +82,19 @@ async def get_controller( """Create a controller object and verify authentication.""" import aiounifi + sslcontext = None + if verify_ssl: session = aiohttp_client.async_get_clientsession(hass) + if isinstance(verify_ssl, str): + sslcontext = ssl.create_default_context(cafile=verify_ssl) else: session = aiohttp_client.async_create_clientsession( hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True)) controller = aiounifi.Controller( host, username=username, password=password, port=port, site=site, - websession=session + websession=session, sslcontext=sslcontext ) try: diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 8bf384eef14f72..30754273254a46 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,8 +1,13 @@ """Support for Unifi WAP controllers.""" +import asyncio import logging from datetime import timedelta import voluptuous as vol +import async_timeout + +import aiounifi + import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) @@ -10,6 +15,9 @@ from homeassistant.const import CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS import homeassistant.util.dt as dt_util +from .controller import get_controller +from .errors import AuthenticationRequired, CannotConnect + _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' @@ -54,10 +62,8 @@ }) -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Set up the Unifi device_tracker.""" - from pyunifi.controller import Controller, APIError - host = config[DOMAIN].get(CONF_HOST) username = config[DOMAIN].get(CONF_USERNAME) password = config[DOMAIN].get(CONF_PASSWORD) @@ -69,9 +75,11 @@ def get_scanner(hass, config): ssid_filter = config[DOMAIN].get(CONF_SSID_FILTER) try: - ctrl = Controller(host, username, password, port, version='v4', - site_id=site_id, ssl_verify=verify_ssl) - except APIError as ex: + controller = await get_controller( + hass, host, username, password, port, site_id, verify_ssl) + await controller.initialize() + + except (AuthenticationRequired, CannotConnect) as ex: _LOGGER.error("Failed to connect to Unifi: %s", ex) hass.components.persistent_notification.create( 'Failed to connect to Unifi. ' @@ -82,8 +90,8 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl, detection_time, ssid_filter, - monitored_conditions) + return UnifiScanner( + controller, detection_time, ssid_filter, monitored_conditions) class UnifiScanner(DeviceScanner): @@ -92,36 +100,45 @@ class UnifiScanner(DeviceScanner): def __init__(self, controller, detection_time: timedelta, ssid_filter, monitored_conditions) -> None: """Initialize the scanner.""" + self.controller = controller self._detection_time = detection_time - self._controller = controller self._ssid_filter = ssid_filter self._monitored_conditions = monitored_conditions - self._update() + self._clients = {} - def _update(self): + async def async_update(self): """Get the clients from the device.""" - from pyunifi.controller import APIError try: - clients = self._controller.get_clients() - except APIError as ex: - _LOGGER.error("Failed to scan clients: %s", ex) + await self.controller.clients.update() + clients = self.controller.clients.values() + + except aiounifi.LoginRequired: + try: + with async_timeout.timeout(5): + await self.controller.login() + except (asyncio.TimeoutError, aiounifi.AiounifiException): + clients = [] + + except aiounifi.AiounifiException: clients = [] # Filter clients to provided SSID list if self._ssid_filter: - clients = [client for client in clients - if 'essid' in client and - client['essid'] in self._ssid_filter] + clients = [ + client for client in clients + if client.essid in self._ssid_filter + ] self._clients = { - client['mac']: client + client.raw['mac']: client.raw for client in clients if (dt_util.utcnow() - dt_util.utc_from_timestamp(float( - client['last_seen']))) < self._detection_time} + client.last_seen))) < self._detection_time + } - def scan_devices(self): + async def async_scan_devices(self): """Scan for devices.""" - self._update() + await self.async_update() return self._clients.keys() def get_device_name(self, device): diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 22ece5addafb6a..64119bae2fecb5 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -4,8 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/unifi", "requirements": [ - "aiounifi==4", - "pyunifi==2.16" + "aiounifi==6" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 6c4421ed2b338a..a40bb6d4e192dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aiopvapi==1.6.14 aioswitcher==2019.3.21 # homeassistant.components.unifi -aiounifi==4 +aiounifi==6 # homeassistant.components.aladdin_connect aladdin_connect==0.3 @@ -1488,9 +1488,6 @@ pytrafikverket==0.1.5.9 # homeassistant.components.ubee pyubee==0.6 -# homeassistant.components.unifi -pyunifi==2.16 - # homeassistant.components.uptimerobot pyuptimerobot==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fde162bdd9741..d945ad1628e3e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,7 +61,7 @@ aiohue==1.9.1 aioswitcher==2019.3.21 # homeassistant.components.unifi -aiounifi==4 +aiounifi==6 # homeassistant.components.ambiclimate ambiclimate==0.1.2 @@ -294,9 +294,6 @@ python_awair==0.0.4 # homeassistant.components.tradfri pytradfri[async]==6.0.1 -# homeassistant.components.unifi -pyunifi==2.16 - # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 0fb0751c5b6248..5bc24c6c26980f 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,8 +1,6 @@ """The tests for the Unifi WAP device tracker platform.""" from unittest import mock from datetime import datetime, timedelta -from pyunifi.controller import APIError - import pytest import voluptuous as vol @@ -13,13 +11,20 @@ from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS) + +from tests.common import mock_coro +from asynctest import CoroutineMock +from aiounifi.clients import Clients + DEFAULT_DETECTION_TIME = timedelta(seconds=300) @pytest.fixture def mock_ctrl(): """Mock pyunifi.""" - with mock.patch('pyunifi.controller.Controller') as mock_control: + with mock.patch('aiounifi.Controller') as mock_control: + mock_control.return_value.login.return_value = mock_coro() + mock_control.return_value.initialize.return_value = mock_coro() yield mock_control @@ -33,7 +38,7 @@ def mock_scanner(): @mock.patch('os.access', return_value=True) @mock.patch('os.path.isfile', mock.Mock(return_value=True)) -def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): +async def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): """Test the setup with a string for ssl_verify. Representing the absolute path to a CA certificate bundle. @@ -46,12 +51,9 @@ def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): CONF_VERIFY_SSL: "/tmp/unifi.crt" }) } - result = unifi.get_scanner(hass, config) + result = await unifi.async_get_scanner(hass, config) assert mock_scanner.return_value == result assert mock_ctrl.call_count == 1 - assert mock_ctrl.mock_calls[0] == \ - mock.call('localhost', 'foo', 'password', 8443, - version='v4', site_id='default', ssl_verify="/tmp/unifi.crt") assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, @@ -59,7 +61,7 @@ def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): None, None) -def test_config_minimal(hass, mock_scanner, mock_ctrl): +async def test_config_minimal(hass, mock_scanner, mock_ctrl): """Test the setup with minimal configuration.""" config = { DOMAIN: unifi.PLATFORM_SCHEMA({ @@ -68,12 +70,10 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): CONF_PASSWORD: 'password', }) } - result = unifi.get_scanner(hass, config) + + result = await unifi.async_get_scanner(hass, config) assert mock_scanner.return_value == result assert mock_ctrl.call_count == 1 - assert mock_ctrl.mock_calls[0] == \ - mock.call('localhost', 'foo', 'password', 8443, - version='v4', site_id='default', ssl_verify=True) assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, @@ -81,7 +81,7 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): None, None) -def test_config_full(hass, mock_scanner, mock_ctrl): +async def test_config_full(hass, mock_scanner, mock_ctrl): """Test the setup with full configuration.""" config = { DOMAIN: unifi.PLATFORM_SCHEMA({ @@ -96,12 +96,9 @@ def test_config_full(hass, mock_scanner, mock_ctrl): 'detection_time': 300, }) } - result = unifi.get_scanner(hass, config) + result = await unifi.async_get_scanner(hass, config) assert mock_scanner.return_value == result assert mock_ctrl.call_count == 1 - assert mock_ctrl.call_args == \ - mock.call('myhost', 'foo', 'password', 123, - version='v4', site_id='abcdef01', ssl_verify=False) assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call( @@ -137,7 +134,7 @@ def test_config_error(): }) -def test_config_controller_failed(hass, mock_ctrl, mock_scanner): +async def test_config_controller_failed(hass, mock_ctrl, mock_scanner): """Test for controller failure.""" config = { 'device_tracker': { @@ -146,13 +143,12 @@ def test_config_controller_failed(hass, mock_ctrl, mock_scanner): CONF_PASSWORD: 'password', } } - mock_ctrl.side_effect = APIError( - '/', 500, 'foo', {}, None) - result = unifi.get_scanner(hass, config) + mock_ctrl.side_effect = unifi.CannotConnect + result = await unifi.async_get_scanner(hass, config) assert result is False -def test_scanner_update(): +async def test_scanner_update(): """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ @@ -161,21 +157,20 @@ def test_scanner_update(): {'mac': '234', 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] - ctrl.get_clients.return_value = fake_clients - unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) - assert ctrl.get_clients.call_count == 1 - assert ctrl.get_clients.call_args == mock.call() + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) + await scnr.async_update() + assert len(scnr._clients) == 2 def test_scanner_update_error(): """Test the scanner update for error.""" ctrl = mock.MagicMock() - ctrl.get_clients.side_effect = APIError( - '/', 500, 'foo', {}, None) + ctrl.get_clients.side_effect = unifi.aiounifi.AiounifiException unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) -def test_scan_devices(): +async def test_scan_devices(): """Test the scanning for devices.""" ctrl = mock.MagicMock() fake_clients = [ @@ -184,12 +179,13 @@ def test_scan_devices(): {'mac': '234', 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) - assert set(scanner.scan_devices()) == set(['123', '234']) + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) + await scnr.async_update() + assert set(await scnr.async_scan_devices()) == set(['123', '234']) -def test_scan_devices_filtered(): +async def test_scan_devices_filtered(): """Test the scanning for devices based on SSID.""" ctrl = mock.MagicMock() fake_clients = [ @@ -204,13 +200,13 @@ def test_scan_devices_filtered(): ] ssid_filter = ['foonet', 'barnet'] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter, - None) - assert set(scanner.scan_devices()) == set(['123', '234', '890']) + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter, None) + await scnr.async_update() + assert set(await scnr.async_scan_devices()) == set(['123', '234', '890']) -def test_get_device_name(): +async def test_get_device_name(): """Test the getting of device names.""" ctrl = mock.MagicMock() fake_clients = [ @@ -226,15 +222,16 @@ def test_get_device_name(): 'essid': 'barnet', 'last_seen': '1504786810'}, ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) - assert scanner.get_device_name('123') == 'foobar' - assert scanner.get_device_name('234') == 'Nice Name' - assert scanner.get_device_name('456') is None - assert scanner.get_device_name('unknown') is None + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None) + await scnr.async_update() + assert scnr.get_device_name('123') == 'foobar' + assert scnr.get_device_name('234') == 'Nice Name' + assert scnr.get_device_name('456') is None + assert scnr.get_device_name('unknown') is None -def test_monitored_conditions(): +async def test_monitored_conditions(): """Test the filtering of attributes.""" ctrl = mock.MagicMock() fake_clients = [ @@ -254,16 +251,17 @@ def test_monitored_conditions(): 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] - ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, - ['essid', 'signal', 'latest_assoc_time']) - assert scanner.get_extra_attributes('123') == { + ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients)) + scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, + ['essid', 'signal', 'latest_assoc_time']) + await scnr.async_update() + assert scnr.get_extra_attributes('123') == { 'essid': 'barnet', 'signal': -60, 'latest_assoc_time': datetime(2000, 1, 1, 0, 0, tzinfo=dt_util.UTC) } - assert scanner.get_extra_attributes('234') == { + assert scnr.get_extra_attributes('234') == { 'essid': 'barnet', 'signal': -42 } - assert scanner.get_extra_attributes('456') == {'essid': 'barnet'} + assert scnr.get_extra_attributes('456') == {'essid': 'barnet'} diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index d2d19204b40fa2..ec5ab5a577bf08 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -146,7 +146,8 @@ async def test_flow_works(hass, aioclient_mock): flow.hass = hass with patch('aiounifi.Controller') as mock_controller: - def mock_constructor(host, username, password, port, site, websession): + def mock_constructor( + host, username, password, port, site, websession, sslcontext): """Fake the controller constructor.""" mock_controller.host = host mock_controller.username = username @@ -254,7 +255,8 @@ async def test_user_permissions_low(hass, aioclient_mock): flow.hass = hass with patch('aiounifi.Controller') as mock_controller: - def mock_constructor(host, username, password, port, site, websession): + def mock_constructor( + host, username, password, port, site, websession, sslcontext): """Fake the controller constructor.""" mock_controller.host = host mock_controller.username = username From b4c858bcdfcee243cc59c45265fde25e52ca856d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 2 Jun 2019 22:31:44 +0200 Subject: [PATCH 052/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index dfd738ea485e97..55917ed25fe0cb 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -41,7 +41,6 @@ jobs: buildArch: 'aarch64' steps: - script: | - sudo apt-get update sudo apt-get install -y --no-install-recommends \ qemu-user-static \ binfmt-support \ @@ -119,7 +118,6 @@ jobs: fi displayName: 'Check version of branch/tag' - script: | - sudo apt-get update sudo apt-get install -y --no-install-recommends \ jq curl @@ -217,7 +215,6 @@ jobs: vmImage: 'ubuntu-latest' steps: - script: | - sudo apt-get update sudo apt-get install -y --no-install-recommends \ git jq curl From 05454b76a6257f8e6b65ceab421619c28ad88fea Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 2 Jun 2019 22:32:56 +0200 Subject: [PATCH 053/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 55917ed25fe0cb..5b798542323215 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -41,6 +41,7 @@ jobs: buildArch: 'aarch64' steps: - script: | + sudo apt-get update sudo apt-get install -y --no-install-recommends \ qemu-user-static \ binfmt-support \ From ca20b0cf17aef4e080500e13287a021598c8d4dd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Jun 2019 13:57:21 -0700 Subject: [PATCH 054/319] Add restore state to OwnTracks device tracker (#24256) * Add restore state to OwnTracks device tracker * Lint * Also store entity devices * Update test_device_tracker.py --- .../components/owntracks/device_tracker.py | 136 ++++++++++-------- .../owntracks/test_device_tracker.py | 44 ++++++ 2 files changed, 124 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index fb9fedf26faa3d..d74fea43c29c51 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -2,10 +2,19 @@ import logging from homeassistant.core import callback -from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT +from homeassistant.const import ( + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_BATTERY_LEVEL, +) +from homeassistant.components.device_tracker.const import ( + ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE) from homeassistant.components.device_tracker.config_entry import ( DeviceTrackerEntity ) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers import device_registry from . import DOMAIN as OT_DOMAIN _LOGGER = logging.getLogger(__name__) @@ -14,53 +23,52 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up OwnTracks based off an entry.""" @callback - def _receive_data(dev_id, host_name, gps, attributes, gps_accuracy=None, - battery=None, source_type=None, location_name=None): + def _receive_data(dev_id, **data): """Receive set location.""" - device = hass.data[OT_DOMAIN]['devices'].get(dev_id) - - if device is not None: - device.update_data( - host_name=host_name, - gps=gps, - attributes=attributes, - gps_accuracy=gps_accuracy, - battery=battery, - source_type=source_type, - location_name=location_name, - ) + entity = hass.data[OT_DOMAIN]['devices'].get(dev_id) + + if entity is not None: + entity.update_data(data) return - device = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( - dev_id=dev_id, - host_name=host_name, - gps=gps, - attributes=attributes, - gps_accuracy=gps_accuracy, - battery=battery, - source_type=source_type, - location_name=location_name, + entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( + dev_id, data ) - async_add_entities([device]) + async_add_entities([entity]) hass.data[OT_DOMAIN]['context'].async_see = _receive_data + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == OT_DOMAIN + } + + if not dev_ids: + return True + + entities = [] + for dev_id in dev_ids: + entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( + dev_id + ) + entities.append(entity) + + async_add_entities(entities) + return True -class OwnTracksEntity(DeviceTrackerEntity): +class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, dev_id, host_name, gps, attributes, gps_accuracy, - battery, source_type, location_name): + def __init__(self, dev_id, data=None): """Set up OwnTracks entity.""" self._dev_id = dev_id - self._host_name = host_name - self._gps = gps - self._gps_accuracy = gps_accuracy - self._location_name = location_name - self._attributes = attributes - self._battery = battery - self._source_type = source_type + self._data = data or {} self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @property @@ -71,43 +79,45 @@ def unique_id(self): @property def battery_level(self): """Return the battery level of the device.""" - return self._battery + return self._data.get('battery') @property def device_state_attributes(self): """Return device specific attributes.""" - return self._attributes + return self._data.get('attributes') @property def location_accuracy(self): """Return the gps accuracy of the device.""" - return self._gps_accuracy + return self._data.get('gps_accuracy') @property def latitude(self): """Return latitude value of the device.""" - if self._gps is not None: - return self._gps[0] + # Check with "get" instead of "in" because value can be None + if self._data.get('gps'): + return self._data['gps'][0] return None @property def longitude(self): """Return longitude value of the device.""" - if self._gps is not None: - return self._gps[1] + # Check with "get" instead of "in" because value can be None + if self._data.get('gps'): + return self._data['gps'][1] return None @property def location_name(self): """Return a location name for the current location of the device.""" - return self._location_name + return self._data.get('location_name') @property def name(self): """Return the name of the device.""" - return self._host_name + return self._data.get('host_name') @property def should_poll(self): @@ -117,26 +127,40 @@ def should_poll(self): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return self._source_type + return self._data.get('source_type') @property def device_info(self): """Return the device info.""" return { - 'name': self._host_name, + 'name': self.name, 'identifiers': {(OT_DOMAIN, self._dev_id)}, } + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() + + # Don't restore if we got set up with data. + if self._data: + return + + state = await self.async_get_last_state() + + if state is None: + return + + attr = state.attributes + self._data = { + 'host_name': state.name, + 'gps': (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), + 'gps_accuracy': attr[ATTR_GPS_ACCURACY], + 'battery': attr[ATTR_BATTERY_LEVEL], + 'source_type': attr[ATTR_SOURCE_TYPE], + } + @callback - def update_data(self, host_name, gps, attributes, gps_accuracy, - battery, source_type, location_name): + def update_data(self, data): """Mark the device as seen.""" - self._host_name = host_name - self._gps = gps - self._gps_accuracy = gps_accuracy - self._location_name = location_name - self._attributes = attributes - self._battery = battery - self._source_type = source_type - + self._data = data self.async_write_ha_state() diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index b81f434a2c131b..7d8d48de586c81 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -1491,3 +1491,47 @@ async def test_region_mapping(hass, setup_comp): await send_message(hass, EVENT_TOPIC, message) assert_location_state(hass, 'inner') + + +async def test_restore_state(hass, hass_client): + """Test that we can restore state.""" + entry = MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client() + resp = await client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) + assert resp.status == 200 + await hass.async_block_till_done() + + state_1 = hass.states.get('device_tracker.paulus_pixel') + assert state_1 is not None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + state_2 = hass.states.get('device_tracker.paulus_pixel') + assert state_2 is not None + + assert state_1 is not state_2 + + assert state_1.state == state_2.state + assert state_1.name == state_2.name + assert state_1.attributes['latitude'] == state_2.attributes['latitude'] + assert state_1.attributes['longitude'] == state_2.attributes['longitude'] + assert state_1.attributes['battery_level'] == \ + state_2.attributes['battery_level'] + assert state_1.attributes['source_type'] == \ + state_2.attributes['source_type'] From fbfc674ca56a9edee60883b9202fef418a194179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Sun, 2 Jun 2019 22:58:27 +0200 Subject: [PATCH 055/319] Add service for adding event to google component (#22473) * Add service for adding event to google component * Add service examples for google.add_event * add refactoring based on reviews * Fix too long lines * Order import * Move to move line * Remove parenthesis * Add service for adding event to google component * Add service examples for google.add_event * add refactoring based on reviews * Add check for correct scopes, otherwise re-authenticate * fix build failure * fix build failure --- homeassistant/components/google/__init__.py | 113 +++++++++++++++++- homeassistant/components/google/services.yaml | 27 +++++ 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index e9bbf3f96cdd9f..027a6b2f56863a 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,4 +1,5 @@ """Support for Google - Calendar Event Devices.""" +from datetime import timedelta, datetime import logging import os import yaml @@ -35,17 +36,32 @@ DEFAULT_CONF_TRACK_NEW = True DEFAULT_CONF_OFFSET = '!!' +EVENT_CALENDAR_ID = 'calendar_id' +EVENT_DESCRIPTION = 'description' +EVENT_END_CONF = 'end' +EVENT_END_DATE = 'end_date' +EVENT_END_DATETIME = 'end_date_time' +EVENT_IN = 'in' +EVENT_IN_DAYS = 'days' +EVENT_IN_WEEKS = 'weeks' +EVENT_START_CONF = 'start' +EVENT_START_DATE = 'start_date' +EVENT_START_DATETIME = 'start_date_time' +EVENT_SUMMARY = 'summary' +EVENT_TYPES_CONF = 'event_types' + NOTIFICATION_ID = 'google_calendar_notification' -NOTIFICATION_TITLE = 'Google Calendar Setup' +NOTIFICATION_TITLE = "Google Calendar Setup" GROUP_NAME_ALL_CALENDARS = "Google Calendar Sensors" SERVICE_SCAN_CALENDARS = 'scan_for_calendars' SERVICE_FOUND_CALENDARS = 'found_calendar' +SERVICE_ADD_EVENT = 'add_event' DATA_INDEX = 'google_calendars' YAML_DEVICES = '{}_calendars.yaml'.format(DOMAIN) -SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' +SCOPES = 'https://www.googleapis.com/auth/calendar' TOKEN_FILE = '.{}.token'.format(DOMAIN) @@ -73,6 +89,27 @@ vol.All(cv.ensure_list, [_SINGLE_CALSEARCH_CONFIG]), }, extra=vol.ALLOW_EXTRA) +_EVENT_IN_TYPES = vol.Schema( + { + vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int, + vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int, + } +) + +ADD_EVENT_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(EVENT_CALENDAR_ID): cv.string, + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date, + vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date, + vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime, + vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime, + vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF): + _EVENT_IN_TYPES + } +) + def do_authentication(hass, hass_config, config): """Notify user of actions and authenticate. @@ -87,10 +124,9 @@ def do_authentication(hass, hass_config, config): oauth = OAuth2WebServerFlow( client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_CLIENT_SECRET], - scope='https://www.googleapis.com/auth/calendar.readonly', + scope='https://www.googleapis.com/auth/calendar', redirect_uri='Home-Assistant.io', ) - try: dev_flow = oauth.step1_get_device_and_user_codes() except OAuth2DeviceCodeError as err: @@ -155,8 +191,20 @@ def setup(hass, config): if not os.path.isfile(token_file): do_authentication(hass, config, conf) else: - do_setup(hass, config, conf) + if not check_correct_scopes(token_file): + do_authentication(hass, config, conf) + else: + do_setup(hass, config, conf) + + return True + +def check_correct_scopes(token_file): + """Check for the correct scopes in file.""" + tokenfile = open(token_file, "r").read() + if "readonly" in tokenfile: + _LOGGER.warning("Please re-authenticate with Google.") + return False return True @@ -195,6 +243,61 @@ def _scan_for_calendars(service): hass.services.register( DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) + + def _add_event(call): + """Add a new event to calendar.""" + service = calendar_service.get() + start = {} + end = {} + + if EVENT_IN in call.data: + if EVENT_IN_DAYS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta( + days=call.data[EVENT_IN][EVENT_IN_DAYS]) + end_in = start_in + timedelta(days=1) + + start = {'date': start_in.strftime('%Y-%m-%d')} + end = {'date': end_in.strftime('%Y-%m-%d')} + + elif EVENT_IN_WEEKS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta( + weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) + end_in = start_in + timedelta(days=1) + + start = {'date': start_in.strftime('%Y-%m-%d')} + end = {'date': end_in.strftime('%Y-%m-%d')} + + elif EVENT_START_DATE in call.data: + start = {'date': str(call.data[EVENT_START_DATE])} + end = {'date': str(call.data[EVENT_END_DATE])} + + elif EVENT_START_DATETIME in call.data: + start_dt = str(call.data[EVENT_START_DATETIME] + .strftime('%Y-%m-%dT%H:%M:%S')) + end_dt = str(call.data[EVENT_END_DATETIME] + .strftime('%Y-%m-%dT%H:%M:%S')) + start = {'dateTime': start_dt, + 'timeZone': str(hass.config.time_zone)} + end = {'dateTime': end_dt, + 'timeZone': str(hass.config.time_zone)} + + event = { + 'summary': call.data[EVENT_SUMMARY], + 'description': call.data[EVENT_DESCRIPTION], + 'start': start, + 'end': end, + } + service_data = {'calendarId': call.data[EVENT_CALENDAR_ID], + 'body': event} + event = service.events().insert(**service_data).execute() + + hass.services.register( + DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA + ) return True diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index 34eecb33fd5e47..048e886dc4e565 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -2,3 +2,30 @@ found_calendar: description: Add calendar if it has not been already discovered. scan_for_calendars: description: Scan for new calendars. +add_event: + description: Add a new calendar event. + fields: + calendar_id: + description: The id of the calendar you want. + example: 'Your email' + summary: + description: Acts as the title of the event. + example: 'Bowling' + description: + description: The description of the event. Optional. + example: 'Birthday bowling' + start_date_time: + description: The date and time the event should start. + example: '2019-03-22 20:00:00' + end_date_time: + description: The date and time the event should end. + example: '2019-03-22 22:00:00' + start_date: + description: The date the whole day event should start. + example: '2019-03-10' + end_date: + description: The date the whole day event should end. + example: '2019-03-11' + in: + description: Days or weeks that you want to create the event in. + example: '"days": 2 or "weeks": 2' \ No newline at end of file From 411e36b0f864a08ecbcc01c529c7d89ae18f19fa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Jun 2019 13:52:52 -0700 Subject: [PATCH 056/319] Updated frontend to 20190602.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index bd93a0f481ce2f..820f17a98bfef6 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190601.0" + "home-assistant-frontend==20190602.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index a40bb6d4e192dd..a62bceb7e77b2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -580,7 +580,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190601.0 +home-assistant-frontend==20190602.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d945ad1628e3e3..8de5cc7d5625b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -151,7 +151,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190601.0 +home-assistant-frontend==20190602.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 6a693546a3d0475517c33b582596b04b31cc379d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 01:29:45 -0700 Subject: [PATCH 057/319] Add restore state to Geofency (#24268) * Add restore state to Geofency * Lint --- .../components/geofency/device_tracker.py | 40 +++++++++++++++++-- tests/components/geofency/test_init.py | 30 ++++++++------ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 0c60d5ef2cee2a..f9a7df638eb80f 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,12 +1,18 @@ """Support for the Geofency device tracker platform.""" import logging +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, +) from homeassistant.core import callback from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.config_entry import ( DeviceTrackerEntity ) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers import device_registry from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE @@ -30,15 +36,28 @@ def _receive_data(device, gps, location_name, attributes): hass.data[GF_DOMAIN]['unsub_device_tracker'][config_entry.entry_id] = \ async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == GF_DOMAIN + } + + if dev_ids: + hass.data[GF_DOMAIN]['devices'].update(dev_ids) + async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids) + return True -class GeofencyEntity(DeviceTrackerEntity): +class GeofencyEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, device, gps, location_name, attributes): + def __init__(self, device, gps=None, location_name=None, attributes=None): """Set up Geofency entity.""" - self._attributes = attributes + self._attributes = attributes or {} self._name = device self._location_name = location_name self._gps = gps @@ -95,12 +114,27 @@ def source_type(self): async def async_added_to_hass(self): """Register state update callback.""" + await super().async_added_to_hass() self._unsub_dispatcher = async_dispatcher_connect( self.hass, TRACKER_UPDATE, self._async_receive_data) + if self._attributes: + return + + state = await self.async_get_last_state() + + if state is None: + self._gps = (None, None) + return + + attr = state.attributes + self._gps = (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + async def async_will_remove_from_hass(self): """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() self._unsub_dispatcher() + self.hass.data[GF_DOMAIN]['devices'].remove(self._unique_id) @callback def _async_receive_data(self, device, gps, location_name, attributes): diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 18f119a753946f..884ef125eabc30 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -5,13 +5,12 @@ import pytest from homeassistant import data_entry_flow -from homeassistant.components import zone, geofency +from homeassistant.components import zone from homeassistant.components.geofency import ( - CONF_MOBILE_BEACONS, DOMAIN, TRACKER_UPDATE) + CONF_MOBILE_BEACONS, DOMAIN) from homeassistant.const import ( HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) -from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -291,9 +290,6 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): assert STATE_HOME == state_name -@pytest.mark.xfail( - reason='The device_tracker component does not support unloading yet.' -) async def test_load_unload_entry(hass, geofency_client, webhook_id): """Test that the appropriate dispatch signals are added and removed.""" url = '/api/webhook/{}'.format(webhook_id) @@ -303,13 +299,23 @@ async def test_load_unload_entry(hass, geofency_client, webhook_id): await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME['device']) - state_name = hass.states.get('{}.{}'.format( - 'device_tracker', device_name)).state - assert STATE_HOME == state_name - assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 + state_1 = hass.states.get('{}.{}'.format('device_tracker', device_name)) + assert STATE_HOME == state_1.state + assert len(hass.data[DOMAIN]['devices']) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] - assert await geofency.async_unload_entry(hass, entry) + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE] + assert len(hass.data[DOMAIN]['devices']) == 0 + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state_2 = hass.states.get('{}.{}'.format('device_tracker', device_name)) + assert state_2 is not None + assert state_1 is not state_2 + + assert STATE_HOME == state_2.state + assert state_2.attributes['latitude'] == HOME_LATITUDE + assert state_2.attributes['longitude'] == HOME_LONGITUDE From 6795db9bd6c87725670a153094dde5f56f1abb3e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 01:30:56 -0700 Subject: [PATCH 058/319] Mobile app device tracker to restore state (#24266) --- .../components/mobile_app/__init__.py | 3 +- homeassistant/components/mobile_app/const.py | 1 - .../components/mobile_app/device_tracker.py | 80 +++++++++++++------ .../mobile_app/test_device_tracker.py | 52 +++++++++++- 4 files changed, 106 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 839aa8a6c3b3e7..1d34babe3acf7b 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -7,7 +7,7 @@ from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, - DATA_DEVICES, DATA_DEVICE_TRACKER, DATA_SENSOR, DATA_STORE, + DATA_DEVICES, DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION) from .http_api import RegistrationsView @@ -34,7 +34,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), DATA_DEVICES: {}, - DATA_DEVICE_TRACKER: {}, DATA_SENSOR: app_config.get(DATA_SENSOR, {}), DATA_STORE: store, } diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 8cb5aa12731b52..922835c1d40d32 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,7 +25,6 @@ DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' DATA_DEVICES = 'devices' -DATA_DEVICE_TRACKER = 'device_tracker' DATA_SENSOR = 'sensor' DATA_STORE = 'store' diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 19aade50876225..22435fadc1638d 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -2,14 +2,17 @@ import logging from homeassistant.core import callback -from homeassistant.components.device_tracker.const import ( - DOMAIN, SOURCE_TYPE_GPS) +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_BATTERY_LEVEL, +) +from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.config_entry import ( DeviceTrackerEntity ) +from homeassistant.helpers.restore_state import RestoreEntity from .const import ( - DOMAIN as MA_DOMAIN, - ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, @@ -26,37 +29,29 @@ from .helpers import device_info _LOGGER = logging.getLogger(__name__) +ATTR_KEYS = ( + ATTR_ALTITUDE, + ATTR_COURSE, + ATTR_SPEED, + ATTR_VERTICAL_ACCURACY +) async def async_setup_entry(hass, entry, async_add_entities): """Set up OwnTracks based off an entry.""" - @callback - def _receive_data(data): - """Receive set location.""" - dev_id = entry.data[ATTR_DEVICE_ID] - device = hass.data[MA_DOMAIN][DOMAIN].get(dev_id) - - if device is not None: - device.update_data(data) - return - - device = hass.data[MA_DOMAIN][DOMAIN][dev_id] = MobileAppEntity( - entry, data - ) - async_add_entities([device]) - - hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_LOCATION_UPDATE.format(entry.entry_id), _receive_data) + entity = MobileAppEntity(entry) + async_add_entities([entity]) return True -class MobileAppEntity(DeviceTrackerEntity): +class MobileAppEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" - def __init__(self, entry, data): + def __init__(self, entry, data=None): """Set up OwnTracks entity.""" self._entry = entry self._data = data + self._dispatch_unsub = None @property def unique_id(self): @@ -72,8 +67,7 @@ def battery_level(self): def device_state_attributes(self): """Return device specific attributes.""" attrs = {} - for key in (ATTR_ALTITUDE, ATTR_COURSE, - ATTR_SPEED, ATTR_VERTICAL_ACCURACY): + for key in ATTR_KEYS: value = self._data.get(key) if value is not None: attrs[key] = value @@ -130,6 +124,42 @@ def device_info(self): """Return the device info.""" return device_info(self._entry.data) + async def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() + self._dispatch_unsub = \ + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_LOCATION_UPDATE.format(self._entry.entry_id), + self.update_data + ) + + # Don't restore if we got set up with data. + if self._data is not None: + return + + state = await self.async_get_last_state() + + if state is None: + self._data = {} + return + + attr = state.attributes + data = { + ATTR_GPS: (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), + ATTR_GPS_ACCURACY: attr[ATTR_GPS_ACCURACY], + ATTR_BATTERY: attr[ATTR_BATTERY_LEVEL], + } + data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) + self._data = data + + async def async_will_remove_from_hass(self): + """Call when entity is being removed from hass.""" + await super().async_will_remove_from_hass() + + if self._dispatch_unsub: + self._dispatch_unsub() + self._dispatch_unsub = None + @callback def update_data(self, data): """Mark the device as seen.""" diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index 448bd9181c8556..53f9ad6f6dd3b6 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -22,7 +22,7 @@ async def test_sending_location(hass, create_registrations, webhook_client): assert resp.status == 200 await hass.async_block_till_done() - state = hass.states.get('device_tracker.test_1') + state = hass.states.get('device_tracker.test_1_2') assert state is not None assert state.name == 'Test 1' assert state.state == 'bar' @@ -54,7 +54,7 @@ async def test_sending_location(hass, create_registrations, webhook_client): assert resp.status == 200 await hass.async_block_till_done() - state = hass.states.get('device_tracker.test_1') + state = hass.states.get('device_tracker.test_1_2') assert state is not None assert state.state == 'not_home' assert state.attributes['source_type'] == 'gps' @@ -66,3 +66,51 @@ async def test_sending_location(hass, create_registrations, webhook_client): assert state.attributes['course'] == 6 assert state.attributes['speed'] == 7 assert state.attributes['vertical_accuracy'] == 8 + + +async def test_restoring_location(hass, create_registrations, webhook_client): + """Test sending a location via a webhook.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json={ + 'type': 'update_location', + 'data': { + 'gps': [10, 20], + 'gps_accuracy': 30, + 'battery': 40, + 'altitude': 50, + 'course': 60, + 'speed': 70, + 'vertical_accuracy': 80, + 'location_name': 'bar', + } + } + ) + + assert resp.status == 200 + await hass.async_block_till_done() + state_1 = hass.states.get('device_tracker.test_1_2') + assert state_1 is not None + + config_entry = hass.config_entries.async_entries('mobile_app')[1] + + # mobile app doesn't support unloading, so we just reload device tracker + await hass.config_entries.async_forward_entry_unload(config_entry, + 'device_tracker') + await hass.config_entries.async_forward_entry_setup(config_entry, + 'device_tracker') + + state_2 = hass.states.get('device_tracker.test_1_2') + assert state_2 is not None + + assert state_1 is not state_2 + assert state_2.name == 'Test 1' + assert state_2.attributes['source_type'] == 'gps' + assert state_2.attributes['latitude'] == 10 + assert state_2.attributes['longitude'] == 20 + assert state_2.attributes['gps_accuracy'] == 30 + assert state_2.attributes['battery_level'] == 40 + assert state_2.attributes['altitude'] == 50 + assert state_2.attributes['course'] == 60 + assert state_2.attributes['speed'] == 70 + assert state_2.attributes['vertical_accuracy'] == 80 From e12cef8d77a965e85c8ffbbf77395db88183ad11 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 3 Jun 2019 11:51:14 +0200 Subject: [PATCH 059/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5b798542323215..71b061f2682574 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ variables: - name: versionBuilder value: '3.2' - name: versionWheels - value: '0.6' + value: '0.7' - group: docker - group: wheels - group: github @@ -22,7 +22,7 @@ variables: jobs: - job: 'Wheels' - condition: eq(variables['Build.SourceBranchName'], 'dev') + condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master')) timeoutInMinutes: 360 pool: vmImage: 'ubuntu-latest' @@ -61,7 +61,11 @@ jobs: displayName: 'Install wheels builder' - script: | cp requirements_all.txt requirements_wheels.txt - curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt + if [ "$(Build.SourceBranchName)" == "dev" ]; then + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt + else + touch requirements_diff.txt + fi requirement_files="requirements_wheels.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do From 263c0322ee0d273f640ad368905a6370d62b8e82 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 3 Jun 2019 12:31:03 +0200 Subject: [PATCH 060/319] Update azure-pipelines.yml for Azure Pipelines --- azure-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 71b061f2682574..7a2967dc4954d0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -5,6 +5,7 @@ trigger: branches: include: - dev + - master tags: include: - '*' From 34260ed09f9c25230ec6d00a65b98fc438b023f9 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 3 Jun 2019 16:30:30 +0200 Subject: [PATCH 061/319] Bump aioesphomeapi to 2.1.0 (#24278) * Bump aioesphomeapi to 2.1.0 * Update requirements txt --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 71d233fee2ed7b..a986a8641897b6 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/esphome", "requirements": [ - "aioesphomeapi==2.0.1" + "aioesphomeapi==2.1.0" ], "dependencies": [], "zeroconf": ["_esphomelib._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index a62bceb7e77b2f..74e979f6c04681 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -129,7 +129,7 @@ aiobotocore==0.10.2 aiodns==2.0.0 # homeassistant.components.esphome -aioesphomeapi==2.0.1 +aioesphomeapi==2.1.0 # homeassistant.components.freebox aiofreepybox==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8de5cc7d5625b7..2e63a55e742edb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -48,7 +48,7 @@ aioautomatic==0.6.5 aiobotocore==0.10.2 # homeassistant.components.esphome -aioesphomeapi==2.0.1 +aioesphomeapi==2.1.0 # homeassistant.components.emulated_hue # homeassistant.components.http From 7fd2e67d1121dfa026f37b30c84ce8ae2ce2e1ba Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 3 Jun 2019 16:31:33 +0200 Subject: [PATCH 062/319] Remove icon() (#24280) --- .../components/meteoalarm/binary_sensor.py | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 8af43d3b087883..e1ffbe1d9ad0ff 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -6,26 +6,22 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Information provided by MeteoAlarm" + CONF_COUNTRY = 'country' -CONF_PROVINCE = 'province' CONF_LANGUAGE = 'language' +CONF_PROVINCE = 'province' -ATTRIBUTION = "Information provided by MeteoAlarm." - -DEFAULT_NAME = 'meteoalarm' DEFAULT_DEVICE_CLASS = 'safety' - -ICON = 'mdi:alert' +DEFAULT_NAME = 'meteoalarm' SCAN_INTERVAL = timedelta(minutes=30) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COUNTRY): cv.string, vol.Required(CONF_PROVINCE): cv.string, @@ -46,7 +42,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: api = Meteoalert(country, province, language) except KeyError(): - _LOGGER.error("Wrong country digits, or province name") + _LOGGER.error("Wrong country digits or province name") return add_entities([MeteoAlertBinarySensor(api, name)], True) @@ -78,14 +74,9 @@ def device_state_attributes(self): self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION return self._attributes - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - @property def device_class(self): - """Return the class of this binary sensor.""" + """Return the device class of this binary sensor.""" return DEFAULT_DEVICE_CLASS def update(self): From 449a7d3fd578b8d3f58a6903f9010623038685b7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 3 Jun 2019 18:26:01 +0200 Subject: [PATCH 063/319] deCONZ migrate to SSDP discovery (#24252) * Migrate deCONZ to use new SSDP discovery Add new discovery info manufacturer URL to be able to separate Hue and deCONZ bridges * Mark deCONZ as migrated in Discovery component * Fix tests * Fix Hue discovery ignore deCONZ bridge * Less snake more badger * Mushroom * Fix indentation * Config flow ignore manufacturer url that is not philips --- .../components/deconz/config_flow.py | 21 +++++++++---- homeassistant/components/deconz/manifest.json | 5 ++++ homeassistant/components/deconz/strings.json | 6 ++-- .../components/discovery/__init__.py | 3 +- homeassistant/components/hue/config_flow.py | 6 ++++ homeassistant/components/hue/strings.json | 3 +- homeassistant/components/ssdp/__init__.py | 2 ++ homeassistant/generated/ssdp.py | 1 + tests/components/deconz/test_config_flow.py | 30 +++++++++++++++---- tests/components/hue/test_config_flow.py | 21 +++++++++++-- 10 files changed, 78 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 24eb3dd4d5d3a6..cf172ad799133a 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -9,12 +9,14 @@ async_discovery, async_get_api_key, async_get_bridgeid) from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from .const import CONF_BRIDGEID, DEFAULT_PORT, DOMAIN +DECONZ_MANUFACTURERURL = 'http://www.dresden-elektronik.de' CONF_SERIAL = 'serial' @@ -149,12 +151,12 @@ async def _update_entry(self, entry, host): entry.data[CONF_HOST] = host self.hass.config_entries.async_update_entry(entry) - async def async_step_discovery(self, discovery_info): - """Prepare configuration for a discovered deCONZ bridge. + async def async_step_ssdp(self, discovery_info): + """Handle a discovered deCONZ bridge.""" + if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL: + return self.async_abort(reason='not_deconz_bridge') - This flow is triggered by the discovery component. - """ - bridgeid = discovery_info[CONF_SERIAL] + bridgeid = discovery_info[ATTR_SERIAL] gateway_entries = configured_gateways(self.hass) if bridgeid in gateway_entries: @@ -162,10 +164,17 @@ async def async_step_discovery(self, discovery_info): await self._update_entry(entry, discovery_info[CONF_HOST]) return self.async_abort(reason='updated_instance') + # pylint: disable=unsupported-assignment-operation + self.context[ATTR_SERIAL] = bridgeid + + if any(bridgeid == flow['context'][ATTR_SERIAL] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + deconz_config = { CONF_HOST: discovery_info[CONF_HOST], CONF_PORT: discovery_info[CONF_PORT], - CONF_BRIDGEID: discovery_info[CONF_SERIAL] + CONF_BRIDGEID: bridgeid } return await self.async_step_import(deconz_config) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 08a01cd137910e..56ea52b7693d52 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -6,6 +6,11 @@ "requirements": [ "pydeconz==59" ], + "ssdp": { + "manufacturer": [ + "Royal Philips Electronics" + ] + }, "dependencies": [], "codeowners": [ "@kane610" diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 16177dbd3cc1d3..d1c70793063ee6 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -34,9 +34,11 @@ }, "abort": { "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", "no_bridges": "No deCONZ bridges discovered", - "updated_instance": "Updated deCONZ instance with new host address", - "one_instance_only": "Component only supports one deCONZ instance" + "not_deconz_bridge": "Not a deCONZ bridge", + "one_instance_only": "Component only supports one deCONZ instance", + "updated_instance": "Updated deCONZ instance with new host address" } } } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index ee6a8590515565..0541b5d223a19f 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -25,7 +25,6 @@ SCAN_INTERVAL = timedelta(seconds=300) SERVICE_APPLE_TV = 'apple_tv' SERVICE_DAIKIN = 'daikin' -SERVICE_DECONZ = 'deconz' SERVICE_DLNA_DMR = 'dlna_dmr' SERVICE_ENIGMA2 = 'enigma2' SERVICE_FREEBOX = 'freebox' @@ -48,7 +47,6 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', - SERVICE_DECONZ: 'deconz', 'google_cast': 'cast', SERVICE_HEOS: 'heos', SERVICE_TELLDUSLIVE: 'tellduslive', @@ -98,6 +96,7 @@ MIGRATED_SERVICE_HANDLERS = { 'axis': None, + 'deconz': None, 'esphome': None, 'ikea_tradfri': None, 'homekit': None, diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 4167027bf892ac..9c81d144d1c210 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -15,6 +16,8 @@ from .const import DOMAIN, LOGGER from .errors import AuthenticationRequired, CannotConnect +HUE_MANUFACTURERURL = 'http://www.philips.com' + @callback def configured_hosts(hass): @@ -143,6 +146,9 @@ async def async_step_ssdp(self, discovery_info): This flow is triggered by the SSDP component. It will check if the host is already configured and delegate to the import step if not. """ + if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL: + return self.async_abort(reason='not_hue_bridge') + # Filter out emulated Hue if "HASS Bridge" in discovery_info.get('name', ''): return self.async_abort(reason='already_configured') diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 079ac1a2b8daee..78b990d5f4276f 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -24,7 +24,8 @@ "unknown": "Unknown error occurred", "cannot_connect": "Unable to connect to the bridge", "already_configured": "Bridge is already configured", - "already_in_progress": "Config flow for bridge is already in progress." + "already_in_progress": "Config flow for bridge is already in progress.", + "not_hue_bridge": "Not a Hue bridge" } } } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index aecca614e7320f..e250b9c16fbb3b 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -23,6 +23,7 @@ ATTR_MODEL_NUMBER = 'model_number' ATTR_SERIAL = 'serial_number' ATTR_MANUFACTURER = 'manufacturer' +ATTR_MANUFACTURERURL = 'manufacturerURL' ATTR_UDN = 'udn' ATTR_UPNP_DEVICE_TYPE = 'upnp_device_type' @@ -164,6 +165,7 @@ def info_from_entry(entry, device_info): info[ATTR_MODEL_NUMBER] = device_info.get('modelNumber') info[ATTR_SERIAL] = device_info.get('serialNumber') info[ATTR_MANUFACTURER] = device_info.get('manufacturer') + info[ATTR_MANUFACTURERURL] = device_info.get('manufacturerURL') info[ATTR_UDN] = device_info.get('UDN') info[ATTR_UPNP_DEVICE_TYPE] = device_info.get('deviceType') diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 17b777e5cb3ab6..e4c4b1e9eb30e2 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -8,6 +8,7 @@ "device_type": {}, "manufacturer": { "Royal Philips Electronics": [ + "deconz", "hue" ] }, diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 46b0084b01b136..2b9f2c013b0e1b 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -168,22 +168,38 @@ async def test_link_no_api_key(hass): assert result['errors'] == {'base': 'no_key'} -async def test_bridge_discovery(hass): - """Test a bridge being discovered.""" +async def test_bridge_ssdp_discovery(hass): + """Test a bridge being discovered over ssdp.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, data={ config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_PORT: 80, - config_flow.CONF_SERIAL: 'id', + config_flow.ATTR_SERIAL: 'id', + config_flow.ATTR_MANUFACTURERURL: + config_flow.DECONZ_MANUFACTURERURL }, - context={'source': 'discovery'} + context={'source': 'ssdp'} ) assert result['type'] == 'form' assert result['step_id'] == 'link' +async def test_bridge_ssdp_discovery_not_deconz_bridge(hass): + """Test a non deconz bridge being discovered over ssdp.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.ATTR_MANUFACTURERURL: 'not deconz bridge' + }, + context={'source': 'ssdp'} + ) + + assert result['type'] == 'abort' + assert result['reason'] == 'not_deconz_bridge' + + async def test_bridge_discovery_update_existing_entry(hass): """Test if a discovered bridge has already been configured.""" entry = MockConfigEntry(domain=config_flow.DOMAIN, data={ @@ -195,9 +211,11 @@ async def test_bridge_discovery_update_existing_entry(hass): config_flow.DOMAIN, data={ config_flow.CONF_HOST: 'mock-deconz', - config_flow.CONF_SERIAL: 'id', + config_flow.ATTR_SERIAL: 'id', + config_flow.ATTR_MANUFACTURERURL: + config_flow.DECONZ_MANUFACTURERURL }, - context={'source': 'discovery'} + context={'source': 'ssdp'} ) assert result['type'] == 'abort' diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 37cece0bbd8bab..b7736e62390ea3 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -195,13 +195,26 @@ async def test_bridge_ssdp(hass): side_effect=errors.AuthenticationRequired): result = await flow.async_step_ssdp({ 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'form' assert result['step_id'] == 'link' +async def test_bridge_ssdp_discover_other_bridge(hass): + """Test that discovery ignores other bridges.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_ssdp({ + 'manufacturerURL': 'http://www.notphilips.com' + }) + + assert result['type'] == 'abort' + + async def test_bridge_ssdp_emulated_hue(hass): """Test if discovery info is from an emulated hue instance.""" flow = config_flow.HueFlowHandler() @@ -211,7 +224,8 @@ async def test_bridge_ssdp_emulated_hue(hass): result = await flow.async_step_ssdp({ 'name': 'HASS Bridge', 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'abort' @@ -229,7 +243,8 @@ async def test_bridge_ssdp_already_configured(hass): result = await flow.async_step_ssdp({ 'host': '0.0.0.0', - 'serial': '1234' + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL }) assert result['type'] == 'abort' From b1dcfaf6b3e95ef8cfdb53fd7908a96d0c9b83a8 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Tue, 4 Jun 2019 00:40:40 +0800 Subject: [PATCH 064/319] Split devices of nodes with multiple instances (#24032) * Split devices of nodes with multiple instances * Note entities to register with device registry * Use EntityPlatform --- homeassistant/components/zwave/__init__.py | 45 ++++++++++++++----- homeassistant/components/zwave/node_entity.py | 5 ++- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 51e956e33144ad..65dd551ebc1f36 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -11,7 +11,8 @@ from homeassistant.core import callback, CoreState from homeassistant.helpers import discovery from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -291,6 +292,8 @@ async def async_setup_entry(hass, config_entry): hass.data[DATA_DEVICES] = {} hass.data[DATA_ENTITY_VALUES] = [] + registry = await async_get_registry(hass) + if use_debug: # pragma: no cover def log_all(signal, value=None): """Log all the signals.""" @@ -332,14 +335,23 @@ def value_added(node, value): new_values = hass.data[DATA_ENTITY_VALUES] + [values] hass.data[DATA_ENTITY_VALUES] = new_values - component = EntityComponent(_LOGGER, DOMAIN, hass) - registry = await async_get_registry(hass) + platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=DOMAIN, + platform_name=DOMAIN, + platform=None, + scan_interval=DEFAULT_SCAN_INTERVAL, + entity_namespace=None, + async_entities_added_callback=lambda: None, + ) + platform.config_entry = config_entry def node_added(node): """Handle a new node on the network.""" entity = ZWaveNodeEntity(node, network) - def _add_node_to_component(): + async def _add_node_to_component(): if hass.data[DATA_DEVICES].get(entity.unique_id): return @@ -353,10 +365,10 @@ def _add_node_to_component(): return hass.data[DATA_DEVICES][entity.unique_id] = entity - component.add_entities([entity]) + await platform.async_add_entities([entity]) if entity.unique_id: - _add_node_to_component() + hass.async_add_job(_add_node_to_component()) return @callback @@ -1057,14 +1069,25 @@ def unique_id(self): @property def device_info(self): """Return device information.""" - return { - 'identifiers': { - (DOMAIN, self.node_id) - }, + info = { 'manufacturer': self.node.manufacturer_name, 'model': self.node.product_name, - 'name': node_name(self.node), } + if self.values.primary.instance > 1: + info['name'] = '{} ({})'.format( + node_name(self.node), self.values.primary.instance) + info['identifiers'] = { + (DOMAIN, self.node_id, self.values.primary.instance, ), + } + info['via_hub'] = (DOMAIN, self.node_id, ) + else: + info['name'] = node_name(self.node) + info['identifiers'] = { + (DOMAIN, self.node_id), + } + if self.node_id > 1: + info['via_hub'] = (DOMAIN, 1, ) + return info @property def name(self): diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 0a24f888c20e1d..86f5ae345203e1 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -124,7 +124,7 @@ def unique_id(self): @property def device_info(self): """Return device information.""" - return { + info = { 'identifiers': { (DOMAIN, self.node_id) }, @@ -132,6 +132,9 @@ def device_info(self): 'model': self.node.product_name, 'name': node_name(self.node) } + if self.node_id > 1: + info['via_hub'] = (DOMAIN, 1) + return info def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" From 4c8857837165a15abda372b53f002bf8b18d0a92 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 10:06:53 -0700 Subject: [PATCH 065/319] Add a discovery config flow to Wemo (#24208) --- .../components/discovery/__init__.py | 22 ++++++++-------- homeassistant/components/wemo/__init__.py | 25 +++++++++++++++---- homeassistant/components/wemo/config_flow.py | 15 +++++++++++ homeassistant/components/wemo/manifest.json | 6 +++++ homeassistant/components/wemo/strings.json | 15 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 3 +++ script/hassfest/ssdp.py | 4 ++- 8 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/wemo/config_flow.py create mode 100644 homeassistant/components/wemo/strings.json diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0541b5d223a19f..a7c306ad241147 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -58,7 +58,6 @@ SERVICE_MOBILE_APP: ('mobile_app', None), SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), - SERVICE_WEMO: ('wemo', None), SERVICE_HASSIO: ('hassio', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_ENIGMA2: ('media_player', 'enigma2'), @@ -94,19 +93,20 @@ SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), } -MIGRATED_SERVICE_HANDLERS = { - 'axis': None, - 'deconz': None, - 'esphome': None, - 'ikea_tradfri': None, - 'homekit': None, - 'philips_hue': None -} +MIGRATED_SERVICE_HANDLERS = [ + 'axis', + 'deconz', + 'esphome', + 'ikea_tradfri', + 'homekit', + 'philips_hue', + SERVICE_WEMO, +] DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + \ - list(MIGRATED_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + \ - list(MIGRATED_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS CONF_IGNORE = 'ignore' CONF_ENABLE = 'enable' diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index d921075bc1a6f9..8353b52b9f0d37 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -4,6 +4,7 @@ import requests import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.discovery import SERVICE_WEMO from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery @@ -68,22 +69,35 @@ def coerce_host_port(value): def setup(hass, config): """Set up for WeMo devices.""" + hass.data[DOMAIN] = config + + if DOMAIN in config: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a wemo config entry.""" import pywemo + config = hass.data[DOMAIN] + # Keep track of WeMo devices devices = [] # Keep track of WeMo device subscriptions for push updates global SUBSCRIPTION_REGISTRY SUBSCRIPTION_REGISTRY = pywemo.SubscriptionRegistry() - SUBSCRIPTION_REGISTRY.start() + await hass.async_add_executor_job(SUBSCRIPTION_REGISTRY.start) def stop_wemo(event): """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") SUBSCRIPTION_REGISTRY.stop() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo) def setup_url_for_device(device): """Determine setup.xml url for given device.""" @@ -119,7 +133,7 @@ def discovery_dispatch(service, discovery_info): discovery.load_platform( hass, component, DOMAIN, discovery_info, config) - discovery.listen(hass, SERVICE_WEMO, discovery_dispatch) + discovery.async_listen(hass, SERVICE_WEMO, discovery_dispatch) def discover_wemo_devices(now): """Run discovery for WeMo devices.""" @@ -145,7 +159,7 @@ def discover_wemo_devices(now): if d[1].serialnumber == device.serialnumber]: devices.append((url, device)) - if config.get(DOMAIN, {}).get(CONF_DISCOVERY): + if config.get(DOMAIN, {}).get(CONF_DISCOVERY, DEFAULT_DISCOVERY): _LOGGER.debug("Scanning network for WeMo devices...") for device in pywemo.discover_devices(): if not [d[1] for d in devices @@ -168,6 +182,7 @@ def discover_wemo_devices(now): _LOGGER.debug("WeMo device discovery has finished") - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, discover_wemo_devices) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, discover_wemo_devices) return True diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py new file mode 100644 index 00000000000000..61094dbab3209b --- /dev/null +++ b/homeassistant/components/wemo/config_flow.py @@ -0,0 +1,15 @@ +"""Config flow for Wemo.""" +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from . import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + import pywemo + + return bool(pywemo.discover_devices()) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Wemo', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 238be891886859..c610c28da394f0 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -1,10 +1,16 @@ { "domain": "wemo", "name": "Wemo", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/wemo", "requirements": [ "pywemo==0.4.34" ], + "ssdp": { + "manufacturer": [ + "Belkin International Inc." + ] + }, "dependencies": [], "codeowners": [ "@sqldiablo" diff --git a/homeassistant/components/wemo/strings.json b/homeassistant/components/wemo/strings.json new file mode 100644 index 00000000000000..d4b40817cb395c --- /dev/null +++ b/homeassistant/components/wemo/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Wemo", + "step": { + "confirm": { + "title": "Wemo", + "description": "Do you want to set up Wemo?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Wemo is possible.", + "no_devices_found": "No Wemo devices found on the network." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 41b03264c4f5be..955cdf3c8c4dcc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -50,6 +50,7 @@ "twilio", "unifi", "upnp", + "wemo", "zha", "zone", "zwave" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e4c4b1e9eb30e2..63dbe7616db370 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -7,6 +7,9 @@ SSDP = { "device_type": {}, "manufacturer": { + "Belkin International Inc.": [ + "wemo" + ], "Royal Philips Electronics": [ "deconz", "hue" diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index b13bc66a8f02e2..9c745e5b033687 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -43,7 +43,9 @@ def generate_and_validate(integrations: Dict[str, Integration]): try: with open(str(integration.path / "config_flow.py")) as fp: - if ' async_step_ssdp(' not in fp.read(): + content = fp.read() + if (' async_step_ssdp(' not in content and + 'register_discovery_flow' not in content): integration.add_error( 'ssdp', 'Config flow has no async_step_ssdp') continue From 6f903db8c4cd1fcee5bf875b8ad0ce91b50e2c6f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 11:43:13 -0700 Subject: [PATCH 066/319] Fix cors on the index view (#24283) --- homeassistant/components/http/cors.py | 5 +++++ tests/components/http/test_cors.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 1ef70b5e0225bc..419b62be2c63ed 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,4 +1,5 @@ """Provide CORS support for the HTTP component.""" +from aiohttp.web_urldispatcher import Resource, ResourceRoute from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN, AUTHORIZATION from homeassistant.const import ( @@ -8,6 +9,7 @@ ALLOWED_CORS_HEADERS = [ ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, HTTP_HEADER_HA_AUTH, AUTHORIZATION] +VALID_CORS_TYPES = (Resource, ResourceRoute) @callback @@ -31,6 +33,9 @@ def _allow_cors(route, config=None): else: path = route + if not isinstance(path, VALID_CORS_TYPES): + return + path = path.canonical if path in cors_added: diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index e17fb105efe057..d9fa6c11309017 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -140,3 +140,15 @@ async def get(self, request): hass.http.app._on_startup.freeze() await hass.http.app.startup() + + +async def test_cors_works_with_frontend(hass, hass_client): + """Test CORS works with the frontend.""" + assert await async_setup_component(hass, 'frontend', { + 'http': { + 'cors_allowed_origins': ['http://home-assistant.io'] + } + }) + client = await hass_client() + resp = await client.get('/') + assert resp.status == 200 From 0b704198599e40a59f1cdfbc67597905193053e2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2019 12:37:27 -0700 Subject: [PATCH 067/319] Remove deps folder in config when on Docker (#24284) * Remove deps folder in config * Fix tests * Fix tests with docker check --- homeassistant/config.py | 16 +++++++++++++--- tests/test_config.py | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 9465025cfd286a..7e8bcec08a51a5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,7 +1,7 @@ """Module to help with parsing and generating configuration files.""" from collections import OrderedDict # pylint: disable=no-name-in-module -from distutils.version import LooseVersion # pylint: disable=import-error +from distutils.version import StrictVersion # pylint: disable=import-error import logging import os import re @@ -31,6 +31,7 @@ Integration, async_get_integration, IntegrationNotFound ) from homeassistant.util.yaml import load_yaml, SECRET_YAML +from homeassistant.util.package import is_docker_env import homeassistant.helpers.config_validation as cv from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from homeassistant.helpers.entity_values import EntityValues @@ -333,13 +334,15 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.info("Upgrading configuration directory from %s to %s", conf_version, __version__) - if LooseVersion(conf_version) < LooseVersion('0.50'): + version_obj = StrictVersion(conf_version) + + if version_obj < StrictVersion('0.50'): # 0.50 introduced persistent deps dir. lib_path = hass.config.path('deps') if os.path.isdir(lib_path): shutil.rmtree(lib_path) - if LooseVersion(conf_version) < LooseVersion('0.92'): + if version_obj < StrictVersion('0.92'): # 0.92 moved google/tts.py to google_translate/tts.py config_path = find_config_file(hass.config.config_dir) assert config_path is not None @@ -357,6 +360,13 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.exception("Migrating to google_translate tts failed") pass + if version_obj < StrictVersion('0.94.0b6') and is_docker_env(): + # In 0.94 we no longer install packages inside the deps folder when + # running inside a Docker container. + lib_path = hass.config.path('deps') + if os.path.isdir(lib_path): + shutil.rmtree(lib_path) + with open(version_path, 'wt') as outp: outp.write(__version__) diff --git a/tests/test_config.py b/tests/test_config.py index a42fc3b809c8ee..8e983c673c5ae1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -256,7 +256,8 @@ async def test_entity_customization(hass): @mock.patch('homeassistant.config.shutil') @mock.patch('homeassistant.config.os') -def test_remove_lib_on_upgrade(mock_os, mock_shutil, hass): +@mock.patch('homeassistant.config.is_docker_env', return_value=False) +def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass): """Test removal of library on upgrade from before 0.50.""" ha_version = '0.49.0' mock_os.path.isdir = mock.Mock(return_value=True) @@ -275,6 +276,28 @@ def test_remove_lib_on_upgrade(mock_os, mock_shutil, hass): assert mock_shutil.rmtree.call_args == mock.call(hass_path) +@mock.patch('homeassistant.config.shutil') +@mock.patch('homeassistant.config.os') +@mock.patch('homeassistant.config.is_docker_env', return_value=True) +def test_remove_lib_on_upgrade_94(mock_docker, mock_os, mock_shutil, hass): + """Test removal of library on upgrade from before 0.94 and in Docker.""" + ha_version = '0.94.0b5' + mock_os.path.isdir = mock.Mock(return_value=True) + mock_open = mock.mock_open() + with mock.patch('homeassistant.config.open', mock_open, create=True): + opened_file = mock_open.return_value + # pylint: disable=no-member + opened_file.readline.return_value = ha_version + hass.config.path = mock.Mock() + config_util.process_ha_config_upgrade(hass) + hass_path = hass.config.path.return_value + + assert mock_os.path.isdir.call_count == 1 + assert mock_os.path.isdir.call_args == mock.call(hass_path) + assert mock_shutil.rmtree.call_count == 1 + assert mock_shutil.rmtree.call_args == mock.call(hass_path) + + def test_process_config_upgrade(hass): """Test update of version on upgrade.""" ha_version = '0.92.0' From 976bf3e979954d502c02fbd92cfb01455d6b08f2 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 3 Jun 2019 21:40:16 +0200 Subject: [PATCH 068/319] Add temperature sensor support to google smarthome thermostat device (#24264) * Add temperature sensor support to google smarthome thermostat device * fix lint for trait_test * Reset temperature unit in tests * Address comment --- .../components/google_assistant/const.py | 2 + .../components/google_assistant/trait.py | 164 +++++++++++------- .../components/google_assistant/test_trait.py | 33 ++++ 3 files changed, 136 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 92afe90a5ac4e9..ebded79447e725 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -12,6 +12,7 @@ media_player, scene, script, + sensor, switch, vacuum, ) @@ -108,6 +109,7 @@ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, (media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER, + (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, } CHALLENGE_ACK_NEEDED = 'ackNeeded' diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index f9590a07b956b8..7776daf65c954f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -13,6 +13,7 @@ lock, scene, script, + sensor, switch, vacuum, ) @@ -550,89 +551,126 @@ class TemperatureSettingTrait(_Trait): @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" - if domain != climate.DOMAIN: - return False + if domain == climate.DOMAIN: + return features & climate.SUPPORT_OPERATION_MODE - return features & climate.SUPPORT_OPERATION_MODE + return (domain == sensor.DOMAIN + and device_class == sensor.DEVICE_CLASS_TEMPERATURE) def sync_attributes(self): """Return temperature point and modes attributes for a sync request.""" - modes = [] - supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + response = {} + attrs = self.state.attributes + domain = self.state.domain + response['thermostatTemperatureUnit'] = _google_temp_unit( + self.hass.config.units.temperature_unit) - if supported & climate.SUPPORT_ON_OFF != 0: - modes.append(STATE_OFF) - modes.append(STATE_ON) + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_TEMPERATURE: + response["queryOnlyTemperatureSetting"] = True - if supported & climate.SUPPORT_OPERATION_MODE != 0: - for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, - []): - google_mode = self.hass_to_google.get(mode) - if google_mode and google_mode not in modes: - modes.append(google_mode) + elif domain == climate.DOMAIN: + modes = [] + supported = attrs.get(ATTR_SUPPORTED_FEATURES) - return { - 'availableThermostatModes': ','.join(modes), - 'thermostatTemperatureUnit': _google_temp_unit( - self.hass.config.units.temperature_unit) - } + if supported & climate.SUPPORT_ON_OFF != 0: + modes.append(STATE_OFF) + modes.append(STATE_ON) + + if supported & climate.SUPPORT_OPERATION_MODE != 0: + for mode in attrs.get(climate.ATTR_OPERATION_LIST, []): + google_mode = self.hass_to_google.get(mode) + if google_mode and google_mode not in modes: + modes.append(google_mode) + response['availableThermostatModes'] = ','.join(modes) + + return response def query_attributes(self): """Return temperature point and modes query attributes.""" - attrs = self.state.attributes response = {} - - operation = attrs.get(climate.ATTR_OPERATION_MODE) - supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) - - if (supported & climate.SUPPORT_ON_OFF - and self.state.state == STATE_OFF): - response['thermostatMode'] = 'off' - elif (supported & climate.SUPPORT_OPERATION_MODE and - operation in self.hass_to_google): - response['thermostatMode'] = self.hass_to_google[operation] - elif supported & climate.SUPPORT_ON_OFF: - response['thermostatMode'] = 'on' - + attrs = self.state.attributes + domain = self.state.domain unit = self.hass.config.units.temperature_unit - - current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) - if current_temp is not None: - response['thermostatTemperatureAmbient'] = \ - round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1) - - current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) - if current_humidity is not None: - response['thermostatHumidityAmbient'] = current_humidity - - if operation == climate.STATE_AUTO: - if (supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and - supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): - response['thermostatTemperatureSetpointHigh'] = \ + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_TEMPERATURE: + current_temp = self.state.state + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ + round(temp_util.convert( + float(current_temp), + unit, + TEMP_CELSIUS + ), 1) + + elif domain == climate.DOMAIN: + operation = attrs.get(climate.ATTR_OPERATION_MODE) + supported = attrs.get(ATTR_SUPPORTED_FEATURES) + + if (supported & climate.SUPPORT_ON_OFF + and self.state.state == STATE_OFF): + response['thermostatMode'] = 'off' + elif (supported & climate.SUPPORT_OPERATION_MODE + and operation in self.hass_to_google): + response['thermostatMode'] = self.hass_to_google[operation] + elif supported & climate.SUPPORT_ON_OFF: + response['thermostatMode'] = 'on' + + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ round(temp_util.convert( - attrs[climate.ATTR_TARGET_TEMP_HIGH], - unit, TEMP_CELSIUS), 1) - response['thermostatTemperatureSetpointLow'] = \ - round(temp_util.convert( - attrs[climate.ATTR_TARGET_TEMP_LOW], - unit, TEMP_CELSIUS), 1) + current_temp, + unit, + TEMP_CELSIUS + ), 1) + + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response['thermostatHumidityAmbient'] = current_humidity + + if operation == climate.STATE_AUTO: + if (supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and + supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): + response['thermostatTemperatureSetpointHigh'] = \ + round(temp_util.convert( + attrs[climate.ATTR_TARGET_TEMP_HIGH], + unit, TEMP_CELSIUS), 1) + response['thermostatTemperatureSetpointLow'] = \ + round(temp_util.convert( + attrs[climate.ATTR_TARGET_TEMP_LOW], + unit, TEMP_CELSIUS), 1) + else: + target_temp = attrs.get(ATTR_TEMPERATURE) + if target_temp is not None: + target_temp = round( + temp_util.convert( + target_temp, + unit, + TEMP_CELSIUS + ), 1) + response['thermostatTemperatureSetpointHigh'] = \ + target_temp + response['thermostatTemperatureSetpointLow'] = \ + target_temp else: target_temp = attrs.get(ATTR_TEMPERATURE) if target_temp is not None: - target_temp = round( + response['thermostatTemperatureSetpoint'] = round( temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) - response['thermostatTemperatureSetpointHigh'] = target_temp - response['thermostatTemperatureSetpointLow'] = target_temp - else: - target_temp = attrs.get(ATTR_TEMPERATURE) - if target_temp is not None: - response['thermostatTemperatureSetpoint'] = round( - temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) return response async def execute(self, command, data, params, challenge): """Execute a temperature point or mode command.""" + domain = self.state.domain + if domain == sensor.DOMAIN: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + 'Execute is not supported by sensor') + # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit min_temp = self.state.attributes[climate.ATTR_MIN_TEMP] @@ -687,8 +725,8 @@ async def execute(self, command, data, params, challenge): ATTR_ENTITY_ID: self.state.entity_id, } - if(supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and - supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): + if(supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH + and supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low else: diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 28cab008201354..6b1b6a7c9f401b 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -14,6 +14,7 @@ media_player, scene, script, + sensor, switch, vacuum, group, @@ -1380,3 +1381,35 @@ async def test_volume_media_player_relative(hass): ATTR_ENTITY_ID: 'media_player.bla', media_player.ATTR_MEDIA_VOLUME_LEVEL: .5 } + + +async def test_temperature_setting_sensor(hass): + """Test TemperatureSetting trait support for temperature sensor.""" + assert helpers.get_google_type(sensor.DOMAIN, + sensor.DEVICE_CLASS_TEMPERATURE) is not None + assert not trait.TemperatureSettingTrait.supported( + sensor.DOMAIN, + 0, + sensor.DEVICE_CLASS_HUMIDITY + ) + assert trait.TemperatureSettingTrait.supported( + sensor.DOMAIN, + 0, + sensor.DEVICE_CLASS_TEMPERATURE + ) + + hass.config.units.temperature_unit = TEMP_FAHRENHEIT + + trt = trait.TemperatureSettingTrait(hass, State('sensor.test', "70", { + ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TEMPERATURE, + }), BASIC_CONFIG) + + assert trt.sync_attributes() == { + 'queryOnlyTemperatureSetting': True, + 'thermostatTemperatureUnit': 'F', + } + + assert trt.query_attributes() == { + 'thermostatTemperatureAmbient': 21.1 + } + hass.config.units.temperature_unit = TEMP_CELSIUS From 0d5e151c6007c5b407d1018e2c5d7838a1be1035 Mon Sep 17 00:00:00 2001 From: Brandon Davidson Date: Tue, 4 Jun 2019 05:47:47 -0700 Subject: [PATCH 069/319] Update pyvera to 0.3.1 for alert support (#24289) --- homeassistant/components/vera/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vera/manifest.json b/homeassistant/components/vera/manifest.json index 7b475c437c3cab..99492753edb960 100644 --- a/homeassistant/components/vera/manifest.json +++ b/homeassistant/components/vera/manifest.json @@ -3,7 +3,7 @@ "name": "Vera", "documentation": "https://www.home-assistant.io/components/vera", "requirements": [ - "pyvera==0.2.45" + "pyvera==0.3.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 74e979f6c04681..e81a817fd25360 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1495,7 +1495,7 @@ pyuptimerobot==0.0.5 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.45 +pyvera==0.3.1 # homeassistant.components.vesync pyvesync_v2==0.9.7 From 618039734ae95aca2eca8492adc3a557146ccb12 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 08:50:25 -0700 Subject: [PATCH 070/319] Updated frontend to 20190604.0 --- homeassistant/components/frontend/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 820f17a98bfef6..0d517aa6560523 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190602.0" + "home-assistant-frontend==20190604.0" ], "dependencies": [ "api", diff --git a/requirements_all.txt b/requirements_all.txt index e81a817fd25360..b4fdc5d4ea4c81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -580,7 +580,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190602.0 +home-assistant-frontend==20190604.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e63a55e742edb..fbfad1a913bab9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -151,7 +151,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190602.0 +home-assistant-frontend==20190604.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 14c0ada9ac5bbe1d98cb6a3d20992046fb13b744 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 08:50:48 -0700 Subject: [PATCH 071/319] Update translations --- .../components/adguard/.translations/ca.json | 29 ++++++++++++++++++ .../components/adguard/.translations/en.json | 30 +++++++++---------- .../components/adguard/.translations/no.json | 29 ++++++++++++++++++ .../adguard/.translations/pt-BR.json | 29 ++++++++++++++++++ .../components/adguard/.translations/ru.json | 29 ++++++++++++++++++ .../components/adguard/.translations/sv.json | 29 ++++++++++++++++++ .../adguard/.translations/zh-Hant.json | 29 ++++++++++++++++++ .../ambiclimate/.translations/pt-BR.json | 23 ++++++++++++++ .../components/axis/.translations/ca.json | 1 + .../components/axis/.translations/de.json | 1 + .../components/axis/.translations/en.json | 1 + .../components/axis/.translations/hu.json | 10 ++++++- .../components/axis/.translations/ko.json | 1 + .../components/axis/.translations/no.json | 1 + .../components/axis/.translations/pl.json | 1 + .../components/axis/.translations/pt-BR.json | 7 +++++ .../components/axis/.translations/ru.json | 1 + .../components/axis/.translations/sv.json | 1 + .../axis/.translations/zh-Hant.json | 1 + .../components/deconz/.translations/ca.json | 4 ++- .../components/deconz/.translations/en.json | 2 ++ .../components/deconz/.translations/no.json | 2 ++ .../deconz/.translations/pt-BR.json | 5 +++- .../components/deconz/.translations/ru.json | 2 ++ .../components/deconz/.translations/sv.json | 2 ++ .../components/esphome/.translations/de.json | 1 + .../components/esphome/.translations/hu.json | 1 + .../components/esphome/.translations/ko.json | 1 + .../components/esphome/.translations/no.json | 1 + .../components/esphome/.translations/pl.json | 1 + .../esphome/.translations/pt-BR.json | 1 + .../components/heos/.translations/hu.json | 3 +- .../components/heos/.translations/pt-BR.json | 13 ++++++++ .../homekit_controller/.translations/ca.json | 1 + .../homekit_controller/.translations/de.json | 1 + .../homekit_controller/.translations/fr.json | 1 + .../homekit_controller/.translations/hu.json | 11 +++++++ .../homekit_controller/.translations/ko.json | 1 + .../homekit_controller/.translations/no.json | 1 + .../homekit_controller/.translations/pl.json | 1 + .../.translations/pt-BR.json | 9 ++++++ .../homekit_controller/.translations/ru.json | 1 + .../homekit_controller/.translations/sv.json | 1 + .../.translations/zh-Hant.json | 1 + .../components/hue/.translations/ca.json | 1 + .../components/hue/.translations/de.json | 1 + .../components/hue/.translations/en.json | 1 + .../components/hue/.translations/ko.json | 1 + .../components/hue/.translations/no.json | 2 ++ .../components/hue/.translations/pl.json | 1 + .../components/hue/.translations/pt-BR.json | 1 + .../components/hue/.translations/ru.json | 1 + .../components/hue/.translations/sv.json | 1 + .../components/iqvia/.translations/pt-BR.json | 18 +++++++++++ .../logi_circle/.translations/pt-BR.json | 22 ++++++++++++++ .../components/mqtt/.translations/ca.json | 2 +- .../components/mqtt/.translations/ru.json | 2 +- .../onboarding/.translations/fr.json | 2 +- .../onboarding/.translations/hu.json | 7 +++++ .../onboarding/.translations/pt-BR.json | 7 +++++ .../components/ps4/.translations/hu.json | 10 +++++++ .../components/ps4/.translations/pt-BR.json | 7 +++++ .../components/toon/.translations/hu.json | 12 ++++++++ .../components/unifi/.translations/ru.json | 2 +- .../components/wemo/.translations/ca.json | 15 ++++++++++ .../components/wemo/.translations/en.json | 15 ++++++++++ .../components/wemo/.translations/no.json | 15 ++++++++++ .../components/wemo/.translations/pt-BR.json | 15 ++++++++++ .../components/wemo/.translations/ru.json | 15 ++++++++++ .../components/wemo/.translations/sv.json | 15 ++++++++++ 70 files changed, 489 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/adguard/.translations/ca.json create mode 100644 homeassistant/components/adguard/.translations/no.json create mode 100644 homeassistant/components/adguard/.translations/pt-BR.json create mode 100644 homeassistant/components/adguard/.translations/ru.json create mode 100644 homeassistant/components/adguard/.translations/sv.json create mode 100644 homeassistant/components/adguard/.translations/zh-Hant.json create mode 100644 homeassistant/components/ambiclimate/.translations/pt-BR.json create mode 100644 homeassistant/components/axis/.translations/pt-BR.json create mode 100644 homeassistant/components/heos/.translations/pt-BR.json create mode 100644 homeassistant/components/homekit_controller/.translations/hu.json create mode 100644 homeassistant/components/homekit_controller/.translations/pt-BR.json create mode 100644 homeassistant/components/iqvia/.translations/pt-BR.json create mode 100644 homeassistant/components/logi_circle/.translations/pt-BR.json create mode 100644 homeassistant/components/onboarding/.translations/hu.json create mode 100644 homeassistant/components/onboarding/.translations/pt-BR.json create mode 100644 homeassistant/components/ps4/.translations/pt-BR.json create mode 100644 homeassistant/components/toon/.translations/hu.json create mode 100644 homeassistant/components/wemo/.translations/ca.json create mode 100644 homeassistant/components/wemo/.translations/en.json create mode 100644 homeassistant/components/wemo/.translations/no.json create mode 100644 homeassistant/components/wemo/.translations/pt-BR.json create mode 100644 homeassistant/components/wemo/.translations/ru.json create mode 100644 homeassistant/components/wemo/.translations/sv.json diff --git a/homeassistant/components/adguard/.translations/ca.json b/homeassistant/components/adguard/.translations/ca.json new file mode 100644 index 00000000000000..1966002ea136f5 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'AdGuard Home." + }, + "error": { + "connection_error": "No s'ha pogut connectar." + }, + "step": { + "hassio_confirm": { + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb l'AdGuard Home proporcionat pel complement de Hass.io: {addon}?", + "title": "AdGuard Home (complement de Hass.io)" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "port": "Port", + "ssl": "AdGuard Home utilitza un certificat SSL", + "username": "Nom d'usuari", + "verify_ssl": "AdGuard Home utilitza un certificat adequat" + }, + "description": "Configuraci\u00f3 de la inst\u00e0ncia d'AdGuard Home, permet el control i la monitoritzaci\u00f3.", + "title": "Enlla\u00e7ar AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/en.json b/homeassistant/components/adguard/.translations/en.json index c88f7085e341c0..d5f5e9ff78c6cf 100644 --- a/homeassistant/components/adguard/.translations/en.json +++ b/homeassistant/components/adguard/.translations/en.json @@ -1,29 +1,29 @@ { "config": { - "title": "AdGuard Home", + "abort": { + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + }, + "error": { + "connection_error": "Failed to connect." + }, "step": { + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?", + "title": "AdGuard Home via Hass.io add-on" + }, "user": { - "title": "Link your AdGuard Home.", - "description": "Set up your AdGuard Home instance to allow monitoring and control.", "data": { "host": "Host", "password": "Password", "port": "Port", - "username": "Username", "ssl": "AdGuard Home uses a SSL certificate", + "username": "Username", "verify_ssl": "AdGuard Home uses a proper certificate" - } - }, - "hassio_confirm": { - "title": "AdGuard Home via Hass.io add-on", - "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?" + }, + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "title": "Link your AdGuard Home." } }, - "error": { - "connection_error": "Failed to connect." - }, - "abort": { - "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." - } + "title": "AdGuard Home" } } \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/no.json b/homeassistant/components/adguard/.translations/no.json new file mode 100644 index 00000000000000..0e18537dcf8b2d --- /dev/null +++ b/homeassistant/components/adguard/.translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av AdGuard Hjemer tillatt." + }, + "error": { + "connection_error": "Tilkobling mislyktes." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til \u00e5 koble til AdGuard Hjem gitt av hass.io tillegget {addon}?", + "title": "AdGuard Hjem via Hass.io tillegg" + }, + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "ssl": "AdGuard Hjem bruker et SSL-sertifikat", + "username": "Brukernavn", + "verify_ssl": "AdGuard Home bruker et riktig sertifikat" + }, + "description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll.", + "title": "Koble til ditt AdGuard Hjem." + } + }, + "title": "AdGuard Hjem" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/pt-BR.json b/homeassistant/components/adguard/.translations/pt-BR.json new file mode 100644 index 00000000000000..a6115800787503 --- /dev/null +++ b/homeassistant/components/adguard/.translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do AdGuard Home \u00e9 permitida." + }, + "error": { + "connection_error": "Falhou ao conectar." + }, + "step": { + "hassio_confirm": { + "description": "Deseja configurar o Home Assistant para se conectar ao AdGuard Home fornecido pelo complemento Hass.io: {addon} ?", + "title": "AdGuard Home via add-on Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "ssl": "O AdGuard Home usa um certificado SSL", + "username": "Nome de usu\u00e1rio", + "verify_ssl": "O AdGuard Home usa um certificado apropriado" + }, + "description": "Configure sua inst\u00e2ncia do AdGuard Home para permitir o monitoramento e o controle.", + "title": "Vincule o seu AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/ru.json b/homeassistant/components/adguard/.translations/ru.json new file mode 100644 index 00000000000000..cddced8018de75 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f." + }, + "step": { + "hassio_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?", + "title": "AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL", + "username": "\u041b\u043e\u0433\u0438\u043d", + "verify_ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f AdGuard Home.", + "title": "AdGuard Home" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/sv.json b/homeassistant/components/adguard/.translations/sv.json new file mode 100644 index 00000000000000..b4bd7f7481b6f4 --- /dev/null +++ b/homeassistant/components/adguard/.translations/sv.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten." + }, + "error": { + "connection_error": "Det gick inte att ansluta." + }, + "step": { + "hassio_confirm": { + "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till AdGuard Home som tillhandah\u00e5lls av Hass.io Add-on: {addon}?", + "title": "AdGuard Home via Hass.io-till\u00e4gget" + }, + "user": { + "data": { + "host": "V\u00e4rd", + "password": "L\u00f6senord", + "port": "Port", + "ssl": "AdGuard Home anv\u00e4nder ett SSL-certifikat", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "AdGuard Home anv\u00e4nder ett korrekt certifikat" + }, + "description": "St\u00e4ll in din AdGuard Home-instans f\u00f6r att till\u00e5ta \u00f6vervakning och kontroll.", + "title": "L\u00e4nka din AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/zh-Hant.json b/homeassistant/components/adguard/.translations/zh-Hant.json new file mode 100644 index 00000000000000..b97d50aa0b6ec6 --- /dev/null +++ b/homeassistant/components/adguard/.translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 AdGuard Home\u3002" + }, + "error": { + "connection_error": "\u9023\u7dda\u5931\u6557\u3002" + }, + "step": { + "hassio_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6\uff1a{addon} \u9023\u7dda\u81f3 AdGuard Home\uff1f", + "title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 AdGuard Home" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "ssl": "AdGuard Home \u4f7f\u7528 SSL \u8a8d\u8b49", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "verify_ssl": "AdGuard Home \u4f7f\u7528\u5c0d\u61c9\u8a8d\u8b49" + }, + "description": "\u8a2d\u5b9a AdGuard Home \u4ee5\u9032\u884c\u76e3\u63a7\u3002", + "title": "\u9023\u7d50 AdGuard Home\u3002" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/pt-BR.json b/homeassistant/components/ambiclimate/.translations/pt-BR.json new file mode 100644 index 00000000000000..4de4190d0558c2 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/pt-BR.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Erro desconhecido ao gerar um token de acesso.", + "already_setup": "A conta Ambiclimate est\u00e1 configurada.", + "no_config": "Voc\u00ea precisa configurar o Ambiclimate antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticado com sucesso no Ambiclimate" + }, + "error": { + "follow_link": "Por favor, siga o link e autentique-se antes de pressionar Enviar", + "no_token": "N\u00e3o autenticado com o Ambiclimate" + }, + "step": { + "auth": { + "description": "Por favor, siga este [link]({authorization_url}) e Permitir acesso \u00e0 sua conta Ambiclimate, em seguida, volte e pressione Enviar abaixo. \n (Verifique se a URL de retorno de chamada especificada \u00e9 {cb_url})", + "title": "Autenticar Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json index 5e98dbf34189d1..e55d23b2a910ab 100644 --- a/homeassistant/components/axis/.translations/ca.json +++ b/homeassistant/components/axis/.translations/ca.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", "device_unavailable": "El dispositiu no est\u00e0 disponible", "faulty_credentials": "Credencials d'usuari incorrectes" }, diff --git a/homeassistant/components/axis/.translations/de.json b/homeassistant/components/axis/.translations/de.json index c979068b922229..123b0621424ac3 100644 --- a/homeassistant/components/axis/.translations/de.json +++ b/homeassistant/components/axis/.translations/de.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.", "device_unavailable": "Ger\u00e4t ist nicht verf\u00fcgbar", "faulty_credentials": "Ung\u00fcltige Anmeldeinformationen" }, diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json index 6c5933dfd97263..5bf0e31b0b22e3 100644 --- a/homeassistant/components/axis/.translations/en.json +++ b/homeassistant/components/axis/.translations/en.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", "device_unavailable": "Device is not available", "faulty_credentials": "Bad user credentials" }, diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/axis/.translations/hu.json index cbf055e2fba477..b0c8051e69f9fa 100644 --- a/homeassistant/components/axis/.translations/hu.json +++ b/homeassistant/components/axis/.translations/hu.json @@ -1,9 +1,17 @@ { "config": { + "error": { + "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk", + "device_unavailable": "Az eszk\u00f6z nem \u00e9rhet\u0151 el", + "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok" + }, "step": { "user": { "data": { - "host": "Hoszt" + "host": "Hoszt", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json index aafa4fc18962e1..d16bd0f6e5e6f6 100644 --- a/homeassistant/components/axis/.translations/ko.json +++ b/homeassistant/components/axis/.translations/ko.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", "device_unavailable": "\uae30\uae30\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "faulty_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, diff --git a/homeassistant/components/axis/.translations/no.json b/homeassistant/components/axis/.translations/no.json index 94b5a1680b7156..24cf845f9f0b57 100644 --- a/homeassistant/components/axis/.translations/no.json +++ b/homeassistant/components/axis/.translations/no.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", "device_unavailable": "Enheten er ikke tilgjengelig", "faulty_credentials": "Ugyldig brukerlegitimasjon" }, diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json index 7903dc63bf8bca..9d8de4c4a7b1ba 100644 --- a/homeassistant/components/axis/.translations/pl.json +++ b/homeassistant/components/axis/.translations/pl.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", "device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne", "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce" }, diff --git a/homeassistant/components/axis/.translations/pt-BR.json b/homeassistant/components/axis/.translations/pt-BR.json new file mode 100644 index 00000000000000..53b8079a1ea29a --- /dev/null +++ b/homeassistant/components/axis/.translations/pt-BR.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ru.json b/homeassistant/components/axis/.translations/ru.json index f303aa947ea8ba..dee7876fffcbf0 100644 --- a/homeassistant/components/axis/.translations/ru.json +++ b/homeassistant/components/axis/.translations/ru.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", "device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e", "faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435" }, diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json index 2f75a9dcfffa6d..d7f014c7800ba9 100644 --- a/homeassistant/components/axis/.translations/sv.json +++ b/homeassistant/components/axis/.translations/sv.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "Enheten \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.", "device_unavailable": "Enheten \u00e4r inte tillg\u00e4nglig", "faulty_credentials": "Felaktiga anv\u00e4ndaruppgifter" }, diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json index ac9f3ceb2b696f..7b93d2f7243ec8 100644 --- a/homeassistant/components/axis/.translations/zh-Hant.json +++ b/homeassistant/components/axis/.translations/zh-Hant.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "device_unavailable": "\u88dd\u7f6e\u7121\u6cd5\u4f7f\u7528", "faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548" }, diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 5f1ae46b48e882..7b69b7477f59c2 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", + "already_in_progress": "El flux de dades de configuraci\u00f3 per l'enlla\u00e7 ja est\u00e0 en curs.", "no_bridges": "No s'han descobert enlla\u00e7os amb deCONZ", + "not_deconz_bridge": "No \u00e9s un enlla\u00e7 deCONZ", "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia deCONZ", "updated_instance": "S'ha actualitzat la inst\u00e0ncia de deCONZ amb una nova adre\u00e7a" }, @@ -15,7 +17,7 @@ "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals", "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ" }, - "description": "Vols configurar Home Assistant per a que es connecti amb la passarel\u00b7la deCONZ proporcionada per l\u2019add-on {addon} de hass.io?", + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb la passarel\u00b7la deCONZ proporcionada pel complement de Hass.io: {addon}?", "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee (complement de Hass.io)" }, "init": { diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 981f579f09f44c..dd8f1cc4026edb 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Bridge is already configured", + "already_in_progress": "Config flow for bridge is already in progress.", "no_bridges": "No deCONZ bridges discovered", + "not_deconz_bridge": "Not a deCONZ bridge", "one_instance_only": "Component only supports one deCONZ instance", "updated_instance": "Updated deCONZ instance with new host address" }, diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 7934d20ec53275..7c674c71022fc0 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Broen er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.", "no_bridges": "Ingen deCONZ broer oppdaget", + "not_deconz_bridge": "Ikke en deCONZ bro", "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst", "updated_instance": "Oppdatert deCONZ forekomst med ny vertsadresse" }, diff --git a/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant/components/deconz/.translations/pt-BR.json index be79e7e461ae0b..dc7e682cafbcef 100644 --- a/homeassistant/components/deconz/.translations/pt-BR.json +++ b/homeassistant/components/deconz/.translations/pt-BR.json @@ -2,8 +2,11 @@ "config": { "abort": { "already_configured": "A ponte j\u00e1 est\u00e1 configurada", + "already_in_progress": "Fluxo de configura\u00e7\u00e3o para ponte j\u00e1 est\u00e1 em andamento.", "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas", - "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ" + "not_deconz_bridge": "N\u00e3o \u00e9 uma ponte deCONZ", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ", + "updated_instance": "Atualiza\u00e7\u00e3o da inst\u00e2ncia deCONZ com novo endere\u00e7o de host" }, "error": { "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index c4f2b2c4fab99a..ea701b3f759434 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "not_deconz_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c deCONZ", "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 deCONZ", "updated_instance": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 deCONZ \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d" }, diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index 17367c49f5bcac..a7b5160e8a3c50 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Bryggan \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r bryggan p\u00e5g\u00e5r redan.", "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes", + "not_deconz_bridge": "Inte en deCONZ-brygga", "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans", "updated_instance": "Uppdaterad deCONZ-instans med ny v\u00e4rdadress" }, diff --git a/homeassistant/components/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json index 30cbf09525f884..80111f34984cbd 100644 --- a/homeassistant/components/esphome/.translations/de.json +++ b/homeassistant/components/esphome/.translations/de.json @@ -8,6 +8,7 @@ "invalid_password": "Ung\u00fcltiges Passwort!", "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, legen Sie eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/hu.json b/homeassistant/components/esphome/.translations/hu.json index c665637ba05248..628983fec03704 100644 --- a/homeassistant/components/esphome/.translations/hu.json +++ b/homeassistant/components/esphome/.translations/hu.json @@ -8,6 +8,7 @@ "invalid_password": "\u00c9rv\u00e9nytelen jelsz\u00f3!", "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rlek, \u00e1ll\u00edts be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/ko.json b/homeassistant/components/esphome/.translations/ko.json index f58d43f9df9ae6..b6bcf3cd1b3377 100644 --- a/homeassistant/components/esphome/.translations/ko.json +++ b/homeassistant/components/esphome/.translations/ko.json @@ -8,6 +8,7 @@ "invalid_password": "\uc798\ubabb\ub41c \ube44\ubc00\ubc88\ud638", "resolve_error": "ESP \uc758 \uc8fc\uc18c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uace0\uc815 IP \uc8fc\uc18c\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json index c71424b6f00e57..f7dac2a9d568dd 100644 --- a/homeassistant/components/esphome/.translations/no.json +++ b/homeassistant/components/esphome/.translations/no.json @@ -8,6 +8,7 @@ "invalid_password": "Ugyldig passord!", "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, m\u00e5 du [angi en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index 5693efde9a8d50..d2fceb93223f17 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -8,6 +8,7 @@ "invalid_password": "Nieprawid\u0142owe has\u0142o!", "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/pt-BR.json b/homeassistant/components/esphome/.translations/pt-BR.json index 87adc69021c699..80a5c28598c8b9 100644 --- a/homeassistant/components/esphome/.translations/pt-BR.json +++ b/homeassistant/components/esphome/.translations/pt-BR.json @@ -8,6 +8,7 @@ "invalid_password": "Senha inv\u00e1lida!", "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, por favor, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/heos/.translations/hu.json b/homeassistant/components/heos/.translations/hu.json index 8cd10b3c2466ff..20ae78ae3161f7 100644 --- a/homeassistant/components/heos/.translations/hu.json +++ b/homeassistant/components/heos/.translations/hu.json @@ -7,6 +7,7 @@ "host": "Kiszolg\u00e1l\u00f3" } } - } + }, + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/pt-BR.json b/homeassistant/components/heos/.translations/pt-BR.json new file mode 100644 index 00000000000000..ac860059b5df3f --- /dev/null +++ b/homeassistant/components/heos/.translations/pt-BR.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "title": "Conecte-se a Heos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json index 8765a859418f59..f2ed4bd0c21596 100644 --- a/homeassistant/components/homekit_controller/.translations/ca.json +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "No s'ha pogut vincular, no s'ha trobat el dispositiu.", "already_configured": "Accessori ja configurat amb aquest controlador.", + "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", "already_paired": "Aquest accessori ja est\u00e0 vinculat amb un altre dispositiu. Reinicia l'accessori i torna-ho a provar.", "ignored_model": "La disponibilitat de HomeKit per aquest model est\u00e0 bloquejada ja que, de moment, no hi ha una integraci\u00f3 nativa completa.", "invalid_config_entry": "Aquest dispositiu s'est\u00e0 mostrant com a llest per a ser vinculat per\u00f2, hi ha una entrada de configuraci\u00f3 conflictiva a Home Assistant que s'ha d'eliminar primer.", diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json index d13d2bb7e2a760..22420b79661e58 100644 --- a/homeassistant/components/homekit_controller/.translations/de.json +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Die Kopplung kann nicht durchgef\u00fchrt werden, da das Ger\u00e4t nicht mehr gefunden werden kann.", "already_configured": "Das Zubeh\u00f6r ist mit diesem Controller bereits konfiguriert.", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.", "already_paired": "Dieses Zubeh\u00f6r ist bereits mit einem anderen Ger\u00e4t gekoppelt. Setzen Sie das Zubeh\u00f6r zur\u00fcck und versuchen Sie es erneut.", "ignored_model": "Die Unterst\u00fctzung von HomeKit f\u00fcr dieses Modell ist blockiert, da eine vollst\u00e4ndige native Integration verf\u00fcgbar ist.", "invalid_config_entry": "Dieses Ger\u00e4t wird als bereit zum Koppeln angezeigt, es gibt jedoch bereits einen widerspr\u00fcchlichen Konfigurationseintrag in Home Assistant, der zuerst entfernt werden muss.", diff --git a/homeassistant/components/homekit_controller/.translations/fr.json b/homeassistant/components/homekit_controller/.translations/fr.json index 955e11d12b0696..15e50a4012701c 100644 --- a/homeassistant/components/homekit_controller/.translations/fr.json +++ b/homeassistant/components/homekit_controller/.translations/fr.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.", "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", + "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", "already_paired": "Cet accessoire est d\u00e9j\u00e0 associ\u00e9 \u00e0 un autre appareil. R\u00e9initialisez l\u2019accessoire et r\u00e9essayez.", "ignored_model": "La prise en charge de HomeKit pour ce mod\u00e8le est bloqu\u00e9e car une int\u00e9gration native plus compl\u00e8te est disponible.", "invalid_config_entry": "Cet appareil est pr\u00eat \u00e0 \u00eatre coupl\u00e9, mais il existe d\u00e9j\u00e0 une entr\u00e9e de configuration en conflit dans Home Assistant \u00e0 supprimer.", diff --git a/homeassistant/components/homekit_controller/.translations/hu.json b/homeassistant/components/homekit_controller/.translations/hu.json new file mode 100644 index 00000000000000..60bd173dc8ecc2 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device": "Eszk\u00f6z" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json index 5ee62ad62b4268..6f494120f1da53 100644 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "\uae30\uae30\ub97c \ub354 \uc774\uc0c1 \ucc3e\uc744 \uc218 \uc5c6\uc73c\ubbc0\ub85c \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "already_configured": "\uc561\uc138\uc11c\ub9ac\uac00 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "invalid_config_entry": "\uc774 \uae30\uae30\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.", diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json index e7ec6c279fa1f6..8dd293dc7c8c8c 100644 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Kan ikke legge til sammenkobling da enheten ikke lenger kan bli funnet.", "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.", + "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.", "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.", "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.", diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json index a0489aa083a3a5..031a7440ed0129 100644 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Nie mo\u017cna rozpocz\u0105\u0107 parowania, poniewa\u017c nie znaleziono urz\u0105dzenia.", "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", + "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", diff --git a/homeassistant/components/homekit_controller/.translations/pt-BR.json b/homeassistant/components/homekit_controller/.translations/pt-BR.json new file mode 100644 index 00000000000000..f13ca355b2e1be --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento, pois o dispositivo n\u00e3o pode mais ser encontrado.", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento." + }, + "flow_title": "Acess\u00f3rio HomeKit: {name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json index 44b4faf455f941..c7770c6a064b34 100644 --- a/homeassistant/components/homekit_controller/.translations/ru.json +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043e.", "already_configured": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u044d\u0442\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", "already_paired": "\u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0431\u0440\u043e\u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", "ignored_model": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 HomeKit \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u0430, \u0442\u0430\u043a \u043a\u0430\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u043b\u043d\u0430\u044f \u043d\u0430\u0442\u0438\u0432\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.", "invalid_config_entry": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u0433\u043e\u0442\u043e\u0432\u043e\u0435 \u043a \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044e, \u043d\u043e \u0432 Home Assistant \u0443\u0436\u0435 \u0435\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0435\u0433\u043e, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0434\u0430\u043b\u0438\u0442\u044c.", diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json index 264fca2de504dc..302f71d4ccfc67 100644 --- a/homeassistant/components/homekit_controller/.translations/sv.json +++ b/homeassistant/components/homekit_controller/.translations/sv.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Kan inte genomf\u00f6ra parningsf\u00f6rs\u00f6ket eftersom enheten inte l\u00e4ngre kan hittas.", "already_configured": "Tillbeh\u00f6ret \u00e4r redan konfigurerat med denna kontroller.", + "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.", "already_paired": "Det h\u00e4r tillbeh\u00f6ret \u00e4r redan kopplat till en annan enhet. \u00c5terst\u00e4ll tillbeh\u00f6ret och f\u00f6rs\u00f6k igen.", "ignored_model": "HomeKit-st\u00f6d f\u00f6r den h\u00e4r modellen blockeras eftersom en mer komplett inbyggd integration \u00e4r tillg\u00e4nglig.", "invalid_config_entry": "Den h\u00e4r enheten visas som redo att paras ihop, men det finns redan en motstridig konfigurations-post f\u00f6r den i Home Assistant som f\u00f6rst m\u00e5ste tas bort.", diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json index 25ca625d7df19c..aaa2c9eda8f7de 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hant.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "\u627e\u4e0d\u5230\u88dd\u7f6e\uff0c\u7121\u6cd5\u65b0\u589e\u914d\u5c0d\u3002", "already_configured": "\u914d\u4ef6\u5df2\u7d93\u7531\u6b64\u63a7\u5236\u5668\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u88dd\u7f6e\u914d\u5c0d\uff0c\u8acb\u91cd\u7f6e\u914d\u4ef6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", "ignored_model": "\u7531\u65bc\u6b64\u578b\u865f\u53ef\u539f\u751f\u652f\u63f4\u66f4\u5b8c\u6574\u529f\u80fd\uff0c\u56e0\u6b64 Homekit \u652f\u63f4\u5df2\u88ab\u7981\u6b62\u3002", "invalid_config_entry": "\u88dd\u7f6e\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002", diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json index 078c4e753770d6..471ce2181fb828 100644 --- a/homeassistant/components/hue/.translations/ca.json +++ b/homeassistant/components/hue/.translations/ca.json @@ -7,6 +7,7 @@ "cannot_connect": "No s'ha pogut connectar amb l'enlla\u00e7", "discover_timeout": "No s'han pogut descobrir enlla\u00e7os Hue", "no_bridges": "No s'han trobat enlla\u00e7os Philips Hue", + "not_hue_bridge": "No \u00e9s un enlla\u00e7 Hue", "unknown": "S'ha produ\u00eft un error desconegut" }, "error": { diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index a0bd50d8514dfc..bb78566a12be09 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Alle Philips Hue Bridges sind bereits konfiguriert", "already_configured": "Bridge ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr die Bridge wird bereits ausgef\u00fchrt.", "cannot_connect": "Verbindung zur Bridge nicht m\u00f6glich", "discover_timeout": "Nicht in der Lage Hue Bridges zu entdecken", "no_bridges": "Keine Philips Hue Bridges entdeckt", diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json index 744efb1b15eec5..350360285af414 100644 --- a/homeassistant/components/hue/.translations/en.json +++ b/homeassistant/components/hue/.translations/en.json @@ -7,6 +7,7 @@ "cannot_connect": "Unable to connect to the bridge", "discover_timeout": "Unable to discover Hue bridges", "no_bridges": "No Philips Hue bridges discovered", + "not_hue_bridge": "Not a Hue bridge", "unknown": "Unknown error occurred" }, "error": { diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json index a4a8051663e5bb..3879eb5b962403 100644 --- a/homeassistant/components/hue/.translations/ko.json +++ b/homeassistant/components/hue/.translations/ko.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "\ubaa8\ub4e0 \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\ube0c\ub9bf\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", "cannot_connect": "\ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "discover_timeout": "Hue \ube0c\ub9bf\uc9c0\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "no_bridges": "\ubc1c\uacac\ub41c \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", diff --git a/homeassistant/components/hue/.translations/no.json b/homeassistant/components/hue/.translations/no.json index 02dd6ef7128c84..e8718fe778b8ee 100644 --- a/homeassistant/components/hue/.translations/no.json +++ b/homeassistant/components/hue/.translations/no.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "Alle Philips Hue Bridger er allerede konfigurert", "already_configured": "Bridge er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.", "cannot_connect": "Kan ikke koble til Bridge", "discover_timeout": "Kunne ikke oppdage Hue Bridger", "no_bridges": "Ingen Philips Hue Bridger oppdaget", + "not_hue_bridge": "Ikke en Hue bro", "unknown": "Ukjent feil oppstod" }, "error": { diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json index 63cbbe016a21ee..8eec1aa662aebc 100644 --- a/homeassistant/components/hue/.translations/pl.json +++ b/homeassistant/components/hue/.translations/pl.json @@ -3,6 +3,7 @@ "abort": { "all_configured": "Wszystkie mostki Hue s\u0105 ju\u017c skonfigurowane", "already_configured": "Mostek jest ju\u017c skonfigurowany", + "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem", "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue", "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue", diff --git a/homeassistant/components/hue/.translations/pt-BR.json b/homeassistant/components/hue/.translations/pt-BR.json index b30764c92393ff..2b78d2f127825a 100644 --- a/homeassistant/components/hue/.translations/pt-BR.json +++ b/homeassistant/components/hue/.translations/pt-BR.json @@ -6,6 +6,7 @@ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se \u00e0 ponte", "discover_timeout": "Incapaz de descobrir pontes Hue", "no_bridges": "N\u00e3o h\u00e1 pontes Philips Hue descobertas", + "not_hue_bridge": "N\u00e3o \u00e9 uma ponte Hue", "unknown": "Ocorreu um erro desconhecido" }, "error": { diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index 713e86f49b7605..be5d2b7159d40b 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -7,6 +7,7 @@ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", "discover_timeout": "\u0428\u043b\u044e\u0437 Philips Hue \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "no_bridges": "\u0428\u043b\u044e\u0437\u044b Philips Hue \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "not_hue_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c Hue", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, "error": { diff --git a/homeassistant/components/hue/.translations/sv.json b/homeassistant/components/hue/.translations/sv.json index b0b8ea3cbfa500..7e5b7c52dd55d9 100644 --- a/homeassistant/components/hue/.translations/sv.json +++ b/homeassistant/components/hue/.translations/sv.json @@ -7,6 +7,7 @@ "cannot_connect": "Det gick inte att ansluta till bryggan", "discover_timeout": "Det gick inte att uppt\u00e4cka n\u00e5gra Hue-bryggor", "no_bridges": "Inga Philips Hue-bryggor uppt\u00e4cktes", + "not_hue_bridge": "Inte en Hue-brygga", "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade" }, "error": { diff --git a/homeassistant/components/iqvia/.translations/pt-BR.json b/homeassistant/components/iqvia/.translations/pt-BR.json new file mode 100644 index 00000000000000..b9f716e8d3eae7 --- /dev/null +++ b/homeassistant/components/iqvia/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "C\u00f3digo postal j\u00e1 registado", + "invalid_zip_code": "C\u00f3digo postal inv\u00e1lido" + }, + "step": { + "user": { + "data": { + "zip_code": "C\u00f3digo postal" + }, + "description": "Preencha o seu CEP dos EUA ou Canad\u00e1.", + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/pt-BR.json b/homeassistant/components/logi_circle/.translations/pt-BR.json new file mode 100644 index 00000000000000..fd742194c6962a --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "config": { + "create_entry": { + "default": "Autenticado com sucesso com o Logi Circle." + }, + "error": { + "auth_error": "Falha na autoriza\u00e7\u00e3o da API." + }, + "step": { + "auth": { + "title": "Autenticar com o Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Provedor" + }, + "title": "Provedor de Autentica\u00e7\u00e3o" + } + }, + "title": "C\u00edrculo Logi" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json index 1fc3ea628bb866..47dc4d344bcee4 100644 --- a/homeassistant/components/mqtt/.translations/ca.json +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -22,7 +22,7 @@ "data": { "discovery": "Habilitar descobriment autom\u00e0tic" }, - "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de hass.io {addon}?", + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de Hass.io: {addon}?", "title": "Broker MQTT a trav\u00e9s del complement de Hass.io" } }, diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index aacac084b198d6..ac27652cbdd6b6 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -13,7 +13,7 @@ "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + "username": "\u041b\u043e\u0433\u0438\u043d" }, "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT.", "title": "MQTT" diff --git a/homeassistant/components/onboarding/.translations/fr.json b/homeassistant/components/onboarding/.translations/fr.json index 8a8ff47a48a417..d8ae0b34033b60 100644 --- a/homeassistant/components/onboarding/.translations/fr.json +++ b/homeassistant/components/onboarding/.translations/fr.json @@ -2,6 +2,6 @@ "area": { "bedroom": "Chambre", "kitchen": "Cuisine", - "living_room": "Salle De S\u00e9jour" + "living_room": "Salon" } } \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/hu.json b/homeassistant/components/onboarding/.translations/hu.json new file mode 100644 index 00000000000000..262fca71470885 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/hu.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "H\u00e1l\u00f3szoba", + "kitchen": "Konyha", + "living_room": "Nappali" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/.translations/pt-BR.json b/homeassistant/components/onboarding/.translations/pt-BR.json new file mode 100644 index 00000000000000..d5a09a0b24002e --- /dev/null +++ b/homeassistant/components/onboarding/.translations/pt-BR.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Quarto", + "kitchen": "Cozinha", + "living_room": "Sala de estar" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/hu.json b/homeassistant/components/ps4/.translations/hu.json index 6a0008957232e8..77b13f33a51c3d 100644 --- a/homeassistant/components/ps4/.translations/hu.json +++ b/homeassistant/components/ps4/.translations/hu.json @@ -1,6 +1,16 @@ { "config": { "step": { + "creds": { + "title": "PlayStation 4" + }, + "link": { + "data": { + "ip_address": "IP-c\u00edm", + "name": "N\u00e9v", + "region": "R\u00e9gi\u00f3" + } + }, "mode": { "data": { "mode": "Konfigur\u00e1ci\u00f3s m\u00f3d" diff --git a/homeassistant/components/ps4/.translations/pt-BR.json b/homeassistant/components/ps4/.translations/pt-BR.json new file mode 100644 index 00000000000000..e74254727872a1 --- /dev/null +++ b/homeassistant/components/ps4/.translations/pt-BR.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "credential_timeout": "Servi\u00e7o de credencial expirou. Pressione Submit para reiniciar." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/hu.json b/homeassistant/components/toon/.translations/hu.json new file mode 100644 index 00000000000000..740e4bd381da5e --- /dev/null +++ b/homeassistant/components/toon/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index c061ab36e7bd55..f4d86300acaed2 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -15,7 +15,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "site": "ID \u0441\u0430\u0439\u0442\u0430", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f", + "username": "\u041b\u043e\u0433\u0438\u043d", "verify_ssl": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442" }, "title": "UniFi Controller" diff --git a/homeassistant/components/wemo/.translations/ca.json b/homeassistant/components/wemo/.translations/ca.json new file mode 100644 index 00000000000000..62db7fa3eb83da --- /dev/null +++ b/homeassistant/components/wemo/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius Wemo a la xarxa.", + "single_instance_allowed": "Nom\u00e9s \u00e9s possible una \u00fanica configuraci\u00f3 de Wemo." + }, + "step": { + "confirm": { + "description": "Vols configurar Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/en.json b/homeassistant/components/wemo/.translations/en.json new file mode 100644 index 00000000000000..a3751b7f5d6345 --- /dev/null +++ b/homeassistant/components/wemo/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Wemo devices found on the network.", + "single_instance_allowed": "Only a single configuration of Wemo is possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/no.json b/homeassistant/components/wemo/.translations/no.json new file mode 100644 index 00000000000000..917eb0ef3a9d76 --- /dev/null +++ b/homeassistant/components/wemo/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Sonos enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Kun en enkelt konfigurasjon av Wemo er mulig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 sette opp Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/pt-BR.json b/homeassistant/components/wemo/.translations/pt-BR.json new file mode 100644 index 00000000000000..b64fab85f78fe5 --- /dev/null +++ b/homeassistant/components/wemo/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Wemo encontrado na rede.", + "single_instance_allowed": "Somente uma \u00fanica configura\u00e7\u00e3o de Wemo \u00e9 poss\u00edvel." + }, + "step": { + "confirm": { + "description": "Voc\u00ea quer configurar o Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/ru.json b/homeassistant/components/wemo/.translations/ru.json new file mode 100644 index 00000000000000..c0572510925053 --- /dev/null +++ b/homeassistant/components/wemo/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Wemo \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/sv.json b/homeassistant/components/wemo/.translations/sv.json new file mode 100644 index 00000000000000..0773b0079bf68d --- /dev/null +++ b/homeassistant/components/wemo/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Wemo-enheter finns p\u00e5 n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Wemo \u00e4r m\u00f6jlig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file From 8b4ef3bbddf4a04c1e48b96f81dbf17531e97fd3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 10:18:26 -0700 Subject: [PATCH 072/319] Guard against bad states in Mobile App/OwnTracks (#24292) --- homeassistant/components/mobile_app/device_tracker.py | 6 +++--- homeassistant/components/owntracks/device_tracker.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 22435fadc1638d..7fb76f3af413b8 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -145,9 +145,9 @@ async def async_added_to_hass(self): attr = state.attributes data = { - ATTR_GPS: (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), - ATTR_GPS_ACCURACY: attr[ATTR_GPS_ACCURACY], - ATTR_BATTERY: attr[ATTR_BATTERY_LEVEL], + ATTR_GPS: (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), + ATTR_GPS_ACCURACY: attr.get(ATTR_GPS_ACCURACY), + ATTR_BATTERY: attr.get(ATTR_BATTERY_LEVEL), } data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) self._data = data diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index d74fea43c29c51..ed2749262bd03b 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -153,10 +153,10 @@ async def async_added_to_hass(self): attr = state.attributes self._data = { 'host_name': state.name, - 'gps': (attr[ATTR_LATITUDE], attr[ATTR_LONGITUDE]), - 'gps_accuracy': attr[ATTR_GPS_ACCURACY], - 'battery': attr[ATTR_BATTERY_LEVEL], - 'source_type': attr[ATTR_SOURCE_TYPE], + 'gps': (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), + 'gps_accuracy': attr.get(ATTR_GPS_ACCURACY), + 'battery': attr.get(ATTR_BATTERY_LEVEL), + 'source_type': attr.get(ATTR_SOURCE_TYPE), } @callback From d7c8adc085cb78914dfa9683f8378574c7512281 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 11:04:02 -0700 Subject: [PATCH 073/319] Run SSDP discovery in parallel (#24299) --- homeassistant/components/ssdp/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index e250b9c16fbb3b..79c9cd94871bef 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -86,13 +86,16 @@ async def _process_entries(self, entries): if not to_load: return - for entry, info, domains in to_load: + tasks = [] + for entry, info, domains in to_load: for domain in domains: _LOGGER.debug("Discovered %s at %s", domain, entry.location) - await self.hass.config_entries.flow.async_init( + tasks.append(self.hass.config_entries.flow.async_init( domain, context={'source': DOMAIN}, data=info - ) + )) + + await asyncio.wait(tasks) async def _process_entry(self, entry): """Process a single entry.""" From bf52aa8ccc81b23c0e879649fcb8badfb7aca96c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 4 Jun 2019 20:04:20 +0200 Subject: [PATCH 074/319] Create progress file for pip installs (#24297) * Create progress file for pip installs * fix dedlock * unflacky test * Address comments * Lint * Types --- homeassistant/requirements.py | 20 ++++++++++++++++---- tests/test_requirements.py | 22 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 1164eff4eb86c2..2ab4fe28bdcdb8 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -1,6 +1,6 @@ """Module to handle installing requirements.""" import asyncio -from functools import partial +from pathlib import Path import logging import os from typing import Any, Dict, List, Optional @@ -11,6 +11,7 @@ DATA_PIP_LOCK = 'pip_lock' DATA_PKG_CACHE = 'pkg_cache' CONSTRAINT_FILE = 'package_constraints.txt' +PROGRESS_FILE = '.pip_progress' _LOGGER = logging.getLogger(__name__) @@ -24,15 +25,16 @@ async def async_process_requirements(hass: HomeAssistant, name: str, if pip_lock is None: pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() - pip_install = partial(pkg_util.install_package, - **pip_kwargs(hass.config.config_dir)) + kwargs = pip_kwargs(hass.config.config_dir) async with pip_lock: for req in requirements: if pkg_util.is_installed(req): continue - ret = await hass.async_add_executor_job(pip_install, req) + ret = await hass.async_add_executor_job( + _install, hass, req, kwargs + ) if not ret: _LOGGER.error("Not initializing %s because could not install " @@ -42,6 +44,16 @@ async def async_process_requirements(hass: HomeAssistant, name: str, return True +def _install(hass: HomeAssistant, req: str, kwargs: Dict) -> bool: + """Install requirement.""" + progress_path = Path(hass.config.path(PROGRESS_FILE)) + progress_path.touch() + try: + return pkg_util.install_package(req, **kwargs) + finally: + progress_path.unlink() + + def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" is_docker = pkg_util.is_docker_env() diff --git a/tests/test_requirements.py b/tests/test_requirements.py index bbf86278bd20b0..fc9dee20ed2909 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,10 +1,11 @@ """Test requirements module.""" import os +from pathlib import Path from unittest.mock import patch, call from homeassistant import setup from homeassistant.requirements import ( - CONSTRAINT_FILE, async_process_requirements) + CONSTRAINT_FILE, async_process_requirements, PROGRESS_FILE, _install) from tests.common import ( get_test_home_assistant, MockModule, mock_coro, mock_integration) @@ -143,3 +144,22 @@ async def test_install_on_docker(hass): constraints=os.path.join('ha_package_path', CONSTRAINT_FILE), no_cache_dir=True, ) + + +async def test_progress_lock(hass): + """Test an install attempt on an existing package.""" + progress_path = Path(hass.config.path(PROGRESS_FILE)) + kwargs = {'hello': 'world'} + + def assert_env(req, **passed_kwargs): + """Assert the env.""" + assert progress_path.exists() + assert req == 'hello' + assert passed_kwargs == kwargs + return True + + with patch('homeassistant.util.package.install_package', + side_effect=assert_env): + _install(hass, 'hello', kwargs) + + assert not progress_path.exists() From ac788a7ee7b5cb1884f2ae2ad017e103b578f6cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 11:05:11 -0700 Subject: [PATCH 075/319] Upgrade Zeroconf to 0.23 (#24300) --- homeassistant/components/zeroconf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index becd5d51c5ac1a..1461a54d147a7c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -3,7 +3,7 @@ "name": "Zeroconf", "documentation": "https://www.home-assistant.io/components/zeroconf", "requirements": [ - "zeroconf==0.22.0" + "zeroconf==0.23.0" ], "dependencies": [ "api" diff --git a/requirements_all.txt b/requirements_all.txt index b4fdc5d4ea4c81..289f00be1b8654 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1875,7 +1875,7 @@ youtube_dl==2019.05.11 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.22.0 +zeroconf==0.23.0 # homeassistant.components.zha zha-quirks==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbfad1a913bab9..f80ea8be37555d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,7 +352,7 @@ vultr==0.1.2 wakeonlan==1.1.6 # homeassistant.components.zeroconf -zeroconf==0.22.0 +zeroconf==0.23.0 # homeassistant.components.zha zigpy-homeassistant==0.4.2 From 6d280084fb5f7386a1cf4d176e390d6d669ec2ee Mon Sep 17 00:00:00 2001 From: gibman Date: Tue, 4 Jun 2019 22:17:43 +0200 Subject: [PATCH 076/319] Expose specific device_class for Velux covers (#24279) * Update cover.py Blinds, Rollingshutters and Awnings did not set their respective device_class attribute Previously they would all appear as device_class "window" * fallback device class is always 'window' fallback device class is always 'window' in the event we have an unknown cover type * trailing whitespace removed, trimmed as well * Update cover.py --- homeassistant/components/velux/cover.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 3c1b6ecb1eb0ff..68e25f7a61fe9c 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -60,7 +60,16 @@ def current_cover_position(self): @property def device_class(self): - """Define this cover as a window.""" + """Define this cover as either window/blind/awning/shutter.""" + from pyvlx.opening_device import Blind, RollerShutter, Window, Awning + if isinstance(self.node, Window): + return 'window' + if isinstance(self.node, Blind): + return 'blind' + if isinstance(self.node, RollerShutter): + return 'shutter' + if isinstance(self.node, Awning): + return 'awning' return 'window' @property From df1da7554c80af533e46404c316758b13f712bcd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2019 14:06:49 -0700 Subject: [PATCH 077/319] Fix OwnTracks race condition (#24303) * Fix OwnTracks race condition * Lint --- .../components/owntracks/__init__.py | 12 +++++++++- .../components/owntracks/device_tracker.py | 2 +- tests/components/owntracks/test_init.py | 23 ++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index a4df4303fa8784..1cc7a050aec321 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -192,6 +192,7 @@ def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, self.region_mapping = region_mapping self.events_only = events_only self.mqtt_topic = mqtt_topic + self._pending_msg = [] @callback def async_valid_accuracy(self, message): @@ -222,10 +223,19 @@ def async_valid_accuracy(self, message): return True + @callback + def set_async_see(self, func): + """Set a new async_see function.""" + self.async_see = func + for msg in self._pending_msg: + func(**msg) + self._pending_msg.clear() + + # pylint: disable=method-hidden @callback def async_see(self, **data): """Send a see message to the device tracker.""" - raise NotImplementedError + self._pending_msg.append(data) @callback def async_see_beacons(self, hass, dev_id, kwargs_param): diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index ed2749262bd03b..742b7c34435387 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -36,7 +36,7 @@ def _receive_data(dev_id, **data): ) async_add_entities([entity]) - hass.data[OT_DOMAIN]['context'].async_see = _receive_data + hass.data[OT_DOMAIN]['context'].set_async_see(_receive_data) # Restore previously loaded devices dev_reg = await device_registry.async_get_registry(hass) diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index fafe9678e78e07..b662bbcd6bdb79 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -4,7 +4,7 @@ import pytest from homeassistant.setup import async_setup_component - +from homeassistant.components import owntracks from tests.common import mock_component, MockConfigEntry MINIMAL_LOCATION_MESSAGE = { @@ -160,3 +160,24 @@ def test_returns_error_missing_device(mock_client): json = yield from resp.json() assert json == [] + + +def test_context_delivers_pending_msg(): + """Test that context is able to hold pending messages while being init.""" + context = owntracks.OwnTracksContext( + None, None, None, None, None, None, None, None + ) + context.async_see(hello='world') + context.async_see(world='hello') + received = [] + + context.set_async_see(lambda **data: received.append(data)) + + assert len(received) == 2 + assert received[0] == {'hello': 'world'} + assert received[1] == {'world': 'hello'} + + received.clear() + + context.set_async_see(lambda **data: received.append(data)) + assert len(received) == 0 From fbfaa41cb0a03941899cc9eb1786403dfc4bcc5c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 4 Jun 2019 23:14:51 +0200 Subject: [PATCH 078/319] address is deprecated in favor of addresses (#24302) --- homeassistant/components/zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 2f93020b4d5eb4..bdb1d52159c4d4 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -127,7 +127,7 @@ def info_from_service(service): except UnicodeDecodeError: _LOGGER.warning("Unicode decode error on %s: %s", key, value) - address = service.address or service.address6 + address = service.addresses[0] info = { ATTR_HOST: str(ipaddress.ip_address(address)), From 2943ad15a5c04f93329a9d5fec393de8706e30ab Mon Sep 17 00:00:00 2001 From: rolfberkenbosch <30292281+rolfberkenbosch@users.noreply.github.com> Date: Wed, 5 Jun 2019 08:55:10 +0200 Subject: [PATCH 079/319] Change meteoalertapi to version 0.1.3 (#24307) * Change meteoalertapi to version 0.1.3 * Add requirements_all.txt file --- homeassistant/components/meteoalarm/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json index d84749547ae6c0..015033a0e38a94 100644 --- a/homeassistant/components/meteoalarm/manifest.json +++ b/homeassistant/components/meteoalarm/manifest.json @@ -3,7 +3,7 @@ "name": "meteoalarm", "documentation": "https://www.home-assistant.io/components/meteoalarm", "requirements": [ - "meteoalertapi==0.0.8" + "meteoalertapi==0.1.3" ], "dependencies": [], "codeowners": ["@rolfberkenbosch"] diff --git a/requirements_all.txt b/requirements_all.txt index 289f00be1b8654..66a62a8d2ec426 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -730,7 +730,7 @@ mbddns==0.1.2 messagebird==1.2.0 # homeassistant.components.meteoalarm -meteoalertapi==0.0.8 +meteoalertapi==0.1.3 # homeassistant.components.meteo_france meteofrance==0.3.4 From 034bbb4f5f262bb8358eafbc9c6f29b5412d614a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:11:56 +0200 Subject: [PATCH 080/319] Create azure-pipelines-wheels.yml --- azure-pipelines-wheels.yml | 96 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 azure-pipelines-wheels.yml diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml new file mode 100644 index 00000000000000..df71ce648dfda3 --- /dev/null +++ b/azure-pipelines-wheels.yml @@ -0,0 +1,96 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev +variables: + - name: versionWheels + value: '0.7' + - group: wheels + + +jobs: + +- job: 'Wheels' + condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master')) + timeoutInMinutes: 360 + pool: + vmImage: 'ubuntu-latest' + strategy: + maxParallel: 3 + matrix: + amd64: + buildArch: 'amd64' + i386: + buildArch: 'i386' + armhf: + buildArch: 'armhf' + armv7: + buildArch: 'armv7' + aarch64: + buildArch: 'aarch64' + steps: + - script: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + qemu-user-static \ + binfmt-support \ + curl + + sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc + sudo update-binfmts --enable qemu-arm + sudo update-binfmts --enable qemu-aarch64 + displayName: 'Initial cross build' + - script: | + mkdir -p .ssh + echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa + ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts + chmod 600 .ssh/* + displayName: 'Install ssh key' + - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels) + displayName: 'Install wheels builder' + - script: | + cp requirements_all.txt requirements_wheels.txt + if [ "$(Build.Reason)" != "Schedule" ]; then + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt + else + touch requirements_diff.txt + fi + + requirement_files="requirements_wheels.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + sed -i "s|# pytradfri|pytradfri|g" ${requirement_file} + sed -i "s|# pybluez|pybluez|g" ${requirement_file} + sed -i "s|# bluepy|bluepy|g" ${requirement_file} + sed -i "s|# beacontools|beacontools|g" ${requirement_file} + sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file} + sed -i "s|# raspihats|raspihats|g" ${requirement_file} + sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file} + sed -i "s|# blinkt|blinkt|g" ${requirement_file} + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file} + sed -i "s|# i2csense|i2csense|g" ${requirement_file} + sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# decora|decora|g" ${requirement_file} + sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} + sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} + sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} + done + displayName: 'Prepare requirements files for Hass.io' + - script: | + sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \ + homeassistant/$(buildArch)-wheels:$(versionWheels) \ + --apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \ + --index $(wheelsIndex) \ + --requirement requirements_wheels.txt \ + --requirement-diff requirements_diff.txt \ + --upload rsync \ + --remote wheels@$(wheelsHost):/opt/wheels + displayName: 'Run wheels build' From 701d258076c5647e0f6913b507368c5e6e6091d1 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:24:14 +0200 Subject: [PATCH 081/319] Update and rename azure-pipelines.yml to azure-pipelines-release.yml --- ...pelines.yml => azure-pipelines-release.yml | 90 +------------------ 1 file changed, 1 insertion(+), 89 deletions(-) rename azure-pipelines.yml => azure-pipelines-release.yml (57%) diff --git a/azure-pipelines.yml b/azure-pipelines-release.yml similarity index 57% rename from azure-pipelines.yml rename to azure-pipelines-release.yml index 7a2967dc4954d0..4f37966f9f5852 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines-release.yml @@ -2,108 +2,20 @@ trigger: batch: true - branches: - include: - - dev - - master tags: include: - '*' +pr: none variables: - name: versionBuilder value: '3.2' - - name: versionWheels - value: '0.7' - group: docker - - group: wheels - group: github - group: twine jobs: -- job: 'Wheels' - condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master')) - timeoutInMinutes: 360 - pool: - vmImage: 'ubuntu-latest' - strategy: - maxParallel: 3 - matrix: - amd64: - buildArch: 'amd64' - i386: - buildArch: 'i386' - armhf: - buildArch: 'armhf' - armv7: - buildArch: 'armv7' - aarch64: - buildArch: 'aarch64' - steps: - - script: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - qemu-user-static \ - binfmt-support \ - curl - - sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc - sudo update-binfmts --enable qemu-arm - sudo update-binfmts --enable qemu-aarch64 - displayName: 'Initial cross build' - - script: | - mkdir -p .ssh - echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa - ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts - chmod 600 .ssh/* - displayName: 'Install ssh key' - - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels) - displayName: 'Install wheels builder' - - script: | - cp requirements_all.txt requirements_wheels.txt - if [ "$(Build.SourceBranchName)" == "dev" ]; then - curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt - else - touch requirements_diff.txt - fi - - requirement_files="requirements_wheels.txt requirements_diff.txt" - for requirement_file in ${requirement_files}; do - sed -i "s|# pytradfri|pytradfri|g" ${requirement_file} - sed -i "s|# pybluez|pybluez|g" ${requirement_file} - sed -i "s|# bluepy|bluepy|g" ${requirement_file} - sed -i "s|# beacontools|beacontools|g" ${requirement_file} - sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file} - sed -i "s|# raspihats|raspihats|g" ${requirement_file} - sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file} - sed -i "s|# blinkt|blinkt|g" ${requirement_file} - sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} - sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} - sed -i "s|# evdev|evdev|g" ${requirement_file} - sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file} - sed -i "s|# i2csense|i2csense|g" ${requirement_file} - sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} - sed -i "s|# pycups|pycups|g" ${requirement_file} - sed -i "s|# homekit|homekit|g" ${requirement_file} - sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} - sed -i "s|# decora|decora|g" ${requirement_file} - sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} - sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} - sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} - done - displayName: 'Prepare requirements files for Hass.io' - - script: | - sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \ - homeassistant/$(buildArch)-wheels:$(versionWheels) \ - --apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \ - --index $(wheelsIndex) \ - --requirement requirements_wheels.txt \ - --requirement-diff requirements_diff.txt \ - --upload rsync \ - --remote wheels@$(wheelsHost):/opt/wheels - displayName: 'Run wheels build' - - job: 'VersionValidate' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') From 279192d3175582f48e908bbc4ceec635ee03ba2c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:25:24 +0200 Subject: [PATCH 082/319] Rename azure-pipelines-release.yml to azure-pipelines.yml --- azure-pipelines-release.yml => azure-pipelines.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename azure-pipelines-release.yml => azure-pipelines.yml (100%) diff --git a/azure-pipelines-release.yml b/azure-pipelines.yml similarity index 100% rename from azure-pipelines-release.yml rename to azure-pipelines.yml From 17b3d3a8e43e33b37ca565fa5ef61d64d30e5fae Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:25:46 +0200 Subject: [PATCH 083/319] Update azure-pipelines-wheels.yml for Azure Pipelines --- azure-pipelines-wheels.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index df71ce648dfda3..9e7692d7934e88 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -5,6 +5,7 @@ trigger: branches: include: - dev +pr: none variables: - name: versionWheels value: '0.7' From fccbd41203bc3c7a61f4382e010dbb995bb2fd2c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:30:08 +0200 Subject: [PATCH 084/319] Update azure-pipelines-wheels.yml for Azure Pipelines --- azure-pipelines-wheels.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index 9e7692d7934e88..0080787cd9f57a 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -5,6 +5,9 @@ trigger: branches: include: - dev + paths: + include: + - requirements_all.txt pr: none variables: - name: versionWheels From bc15f11473a612c0a5b6b01dd55eb4b54568792b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 09:36:10 +0200 Subject: [PATCH 085/319] Rename azure-pipelines.yml to azure-pipelines-release.yml --- azure-pipelines.yml => azure-pipelines-release.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename azure-pipelines.yml => azure-pipelines-release.yml (100%) diff --git a/azure-pipelines.yml b/azure-pipelines-release.yml similarity index 100% rename from azure-pipelines.yml rename to azure-pipelines-release.yml From bf9c2c74fa3500c8bd420dc0e11ecafdd2907ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 5 Jun 2019 12:09:24 +0300 Subject: [PATCH 086/319] Upgrade pytest and -cov (#24258) * Upgrade pytest to 4.5.0 https://docs.pytest.org/en/latest/changelog.html#pytest-4-5-0-2019-05-11 https://docs.pytest.org/en/latest/changelog.html#pytest-4-4-2-2019-05-08 * Upgrade pytest to 4.6.0 https://docs.pytest.org/en/latest/changelog.html#pytest-4-6-0-2019-05-31 * Upgrade pytest-cov to 2.7.1 https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst#271-2019-05-03 https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst#270-2019-05-03 * Upgrade pytest to 4.6.1 https://docs.pytest.org/en/latest/changelog.html#pytest-4-6-1-2019-06-02 --- requirements_test.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index ff4d86436bb7ae..7de1ad9ab1d447 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,8 +11,8 @@ mypy==0.701 pydocstyle==3.0.0 pylint==2.3.1 pytest-aiohttp==0.3.0 -pytest-cov==2.6.1 +pytest-cov==2.7.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==4.4.1 +pytest==4.6.1 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f80ea8be37555d..fb410b9d661ec7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -12,10 +12,10 @@ mypy==0.701 pydocstyle==3.0.0 pylint==2.3.1 pytest-aiohttp==0.3.0 -pytest-cov==2.6.1 +pytest-cov==2.7.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==4.4.1 +pytest==4.6.1 requests_mock==1.5.2 From 408ae44bdd065775dd5b7de4a309d1464e9ee3e7 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 5 Jun 2019 11:12:05 +0200 Subject: [PATCH 087/319] Add LCN scene platform (#24242) --- homeassistant/components/lcn/__init__.py | 26 ++++++++-- homeassistant/components/lcn/const.py | 4 ++ homeassistant/components/lcn/scene.py | 66 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100755 homeassistant/components/lcn/scene.py diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 4a421274a18704..cf21f705b31844 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -16,10 +16,11 @@ from .const import ( BINSENSOR_PORTS, CONF_CLIMATES, CONF_CONNECTIONS, CONF_DIM_MODE, CONF_DIMMABLE, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_MOTOR, - CONF_OUTPUT, CONF_SETPOINT, CONF_SK_NUM_TRIES, CONF_SOURCE, - CONF_TRANSITION, DATA_LCN, DIM_MODES, DOMAIN, KEYS, LED_PORTS, - LOGICOP_PORTS, MOTOR_PORTS, OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, - SETPOINTS, THRESHOLDS, VAR_UNITS, VARIABLES) + CONF_OUTPUT, CONF_OUTPUTS, CONF_REGISTER, CONF_SCENE, CONF_SCENES, + CONF_SETPOINT, CONF_SK_NUM_TRIES, CONF_SOURCE, CONF_TRANSITION, DATA_LCN, + DIM_MODES, DOMAIN, KEYS, LED_PORTS, LOGICOP_PORTS, MOTOR_PORTS, + OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, SETPOINTS, THRESHOLDS, VAR_UNITS, + VARIABLES) from .helpers import has_unique_connection_names, is_address from .services import ( DynText, Led, LockKeys, LockRegulator, OutputAbs, OutputRel, OutputToggle, @@ -64,6 +65,20 @@ lambda value: value * 1000), }) +SCENES_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ADDRESS): is_address, + vol.Required(CONF_REGISTER): vol.All(vol.Coerce(int), vol.Range(0, 9)), + vol.Required(CONF_SCENE): vol.All(vol.Coerce(int), vol.Range(0, 9)), + vol.Optional(CONF_OUTPUTS): vol.All( + cv.ensure_list, [vol.All(vol.Upper, + vol.In(OUTPUT_PORTS + RELAY_PORTS))]), + vol.Optional(CONF_TRANSITION, default=None): + vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0., max=486.), + lambda value: value * 1000), + None) +}) + SENSORS_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): is_address, @@ -105,6 +120,8 @@ cv.ensure_list, [COVERS_SCHEMA]), vol.Optional(CONF_LIGHTS): vol.All( cv.ensure_list, [LIGHTS_SCHEMA]), + vol.Optional(CONF_SCENES): vol.All( + cv.ensure_list, [SCENES_SCHEMA]), vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, [SENSORS_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All( @@ -152,6 +169,7 @@ async def async_setup(hass, config): ('climate', CONF_CLIMATES), ('cover', CONF_COVERS), ('light', CONF_LIGHTS), + ('scene', CONF_SCENES), ('sensor', CONF_SENSORS), ('switch', CONF_SWITCHES)): if conf_key in config[DOMAIN]: diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 9307fb4d706b12..1cf88851456e04 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -32,6 +32,10 @@ CONF_CLIMATES = 'climates' CONF_MAX_TEMP = 'max_temp' CONF_MIN_TEMP = 'min_temp' +CONF_SCENES = 'scenes' +CONF_REGISTER = 'register' +CONF_SCENE = 'scene' +CONF_OUTPUTS = 'outputs' DIM_MODES = ['STEPS50', 'STEPS200'] diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py new file mode 100755 index 00000000000000..09f0292758a026 --- /dev/null +++ b/homeassistant/components/lcn/scene.py @@ -0,0 +1,66 @@ +"""Support for LCN scenes.""" +import pypck + +from homeassistant.components.scene import Scene +from homeassistant.const import CONF_ADDRESS + +from . import LcnDevice +from .const import ( + CONF_CONNECTIONS, CONF_OUTPUTS, CONF_REGISTER, CONF_SCENE, CONF_TRANSITION, + DATA_LCN, OUTPUT_PORTS) +from .helpers import get_connection + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up the LCN scene platform.""" + if discovery_info is None: + return + + devices = [] + for config in discovery_info: + address, connection_id = config[CONF_ADDRESS] + addr = pypck.lcn_addr.LcnAddr(*address) + connections = hass.data[DATA_LCN][CONF_CONNECTIONS] + connection = get_connection(connections, connection_id) + address_connection = connection.get_address_conn(addr) + + devices.append(LcnScene(config, address_connection)) + + async_add_entities(devices) + + +class LcnScene(LcnDevice, Scene): + """Representation of a LCN scene.""" + + def __init__(self, config, address_connection): + """Initialize the LCN scene.""" + super().__init__(config, address_connection) + + self.register_id = config[CONF_REGISTER] + self.scene_id = config[CONF_SCENE] + self.output_ports = [] + self.relay_ports = [] + + for port in config[CONF_OUTPUTS]: + if port in OUTPUT_PORTS: + self.output_ports.append(pypck.lcn_defs.OutputPort[port]) + else: # in RELEAY_PORTS + self.relay_ports.append(pypck.lcn_defs.RelayPort[port]) + + if config[CONF_TRANSITION] is None: + self.transition = None + else: + self.transition = pypck.lcn_defs.time_to_ramp_value( + config[CONF_TRANSITION]) + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + + async def async_activate(self): + """Activate scene.""" + self.address_connection.activate_scene(self.register_id, + self.scene_id, + self.output_ports, + self.relay_ports, + self.transition) From 0ed9e185b21aa9471db1f2f2392fe46b16386082 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Wed, 5 Jun 2019 06:32:59 -0300 Subject: [PATCH 088/319] Add support for learning new commands (#23888) * Add support for learning new commands This update creates a generic service in the 'remote' component to enable remote control platforms to learn new commands. * Update __init__.py with the proposed changes - Add 'supported_features' property and a constant related to the 'learn_command' functionality. - Redefine 'async_learn_command' function as a coroutine. * Update __init__.py * Fix assertion error Adding the 'supported_features' attribute generated an assertion error on the 'Demo Remote' platform. This update fixes this. * Fix duplicated 'hass' object This update fixes a typo that occurred at the last update. --- homeassistant/components/remote/__init__.py | 38 ++++++++++++++++++- homeassistant/components/remote/services.yaml | 21 +++++++++- tests/components/demo/test_remote.py | 7 +++- tests/components/remote/common.py | 28 +++++++++++++- tests/components/remote/test_init.py | 38 ++++++++++++++----- 5 files changed, 116 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index f08abf5fd4a4cd..568ea8ece325f9 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -24,6 +24,8 @@ ATTR_NUM_REPEATS = 'num_repeats' ATTR_DELAY_SECS = 'delay_secs' ATTR_HOLD_SECS = 'hold_secs' +ATTR_ALTERNATIVE = 'alternative' +ATTR_TIMEOUT = 'timeout' DOMAIN = 'remote' SCAN_INTERVAL = timedelta(seconds=30) @@ -36,12 +38,15 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) SERVICE_SEND_COMMAND = 'send_command' +SERVICE_LEARN_COMMAND = 'learn_command' SERVICE_SYNC = 'sync' DEFAULT_NUM_REPEATS = 1 DEFAULT_DELAY_SECS = 0.4 DEFAULT_HOLD_SECS = 0 +SUPPORT_LEARN_COMMAND = 1 + REMOTE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, }) @@ -59,6 +64,13 @@ vol.Optional(ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS): vol.Coerce(float), }) +REMOTE_SERVICE_LEARN_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ + vol.Optional(ATTR_DEVICE): cv.string, + vol.Optional(ATTR_COMMAND): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ALTERNATIVE): cv.boolean, + vol.Optional(ATTR_TIMEOUT): cv.positive_int +}) + @bind_hass def is_on(hass, entity_id=None): @@ -93,12 +105,22 @@ async def async_setup(hass, config): 'async_send_command' ) + component.async_register_entity_service( + SERVICE_LEARN_COMMAND, REMOTE_SERVICE_LEARN_COMMAND_SCHEMA, + 'async_learn_command' + ) + return True class RemoteDevice(ToggleEntity): """Representation of a remote.""" + @property + def supported_features(self): + """Flag supported features.""" + return 0 + def send_command(self, command, **kwargs): """Send a command to a device.""" raise NotImplementedError() @@ -108,5 +130,17 @@ def async_send_command(self, command, **kwargs): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(ft.partial( - self.send_command, command, **kwargs)) + return self.hass.async_add_executor_job( + ft.partial(self.send_command, command, **kwargs)) + + def learn_command(self, **kwargs): + """Learn a command from a device.""" + raise NotImplementedError() + + def async_learn_command(self, **kwargs): + """Learn a command from a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_executor_job( + ft.partial(self.learn_command, **kwargs)) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 62615f28714c76..a551ba18ed4587 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -25,7 +25,7 @@ turn_off: example: 'remote.family_room' send_command: - description: Sends a single command to a single device. + description: Sends a command or a list of commands to a device. fields: entity_id: description: Name(s) of entities to send command from. @@ -46,6 +46,25 @@ send_command: description: An optional value that specifies that number of seconds you want to have it held before the release is send. If not specified, the release will be send immediately after the press. example: '2.5' +learn_command: + description: Learns a command or a list of commands from a device. + fields: + entity_id: + description: Name(s) of entities to learn command from. + example: 'remote.bedroom' + device: + description: Device ID to learn command from. + example: 'television' + command: + description: A single command or a list of commands to learn. + example: 'Turn on' + alternative: + description: If code must be stored as alternative (useful for discrete remotes). + example: 'True' + timeout: + description: Timeout, in seconds, for the command to be learned. + example: '30' + harmony_sync: description: Syncs the remote's configuration. diff --git a/tests/components/demo/test_remote.py b/tests/components/demo/test_remote.py index c68e34ddc18e5d..e5db98c381b6c0 100644 --- a/tests/components/demo/test_remote.py +++ b/tests/components/demo/test_remote.py @@ -48,5 +48,8 @@ def test_methods(self): common.send_command(self.hass, 'test', entity_id=ENTITY_ID) self.hass.block_till_done() state = self.hass.states.get(ENTITY_ID) - assert state.attributes == \ - {'friendly_name': 'Remote One', 'last_command_sent': 'test'} + assert state.attributes == { + 'friendly_name': 'Remote One', + 'last_command_sent': 'test', + 'supported_features': 0 + } diff --git a/tests/components/remote/common.py b/tests/components/remote/common.py index d03cf5d6d16139..30b158bca4b986 100644 --- a/tests/components/remote/common.py +++ b/tests/components/remote/common.py @@ -4,8 +4,9 @@ components. Instead call the service directly. """ from homeassistant.components.remote import ( - ATTR_ACTIVITY, ATTR_COMMAND, ATTR_DELAY_SECS, ATTR_DEVICE, - ATTR_NUM_REPEATS, DOMAIN, SERVICE_SEND_COMMAND) + ATTR_ACTIVITY, ATTR_ALTERNATIVE, ATTR_COMMAND, ATTR_DELAY_SECS, + ATTR_DEVICE, ATTR_NUM_REPEATS, ATTR_TIMEOUT, DOMAIN, + SERVICE_LEARN_COMMAND, SERVICE_SEND_COMMAND) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.loader import bind_hass @@ -53,3 +54,26 @@ def send_command(hass, command, entity_id=None, device=None, data[ATTR_DELAY_SECS] = delay_secs hass.services.call(DOMAIN, SERVICE_SEND_COMMAND, data) + + +@bind_hass +def learn_command(hass, entity_id=None, device=None, command=None, + alternative=None, timeout=None): + """Learn a command from a device.""" + data = {} + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + if device: + data[ATTR_DEVICE] = device + + if command: + data[ATTR_COMMAND] = command + + if alternative: + data[ATTR_ALTERNATIVE] = alternative + + if timeout: + data[ATTR_TIMEOUT] = timeout + + hass.services.call(DOMAIN, SERVICE_LEARN_COMMAND, data) diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 2315dc1cf64514..2d1419c66aead3 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -13,6 +13,7 @@ TEST_PLATFORM = {remote.DOMAIN: {CONF_PLATFORM: 'test'}} SERVICE_SEND_COMMAND = 'send_command' +SERVICE_LEARN_COMMAND = 'learn_command' class TestRemote(unittest.TestCase): @@ -53,7 +54,7 @@ def test_turn_on(self): self.hass.block_till_done() - assert 1 == len(turn_on_calls) + assert len(turn_on_calls) == 1 call = turn_on_calls[-1] assert remote.DOMAIN == call.domain @@ -68,12 +69,12 @@ def test_turn_off(self): self.hass.block_till_done() - assert 1 == len(turn_off_calls) + assert len(turn_off_calls) == 1 call = turn_off_calls[-1] - assert remote.DOMAIN == call.domain - assert SERVICE_TURN_OFF == call.service - assert 'entity_id_val' == call.data[ATTR_ENTITY_ID] + assert call.domain == remote.DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data[ATTR_ENTITY_ID] == 'entity_id_val' def test_send_command(self): """Test send_command.""" @@ -87,9 +88,28 @@ def test_send_command(self): self.hass.block_till_done() - assert 1 == len(send_command_calls) + assert len(send_command_calls) == 1 call = send_command_calls[-1] - assert remote.DOMAIN == call.domain - assert SERVICE_SEND_COMMAND == call.service - assert 'entity_id_val' == call.data[ATTR_ENTITY_ID] + assert call.domain == remote.DOMAIN + assert call.service == SERVICE_SEND_COMMAND + assert call.data[ATTR_ENTITY_ID] == 'entity_id_val' + + def test_learn_command(self): + """Test learn_command.""" + learn_command_calls = mock_service( + self.hass, remote.DOMAIN, SERVICE_LEARN_COMMAND) + + common.learn_command( + self.hass, entity_id='entity_id_val', + device='test_device', command=['test_command'], + alternative=True, timeout=20) + + self.hass.block_till_done() + + assert len(learn_command_calls) == 1 + call = learn_command_calls[-1] + + assert call.domain == remote.DOMAIN + assert call.service == SERVICE_LEARN_COMMAND + assert call.data[ATTR_ENTITY_ID] == 'entity_id_val' From d31140f8cdbb2a2590de97127e35b91d08ee3997 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Wed, 5 Jun 2019 12:04:06 +0200 Subject: [PATCH 089/319] Upgrade to newer version of rflink with improve error handling on incoming data. (#24263) Related: https://github.com/home-assistant/home-assistant/issues/23942 --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index a3b81f39c55f71..bbdb49ad401240 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -3,7 +3,7 @@ "name": "Rflink", "documentation": "https://www.home-assistant.io/components/rflink", "requirements": [ - "rflink==0.0.37" + "rflink==0.0.46" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 66a62a8d2ec426..f383cd9402cf7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ restrictedpython==4.0b8 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.37 +rflink==0.0.46 # homeassistant.components.ring ring_doorbell==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb410b9d661ec7..67cff636d0ad22 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -304,7 +304,7 @@ regenmaschine==1.4.0 restrictedpython==4.0b8 # homeassistant.components.rflink -rflink==0.0.37 +rflink==0.0.46 # homeassistant.components.ring ring_doorbell==0.2.3 From 4c6ddd435ce790e7adb6c13c2136de834fea5530 Mon Sep 17 00:00:00 2001 From: David Roberts Date: Wed, 5 Jun 2019 10:45:05 -0400 Subject: [PATCH 090/319] SolarEdge Local Component (#23996) * Basic local SolarEdge monitoring for energy / power * Basic local SolarEdge monitoring for energy / power * generated CODEOWNERS, requirements, excluded tests * generated CODEOWNERS, requirements, excluded tests * lint fixes, etc * lint fixes, etc * Fix docstyle for init Of course thats the file I forgot to run tests on * Load all sensors by default They use the same API endpoint. This changes was made per https://github.com/home-assistant/architecture/pull/244 * remve unneded date/time * ran hassfest again * add throttle when updating * readd solax, mistakenly removed * Update sensor.py --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/solaredge_local/__init__.py | 1 + .../components/solaredge_local/manifest.json | 8 + .../components/solaredge_local/sensor.py | 159 ++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 173 insertions(+) create mode 100644 homeassistant/components/solaredge_local/__init__.py create mode 100644 homeassistant/components/solaredge_local/manifest.json create mode 100644 homeassistant/components/solaredge_local/sensor.py diff --git a/.coveragerc b/.coveragerc index 5480e0f1766180..e329e2f31b9dbd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -555,6 +555,7 @@ omit = homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py homeassistant/components/solaredge/sensor.py + homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0fa8e54acc0925..888ff2adb1c7bf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -215,6 +215,7 @@ homeassistant/components/sma/* @kellerza homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smtp/* @fabaff +homeassistant/components/solaredge_local/* @drobtravels homeassistant/components/solax/* @squishykid homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff diff --git a/homeassistant/components/solaredge_local/__init__.py b/homeassistant/components/solaredge_local/__init__.py new file mode 100644 index 00000000000000..bf9d724dd545e3 --- /dev/null +++ b/homeassistant/components/solaredge_local/__init__.py @@ -0,0 +1 @@ +"""The SolarEdge Local Integration.""" diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json new file mode 100644 index 00000000000000..5fb07011983edd --- /dev/null +++ b/homeassistant/components/solaredge_local/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "solaredge_local", + "name": "Solar Edge Local", + "documentation": "", + "dependencies": [], + "codeowners": ["@drobtravels"], + "requirements": ["solaredge-local==0.1.4"] + } \ No newline at end of file diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py new file mode 100644 index 00000000000000..8be4ceda7c7e8a --- /dev/null +++ b/homeassistant/components/solaredge_local/sensor.py @@ -0,0 +1,159 @@ +""" +Support for SolarEdge Monitoring API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.solaredge_local/ +""" +import logging +from datetime import timedelta + +from requests.exceptions import HTTPError, ConnectTimeout +from solaredge_local import SolarEdge +import voluptuous as vol + + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_NAME, POWER_WATT, + ENERGY_WATT_HOUR) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +DOMAIN = 'solaredge_local' +UPDATE_DELAY = timedelta(seconds=10) + +# Supported sensor types: +# Key: ['json_key', 'name', unit, icon] +SENSOR_TYPES = { + 'lifetime_energy': ['energyTotal', "Lifetime energy", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_year': ['energyThisYear', "Energy this year", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_month': ['energyThisMonth', "Energy this month", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_today': ['energyToday', "Energy today", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'current_power': ['currentPower', "Current Power", + POWER_WATT, 'mdi:solar-power'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default='SolarEdge'): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create the SolarEdge Monitoring API sensor.""" + ip_address = config[CONF_IP_ADDRESS] + platform_name = config[CONF_NAME] + + # Create new SolarEdge object to retrieve data + api = SolarEdge("http://{}/".format(ip_address)) + + # Check if api can be reached and site is active + try: + status = api.get_status() + + status.energy # pylint: disable=pointless-statement + _LOGGER.debug("Credentials correct and site is active") + except AttributeError: + _LOGGER.error("Missing details data in solaredge response") + _LOGGER.debug("Response is: %s", status) + return + except (ConnectTimeout, HTTPError): + _LOGGER.error("Could not retrieve details from SolarEdge API") + return + + # Create solaredge data service which will retrieve and update the data. + data = SolarEdgeData(hass, api) + + # Create a new sensor for each sensor type. + entities = [] + for sensor_key in SENSOR_TYPES: + sensor = SolarEdgeSensor(platform_name, sensor_key, data) + entities.append(sensor) + + add_entities(entities, True) + + +class SolarEdgeSensor(Entity): + """Representation of an SolarEdge Monitoring API sensor.""" + + def __init__(self, platform_name, sensor_key, data): + """Initialize the sensor.""" + self.platform_name = platform_name + self.sensor_key = sensor_key + self.data = data + self._state = None + + self._json_key = SENSOR_TYPES[self.sensor_key][0] + self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2] + + @property + def name(self): + """Return the name.""" + return "{} ({})".format(self.platform_name, + SENSOR_TYPES[self.sensor_key][1]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the sensor icon.""" + return SENSOR_TYPES[self.sensor_key][3] + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data from the sensor and update the state.""" + self.data.update() + self._state = self.data.data[self._json_key] + + +class SolarEdgeData: + """Get and update the latest data.""" + + def __init__(self, hass, api): + """Initialize the data object.""" + self.hass = hass + self.api = api + self.data = {} + + @Throttle(UPDATE_DELAY) + def update(self): + """Update the data from the SolarEdge Monitoring API.""" + try: + response = self.api.get_status() + _LOGGER.debug("response from SolarEdge: %s", response) + + self.data["energyTotal"] = response.energy.total + self.data["energyThisYear"] = response.energy.thisYear + self.data["energyThisMonth"] = response.energy.thisMonth + self.data["energyToday"] = response.energy.today + self.data["currentPower"] = response.powerWatt + + _LOGGER.debug("Updated SolarEdge overview data: %s", self.data) + except AttributeError: + _LOGGER.error("Missing details data in solaredge response") + _LOGGER.debug("Response is: %s", response) + return + except (ConnectTimeout, HTTPError): + _LOGGER.error("Could not retrieve data, skipping update") + return + + self.data["energyTotal"] = response.energy.total + self.data["energyThisYear"] = response.energy.thisYear + self.data["energyThisMonth"] = response.energy.thisMonth + self.data["energyToday"] = response.energy.today + self.data["currentPower"] = response.powerWatt + _LOGGER.debug("Updated SolarEdge overview data: %s", self.data) diff --git a/requirements_all.txt b/requirements_all.txt index f383cd9402cf7a..7748fc7a4f2481 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1658,6 +1658,9 @@ snapcast==2.0.9 # homeassistant.components.socialblade socialbladeclient==0.2 +# homeassistant.components.solaredge_local +solaredge-local==0.1.4 + # homeassistant.components.solaredge solaredge==0.0.2 From c311e480fd370df93a926b1a385a801167b17b7b Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 5 Jun 2019 17:13:40 +0200 Subject: [PATCH 091/319] Don't let zeroconf be smart with addresses (#24321) --- homeassistant/components/zeroconf/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bdb1d52159c4d4..289aba6ef56292 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -3,12 +3,14 @@ # https://github.com/PyCQA/pylint/issues/1931 # pylint: disable=no-name-in-module import logging +import socket import ipaddress import voluptuous as vol from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf +from homeassistant import util from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__) from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT @@ -42,8 +44,16 @@ def setup(hass, config): 'requires_api_password': True, } - info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, - port=hass.http.server_port, properties=params) + host_ip = util.get_local_ip() + + try: + host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip) + except socket.error: + host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip) + + info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, None, + addresses=[host_ip_pton], port=hass.http.server_port, + properties=params) zeroconf = Zeroconf() From 6d4545cb3ea25a857b3a8c97d39c4499f1265915 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 5 Jun 2019 18:23:17 +0200 Subject: [PATCH 092/319] Push to version 0.7.9 of denonavr (#24260) --- homeassistant/components/denonavr/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index df7d58169e056a..5e40dbb89da102 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -3,7 +3,7 @@ "name": "Denonavr", "documentation": "https://www.home-assistant.io/components/denonavr", "requirements": [ - "denonavr==0.7.8" + "denonavr==0.7.9" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 7748fc7a4f2481..b79d8e14de3ede 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -355,7 +355,7 @@ defusedxml==0.6.0 deluge-client==1.4.0 # homeassistant.components.denonavr -denonavr==0.7.8 +denonavr==0.7.9 # homeassistant.components.directv directpy==0.5 From 607b44f7c00c12a9025438b3657746b3a404d71a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 18:57:10 +0200 Subject: [PATCH 093/319] Update azure-pipelines-wheels.yml for Azure Pipelines --- azure-pipelines-wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index 0080787cd9f57a..c49c7ee0358d44 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -57,10 +57,10 @@ jobs: displayName: 'Install wheels builder' - script: | cp requirements_all.txt requirements_wheels.txt - if [ "$(Build.Reason)" != "Schedule" ]; then - curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt - else + if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then touch requirements_diff.txt + else + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt fi requirement_files="requirements_wheels.txt requirements_diff.txt" From f62d473fc46075aa03934da54778244acd5dc4f2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Jun 2019 22:14:03 +0200 Subject: [PATCH 094/319] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 4f37966f9f5852..8f250f16ce3456 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -78,7 +78,7 @@ jobs: condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate')) dependsOn: - 'VersionValidate' - timeoutInMinutes: 120 + timeoutInMinutes: 240 pool: vmImage: 'ubuntu-latest' strategy: From 96a51d16a7fdf18bc976d270d67d3ac9fb8196dc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 5 Jun 2019 15:05:52 -0600 Subject: [PATCH 095/319] Bump simplipy to 3.4.2 (#24326) * Bump simplipy to 3.4.2 * Updated requirements --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index b6bb1285daac75..ac94f5801195fc 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/simplisafe", "requirements": [ - "simplisafe-python==3.4.1" + "simplisafe-python==3.4.2" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index b79d8e14de3ede..dd1e8947039180 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1618,7 +1618,7 @@ shodan==1.13.0 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==3.4.1 +simplisafe-python==3.4.2 # homeassistant.components.sisyphus sisyphus-control==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67cff636d0ad22..8729f50411b3e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -313,7 +313,7 @@ ring_doorbell==0.2.3 rxv==0.6.0 # homeassistant.components.simplisafe -simplisafe-python==3.4.1 +simplisafe-python==3.4.2 # homeassistant.components.sleepiq sleepyq==0.6 From 859ae2fbad210b511604e0e328c4cffc57fd1758 Mon Sep 17 00:00:00 2001 From: Victor Cerutti Date: Thu, 6 Jun 2019 02:09:11 +0200 Subject: [PATCH 096/319] Meteofrance fix 24244 (#24315) * Update meteofrance package version Fix https://github.com/home-assistant/home-assistant/issues/24244 * Add code owner to manifest * Update CODEOWNERS --- CODEOWNERS | 1 + homeassistant/components/meteo_france/manifest.json | 6 ++++-- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 888ff2adb1c7bf..2dc7d5f3701ed1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -151,6 +151,7 @@ homeassistant/components/mcp23017/* @jardiamj homeassistant/components/mediaroom/* @dgomes homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen +homeassistant/components/meteo_france/* @victorcerutti homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/mill/* @danielhiversen diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 20ad5e46fe628b..301d9538c20142 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,8 +3,10 @@ "name": "Meteo france", "documentation": "https://www.home-assistant.io/components/meteo_france", "requirements": [ - "meteofrance==0.3.4" + "meteofrance==0.3.7" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@victorcerutti" + ] } diff --git a/requirements_all.txt b/requirements_all.txt index dd1e8947039180..5136e040bd7aab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -733,7 +733,7 @@ messagebird==1.2.0 meteoalertapi==0.1.3 # homeassistant.components.meteo_france -meteofrance==0.3.4 +meteofrance==0.3.7 # homeassistant.components.mfi mficlient==0.3.0 From f5db7707bb9e140010b65c0f718a02ab40dab2c2 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 5 Jun 2019 19:32:43 -0500 Subject: [PATCH 097/319] Only update media icon when necessary (#24324) * Only update media icon when necessary * Lint * Comment --- homeassistant/components/webostv/media_player.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index fa62e29f233be8..4e7f07af857627 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -167,6 +167,7 @@ def __init__(self, host, name, customize, config, timeout, self._source_list = {} self._app_list = {} self._channel = None + self._last_icon = None @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update(self): @@ -271,6 +272,13 @@ def media_image_url(self): icon = self._app_list[self._current_source_id]['largeIcon'] if not icon.startswith('http'): icon = self._app_list[self._current_source_id]['icon'] + + # 'icon' holds a URL with a transient key. Avoid unnecessary + # updates by returning the same URL until the image changes. + if self._last_icon and \ + (icon.split('/')[-1] == self._last_icon.split('/')[-1]): + return self._last_icon + self._last_icon = icon return icon return None From 6cc1bf37cc772e62a6f2d304605af2a32fb82721 Mon Sep 17 00:00:00 2001 From: Andre Richter Date: Thu, 6 Jun 2019 02:34:09 +0200 Subject: [PATCH 098/319] components/cover: Typo in docstring. (#24329) --- homeassistant/components/cover/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 8609d3c9cf6402..4b05dedbf5e125 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -159,7 +159,7 @@ async def async_unload_entry(hass, entry): class CoverDevice(Entity): - """Representation a cover.""" + """Representation of a cover.""" @property def current_cover_position(self): From 9ca5bdda7f6a7917856984914aac2cc6e8a54ed9 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Thu, 6 Jun 2019 09:30:16 +0200 Subject: [PATCH 099/319] Add exception handling for Netatmo climate (#24311) * Add exception handling * Make pylint happy --- homeassistant/components/netatmo/climate.py | 85 ++++++++++++--------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 33ad34b25ff3af..a49c83d2dd97d6 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -344,8 +344,8 @@ def get_room_ids(self): """Return all module available on the API as a list.""" if not self.setup(): return [] - for key in self.homestatus.rooms: - self.room_ids.append(key) + for room in self.homestatus.rooms: + self.room_ids.append(room) return self.room_ids def setup(self): @@ -365,6 +365,7 @@ def setup(self): def update(self): """Call the NetAtmo API to update the data.""" import pyatmo + try: self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home) except TypeError: @@ -372,40 +373,52 @@ def update(self): return _LOGGER.debug("Following is the debugging output for homestatus:") _LOGGER.debug(self.homestatus.rawData) - for key in self.homestatus.rooms: - roomstatus = {} - homestatus_room = self.homestatus.rooms[key] - homedata_room = self.homedata.rooms[self.home][key] - roomstatus['roomID'] = homestatus_room['id'] - roomstatus['roomname'] = homedata_room['name'] - roomstatus['target_temperature'] = \ - homestatus_room['therm_setpoint_temperature'] - roomstatus['setpoint_mode'] = \ - homestatus_room['therm_setpoint_mode'] - roomstatus['current_temperature'] = \ - homestatus_room['therm_measured_temperature'] - roomstatus['module_type'] = \ - self.homestatus.thermostatType(self.home, key) - roomstatus['module_id'] = None - roomstatus['heating_status'] = None - roomstatus['heating_power_request'] = None - for module_id in homedata_room['module_ids']: - if self.homedata.modules[self.home][module_id]['type'] == \ - NA_THERM or roomstatus['module_id'] is None: - roomstatus['module_id'] = module_id - if roomstatus['module_type'] == NA_THERM: - self.boilerstatus = self.homestatus.boilerStatus( - rid=roomstatus['module_id']) - roomstatus['heating_status'] = self.boilerstatus - elif roomstatus['module_type'] == NA_VALVE: - roomstatus['heating_power_request'] = \ - homestatus_room['heating_power_request'] - roomstatus['heating_status'] = \ - roomstatus['heating_power_request'] > 0 - if self.boilerstatus is not None: - roomstatus['heating_status'] = \ - self.boilerstatus and roomstatus['heating_status'] - self.room_status[key] = roomstatus + for room in self.homestatus.rooms: + try: + roomstatus = {} + homestatus_room = self.homestatus.rooms[room] + homedata_room = self.homedata.rooms[self.home][room] + roomstatus["roomID"] = homestatus_room["id"] + roomstatus["roomname"] = homedata_room["name"] + roomstatus["target_temperature"] = homestatus_room[ + "therm_setpoint_temperature" + ] + roomstatus["setpoint_mode"] = homestatus_room[ + "therm_setpoint_mode" + ] + roomstatus["current_temperature"] = homestatus_room[ + "therm_measured_temperature" + ] + roomstatus["module_type"] = self.homestatus.thermostatType( + self.home, room + ) + roomstatus["module_id"] = None + roomstatus["heating_status"] = None + roomstatus["heating_power_request"] = None + for module_id in homedata_room["module_ids"]: + if (self.homedata.modules[self.home][module_id]["type"] + == NA_THERM + or roomstatus["module_id"] is None): + roomstatus["module_id"] = module_id + if roomstatus["module_type"] == NA_THERM: + self.boilerstatus = self.homestatus.boilerStatus( + rid=roomstatus["module_id"] + ) + roomstatus["heating_status"] = self.boilerstatus + elif roomstatus["module_type"] == NA_VALVE: + roomstatus["heating_power_request"] = homestatus_room[ + "heating_power_request" + ] + roomstatus["heating_status"] = ( + roomstatus["heating_power_request"] > 0 + ) + if self.boilerstatus is not None: + roomstatus["heating_status"] = ( + self.boilerstatus and roomstatus["heating_status"] + ) + self.room_status[room] = roomstatus + except KeyError as err: + _LOGGER.error("Update of room %s failed. Error: %s", room, err) self.away_temperature = self.homestatus.getAwaytemp(self.home) self.hg_temperature = self.homestatus.getHgtemp(self.home) self.setpoint_duration = self.homedata.setpoint_duration[self.home] From d261c6ccc12870204340b06b64484d563af6b641 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Jun 2019 03:07:30 -0700 Subject: [PATCH 100/319] Initiate websession inside event loop (#24331) --- homeassistant/components/tado/device_tracker.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 3bb62f328b9e29..31b424b9cd4676 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -42,6 +42,7 @@ class TadoDeviceScanner(DeviceScanner): def __init__(self, hass, config): """Initialize the scanner.""" + self.hass = hass self.last_results = [] self.username = config[CONF_USERNAME] @@ -60,8 +61,7 @@ def __init__(self, hass, config): # The API URL always needs a username and password self.tadoapiurl += '?username={username}&password={password}' - self.websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + self.websession = None self.success_init = asyncio.run_coroutine_threadsafe( self._async_update_info(), hass.loop @@ -92,6 +92,10 @@ async def _async_update_info(self): """ _LOGGER.debug("Requesting Tado") + if self.websession is None: + self.websession = async_create_clientsession( + self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + last_results = [] try: From 9fb1f2fa1704efb5ee3b97b6ec32a1bea935aa26 Mon Sep 17 00:00:00 2001 From: Markus Jankowski Date: Thu, 6 Jun 2019 12:09:02 +0200 Subject: [PATCH 101/319] Remove deprecated AlarmControlPanel (#24322) --- .../homematicip_cloud/alarm_control_panel.py | 48 +------------------ 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 1e072c6784c1fa..ccd19f26d68707 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -12,7 +12,7 @@ STATE_ALARM_TRIGGERED) from homeassistant.core import HomeAssistant -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID _LOGGER = logging.getLogger(__name__) @@ -34,12 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, for group in home.groups: if isinstance(group, AsyncSecurityZoneGroup): security_zones.append(group) - # To be removed in a later release. - devices.append(HomematicipSecurityZone(home, group)) - _LOGGER.warning("Homematic IP: alarm_control_panel.%s is " - "deprecated. Please switch to " - "alarm_control_panel.*hmip_alarm_control_panel.", - group.label) + if security_zones: devices.append(HomematicipAlarmControlPanel(home, security_zones)) @@ -47,45 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities(devices) -class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): - """Representation of an HomematicIP Cloud security zone group.""" - - def __init__(self, home: AsyncHome, device) -> None: - """Initialize the security zone group.""" - device.modelType = 'Group-SecurityZone' - device.windowState = None - super().__init__(home, device) - - @property - def state(self) -> str: - """Return the state of the device.""" - if self._device.active: - if (self._device.sabotage or self._device.motionDetected or - self._device.windowState == WindowState.OPEN or - self._device.windowState == WindowState.TILTED): - return STATE_ALARM_TRIGGERED - - active = self._home.get_security_zones_activation() - if active == (True, True): - return STATE_ALARM_ARMED_AWAY - if active == (False, True): - return STATE_ALARM_ARMED_HOME - - return STATE_ALARM_DISARMED - - async def async_alarm_disarm(self, code=None): - """Send disarm command.""" - await self._home.set_security_zones_activation(False, False) - - async def async_alarm_arm_home(self, code=None): - """Send arm home command.""" - await self._home.set_security_zones_activation(False, True) - - async def async_alarm_arm_away(self, code=None): - """Send arm away command.""" - await self._home.set_security_zones_activation(True, True) - - class HomematicipAlarmControlPanel(AlarmControlPanel): """Representation of an alarm control panel.""" From ae1bcd5fef18d3e829f0dd7dbdf769db65916877 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 6 Jun 2019 08:31:03 -0400 Subject: [PATCH 102/319] Use node descriptor from Zigpy for ZHA (#24316) * use zigpy node descriptor * cleanup --- .../components/zha/core/channels/__init__.py | 50 ------------------- .../components/zha/core/channels/general.py | 4 +- homeassistant/components/zha/core/const.py | 2 + homeassistant/components/zha/core/device.py | 50 ++++++++++++------- homeassistant/components/zha/core/gateway.py | 9 +--- homeassistant/components/zha/core/store.py | 15 ------ homeassistant/components/zha/entity.py | 3 +- tests/components/zha/common.py | 2 + 8 files changed, 40 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 1845ae8e999203..3eb24050195103 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -22,10 +22,6 @@ ) from ..registries import CLUSTER_REPORT_CONFIGS -NODE_DESCRIPTOR_REQUEST = 0x0002 -MAINS_POWERED = 1 -BATTERY_OR_UNKNOWN = 0 - ZIGBEE_CHANNEL_REGISTRY = {} _LOGGER = logging.getLogger(__name__) @@ -268,11 +264,6 @@ async def async_initialize(self, from_cache): class ZDOChannel: """Channel for ZDO events.""" - POWER_SOURCES = { - MAINS_POWERED: 'Mains', - BATTERY_OR_UNKNOWN: 'Battery or Unknown' - } - def __init__(self, cluster, device): """Initialize ZDOChannel.""" self.name = ZDO_CHANNEL @@ -281,8 +272,6 @@ def __init__(self, cluster, device): self._status = ChannelStatus.CREATED self._unique_id = "{}_ZDO".format(device.name) self._cluster.add_listener(self) - self.power_source = None - self.manufacturer_code = None @property def unique_id(self): @@ -314,49 +303,10 @@ async def async_initialize(self, from_cache): entry = self._zha_device.gateway.zha_storage.async_get_or_create( self._zha_device) _LOGGER.debug("entry loaded from storage: %s", entry) - if entry is not None: - self.power_source = entry.power_source - self.manufacturer_code = entry.manufacturer_code - - if self.power_source is None: - self.power_source = BATTERY_OR_UNKNOWN - - if self.manufacturer_code is None and not from_cache: - # this should always be set. This is from us not doing - # this previously so lets set it up so users don't have - # to reconfigure every device. - await self.async_get_node_descriptor(False) - entry = self._zha_device.gateway.zha_storage.async_update( - self._zha_device) - _LOGGER.debug("entry after getting node desc in init: %s", entry) self._status = ChannelStatus.INITIALIZED - async def async_get_node_descriptor(self, from_cache): - """Request the node descriptor from the device.""" - from zigpy.zdo.types import Status - - if from_cache: - return - - node_descriptor = await self._cluster.request( - NODE_DESCRIPTOR_REQUEST, - self._cluster.device.nwk, tries=3, delay=2) - - def get_bit(byteval, idx): - return int(((byteval & (1 << idx)) != 0)) - - if node_descriptor is not None and\ - node_descriptor[0] == Status.SUCCESS: - mac_capability_flags = node_descriptor[2].mac_capability_flags - - self.power_source = get_bit(mac_capability_flags, 2) - self.manufacturer_code = node_descriptor[2].manufacturer_code - - _LOGGER.debug("node descriptor: %s", node_descriptor) - async def async_configure(self): """Configure channel.""" - await self.async_get_node_descriptor(False) self._status = ChannelStatus.CONFIGURED diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 470cd6b38cffc6..3f08a738a13e97 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -8,7 +8,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from . import ZigbeeChannel, parse_and_log_command, MAINS_POWERED +from . import ZigbeeChannel, parse_and_log_command from ..helpers import get_attr_id_by_name from ..const import ( SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, @@ -87,7 +87,7 @@ async def async_initialize(self, from_cache): async def async_update(self): """Initialize channel.""" - from_cache = not self.device.power_source == MAINS_POWERED + from_cache = not self.device.is_mains_powered _LOGGER.debug( "%s is attempting to update onoff state - from cache: %s", self._unique_id, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 193780c9124728..9e42f6343a150e 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -104,6 +104,8 @@ QUIRK_CLASS = 'quirk_class' MANUFACTURER_CODE = 'manufacturer_code' POWER_SOURCE = 'power_source' +MAINS_POWERED = 'Mains' +BATTERY_OR_UNKNOWN = 'Battery or Unknown' BELLOWS = 'bellows' ZHA = 'homeassistant.components.zha' diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 1a619dff981836..85373517aa213d 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,9 +17,10 @@ ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED, - QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE + QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE, MAINS_POWERED, + BATTERY_OR_UNKNOWN ) -from .channels import EventRelayChannel, ZDOChannel +from .channels import EventRelayChannel _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,6 @@ def __init__(self, hass, zigpy_device, zha_gateway): self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) - self._power_source = None self.status = DeviceStatus.CREATED @property @@ -91,6 +91,13 @@ def model(self): """Return model for device.""" return self._model + @property + def manufacturer_code(self): + """Return the manufacturer code for the device.""" + if self._zigpy_device.node_desc.is_valid: + return self._zigpy_device.node_desc.manufacturer_code + return None + @property def nwk(self): """Return nwk for device.""" @@ -112,20 +119,29 @@ def last_seen(self): return self._zigpy_device.last_seen @property - def manufacturer_code(self): - """Return manufacturer code for device.""" - if ZDO_CHANNEL in self.cluster_channels: - return self.cluster_channels.get(ZDO_CHANNEL).manufacturer_code - return None + def is_mains_powered(self): + """Return true if device is mains powered.""" + return self._zigpy_device.node_desc.is_mains_powered @property def power_source(self): """Return the power source for the device.""" - if self._power_source is not None: - return self._power_source - if ZDO_CHANNEL in self.cluster_channels: - return self.cluster_channels.get(ZDO_CHANNEL).power_source - return None + return MAINS_POWERED if self.is_mains_powered else BATTERY_OR_UNKNOWN + + @property + def is_router(self): + """Return true if this is a routing capable device.""" + return self._zigpy_device.node_desc.is_router + + @property + def is_coordinator(self): + """Return true if this device represents the coordinator.""" + return self._zigpy_device.node_desc.is_coordinator + + @property + def is_end_device(self): + """Return true if this device is an end device.""" + return self._zigpy_device.node_desc.is_end_device @property def gateway(self): @@ -151,10 +167,6 @@ def set_available(self, available): """Set availability from restore and prevent signals.""" self._available = available - def set_power_source(self, power_source): - """Set the power source.""" - self._power_source = power_source - def update_available(self, available): """Set sensor availability.""" if self._available != available and available: @@ -183,7 +195,7 @@ def device_info(self): QUIRK_APPLIED: self.quirk_applied, QUIRK_CLASS: self.quirk_class, MANUFACTURER_CODE: self.manufacturer_code, - POWER_SOURCE: ZDOChannel.POWER_SOURCES.get(self.power_source) + POWER_SOURCE: self.power_source } def add_cluster_channel(self, cluster_channel): @@ -256,7 +268,7 @@ async def async_initialize(self, from_cache=False): _LOGGER.debug( '%s: power source: %s', self.name, - ZDOChannel.POWER_SOURCES.get(self.power_source) + self.power_source ) self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 740cd450181654..f8458848fc2f7c 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_component import EntityComponent from ..api import async_get_device_info -from .channels import MAINS_POWERED, ZDOChannel from .const import ( ADD_DEVICE_RELAY_LOGGERS, ATTR_MANUFACTURER, BELLOWS, CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, CONF_USB_PATH, CONTROLLER, CURRENT, @@ -234,7 +233,6 @@ def _async_get_or_create_device(self, zigpy_device, is_new_join): if not is_new_join: entry = self.zha_storage.async_get_or_create(zha_device) zha_device.async_update_last_seen(entry.last_seen) - zha_device.set_power_source(entry.power_source) return zha_device @callback @@ -290,16 +288,13 @@ async def async_device_initialized(self, device, is_new_join): # configure the device await zha_device.async_configure() zha_device.update_available(True) - elif zha_device.power_source is not None\ - and zha_device.power_source == MAINS_POWERED: + elif zha_device.is_mains_powered: # the device isn't a battery powered device so we should be able # to update it now _LOGGER.debug( "attempting to request fresh state for %s %s", zha_device.name, - "with power source: {}".format( - ZDOChannel.POWER_SOURCES.get(zha_device.power_source) - ) + "with power source: {}".format(zha_device.power_source) ) await zha_device.async_initialize(from_cache=False) else: diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index f3547cea8a4154..c14345e89dd56a 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -26,8 +26,6 @@ class ZhaDeviceEntry: name = attr.ib(type=str, default=None) ieee = attr.ib(type=str, default=None) - power_source = attr.ib(type=int, default=None) - manufacturer_code = attr.ib(type=int, default=None) last_seen = attr.ib(type=float, default=None) @@ -46,8 +44,6 @@ def async_create(self, device) -> ZhaDeviceEntry: device_entry = ZhaDeviceEntry( name=device.name, ieee=str(device.ieee), - power_source=device.power_source, - manufacturer_code=device.manufacturer_code, last_seen=device.last_seen ) @@ -85,13 +81,6 @@ def async_update(self, device) -> ZhaDeviceEntry: old = self.devices[ieee_str] changes = {} - - if device.power_source != old.power_source: - changes['power_source'] = device.power_source - - if device.manufacturer_code != old.manufacturer_code: - changes['manufacturer_code'] = device.manufacturer_code - changes['last_seen'] = device.last_seen new = self.devices[ieee_str] = attr.evolve(old, **changes) @@ -109,8 +98,6 @@ async def async_load(self) -> None: devices[device['ieee']] = ZhaDeviceEntry( name=device['name'], ieee=device['ieee'], - power_source=device['power_source'], - manufacturer_code=device['manufacturer_code'], last_seen=device['last_seen'] if 'last_seen' in device else None ) @@ -135,8 +122,6 @@ def _data_to_save(self) -> dict: { 'name': entry.name, 'ieee': entry.ieee, - 'power_source': entry.power_source, - 'manufacturer_code': entry.manufacturer_code, 'last_seen': entry.last_seen } for entry in self.devices.values() ] diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index d894ef5d7a37c4..36df8aada2bc11 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -14,7 +14,6 @@ DOMAIN, ATTR_MANUFACTURER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, MODEL, NAME, SIGNAL_REMOVE ) -from .core.channels import MAINS_POWERED _LOGGER = logging.getLogger(__name__) @@ -157,7 +156,7 @@ async def async_check_recently_seen(self): time.time() - self._zha_device.last_seen < RESTART_GRACE_PERIOD): self.async_set_available(True) - if self.zha_device.power_source != MAINS_POWERED: + if not self.zha_device.is_mains_powered: # mains powered devices will get real time state self.async_restore_last_state(last_state) self._zha_device.set_available(True) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index cd2eb53c3fe767..4cc7dec1edfaf1 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -82,6 +82,8 @@ def __init__(self, ieee, manufacturer, model): self.initializing = False self.manufacturer = manufacturer self.model = model + from zigpy.zdo.types import NodeDescriptor + self.node_desc = NodeDescriptor() def make_device(in_cluster_ids, out_cluster_ids, device_type, ieee, From 3b4a9a337ba336362afbc4cc091ddb23a04d7de6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 6 Jun 2019 18:10:23 +0200 Subject: [PATCH 103/319] Add abbreviation for light template variable names (#24336) --- homeassistant/components/mqtt/discovery.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c99c73018ea08f..fb9626ac6e2f47 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -78,9 +78,11 @@ 'away_mode_cmd_t': 'away_mode_command_topic', 'away_mode_stat_tpl': 'away_mode_state_template', 'away_mode_stat_t': 'away_mode_state_topic', + 'b_tpl': 'blue_template', 'bri_cmd_t': 'brightness_command_topic', 'bri_scl': 'brightness_scale', 'bri_stat_t': 'brightness_state_topic', + 'bri_tpl': 'brightness_template', 'bri_val_tpl': 'brightness_value_template', 'clr_temp_cmd_tpl': 'color_temp_command_template', 'bat_lev_t': 'battery_level_topic', @@ -92,6 +94,8 @@ 'clr_temp_val_tpl': 'color_temp_value_template', 'cln_t': 'cleaning_topic', 'cln_tpl': 'cleaning_template', + 'cmd_off_tpl': 'command_off_template', + 'cmd_on_tpl': 'command_on_template', 'cmd_t': 'command_topic', 'curr_temp_t': 'current_temperature_topic', 'curr_temp_tpl': 'current_temperature_template', @@ -107,12 +111,14 @@ 'fx_cmd_t': 'effect_command_topic', 'fx_list': 'effect_list', 'fx_stat_t': 'effect_state_topic', + 'fx_tpl': 'effect_template', 'fx_val_tpl': 'effect_value_template', 'exp_aft': 'expire_after', 'fan_mode_cmd_t': 'fan_mode_command_topic', 'fan_mode_stat_tpl': 'fan_mode_state_template', 'fan_mode_stat_t': 'fan_mode_state_topic', 'frc_upd': 'force_update', + 'g_tpl': 'green_template', 'hold_cmd_t': 'hold_command_topic', 'hold_stat_tpl': 'hold_state_template', 'hold_stat_t': 'hold_state_topic', @@ -149,6 +155,7 @@ 'pl_stop': 'payload_stop', 'pl_unlk': 'payload_unlock', 'pow_cmd_t': 'power_command_topic', + 'r_tpl': 'red_template', 'ret': 'retain', 'rgb_cmd_tpl': 'rgb_command_template', 'rgb_cmd_t': 'rgb_command_topic', @@ -168,6 +175,7 @@ 'stat_on': 'state_on', 'stat_open': 'state_open', 'stat_t': 'state_topic', + 'stat_tpl': 'state_template', 'stat_val_tpl': 'state_value_template', 'sup_feat': 'supported_features', 'swing_mode_cmd_t': 'swing_mode_command_topic', From 64d6fa8e8623a75e3e84b5bf30937dd6851b955a Mon Sep 17 00:00:00 2001 From: Markus Jankowski Date: Thu, 6 Jun 2019 18:11:38 +0200 Subject: [PATCH 104/319] Remove attribute lowBat (#24323) --- homeassistant/components/homematicip_cloud/binary_sensor.py | 4 +++- homeassistant/components/homematicip_cloud/device.py | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index b006ec8068654a..ba30591dc6da9c 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__) +ATTR_LOW_BATTERY = 'low_battery' ATTR_MOTIONDETECTED = 'motion detected' ATTR_PRESENCEDETECTED = 'presence detected' ATTR_POWERMAINSFAILURE = 'power mains failure' @@ -312,7 +313,8 @@ def device_state_attributes(self): attr[ATTR_MOISTUREDETECTED] = True if self._device.waterlevelDetected: attr[ATTR_WATERLEVELDETECTED] = True - + if self._device.lowBat: + attr[ATTR_LOW_BATTERY] = True if self._device.smokeDetectorAlarmType is not None and \ self._device.smokeDetectorAlarmType != \ SmokeDetectorAlarmType.IDLE_OFF: diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 2c77d225263ca2..57e04d1f32cd87 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -10,7 +10,6 @@ _LOGGER = logging.getLogger(__name__) -ATTR_LOW_BATTERY = 'low_battery' ATTR_MODEL_TYPE = 'model_type' # RSSI HAP -> Device ATTR_RSSI_DEVICE = 'rssi_device' @@ -96,8 +95,6 @@ def icon(self) -> Optional[str]: def device_state_attributes(self): """Return the state attributes of the generic device.""" attr = {ATTR_MODEL_TYPE: self._device.modelType} - if hasattr(self._device, 'lowBat') and self._device.lowBat: - attr[ATTR_LOW_BATTERY] = self._device.lowBat if hasattr(self._device, 'sabotage') and self._device.sabotage: attr[ATTR_SABOTAGE] = self._device.sabotage if hasattr(self._device, 'rssiDeviceValue') and \ From 3b8f254dfd901b6ef36025b5c75ccb3fe33c12d4 Mon Sep 17 00:00:00 2001 From: David Barrera Date: Thu, 6 Jun 2019 12:20:30 -0400 Subject: [PATCH 105/319] Don't load last_checkpoint if shipment is pending (#24301) --- homeassistant/components/aftership/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index fae3e38c96b3a3..630b0d400c8f42 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -177,6 +177,11 @@ async def async_update(self, **kwargs): if track['title'] is None else track['title'] ) + last_checkpoint = ( + "Shipment pending" + if track['tag'] == "Pending" + else track['checkpoints'][-1] + ) status_counts[status] = status_counts.get(status, 0) + 1 trackings.append({ 'name': name, @@ -187,7 +192,7 @@ async def async_update(self, **kwargs): 'last_update': track['updated_at'], 'expected_delivery': track['expected_delivery'], 'status': track['tag'], - 'last_checkpoint': track['checkpoints'][-1] + 'last_checkpoint': last_checkpoint }) if status not in status_to_ignore: From 8f4bb8d44547155308eb63a4e66bfe1a29dcd0e5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 6 Jun 2019 19:46:36 +0200 Subject: [PATCH 106/319] UPgrade youtube_dl to 2019.05.20 (#24347) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index dbdb64b8421564..7d57cbf1ab96bc 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -3,7 +3,7 @@ "name": "Media extractor", "documentation": "https://www.home-assistant.io/components/media_extractor", "requirements": [ - "youtube_dl==2019.05.11" + "youtube_dl==2019.05.20" ], "dependencies": [ "media_player" diff --git a/requirements_all.txt b/requirements_all.txt index 5136e040bd7aab..aeb768221fd266 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1872,7 +1872,7 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.05.11 +youtube_dl==2019.05.20 # homeassistant.components.zengge zengge==0.2 From 0eba9200757796f625e0b211f222ad35403336eb Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 6 Jun 2019 20:08:29 +0200 Subject: [PATCH 107/319] Add new movement type "actively" of the Xiaomi Vibration Sensor (#24334) --- homeassistant/components/xiaomi_aqara/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 56818c51b817b3..7085fe49aeb6cd 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -374,7 +374,7 @@ def parse_data(self, data, raw_data): if value is None: return False - if value not in ('vibrate', 'tilt', 'free_fall'): + if value not in ('vibrate', 'tilt', 'free_fall', 'actively'): _LOGGER.warning("Unsupported movement_type detected: %s", value) return False From 4ec2af785a2a015516c77520d975ff69936d3abc Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 6 Jun 2019 20:09:11 +0200 Subject: [PATCH 108/319] Fix set_cover_position of the xiaomi_aqara cover for LAN protocol v2 (#24333) * Fix set_cover_position of the xiaomi_aqara cover for LAN protocol v2 (Closes: #24293) * Fix lint --- homeassistant/components/xiaomi_aqara/cover.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index cd9190dca351f6..f07edc973c4524 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -9,6 +9,9 @@ ATTR_CURTAIN_LEVEL = 'curtain_level' +DATA_KEY_PROTO_V1 = 'status' +DATA_KEY_PROTO_V2 = 'curtain_status' + def setup_platform(hass, config, add_entities, discovery_info=None): """Perform the setup for Xiaomi devices.""" @@ -18,9 +21,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): model = device['model'] if model == 'curtain': if 'proto' not in device or int(device['proto'][0:1]) == 1: - data_key = 'status' + data_key = DATA_KEY_PROTO_V1 else: - data_key = 'curtain_status' + data_key = DATA_KEY_PROTO_V2 devices.append(XiaomiGenericCover(device, "Curtain", data_key, gateway)) add_entities(devices) @@ -60,7 +63,12 @@ def stop_cover(self, **kwargs): def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) - self._write_to_hub(self._sid, **{ATTR_CURTAIN_LEVEL: str(position)}) + if self._data_key == DATA_KEY_PROTO_V2: + self._write_to_hub( + self._sid, **{ATTR_CURTAIN_LEVEL: position}) + else: + self._write_to_hub( + self._sid, **{ATTR_CURTAIN_LEVEL: str(position)}) def parse_data(self, data, raw_data): """Parse data sent by gateway.""" From fcfbdd2d89fc4225a0c661989cbc536341666619 Mon Sep 17 00:00:00 2001 From: michaeldavie Date: Thu, 6 Jun 2019 14:47:27 -0400 Subject: [PATCH 109/319] Add Environment Canada weather, sensor, and camera platforms (#21110) * Added Environment Canada weather platform * Added Environment Canada weather platform * Migrate to new folder structure * Migrate to new folder structure * Fix updates * Fix updates again * Bump env_canada to 0.0.4 * Bump env_canada to 0.0.4 * Bump env_canada to 0.0.4 in requirements_all.txt * Change daily forecast timestamp and high/low test * Change daily forecast timestamp and high/low test * Bump env_canada to 0.0.5 * Break alerts into multiple sensors, bump env_canada to 0.0.6 * Bump env_canada to 0.0.7 * Remove blank line * Remove 'ec' sensor prefix, bump env_canada to 0.0.8 * Corrections * Change to manifests.json * Add docstring to __init.py__ * Update CODEOWNERS * pylint correction * pylint correction * Add alert details, bump env_canada to 0.0.9 * Update requirements_all.txt * Update .coveragerc * Bump env_canada to 0.0.10 * Update requirements_all.txt --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/environment_canada/__init__.py | 1 + .../components/environment_canada/camera.py | 101 ++++++++ .../environment_canada/manifest.json | 12 + .../components/environment_canada/sensor.py | 178 ++++++++++++++ .../components/environment_canada/weather.py | 219 ++++++++++++++++++ requirements_all.txt | 3 + 8 files changed, 516 insertions(+) create mode 100644 homeassistant/components/environment_canada/__init__.py create mode 100755 homeassistant/components/environment_canada/camera.py create mode 100644 homeassistant/components/environment_canada/manifest.json create mode 100755 homeassistant/components/environment_canada/sensor.py create mode 100644 homeassistant/components/environment_canada/weather.py diff --git a/.coveragerc b/.coveragerc index e329e2f31b9dbd..8cde0c194fdc51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -165,6 +165,7 @@ omit = homeassistant/components/enocean/* homeassistant/components/enphase_envoy/sensor.py homeassistant/components/entur_public_transport/* + homeassistant/components/environment_canada/* homeassistant/components/envirophat/sensor.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 2dc7d5f3701ed1..97aad6177d3067 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,6 +72,7 @@ homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/emby/* @mezz64 homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer +homeassistant/components/environment_canada/* @michaeldavie homeassistant/components/ephember/* @ttroy50 homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py new file mode 100644 index 00000000000000..356e18fe23fd4d --- /dev/null +++ b/homeassistant/components/environment_canada/__init__.py @@ -0,0 +1 @@ +"""A component for Environment Canada weather.""" diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py new file mode 100755 index 00000000000000..18a88129e1dd01 --- /dev/null +++ b/homeassistant/components/environment_canada/camera.py @@ -0,0 +1,101 @@ +""" +Support for the Environment Canada radar imagery. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.environment_canada/ +""" +import datetime +import logging + +import voluptuous as vol + +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, ATTR_ATTRIBUTION) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION = 'station' +ATTR_LOCATION = 'location' + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' +CONF_LOOP = 'loop' +CONF_PRECIP_TYPE = 'precip_type' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LOOP, default=True): cv.boolean, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): cv.string, + vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude, + vol.Optional(CONF_PRECIP_TYPE): ['RAIN', 'SNOW'], +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada camera.""" + from env_canada import ECRadar + + if config.get(CONF_STATION): + radar_object = ECRadar(station_id=config[CONF_STATION], + precip_type=config.get(CONF_PRECIP_TYPE)) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + radar_object = ECRadar(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE]), + precip_type=config.get(CONF_PRECIP_TYPE)) + else: + radar_object = ECRadar(coordinates=(hass.config.latitude, + hass.config.longitude), + precip_type=config.get(CONF_PRECIP_TYPE)) + + add_devices([ECCamera(radar_object, config.get(CONF_NAME))], True) + + +class ECCamera(Camera): + """Implementation of an Environment Canada radar camera.""" + + def __init__(self, radar_object, camera_name): + """Initialize the camera.""" + super().__init__() + + self.radar_object = radar_object + self.camera_name = camera_name + self.content_type = 'image/gif' + self.image = None + + def camera_image(self): + """Return bytes of camera image.""" + self.update() + return self.image + + @property + def name(self): + """Return the name of the camera.""" + if self.camera_name is not None: + return self.camera_name + return ' '.join([self.radar_object.station_name, 'Radar']) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_LOCATION: self.radar_object.station_name, + ATTR_STATION: self.radar_object.station_code + } + + return attr + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update radar image.""" + if CONF_LOOP: + self.image = self.radar_object.get_loop() + else: + self.image = self.radar_object.get_latest_frame() diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json new file mode 100644 index 00000000000000..ea809238499199 --- /dev/null +++ b/homeassistant/components/environment_canada/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "environment_canada", + "name": "Environment Canada", + "documentation": "https://www.home-assistant.io/components/environment_canada", + "requirements": [ + "env_canada==0.0.10" + ], + "dependencies": [], + "codeowners": [ + "@michaeldavie" + ] +} diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py new file mode 100755 index 00000000000000..c0b78cd4f3509b --- /dev/null +++ b/homeassistant/components/environment_canada/sensor.py @@ -0,0 +1,178 @@ +""" +Support for the Environment Canada weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.environment_canada/ +""" +import datetime +import logging +import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, CONF_LATITUDE, + CONF_LONGITUDE, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_HIDDEN) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_UPDATED = 'updated' +ATTR_STATION = 'station' +ATTR_DETAIL = 'alert detail' +ATTR_TIME = 'alert time' + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + +SENSOR_TYPES = { + 'temperature': {'name': 'Temperature', + 'unit': TEMP_CELSIUS}, + 'dewpoint': {'name': 'Dew Point', + 'unit': TEMP_CELSIUS}, + 'wind_chill': {'name': 'Wind Chill', + 'unit': TEMP_CELSIUS}, + 'humidex': {'name': 'Humidex', + 'unit': TEMP_CELSIUS}, + 'pressure': {'name': 'Pressure', + 'unit': 'kPa'}, + 'tendency': {'name': 'Tendency'}, + 'humidity': {'name': 'Humidity', + 'unit': '%'}, + 'visibility': {'name': 'Visibility', + 'unit': 'km'}, + 'condition': {'name': 'Condition'}, + 'wind_speed': {'name': 'Wind Speed', + 'unit': 'km/h'}, + 'wind_gust': {'name': 'Wind Gust', + 'unit': 'km/h'}, + 'wind_dir': {'name': 'Wind Direction'}, + 'high_temp': {'name': 'High Temperature', + 'unit': TEMP_CELSIUS}, + 'low_temp': {'name': 'Low Temperature', + 'unit': TEMP_CELSIUS}, + 'pop': {'name': 'Chance of Precip.', + 'unit': '%'}, + 'warnings': {'name': 'Warnings'}, + 'watches': {'name': 'Watches'}, + 'advisories': {'name': 'Advisories'}, + 'statements': {'name': 'Statements'}, + 'endings': {'name': 'Ended'} +} + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + if not re.fullmatch(r'[A-Z]{2}/s0000\d{3}', station): + raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, + vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada sensor.""" + from env_canada import ECData + + if config.get(CONF_STATION): + ec_data = ECData(station_id=config[CONF_STATION]) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + ec_data = ECData(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE])) + else: + ec_data = ECData(coordinates=(hass.config.latitude, + hass.config.longitude)) + + add_devices([ECSensor(sensor_type, ec_data, config.get(CONF_NAME)) + for sensor_type in config[CONF_MONITORED_CONDITIONS]], + True) + + +class ECSensor(Entity): + """Implementation of an Environment Canada sensor.""" + + def __init__(self, sensor_type, ec_data, platform_name): + """Initialize the sensor.""" + self.sensor_type = sensor_type + self.ec_data = ec_data + self.platform_name = platform_name + self._state = None + self._attr = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.platform_name is None: + return SENSOR_TYPES[self.sensor_type]['name'] + + return ' '.join([self.platform_name, + SENSOR_TYPES[self.sensor_type]['name']]) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._attr + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES[self.sensor_type].get('unit') + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update current conditions.""" + self.ec_data.update() + self.ec_data.conditions.update(self.ec_data.alerts) + + self._attr = {} + + sensor_data = self.ec_data.conditions.get(self.sensor_type) + if isinstance(sensor_data, list): + self._state = ' | '.join([str(s.get('title')) + for s in sensor_data]) + self._attr.update({ + ATTR_DETAIL: ' | '.join([str(s.get('detail')) + for s in sensor_data]), + ATTR_TIME: ' | '.join([str(s.get('date')) + for s in sensor_data]) + }) + else: + self._state = sensor_data + + timestamp = self.ec_data.conditions.get('timestamp') + if timestamp: + updated_utc = datetime.datetime.strptime(timestamp, '%Y%m%d%H%M%S') + updated_local = dt.as_local(updated_utc).isoformat() + else: + updated_local = None + + hidden = bool(self._state is None or self._state == '') + + self._attr.update({ + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_UPDATED: updated_local, + ATTR_LOCATION: self.ec_data.conditions.get('location'), + ATTR_STATION: self.ec_data.conditions.get('station'), + ATTR_HIDDEN: hidden + }) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py new file mode 100644 index 00000000000000..0589a23445ec5f --- /dev/null +++ b/homeassistant/components/environment_canada/weather.py @@ -0,0 +1,219 @@ +""" +Platform for retrieving meteorological data from Environment Canada. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/weather.environmentcanada/ +""" +import datetime +import logging +import re + +from env_canada import ECData +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = 'forecast' +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + if not re.fullmatch(r'[A-Z]{2}/s0000\d{3}', station): + raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, + vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude, + vol.Optional(CONF_FORECAST, default='daily'): + vol.In(['daily', 'hourly']), +}) + +# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ +# docs/current_conditions_icon_code_descriptions_e.csv +ICON_CONDITION_MAP = {'sunny': [0, 1], + 'clear-night': [30, 31], + 'partlycloudy': [2, 3, 4, 5, 22, 32, 33, 34, 35], + 'cloudy': [10], + 'rainy': [6, 9, 11, 12, 28, 36], + 'lightning-rainy': [19, 39, 46, 47], + 'pouring': [13], + 'snowy-rainy': [7, 14, 15, 27, 37], + 'snowy': [8, 16, 17, 18, 25, 26, 38, 40], + 'windy': [43], + 'fog': [20, 21, 23, 24, 44], + 'hail': [26, 27]} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada weather.""" + if config.get(CONF_STATION): + ec_data = ECData(station_id=config[CONF_STATION]) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + ec_data = ECData(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE])) + else: + ec_data = ECData(coordinates=(hass.config.latitude, + hass.config.longitude)) + + add_devices([ECWeather(ec_data, config)]) + + +class ECWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, ec_data, config): + """Initialize Environment Canada weather.""" + self.ec_data = ec_data + self.platform_name = config.get(CONF_NAME) + self.forecast_type = config[CONF_FORECAST] + + @property + def attribution(self): + """Return the attribution.""" + return CONF_ATTRIBUTION + + @property + def name(self): + """Return the name of the weather entity.""" + if self.platform_name: + return self.platform_name + return self.ec_data.conditions['location'] + + @property + def temperature(self): + """Return the temperature.""" + if self.ec_data.conditions.get('temperature'): + return float(self.ec_data.conditions['temperature']) + return None + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self): + """Return the humidity.""" + if self.ec_data.conditions.get('humidity'): + return float(self.ec_data.conditions['humidity']) + return None + + @property + def wind_speed(self): + """Return the wind speed.""" + if self.ec_data.conditions.get('wind_speed'): + return float(self.ec_data.conditions['wind_speed']) + return None + + @property + def wind_bearing(self): + """Return the wind bearing.""" + if self.ec_data.conditions.get('wind_bearing'): + return float(self.ec_data.conditions['wind_bearing']) + return None + + @property + def pressure(self): + """Return the pressure.""" + if self.ec_data.conditions.get('pressure'): + return 10 * float(self.ec_data.conditions['pressure']) + return None + + @property + def visibility(self): + """Return the visibility.""" + if self.ec_data.conditions.get('visibility'): + return float(self.ec_data.conditions['visibility']) + return None + + @property + def condition(self): + """Return the weather condition.""" + icon_code = self.ec_data.conditions.get('icon_code') + if icon_code: + return icon_code_to_condition(int(icon_code)) + condition = self.ec_data.conditions.get('condition') + if condition: + return condition + return 'Condition not observed' + + @property + def forecast(self): + """Return the forecast array.""" + return get_forecast(self.ec_data, self.forecast_type) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Environment Canada.""" + self.ec_data.update() + + +def get_forecast(ec_data, forecast_type): + """Build the forecast array.""" + forecast_array = [] + + if forecast_type == 'daily': + half_days = ec_data.daily_forecasts + if half_days[0]['temperature_class'] == 'high': + forecast_array.append({ + ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_TEMP: int(half_days[0]['temperature']), + ATTR_FORECAST_TEMP_LOW: int(half_days[1]['temperature']), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[0]['icon_code'])) + }) + half_days = half_days[2:] + else: + half_days = half_days[1:] + + for day, high, low in zip(range(1, 6), + range(0, 9, 2), + range(1, 10, 2)): + forecast_array.append({ + ATTR_FORECAST_TIME: + (dt.now() + datetime.timedelta(days=day)).isoformat(), + ATTR_FORECAST_TEMP: int(half_days[high]['temperature']), + ATTR_FORECAST_TEMP_LOW: int(half_days[low]['temperature']), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[high]['icon_code'])) + }) + + elif forecast_type == 'hourly': + hours = ec_data.hourly_forecasts + for hour in range(0, 24): + forecast_array.append({ + ATTR_FORECAST_TIME: dt.as_local(datetime.datetime.strptime( + hours[hour]['period'], '%Y%m%d%H%M')).isoformat(), + ATTR_FORECAST_TEMP: int(hours[hour]['temperature']), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(hours[hour]['icon_code'])) + }) + + return forecast_array + + +def icon_code_to_condition(icon_code): + """Return the condition corresponding to an icon code.""" + for condition, codes in ICON_CONDITION_MAP.items(): + if icon_code in codes: + return condition + return None diff --git a/requirements_all.txt b/requirements_all.txt index aeb768221fd266..7106f03ea43ef1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -411,6 +411,9 @@ enocean==0.50 # homeassistant.components.entur_public_transport enturclient==0.2.0 +# homeassistant.components.environment_canada +env_canada==0.0.10 + # homeassistant.components.envirophat # envirophat==0.0.6 From 984d41e334239a38f75ea29276ea6cecc26eb285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D1=83=D0=B1=D0=BE=D0=B2=D0=B8=D0=BA=20=D0=9C=D0=B0?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D0=BC?= Date: Thu, 6 Jun 2019 22:45:29 +0300 Subject: [PATCH 110/319] Google Cloud Platform component (TTS) (#23629) * Added Google Cloud TTS service component feature * Added Neutral voice gender * Added line break at the end of files * Updated CODEOWNERS, reqirements_all.txt and .coveragerc * Fixed some ci/circleci: static-check errors * Fixed some ci/circleci: static-check error * Fixed some ci/circleci: pylint errors * Fixed some ci/circleci: pylint errors * * made supported_options const * fixed direct env variable access * Fixed import order * * Component renamed * Added encoding parameter * Other fixes * Changed folder name in .coveragerc * * Removed whitespaces in blank lines * Split long line * Removed whitespaces in blank lines * ci/circleci: static-check * Fixed requirements_all.txt * Added speed, pitch and gain parameters * Added speed, pitch and gain as supported options * Split too long line * * Added profiles parameter * Changed supported languages and encodings values * Added parameters validations * Fixes * Fixes * Fixes * Fixes * Fixes * Changed options validation * Added ToggleEntity save and restore state mechanism * Revert "Added ToggleEntity save and restore state mechanism" This reverts commit 0e275014 --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/google_cloud/__init__.py | 1 + .../components/google_cloud/manifest.json | 12 + homeassistant/components/google_cloud/tts.py | 253 ++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 271 insertions(+) create mode 100644 homeassistant/components/google_cloud/__init__.py create mode 100644 homeassistant/components/google_cloud/manifest.json create mode 100644 homeassistant/components/google_cloud/tts.py diff --git a/.coveragerc b/.coveragerc index 8cde0c194fdc51..60c2df14ceabc3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -228,6 +228,7 @@ omit = homeassistant/components/goalfeed/* homeassistant/components/gogogate2/cover.py homeassistant/components/google/* + homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_travel_time/sensor.py homeassistant/components/googlehome/* diff --git a/CODEOWNERS b/CODEOWNERS index 97aad6177d3067..a74523aa530e9c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -93,6 +93,7 @@ homeassistant/components/geniushub/* @zxdavb homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff homeassistant/components/gntp/* @robbiet480 +homeassistant/components/google_cloud/* @lufton homeassistant/components/google_translate/* @awarecan homeassistant/components/google_travel_time/* @robbiet480 homeassistant/components/googlehome/* @ludeeus diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py new file mode 100644 index 00000000000000..97b669245d2de8 --- /dev/null +++ b/homeassistant/components/google_cloud/__init__.py @@ -0,0 +1 @@ +"""The google_cloud component.""" diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json new file mode 100644 index 00000000000000..c8ac0d2e81e588 --- /dev/null +++ b/homeassistant/components/google_cloud/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_cloud", + "name": "Google Cloud Platform", + "documentation": "https://www.home-assistant.io/components/google_cloud", + "requirements": [ + "google-cloud-texttospeech==0.4.0" + ], + "dependencies": [], + "codeowners": [ + "@lufton" + ] +} diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py new file mode 100644 index 00000000000000..4f0c2c20914b24 --- /dev/null +++ b/homeassistant/components/google_cloud/tts.py @@ -0,0 +1,253 @@ +"""Support for the Google Cloud TTS service.""" +import logging +import os + +import asyncio +import async_timeout +import voluptuous as vol +from google.cloud import texttospeech + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_KEY_FILE = 'key_file' +CONF_GENDER = 'gender' +CONF_VOICE = 'voice' +CONF_ENCODING = 'encoding' +CONF_SPEED = 'speed' +CONF_PITCH = 'pitch' +CONF_GAIN = 'gain' +CONF_PROFILES = 'profiles' + +SUPPORTED_LANGUAGES = [ + 'da-DK', 'de-DE', 'en-AU', 'en-GB', 'en-US', 'es-ES', 'fr-CA', 'fr-FR', + 'it-IT', 'ja-JP', 'ko-KR', 'nb-NO', 'nl-NL', 'pl-PL', 'pt-BR', 'pt-PT', + 'ru-RU', 'sk-SK', 'sv-SE', 'tr-TR', 'uk-UA', +] +DEFAULT_LANG = 'en-US' + +DEFAULT_GENDER = 'NEUTRAL' + +VOICE_REGEX = r'[a-z]{2}-[A-Z]{2}-(Standard|Wavenet)-[A-Z]|' +DEFAULT_VOICE = '' + +DEFAULT_ENCODING = 'OGG_OPUS' + +MIN_SPEED = 0.25 +MAX_SPEED = 4.0 +DEFAULT_SPEED = 1.0 + +MIN_PITCH = -20.0 +MAX_PITCH = 20.0 +DEFAULT_PITCH = 0 + +MIN_GAIN = -96.0 +MAX_GAIN = 16.0 +DEFAULT_GAIN = 0 + +SUPPORTED_PROFILES = [ + "wearable-class-device", + "handset-class-device", + "headphone-class-device", + "small-bluetooth-speaker-class-device", + "medium-bluetooth-speaker-class-device", + "large-home-entertainment-class-device", + "large-automotive-class-device", + "telephony-class-application", +] + +SUPPORTED_OPTIONS = [ + CONF_VOICE, + CONF_GENDER, + CONF_ENCODING, + CONF_SPEED, + CONF_PITCH, + CONF_GAIN, + CONF_PROFILES, +] + +GENDER_SCHEMA = vol.All( + vol.Upper, + vol.In(texttospeech.enums.SsmlVoiceGender.__members__) +) +VOICE_SCHEMA = cv.matches_regex(VOICE_REGEX) +SCHEMA_ENCODING = vol.All( + vol.Upper, + vol.In(texttospeech.enums.AudioEncoding.__members__) +) +SPEED_SCHEMA = vol.All( + vol.Coerce(float), + vol.Clamp(min=MIN_SPEED, max=MAX_SPEED) +) +PITCH_SCHEMA = vol.All( + vol.Coerce(float), + vol.Clamp(min=MIN_PITCH, max=MAX_PITCH) +) +GAIN_SCHEMA = vol.All( + vol.Coerce(float), + vol.Clamp(min=MIN_GAIN, max=MAX_GAIN) +) +PROFILES_SCHEMA = vol.All( + cv.ensure_list, + [vol.In(SUPPORTED_PROFILES)] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_KEY_FILE): cv.string, + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES), + vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): GENDER_SCHEMA, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): VOICE_SCHEMA, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): SCHEMA_ENCODING, + vol.Optional(CONF_SPEED, default=DEFAULT_SPEED): SPEED_SCHEMA, + vol.Optional(CONF_PITCH, default=DEFAULT_PITCH): PITCH_SCHEMA, + vol.Optional(CONF_GAIN, default=DEFAULT_GAIN): GAIN_SCHEMA, + vol.Optional(CONF_PROFILES, default=[]): PROFILES_SCHEMA, +}) + + +async def async_get_engine(hass, config): + """Set up Google Cloud TTS component.""" + key_file = config.get(CONF_KEY_FILE) + if key_file: + key_file = hass.config.path(key_file) + if not os.path.isfile(key_file): + _LOGGER.error("File %s doesn't exist", key_file) + return None + + return GoogleCloudTTSProvider( + hass, + key_file, + config.get(CONF_LANG), + config.get(CONF_GENDER), + config.get(CONF_VOICE), + config.get(CONF_ENCODING), + config.get(CONF_SPEED), + config.get(CONF_PITCH), + config.get(CONF_GAIN), + config.get(CONF_PROFILES) + ) + + +class GoogleCloudTTSProvider(Provider): + """The Google Cloud TTS API provider.""" + + def __init__( + self, + hass, + key_file=None, + language=DEFAULT_LANG, + gender=DEFAULT_GENDER, + voice=DEFAULT_VOICE, + encoding=DEFAULT_ENCODING, + speed=1.0, + pitch=0, + gain=0, + profiles=None + ): + """Init Google Cloud TTS service.""" + self.hass = hass + self.name = 'Google Cloud TTS' + self._language = language + self._gender = gender + self._voice = voice + self._encoding = encoding + self._speed = speed + self._pitch = pitch + self._gain = gain + self._profiles = profiles + + if key_file: + self._client = texttospeech \ + .TextToSpeechClient.from_service_account_json(key_file) + else: + self._client = texttospeech.TextToSpeechClient() + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORTED_LANGUAGES + + @property + def default_language(self): + """Return the default language.""" + return self._language + + @property + def supported_options(self): + """Return a list of supported options.""" + return SUPPORTED_OPTIONS + + @property + def default_options(self): + """Return a dict including default options.""" + return { + CONF_GENDER: self._gender, + CONF_VOICE: self._voice, + CONF_ENCODING: self._encoding, + CONF_SPEED: self._speed, + CONF_PITCH: self._pitch, + CONF_GAIN: self._gain, + CONF_PROFILES: self._profiles + } + + async def async_get_tts_audio(self, message, language, options=None): + """Load TTS from google.""" + options_schema = vol.Schema({ + vol.Optional(CONF_GENDER, default=self._gender): GENDER_SCHEMA, + vol.Optional(CONF_VOICE, default=self._voice): VOICE_SCHEMA, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): + SCHEMA_ENCODING, + vol.Optional(CONF_SPEED, default=self._speed): SPEED_SCHEMA, + vol.Optional(CONF_PITCH, default=self._speed): SPEED_SCHEMA, + vol.Optional(CONF_GAIN, default=DEFAULT_GAIN): GAIN_SCHEMA, + vol.Optional(CONF_PROFILES, default=[]): PROFILES_SCHEMA, + }) + options = options_schema(options) + + _encoding = options[CONF_ENCODING] + _voice = options[CONF_VOICE] + if _voice and not _voice.startswith(language): + language = _voice[:5] + + try: + # pylint: disable=no-member + synthesis_input = texttospeech.types.SynthesisInput( + text=message + ) + + voice = texttospeech.types.VoiceSelectionParams( + language_code=language, + ssml_gender=texttospeech.enums.SsmlVoiceGender[ + options[CONF_GENDER] + ], + name=_voice + ) + + audio_config = texttospeech.types.AudioConfig( + audio_encoding=texttospeech.enums.AudioEncoding[_encoding], + speaking_rate=options.get(CONF_SPEED), + pitch=options.get(CONF_PITCH), + volume_gain_db=options.get(CONF_GAIN), + effects_profile_id=options.get(CONF_PROFILES), + ) + # pylint: enable=no-member + + with async_timeout.timeout(10, loop=self.hass.loop): + response = await self.hass.async_add_executor_job( + self._client.synthesize_speech, + synthesis_input, + voice, + audio_config + ) + return _encoding, response.audio_content + + except asyncio.TimeoutError as ex: + _LOGGER.error("Timeout for Google Cloud TTS call: %s", ex) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception( + "Error occured during Google Cloud TTS call: %s", ex + ) + + return None, None diff --git a/requirements_all.txt b/requirements_all.txt index 7106f03ea43ef1..45e64139ca1a4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -519,6 +519,9 @@ google-api-python-client==1.6.4 # homeassistant.components.google_pubsub google-cloud-pubsub==0.39.1 +# homeassistant.components.google_cloud +google-cloud-texttospeech==0.4.0 + # homeassistant.components.googlehome googledevices==1.0.2 From 1bca31342188a2897ca019b3f5fa329938fc955d Mon Sep 17 00:00:00 2001 From: cpopp Date: Thu, 6 Jun 2019 15:55:08 -0500 Subject: [PATCH 111/319] Add Streamlabs Water Monitor (#21205) * Add Streamlabs Water Monitor * Fail Streamlabswater component setup when given invalid parameters The Streamlabs Water component is unable to recover if it is given an invalid API key or location id so this change is to ensure we validate they are correct during setup and return a failure if they are not. * Prime Streamlabswater component sensors so data is available immediately The sensors for the component were not causing an immediate load of data from the API when being set up so there was some lag after startup before values would show up. This change does an explicit update when the sensors are setup to ensure data is viewable immediately after startup. * Switch Streamlabswater logging to use %s for string formatting * Update Streamlabswater component with correct dependencies Dependencies were incorrectly specified using DEPENDS rather than DEPENDENCIES * Streamlabswater pull request feedback Remove detailed class docstrings since they're in the documentation, reduce code duplication in sensor classes, and remove periods from the end of log messages. * Reduce line length in Streamlabswater sensor * Add docstring on Streamlabswater service callback method * Get rid of unnecessary initializers in Streamlabswater sensor * Add manifest file for Streamlabs Water Monitor * Remove unused REQUIREMENTS --- .coveragerc | 1 + .../components/streamlabswater/__init__.py | 84 ++++++++++++ .../streamlabswater/binary_sensor.py | 73 ++++++++++ .../components/streamlabswater/manifest.json | 10 ++ .../components/streamlabswater/sensor.py | 128 ++++++++++++++++++ .../components/streamlabswater/services.yaml | 4 + requirements_all.txt | 3 + 7 files changed, 303 insertions(+) create mode 100644 homeassistant/components/streamlabswater/__init__.py create mode 100644 homeassistant/components/streamlabswater/binary_sensor.py create mode 100644 homeassistant/components/streamlabswater/manifest.json create mode 100644 homeassistant/components/streamlabswater/sensor.py create mode 100644 homeassistant/components/streamlabswater/services.yaml diff --git a/.coveragerc b/.coveragerc index 60c2df14ceabc3..7934fe64658209 100644 --- a/.coveragerc +++ b/.coveragerc @@ -574,6 +574,7 @@ omit = homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* + homeassistant/components/streamlabswater/* homeassistant/components/stride/notify.py homeassistant/components/supervisord/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py new file mode 100644 index 00000000000000..7e4fdd855c3408 --- /dev/null +++ b/homeassistant/components/streamlabswater/__init__.py @@ -0,0 +1,84 @@ +"""Support for Streamlabs Water Monitor devices.""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'streamlabswater' + +_LOGGER = logging.getLogger(__name__) + +ATTR_AWAY_MODE = 'away_mode' +SERVICE_SET_AWAY_MODE = 'set_away_mode' +AWAY_MODE_AWAY = 'away' +AWAY_MODE_HOME = 'home' + +STREAMLABSWATER_COMPONENTS = [ + 'sensor', 'binary_sensor' +] + +CONF_LOCATION_ID = "location_id" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LOCATION_ID): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]) +}) + + +def setup(hass, config): + """Set up the streamlabs water component.""" + from streamlabswater import streamlabswater + + conf = config[DOMAIN] + api_key = conf.get(CONF_API_KEY) + location_id = conf.get(CONF_LOCATION_ID) + + client = streamlabswater.StreamlabsClient(api_key) + locations = client.get_locations().get('locations') + + if locations is None: + _LOGGER.error("Unable to retrieve locations. Verify API key") + return False + + if location_id is None: + location = locations[0] + location_id = location['locationId'] + _LOGGER.info("Streamlabs Water Monitor auto-detected location_id=%s", + location_id) + else: + location = next(( + l for l in locations if location_id == l['locationId']), None) + if location is None: + _LOGGER.error("Supplied location_id is invalid") + return False + + location_name = location['name'] + + hass.data[DOMAIN] = { + 'client': client, + 'location_id': location_id, + 'location_name': location_name + } + + for component in STREAMLABSWATER_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + def set_away_mode(service): + """Set the StreamLabsWater Away Mode.""" + away_mode = service.data.get(ATTR_AWAY_MODE) + client.update_location(location_id, away_mode) + + hass.services.register( + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, + schema=SET_AWAY_MODE_SCHEMA) + + return True diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py new file mode 100644 index 00000000000000..d6351cc2dc6bda --- /dev/null +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -0,0 +1,73 @@ +"""Support for Streamlabs Water Monitor Away Mode.""" + +from datetime import timedelta + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.streamlabswater import ( + DOMAIN as STREAMLABSWATER_DOMAIN) +from homeassistant.util import Throttle + +DEPENDS = ['streamlabswater'] + +MIN_TIME_BETWEEN_LOCATION_UPDATES = timedelta(seconds=60) + +ATTR_LOCATION_ID = "location_id" +NAME_AWAY_MODE = "Water Away Mode" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the StreamLabsWater mode sensor.""" + client = hass.data[STREAMLABSWATER_DOMAIN]['client'] + location_id = hass.data[STREAMLABSWATER_DOMAIN]['location_id'] + location_name = hass.data[STREAMLABSWATER_DOMAIN]['location_name'] + + streamlabs_location_data = StreamlabsLocationData(location_id, client) + streamlabs_location_data.update() + + add_devices([ + StreamlabsAwayMode(location_name, streamlabs_location_data) + ]) + + +class StreamlabsLocationData: + """Track and query location data.""" + + def __init__(self, location_id, client): + """Initialize the location data.""" + self._location_id = location_id + self._client = client + self._is_away = None + + @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) + def update(self): + """Query and store location data.""" + location = self._client.get_location(self._location_id) + self._is_away = location['homeAway'] == 'away' + + def is_away(self): + """Return whether away more is enabled.""" + return self._is_away + + +class StreamlabsAwayMode(BinarySensorDevice): + """Monitor the away mode state.""" + + def __init__(self, location_name, streamlabs_location_data): + """Initialize the away mode device.""" + self._location_name = location_name + self._streamlabs_location_data = streamlabs_location_data + self._is_away = None + + @property + def name(self): + """Return the name for away mode.""" + return "{} {}".format(self._location_name, NAME_AWAY_MODE) + + @property + def is_on(self): + """Return if away mode is on.""" + return self._streamlabs_location_data.is_away() + + def update(self): + """Retrieve the latest location data and away mode state.""" + self._streamlabs_location_data.update() diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json new file mode 100644 index 00000000000000..b4173ebf0e9297 --- /dev/null +++ b/homeassistant/components/streamlabswater/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "streamlabswater", + "name": "Streamlabs Water", + "documentation": "https://www.home-assistant.io/components/streamlabswater", + "requirements": [ + "streamlabswater==1.0.1" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py new file mode 100644 index 00000000000000..9d55b4931ad598 --- /dev/null +++ b/homeassistant/components/streamlabswater/sensor.py @@ -0,0 +1,128 @@ +"""Support for Streamlabs Water Monitor Usage.""" + +from datetime import timedelta + +from homeassistant.components.streamlabswater import ( + DOMAIN as STREAMLABSWATER_DOMAIN) +from homeassistant.const import VOLUME_GALLONS +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +DEPENDENCIES = ['streamlabswater'] + +WATER_ICON = 'mdi:water' +MIN_TIME_BETWEEN_USAGE_UPDATES = timedelta(seconds=60) + +NAME_DAILY_USAGE = "Daily Water" +NAME_MONTHLY_USAGE = "Monthly Water" +NAME_YEARLY_USAGE = "Yearly Water" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up water usage sensors.""" + client = hass.data[STREAMLABSWATER_DOMAIN]['client'] + location_id = hass.data[STREAMLABSWATER_DOMAIN]['location_id'] + location_name = hass.data[STREAMLABSWATER_DOMAIN]['location_name'] + + streamlabs_usage_data = StreamlabsUsageData(location_id, client) + streamlabs_usage_data.update() + + add_devices([ + StreamLabsDailyUsage(location_name, streamlabs_usage_data), + StreamLabsMonthlyUsage(location_name, streamlabs_usage_data), + StreamLabsYearlyUsage(location_name, streamlabs_usage_data) + ]) + + +class StreamlabsUsageData: + """Track and query usage data.""" + + def __init__(self, location_id, client): + """Initialize the usage data.""" + self._location_id = location_id + self._client = client + self._today = None + self._this_month = None + self._this_year = None + + @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) + def update(self): + """Query and store usage data.""" + water_usage = self._client.get_water_usage_summary(self._location_id) + self._today = round(water_usage['today'], 1) + self._this_month = round(water_usage['thisMonth'], 1) + self._this_year = round(water_usage['thisYear'], 1) + + def get_daily_usage(self): + """Return the day's usage.""" + return self._today + + def get_monthly_usage(self): + """Return the month's usage.""" + return self._this_month + + def get_yearly_usage(self): + """Return the year's usage.""" + return self._this_year + + +class StreamLabsDailyUsage(Entity): + """Monitors the daily water usage.""" + + def __init__(self, location_name, streamlabs_usage_data): + """Initialize the daily water usage device.""" + self._location_name = location_name + self._streamlabs_usage_data = streamlabs_usage_data + self._state = None + + @property + def name(self): + """Return the name for daily usage.""" + return "{} {}".format(self._location_name, NAME_DAILY_USAGE) + + @property + def icon(self): + """Return the daily usage icon.""" + return WATER_ICON + + @property + def state(self): + """Return the current daily usage.""" + return self._streamlabs_usage_data.get_daily_usage() + + @property + def unit_of_measurement(self): + """Return gallons as the unit measurement for water.""" + return VOLUME_GALLONS + + def update(self): + """Retrieve the latest daily usage.""" + self._streamlabs_usage_data.update() + + +class StreamLabsMonthlyUsage(StreamLabsDailyUsage): + """Monitors the monthly water usage.""" + + @property + def name(self): + """Return the name for monthly usage.""" + return "{} {}".format(self._location_name, NAME_MONTHLY_USAGE) + + @property + def state(self): + """Return the current monthly usage.""" + return self._streamlabs_usage_data.get_monthly_usage() + + +class StreamLabsYearlyUsage(StreamLabsDailyUsage): + """Monitors the yearly water usage.""" + + @property + def name(self): + """Return the name for yearly usage.""" + return "{} {}".format(self._location_name, NAME_YEARLY_USAGE) + + @property + def state(self): + """Return the current yearly usage.""" + return self._streamlabs_usage_data.get_yearly_usage() diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml new file mode 100644 index 00000000000000..fa2a04c95864ed --- /dev/null +++ b/homeassistant/components/streamlabswater/services.yaml @@ -0,0 +1,4 @@ +set_away_mode: + description: 'Set the home/away mode for a Streamlabs Water Monitor.' + fields: + away_mode: {description: home or away, example: 'home'} \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 45e64139ca1a4e..6ef024957e72a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1707,6 +1707,9 @@ statsd==3.2.1 # homeassistant.components.steam_online steamodd==4.21 +# homeassistant.components.streamlabswater +streamlabswater==1.0.1 + # homeassistant.components.solaredge # homeassistant.components.thermoworks_smoke # homeassistant.components.traccar From 32844bb318d685cf32e9500780d1b6ab71016a90 Mon Sep 17 00:00:00 2001 From: Daniel Kucera Date: Thu, 6 Jun 2019 23:59:57 +0200 Subject: [PATCH 112/319] ebusd: added check for monitored conditions validity within correct circuit (#22461) --- homeassistant/components/ebusd/__init__.py | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index 15ff523f4fbf90..e662e661afb3a4 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -23,15 +23,29 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) + +def verify_ebusd_config(config): + """Verify eBusd config.""" + circuit = config[CONF_CIRCUIT] + for condition in config[CONF_MONITORED_CONDITIONS]: + if condition not in SENSOR_TYPES[circuit]: + raise vol.Invalid( + "Condition '" + condition + "' not in '" + circuit + "'.") + return config + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_CIRCUIT): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES['700'])]) - }) + DOMAIN: vol.Schema( + vol.All({ + vol.Required(CONF_CIRCUIT): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): + cv.ensure_list, + }, + verify_ebusd_config) + ) }, extra=vol.ALLOW_EXTRA) From bf7e09ce59f541aade77179c20c5b984efdde06b Mon Sep 17 00:00:00 2001 From: Jonathan Keljo Date: Thu, 6 Jun 2019 15:16:27 -0700 Subject: [PATCH 113/319] Bring the Sisyphus integration to silver quality (#22457) * Bring the Sisyphus integration to silver quality Checklist: - [x] (N/A - push integration) Set an appropriate SCAN_INTERVAL (if a polling integration) - [x] Raise PlatformNotReady if unable to connect during platform setup - [x] (N/A - no credentials) Handles expiration of auth credentials. Refresh if possible or print correct error and fail setup. If based on a config entry, should trigger a new config entry flow to re-authorize. - [x] (N/A - local integration) Handles internet unavailable. Log a warning once when unavailable, log once when reconnected. - [x] Handles device/service unavailable. Log a warning once when unavailable, log once when reconnected. - [x] Set available property to False if appropriate - [x] Entities have unique ID (if available) * Feedback (fix a couple verbose places) * Use a task instead of a lock * Initialize field in constructor * Revert package upgrade. --- homeassistant/components/sisyphus/__init__.py | 67 +++++++++++++++++-- homeassistant/components/sisyphus/light.py | 28 ++++++-- .../components/sisyphus/media_player.py | 27 ++++++-- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index 7cc8e3efd33a53..bfe1cb7bebb2a5 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -35,26 +35,31 @@ async def async_setup(hass, config): """Set up the sisyphus component.""" from sisyphus_control import Table + + class SocketIONoiseFilter(logging.Filter): + """Filters out excessively verbose logs from SocketIO.""" + + def filter(self, record): + if record.msg.contains('waiting for connection'): + return False + return True + + logging.getLogger('socketIO-client').addFilter(SocketIONoiseFilter()) tables = hass.data.setdefault(DATA_SISYPHUS, {}) table_configs = config.get(DOMAIN) session = async_get_clientsession(hass) async def add_table(host, name=None): """Add platforms for a single table with the given hostname.""" - table = await Table.connect(host, session) - if name is None: - name = table.name - tables[name] = table - _LOGGER.debug("Connected to %s at %s", name, host) + tables[host] = TableHolder(hass, session, host, name) hass.async_create_task(async_load_platform( hass, 'light', DOMAIN, { - CONF_NAME: name, + CONF_HOST: host, }, config )) hass.async_create_task(async_load_platform( hass, 'media_player', DOMAIN, { - CONF_NAME: name, CONF_HOST: host, }, config )) @@ -75,3 +80,51 @@ async def close_tables(*args): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_tables) return True + + +class TableHolder: + """Holds table objects and makes them available to platforms.""" + + def __init__(self, hass, session, host, name): + """Initialize the table holder.""" + self._hass = hass + self._session = session + self._host = host + self._name = name + self._table = None + self._table_task = None + + @property + def available(self): + """Return true if the table is responding to heartbeats.""" + if self._table_task and self._table_task.done(): + return self._table_task.result().is_connected + return False + + @property + def name(self): + """Return the name of the table.""" + return self._name + + async def get_table(self): + """Return the Table held by this holder, connecting to it if needed.""" + if not self._table_task: + self._table_task = self._hass.async_create_task( + self._connect_table()) + + return await self._table_task + + async def _connect_table(self): + from sisyphus_control import Table + self._table = await Table.connect(self._host, self._session) + if self._name is None: + self._name = self._table.name + _LOGGER.debug("Connected to %s at %s", self._name, self._host) + return self._table + + async def close(self): + """Close the table held by this holder, if any.""" + if self._table: + await self._table.close() + self._table = None + self._table_task = None diff --git a/homeassistant/components/sisyphus/light.py b/homeassistant/components/sisyphus/light.py index 8d882925796ad6..9ad36df6118e55 100644 --- a/homeassistant/components/sisyphus/light.py +++ b/homeassistant/components/sisyphus/light.py @@ -1,8 +1,11 @@ """Support for the light on the Sisyphus Kinetic Art Table.""" import logging +import aiohttp + from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_HOST +from homeassistant.exceptions import PlatformNotReady from . import DATA_SISYPHUS @@ -11,11 +14,18 @@ SUPPORTED_FEATURES = SUPPORT_BRIGHTNESS -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, + discovery_info=None): """Set up a single Sisyphus table.""" - name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + try: + table_holder = hass.data[DATA_SISYPHUS][host] + table = await table_holder.get_table() + except aiohttp.ClientError: + raise PlatformNotReady() + add_entities( - [SisyphusLight(name, hass.data[DATA_SISYPHUS][name])], + [SisyphusLight(table_holder.name, table)], update_before_add=True) @@ -32,6 +42,16 @@ async def async_added_to_hass(self): self._table.add_listener( lambda: self.async_schedule_update_ha_state(False)) + @property + def available(self): + """Return true if the table is responding to heartbeats.""" + return self._table.is_connected + + @property + def unique_id(self): + """Return the UUID of the table.""" + return self._table.id + @property def name(self): """Return the ame of the table.""" diff --git a/homeassistant/components/sisyphus/media_player.py b/homeassistant/components/sisyphus/media_player.py index 65f5cb48e59b78..46ff00283b4052 100644 --- a/homeassistant/components/sisyphus/media_player.py +++ b/homeassistant/components/sisyphus/media_player.py @@ -1,13 +1,16 @@ """Support for track controls on the Sisyphus Kinetic Art Table.""" import logging +import aiohttp + from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) + CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) +from homeassistant.exceptions import PlatformNotReady from . import DATA_SISYPHUS @@ -27,12 +30,18 @@ # pylint: disable=unused-argument -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, + discovery_info=None): """Set up a media player entity for a Sisyphus table.""" - name = discovery_info[CONF_NAME] host = discovery_info[CONF_HOST] + try: + table_holder = hass.data[DATA_SISYPHUS][host] + table = await table_holder.get_table() + except aiohttp.ClientError: + raise PlatformNotReady() + add_entities( - [SisyphusPlayer(name, host, hass.data[DATA_SISYPHUS][name])], True) + [SisyphusPlayer(table_holder.name, host, table)], True) class SisyphusPlayer(MediaPlayerDevice): @@ -49,6 +58,16 @@ async def async_added_to_hass(self): self._table.add_listener( lambda: self.async_schedule_update_ha_state(False)) + @property + def unique_id(self): + """Return the UUID of the table.""" + return self._table.id + + @property + def available(self): + """Return true if the table is responding to heartbeats.""" + return self._table.is_connected + @property def name(self): """Return the name of the table.""" From 6cd9667364d4443ab534ff2b0d866616dbd7fc33 Mon Sep 17 00:00:00 2001 From: z0p Date: Fri, 7 Jun 2019 01:23:00 +0300 Subject: [PATCH 114/319] Support for Salda Smarty XV/XP Ventilation Unit (#21491) * Support for Salda Smarty XV/XP Ventilation Unit * Update binary_sensor.py * Update fan.py * Update sensor.py * Update __init__.py --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/smarty/__init__.py | 71 +++++++ .../components/smarty/binary_sensor.py | 110 +++++++++++ homeassistant/components/smarty/fan.py | 121 ++++++++++++ homeassistant/components/smarty/manifest.json | 13 ++ homeassistant/components/smarty/sensor.py | 180 ++++++++++++++++++ requirements_all.txt | 3 + 8 files changed, 500 insertions(+) create mode 100644 homeassistant/components/smarty/__init__.py create mode 100644 homeassistant/components/smarty/binary_sensor.py create mode 100644 homeassistant/components/smarty/fan.py create mode 100644 homeassistant/components/smarty/manifest.json create mode 100644 homeassistant/components/smarty/sensor.py diff --git a/.coveragerc b/.coveragerc index 7934fe64658209..d2c271acd5998c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -550,6 +550,7 @@ omit = homeassistant/components/slack/notify.py homeassistant/components/sma/sensor.py homeassistant/components/smappee/* + homeassistant/components/smarty/* homeassistant/components/smarthab/* homeassistant/components/smtp/notify.py homeassistant/components/snapcast/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index a74523aa530e9c..a391241ed4cd81 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -217,6 +217,7 @@ homeassistant/components/simplisafe/* @bachya homeassistant/components/sma/* @kellerza homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre +homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smtp/* @fabaff homeassistant/components/solaredge_local/* @drobtravels homeassistant/components/solax/* @squishykid diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py new file mode 100644 index 00000000000000..d66c06de3eb31a --- /dev/null +++ b/homeassistant/components/smarty/__init__.py @@ -0,0 +1,71 @@ +"""Support to control a Salda Smarty XP/XV ventilation unit.""" + +from datetime import timedelta + +import ipaddress +import logging +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_HOST) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval + +DOMAIN = 'smarty' +DATA_SMARTY = 'smarty' +SMARTY_NAME = 'Smarty' + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_NAME, default=SMARTY_NAME): cv.string + }), + }, + extra=vol.ALLOW_EXTRA) + +RPM = 'rpm' +SIGNAL_UPDATE_SMARTY = 'smarty_update' + + +def setup(hass, config): + """Set up the smarty environment.""" + from pysmarty import (Smarty) + conf = config[DOMAIN] + + host = conf[CONF_HOST] + name = conf[CONF_NAME] + + _LOGGER.debug("Name: %s, host: %s", name, host) + + smarty = Smarty(host=host) + + hass.data[DOMAIN] = { + 'api': smarty, + 'name': name + } + + # Initial update + smarty.update() + + # Load platforms + discovery.load_platform(hass, 'fan', DOMAIN, {}, config) + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + + def poll_device_update(event_time): + """Update Smarty device.""" + _LOGGER.debug("Updating Smarty device...") + if smarty.update(): + _LOGGER.debug("Update success...") + dispatcher_send(hass, SIGNAL_UPDATE_SMARTY) + else: + _LOGGER.debug("Update failed...") + + track_time_interval(hass, poll_device_update, + timedelta(seconds=30)) + + return True diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py new file mode 100644 index 00000000000000..a17e8fa85dc7dd --- /dev/null +++ b/homeassistant/components/smarty/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Salda Smarty XP/XV Ventilation Unit Binary Sensors.""" + +import logging + +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import (DOMAIN, SIGNAL_UPDATE_SMARTY) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Smarty Binary Sensor Platform.""" + smarty = hass.data[DOMAIN]['api'] + name = hass.data[DOMAIN]['name'] + + sensors = [AlarmSensor(name, smarty), + WarningSensor(name, smarty), + BoostSensor(name, smarty)] + + async_add_entities(sensors, True) + + +class SmartyBinarySensor(BinarySensorDevice): + """Representation of a Smarty Binary Sensor.""" + + def __init__(self, name, device_class, smarty): + """Initialize the entity.""" + self._name = name + self._state = None + self._sensor_type = device_class + self._smarty = smarty + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._sensor_type + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + async def async_added_to_hass(self): + """Call to update.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SMARTY, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + +class BoostSensor(SmartyBinarySensor): + """Boost State Binary Sensor.""" + + def __init__(self, name, smarty): + """Alarm Sensor Init.""" + super().__init__(name='{} Boost State'.format(name), + device_class=None, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.boost + + +class AlarmSensor(SmartyBinarySensor): + """Alarm Binary Sensor.""" + + def __init__(self, name, smarty): + """Alarm Sensor Init.""" + super().__init__(name='{} Alarm'.format(name), + device_class='problem', + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.alarm + + +class WarningSensor(SmartyBinarySensor): + """Warning Sensor.""" + + def __init__(self, name, smarty): + """Warning Sensor Init.""" + super().__init__(name='{} Warning'.format(name), + device_class='problem', + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.warning diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py new file mode 100644 index 00000000000000..64a1e89ea889af --- /dev/null +++ b/homeassistant/components/smarty/fan.py @@ -0,0 +1,121 @@ +"""Platform to control a Salda Smarty XP/XV ventilation unit.""" + +import logging + +from homeassistant.core import callback +from homeassistant.components.fan import ( + SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, + FanEntity) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import (DOMAIN, SIGNAL_UPDATE_SMARTY) + +_LOGGER = logging.getLogger(__name__) + +SPEED_LIST = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + +SPEED_MAPPING = { + 1: SPEED_LOW, + 2: SPEED_MEDIUM, + 3: SPEED_HIGH +} +SPEED_TO_MODE = {v: k for k, v in SPEED_MAPPING.items()} + + +async def async_setup_platform(hass, config, + async_add_entities, discovery_info=None): + """Set up the Smarty Fan Platform.""" + smarty = hass.data[DOMAIN]['api'] + name = hass.data[DOMAIN]['name'] + + async_add_entities([SmartyFan(name, smarty)], True) + + +class SmartyFan(FanEntity): + """Representation of a Smarty Fan.""" + + def __init__(self, name, smarty): + """Initialize the entity.""" + self._name = name + self._speed = SPEED_OFF + self._state = None + self._smarty = smarty + + @property + def should_poll(self): + """Do not poll.""" + return False + + @property + def name(self): + """Return the name of the fan.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return 'mdi:air-conditioner' + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_SET_SPEED + + @property + def speed_list(self): + """List of available fan modes.""" + return SPEED_LIST + + @property + def is_on(self): + """Return state of the fan.""" + return self._state + + @property + def speed(self) -> str: + """Return speed of the fan.""" + return self._speed + + def turn_on(self, speed=None, **kwargs): + """Turn on the fan.""" + _LOGGER.debug('Turning on fan. Speed is %s', speed) + if speed is None: + if self._smarty.turn_on(SPEED_TO_MODE.get(self._speed)): + self._state = True + self._speed = SPEED_MEDIUM + else: + if self._smarty.set_fan_speed(SPEED_TO_MODE.get(speed)): + self._speed = speed + self._state = True + + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn off the fan.""" + _LOGGER.debug('Turning off fan') + if self._smarty.turn_off(): + self._state = False + + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + """Call to update fan.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SMARTY, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + def update(self): + """Update state.""" + _LOGGER.debug('Updating state') + result = self._smarty.fan_speed + if result: + self._speed = SPEED_MAPPING[result] + _LOGGER.debug('Speed is %s, Mode is %s', self._speed, result) + self._state = True + else: + self._state = False diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json new file mode 100644 index 00000000000000..b2e3deb4008c5f --- /dev/null +++ b/homeassistant/components/smarty/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "smarty", + "name": "smarty", + "documentation": "https://www.home-assistant.io/components/smarty", + "requirements": [ + "pysmarty==0.8" + ], + "dependencies": [], + "codeowners": [ + "@z0mbieprocess" + ] +} + diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py new file mode 100644 index 00000000000000..5b33c9393b905e --- /dev/null +++ b/homeassistant/components/smarty/sensor.py @@ -0,0 +1,180 @@ +"""Support for Salda Smarty XP/XV Ventilation Unit Sensors.""" + +import datetime as dt +import logging + +from homeassistant.core import callback +from homeassistant.const import ( + TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP) +import homeassistant.util.dt as dt_util +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from . import (DOMAIN, SIGNAL_UPDATE_SMARTY) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Smarty Sensor Platform.""" + smarty = hass.data[DOMAIN]['api'] + name = hass.data[DOMAIN]['name'] + + sensors = [SupplyAirTemperatureSensor(name, smarty), + ExtractAirTemperatureSensor(name, smarty), + OutdoorAirTemperatureSensor(name, smarty), + SupplyFanSpeedSensor(name, smarty), + ExtractFanSpeedSensor(name, smarty), + FilterDaysLeftSensor(name, smarty)] + + async_add_entities(sensors, True) + + +class SmartySensor(Entity): + """Representation of a Smarty Sensor.""" + + def __init__(self, name: str, device_class: str, + smarty, unit_of_measurement: str = ''): + """Initialize the entity.""" + self._name = name + self._state = None + self._sensor_type = device_class + self._unit_of_measurement = unit_of_measurement + self._smarty = smarty + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + async def async_added_to_hass(self): + """Call to update.""" + async_dispatcher_connect(self.hass, + SIGNAL_UPDATE_SMARTY, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + +class SupplyAirTemperatureSensor(SmartySensor): + """Supply Air Temperature Sensor.""" + + def __init__(self, name, smarty): + """Supply Air Temperature Init.""" + super().__init__(name='{} Supply Air Temperature'.format(name), + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.supply_air_temperature + + +class ExtractAirTemperatureSensor(SmartySensor): + """Extract Air Temperature Sensor.""" + + def __init__(self, name, smarty): + """Supply Air Temperature Init.""" + super().__init__(name='{} Extract Air Temperature'.format(name), + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.extract_air_temperature + + +class OutdoorAirTemperatureSensor(SmartySensor): + """Extract Air Temperature Sensor.""" + + def __init__(self, name, smarty): + """Outdoor Air Temperature Init.""" + super().__init__(name='{} Outdoor Air Temperature'.format(name), + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.outdoor_air_temperature + + +class SupplyFanSpeedSensor(SmartySensor): + """Supply Fan Speed RPM.""" + + def __init__(self, name, smarty): + """Supply Fan Speed RPM Init.""" + super().__init__(name='{} Supply Fan Speed'.format(name), + device_class=None, + unit_of_measurement=None, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.supply_fan_speed + + +class ExtractFanSpeedSensor(SmartySensor): + """Extract Fan Speed RPM.""" + + def __init__(self, name, smarty): + """Extract Fan Speed RPM Init.""" + super().__init__(name='{} Extract Fan Speed'.format(name), + device_class=None, + unit_of_measurement=None, + smarty=smarty) + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + self._state = self._smarty.extract_fan_speed + + +class FilterDaysLeftSensor(SmartySensor): + """Filter Days Left.""" + + def __init__(self, name, smarty): + """Filter Days Left Init.""" + super().__init__(name='{} Filter Days Left'.format(name), + device_class=DEVICE_CLASS_TIMESTAMP, + unit_of_measurement=None, + smarty=smarty) + self._days_left = 91 + + def update(self) -> None: + """Update state.""" + _LOGGER.debug('Updating sensor %s', self._name) + days_left = self._smarty.filter_timer + if days_left is not None and days_left != self._days_left: + self._state = dt_util.now() + dt.timedelta(days=days_left) + self._days_left = days_left diff --git a/requirements_all.txt b/requirements_all.txt index 6ef024957e72a0..5a1d7aa96d7eb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1332,6 +1332,9 @@ pysmartapp==0.3.2 # homeassistant.components.smartthings pysmartthings==0.6.8 +# homeassistant.components.smarty +pysmarty==0.8 + # homeassistant.components.snmp pysnmp==4.4.9 From 7771ecfe5839e00f331e6a50e58958e95e3e521a Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 7 Jun 2019 00:30:14 +0200 Subject: [PATCH 115/319] Bump to pypck==0.6.1 (#24356) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 5f0d1052741116..c5ec117a53e887 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -3,7 +3,7 @@ "name": "Lcn", "documentation": "https://www.home-assistant.io/components/lcn", "requirements": [ - "pypck==0.6.0" + "pypck==0.6.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 5a1d7aa96d7eb3..76e0ea721dbc68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1273,7 +1273,7 @@ pyowlet==1.0.2 pyowm==2.10.0 # homeassistant.components.lcn -pypck==0.6.0 +pypck==0.6.1 # homeassistant.components.pjlink pypjlink2==1.2.0 From e98054accb777624a01a0a1534f6d3e0f7d36481 Mon Sep 17 00:00:00 2001 From: Tommy Long <35182132+tommyjlong@users.noreply.github.com> Date: Thu, 6 Jun 2019 18:30:27 -0400 Subject: [PATCH 116/319] Add templating to MQTT Cover tilt_status (#24355) --- homeassistant/components/mqtt/cover.py | 17 ++++-- tests/components/mqtt/test_cover.py | 74 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index e1ad21564b5bbd..17385e77ec3ac6 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -30,6 +30,7 @@ CONF_SET_POSITION_TOPIC = 'set_position_topic' CONF_TILT_COMMAND_TOPIC = 'tilt_command_topic' CONF_TILT_STATUS_TOPIC = 'tilt_status_topic' +CONF_TILT_STATUS_TEMPLATE = 'tilt_status_template' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_OPEN = 'payload_open' @@ -110,6 +111,7 @@ def validate_options(value): vol.Optional(CONF_TILT_STATE_OPTIMISTIC, default=DEFAULT_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_STATUS_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TILT_STATUS_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( @@ -203,17 +205,26 @@ async def _subscribe_topics(self): set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) if set_position_template is not None: set_position_template.hass = self.hass + tilt_status_template = self._config.get(CONF_TILT_STATUS_TEMPLATE) + if tilt_status_template is not None: + tilt_status_template.hass = self.hass topics = {} @callback def tilt_updated(msg): """Handle tilt updates.""" - if (msg.payload.isnumeric() and - (self._config[CONF_TILT_MIN] <= int(msg.payload) <= + payload = msg.payload + if tilt_status_template is not None: + payload = \ + tilt_status_template.async_render_with_possible_json_value( + payload) + + if (payload.isnumeric() and + (self._config[CONF_TILT_MIN] <= int(payload) <= self._config[CONF_TILT_MAX])): - level = self.find_percentage_in_range(float(msg.payload)) + level = self.find_percentage_in_range(float(payload)) self._tilt_value = level self.async_write_ha_state() diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 8bf136c6f0fff3..91b0355ad22f2c 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -603,6 +603,39 @@ async def test_tilt_via_topic(hass, mqtt_mock): assert current_cover_tilt_position == 50 +async def test_tilt_via_topic_template(hass, mqtt_mock): + """Test tilt by updating status via MQTT and template.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'tilt_status_template': '{{ (value | multiply(0.01)) | int }}', + 'tilt_opened_value': 400, + 'tilt_closed_value': 125 + } + }) + + async_fire_mqtt_message(hass, 'tilt-status-topic', '99') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + async_fire_mqtt_message(hass, 'tilt-status-topic', '5000') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + async def test_tilt_via_topic_altered_range(hass, mqtt_mock): """Test tilt status via MQTT with altered tilt range.""" assert await async_setup_component(hass, cover.DOMAIN, { @@ -643,6 +676,47 @@ async def test_tilt_via_topic_altered_range(hass, mqtt_mock): assert current_cover_tilt_position == 50 +async def test_tilt_via_topic_template_altered_range(hass, mqtt_mock): + """Test tilt status via MQTT and template with altered tilt range.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'tilt_status_template': '{{ (value | multiply(0.01)) | int }}', + 'tilt_opened_value': 400, + 'tilt_closed_value': 125, + 'tilt_min': 0, + 'tilt_max': 50 + } + }) + + async_fire_mqtt_message(hass, 'tilt-status-topic', '99') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + async_fire_mqtt_message(hass, 'tilt-status-topic', '5000') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 100 + + async_fire_mqtt_message(hass, 'tilt-status-topic', '2500') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + async def test_tilt_position(hass, mqtt_mock): """Test tilt via method invocation.""" assert await async_setup_component(hass, cover.DOMAIN, { From 4db0e7888ad69c9100e2155fb01e46e48f323576 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 7 Jun 2019 00:30:45 +0200 Subject: [PATCH 117/319] Upgrade ruamel.yaml to 0.15.97 (#24350) * Upgrade ruamel.yaml to 0.15.97 * Fix req --- 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 8ae1023e1a9c5c..5fb81ba93591a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ python-slugify==3.0.2 pytz>=2019.01 pyyaml>=3.13,<4 requests==2.22.0 -ruamel.yaml==0.15.94 +ruamel.yaml==0.15.97 voluptuous==0.11.5 voluptuous-serialize==2.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 76e0ea721dbc68..5b0733af4b606a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ python-slugify==3.0.2 pytz>=2019.01 pyyaml>=3.13,<4 requests==2.22.0 -ruamel.yaml==0.15.94 +ruamel.yaml==0.15.97 voluptuous==0.11.5 voluptuous-serialize==2.1.0 diff --git a/setup.py b/setup.py index 2ae5d8e8c3b553..d9c13524070733 100755 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ 'pytz>=2019.01', 'pyyaml>=3.13,<4', 'requests==2.22.0', - 'ruamel.yaml==0.15.94', + 'ruamel.yaml==0.15.97', 'voluptuous==0.11.5', 'voluptuous-serialize==2.1.0', ] From 156ab7dc2b3f6ca39ab5b33c288bd51a618e9df0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 6 Jun 2019 16:31:17 -0600 Subject: [PATCH 118/319] Bump regenmaschine to 1.5.1 (#24358) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index b99798bb4b6cae..25b36c798c593d 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/rainmachine", "requirements": [ - "regenmaschine==1.4.0" + "regenmaschine==1.5.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 5b0733af4b606a..64040c61060c8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.4.0 +regenmaschine==1.5.1 # homeassistant.components.python_script restrictedpython==4.0b8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8729f50411b3e9..3aed5ca77d8d1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -298,7 +298,7 @@ pytradfri[async]==6.0.1 pywebpush==1.9.2 # homeassistant.components.rainmachine -regenmaschine==1.4.0 +regenmaschine==1.5.1 # homeassistant.components.python_script restrictedpython==4.0b8 From 1c1363875c62dff94e6a5030beabba6df2c51b62 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 6 Jun 2019 18:07:15 -0500 Subject: [PATCH 119/319] Life360 integration (#24227) --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/life360/.translations/en.json | 27 ++ homeassistant/components/life360/__init__.py | 139 +++++++ .../components/life360/config_flow.py | 100 +++++ homeassistant/components/life360/const.py | 11 + .../components/life360/device_tracker.py | 354 ++++++++++++++++++ homeassistant/components/life360/helpers.py | 7 + .../components/life360/manifest.json | 13 + homeassistant/components/life360/strings.json | 27 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + 12 files changed, 684 insertions(+) create mode 100644 homeassistant/components/life360/.translations/en.json create mode 100644 homeassistant/components/life360/__init__.py create mode 100644 homeassistant/components/life360/config_flow.py create mode 100644 homeassistant/components/life360/const.py create mode 100644 homeassistant/components/life360/device_tracker.py create mode 100644 homeassistant/components/life360/helpers.py create mode 100644 homeassistant/components/life360/manifest.json create mode 100644 homeassistant/components/life360/strings.json diff --git a/.coveragerc b/.coveragerc index d2c271acd5998c..aea77eb99772b1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -318,6 +318,7 @@ omit = homeassistant/components/lcn/* homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/media_player.py + homeassistant/components/life360/* homeassistant/components/lifx/* homeassistant/components/lifx_cloud/scene.py homeassistant/components/lifx_legacy/light.py diff --git a/CODEOWNERS b/CODEOWNERS index a391241ed4cd81..b17d5a354dc42b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -137,6 +137,7 @@ homeassistant/components/konnected/* @heythisisnate homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus +homeassistant/components/life360/* @pnbruckner homeassistant/components/lifx/* @amelchio homeassistant/components/lifx_cloud/* @amelchio homeassistant/components/lifx_legacy/* @amelchio diff --git a/homeassistant/components/life360/.translations/en.json b/homeassistant/components/life360/.translations/en.json new file mode 100644 index 00000000000000..cff3f39e5d5850 --- /dev/null +++ b/homeassistant/components/life360/.translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "Life360", + "step": { + "user": { + "title": "Life360 Account Info", + "data": { + "username": "Username", + "password": "Password" + }, + "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts." + } + }, + "error": { + "invalid_username": "Invalid username", + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + }, + "create_entry": { + "default": "To set advanced options, see [Life360 documentation]({docs_url})." + }, + "abort": { + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + } + } +} diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py new file mode 100644 index 00000000000000..3cb5ad83304130 --- /dev/null +++ b/homeassistant/components/life360/__init__.py @@ -0,0 +1,139 @@ +"""Life360 integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.device_tracker import ( + CONF_SCAN_INTERVAL, DOMAIN as DEVICE_TRACKER) +from homeassistant.components.device_tracker.const import ( + SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL) +from homeassistant.const import ( + CONF_EXCLUDE, CONF_INCLUDE, CONF_PASSWORD, CONF_PREFIX, CONF_USERNAME) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, + CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS, + CONF_WARNING_THRESHOLD, DOMAIN) +from .helpers import get_api + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PREFIX = DOMAIN + +CONF_ACCOUNTS = 'accounts' + + +def _excl_incl_list_to_filter_dict(value): + return { + 'include': CONF_INCLUDE in value, + 'list': value.get(CONF_EXCLUDE) or value.get(CONF_INCLUDE) + } + + +def _prefix(value): + if not value: + return '' + if not value.endswith('_'): + return value + '_' + return value + + +def _thresholds(config): + error_threshold = config.get(CONF_ERROR_THRESHOLD) + warning_threshold = config.get(CONF_WARNING_THRESHOLD) + if error_threshold and warning_threshold: + if error_threshold <= warning_threshold: + raise vol.Invalid('{} must be larger than {}'.format( + CONF_ERROR_THRESHOLD, CONF_WARNING_THRESHOLD)) + elif not error_threshold and warning_threshold: + config[CONF_ERROR_THRESHOLD] = warning_threshold + 1 + elif error_threshold and not warning_threshold: + # Make them the same which effectively prevents warnings. + config[CONF_WARNING_THRESHOLD] = error_threshold + else: + # Log all errors as errors. + config[CONF_ERROR_THRESHOLD] = 1 + config[CONF_WARNING_THRESHOLD] = 1 + return config + + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + +_SLUG_LIST = vol.All( + cv.ensure_list, [cv.slugify], + vol.Length(min=1, msg='List cannot be empty')) + +_LOWER_STRING_LIST = vol.All( + cv.ensure_list, [vol.All(cv.string, vol.Lower)], + vol.Length(min=1, msg='List cannot be empty')) + +_EXCL_INCL_SLUG_LIST = vol.All( + vol.Schema({ + vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _SLUG_LIST, + vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _SLUG_LIST, + }), + cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), + _excl_incl_list_to_filter_dict, +) + +_EXCL_INCL_LOWER_STRING_LIST = vol.All( + vol.Schema({ + vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _LOWER_STRING_LIST, + vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _LOWER_STRING_LIST, + }), + cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), + _excl_incl_list_to_filter_dict +) + +_THRESHOLD = vol.All(vol.Coerce(int), vol.Range(min=1)) + +LIFE360_SCHEMA = vol.All( + vol.Schema({ + vol.Optional(CONF_ACCOUNTS): vol.All( + cv.ensure_list, [ACCOUNT_SCHEMA], vol.Length(min=1)), + vol.Optional(CONF_CIRCLES): _EXCL_INCL_LOWER_STRING_LIST, + vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), + vol.Optional(CONF_ERROR_THRESHOLD): _THRESHOLD, + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_MAX_UPDATE_WAIT): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MEMBERS): _EXCL_INCL_SLUG_LIST, + vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): + vol.All(vol.Any(None, cv.string), _prefix), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD, + }), + _thresholds +) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: LIFE360_SCHEMA +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up integration.""" + conf = config.get(DOMAIN, LIFE360_SCHEMA({})) + hass.data[DOMAIN] = {'config': conf, 'apis': []} + discovery.load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) + + if CONF_ACCOUNTS in conf: + for account in conf[CONF_ACCOUNTS]: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data=account)) + return True + + +async def async_setup_entry(hass, entry): + """Set up config entry.""" + hass.data[DOMAIN]['apis'].append( + get_api(entry.data[CONF_AUTHORIZATION])) + return True diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py new file mode 100644 index 00000000000000..2ec7d34610ec45 --- /dev/null +++ b/homeassistant/components/life360/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow to configure Life360 integration.""" +from collections import OrderedDict +import logging + +from life360 import LoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_AUTHORIZATION, DOMAIN +from .helpers import get_api + +_LOGGER = logging.getLogger(__name__) + +DOCS_URL = 'https://www.home-assistant.io/components/life360' + + +@config_entries.HANDLERS.register(DOMAIN) +class Life360ConfigFlow(config_entries.ConfigFlow): + """Life360 integration config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._api = get_api() + self._username = vol.UNDEFINED + self._password = vol.UNDEFINED + + @property + def configured_usernames(self): + """Return tuple of configured usernames.""" + entries = self.hass.config_entries.async_entries(DOMAIN) + if entries: + return (entry.data[CONF_USERNAME] for entry in entries) + return () + + async def async_step_user(self, user_input=None): + """Handle a user initiated config flow.""" + errors = {} + + if user_input is not None: + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + try: + # pylint: disable=no-value-for-parameter + vol.Email()(self._username) + authorization = self._api.get_authorization( + self._username, self._password) + except vol.Invalid: + errors[CONF_USERNAME] = 'invalid_username' + except LoginError: + errors['base'] = 'invalid_credentials' + else: + if self._username in self.configured_usernames: + errors['base'] = 'user_already_configured' + else: + return self.async_create_entry( + title=self._username, + data={ + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_AUTHORIZATION: authorization + }, + description_placeholders={'docs_url': DOCS_URL} + ) + + data_schema = OrderedDict() + data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str + data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(data_schema), + errors=errors, + description_placeholders={'docs_url': DOCS_URL} + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + if username in self.configured_usernames: + _LOGGER.warning('%s already configured', username) + return self.async_abort(reason='user_already_configured') + try: + authorization = self._api.get_authorization(username, password) + except LoginError: + _LOGGER.error('Invalid credentials for %s', username) + return self.async_abort(reason='invalid_credentials') + return self.async_create_entry( + title='{} (from configuration)'.format(username), + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_AUTHORIZATION: authorization + } + ) diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py new file mode 100644 index 00000000000000..4c4016c6b4039c --- /dev/null +++ b/homeassistant/components/life360/const.py @@ -0,0 +1,11 @@ +"""Constants for Life360 integration.""" +DOMAIN = 'life360' + +CONF_AUTHORIZATION = 'authorization' +CONF_CIRCLES = 'circles' +CONF_DRIVING_SPEED = 'driving_speed' +CONF_ERROR_THRESHOLD = 'error_threshold' +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_MAX_UPDATE_WAIT = 'max_update_wait' +CONF_MEMBERS = 'members' +CONF_WARNING_THRESHOLD = 'warning_threshold' diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py new file mode 100644 index 00000000000000..1f8574b2d7242e --- /dev/null +++ b/homeassistant/components/life360/device_tracker.py @@ -0,0 +1,354 @@ +"""Support for Life360 device tracking.""" +from datetime import timedelta +import logging + +from life360 import Life360Error +import voluptuous as vol + +from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT) +from homeassistant.const import ( + ATTR_BATTERY_CHARGING, ATTR_ENTITY_ID, CONF_PREFIX, LENGTH_FEET, + LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval +from homeassistant.util.distance import convert +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, + CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS, + CONF_WARNING_THRESHOLD, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + +SPEED_FACTOR_MPH = 2.25 +EVENT_DELAY = timedelta(seconds=30) + +ATTR_ADDRESS = 'address' +ATTR_AT_LOC_SINCE = 'at_loc_since' +ATTR_DRIVING = 'driving' +ATTR_LAST_SEEN = 'last_seen' +ATTR_MOVING = 'moving' +ATTR_PLACE = 'place' +ATTR_RAW_SPEED = 'raw_speed' +ATTR_SPEED = 'speed' +ATTR_WAIT = 'wait' +ATTR_WIFI_ON = 'wifi_on' + +EVENT_UPDATE_OVERDUE = 'life360_update_overdue' +EVENT_UPDATE_RESTORED = 'life360_update_restored' + + +def _include_name(filter_dict, name): + if not name: + return False + if not filter_dict: + return True + name = name.lower() + if filter_dict['include']: + return name in filter_dict['list'] + return name not in filter_dict['list'] + + +def _exc_msg(exc): + return '{}: {}'.format(exc.__class__.__name__, str(exc)) + + +def _dump_filter(filter_dict, desc, func=lambda x: x): + if not filter_dict: + return + _LOGGER.debug( + '%scluding %s: %s', + 'In' if filter_dict['include'] else 'Ex', desc, + ', '.join([func(name) for name in filter_dict['list']])) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up device scanner.""" + config = hass.data[DOMAIN]['config'] + apis = hass.data[DOMAIN]['apis'] + Life360Scanner(hass, config, see, apis) + return True + + +def _utc_from_ts(val): + try: + return dt_util.utc_from_timestamp(float(val)) + except (TypeError, ValueError): + return None + + +def _dt_attr_from_ts(timestamp): + utc = _utc_from_ts(timestamp) + if utc: + return utc + return STATE_UNKNOWN + + +def _bool_attr_from_int(val): + try: + return bool(int(val)) + except (TypeError, ValueError): + return STATE_UNKNOWN + + +class Life360Scanner: + """Life360 device scanner.""" + + def __init__(self, hass, config, see, apis): + """Initialize Life360Scanner.""" + self._hass = hass + self._see = see + self._max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + self._max_update_wait = config.get(CONF_MAX_UPDATE_WAIT) + self._prefix = config[CONF_PREFIX] + self._circles_filter = config.get(CONF_CIRCLES) + self._members_filter = config.get(CONF_MEMBERS) + self._driving_speed = config.get(CONF_DRIVING_SPEED) + self._apis = apis + self._errs = {} + self._error_threshold = config[CONF_ERROR_THRESHOLD] + self._warning_threshold = config[CONF_WARNING_THRESHOLD] + self._max_errs = self._error_threshold + 1 + self._dev_data = {} + self._circles_logged = set() + self._members_logged = set() + + _dump_filter(self._circles_filter, 'Circles') + _dump_filter(self._members_filter, 'device IDs', self._dev_id) + + self._started = dt_util.utcnow() + self._update_life360() + track_time_interval( + self._hass, self._update_life360, config[CONF_SCAN_INTERVAL]) + + def _dev_id(self, name): + return self._prefix + name + + def _ok(self, key): + if self._errs.get(key, 0) >= self._max_errs: + _LOGGER.error('%s: OK again', key) + self._errs[key] = 0 + + def _err(self, key, err_msg): + _errs = self._errs.get(key, 0) + if _errs < self._max_errs: + self._errs[key] = _errs = _errs + 1 + msg = '{}: {}'.format(key, err_msg) + if _errs >= self._error_threshold: + if _errs == self._max_errs: + msg = 'Suppressing further errors until OK: ' + msg + _LOGGER.error(msg) + elif _errs >= self._warning_threshold: + _LOGGER.warning(msg) + + def _exc(self, key, exc): + self._err(key, _exc_msg(exc)) + + def _prev_seen(self, dev_id, last_seen): + prev_seen, reported = self._dev_data.get(dev_id, (None, False)) + + if self._max_update_wait: + now = dt_util.utcnow() + most_recent_update = last_seen or prev_seen or self._started + overdue = now - most_recent_update > self._max_update_wait + if overdue and not reported and now - self._started > EVENT_DELAY: + self._hass.bus.fire( + EVENT_UPDATE_OVERDUE, + {ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id)}) + reported = True + elif not overdue and reported: + self._hass.bus.fire( + EVENT_UPDATE_RESTORED, { + ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id), + ATTR_WAIT: + str(last_seen - (prev_seen or self._started)) + .split('.')[0]}) + reported = False + + self._dev_data[dev_id] = last_seen or prev_seen, reported + + return prev_seen + + def _update_member(self, member, dev_id): + loc = member.get('location', {}) + last_seen = _utc_from_ts(loc.get('timestamp')) + prev_seen = self._prev_seen(dev_id, last_seen) + + if not loc: + err_msg = member['issues']['title'] + if err_msg: + if member['issues']['dialog']: + err_msg += ': ' + member['issues']['dialog'] + else: + err_msg = 'Location information missing' + self._err(dev_id, err_msg) + return + + # Only update when we truly have an update. + if not last_seen or prev_seen and last_seen <= prev_seen: + return + + lat = loc.get('latitude') + lon = loc.get('longitude') + gps_accuracy = loc.get('accuracy') + try: + lat = float(lat) + lon = float(lon) + # Life360 reports accuracy in feet, but Device Tracker expects + # gps_accuracy in meters. + gps_accuracy = round( + convert(float(gps_accuracy), LENGTH_FEET, LENGTH_METERS)) + except (TypeError, ValueError): + self._err(dev_id, 'GPS data invalid: {}, {}, {}'.format( + lat, lon, gps_accuracy)) + return + + self._ok(dev_id) + + msg = 'Updating {}'.format(dev_id) + if prev_seen: + msg += '; Time since last update: {}'.format(last_seen - prev_seen) + _LOGGER.debug(msg) + + if (self._max_gps_accuracy is not None + and gps_accuracy > self._max_gps_accuracy): + _LOGGER.warning( + '%s: Ignoring update because expected GPS ' + 'accuracy (%.0f) is not met: %.0f', + dev_id, self._max_gps_accuracy, gps_accuracy) + return + + # Get raw attribute data, converting empty strings to None. + place = loc.get('name') or None + address1 = loc.get('address1') or None + address2 = loc.get('address2') or None + if address1 and address2: + address = ', '.join([address1, address2]) + else: + address = address1 or address2 + raw_speed = loc.get('speed') or None + driving = _bool_attr_from_int(loc.get('isDriving')) + moving = _bool_attr_from_int(loc.get('inTransit')) + try: + battery = int(float(loc.get('battery'))) + except (TypeError, ValueError): + battery = None + + # Try to convert raw speed into real speed. + try: + speed = float(raw_speed) * SPEED_FACTOR_MPH + if self._hass.config.units.is_metric: + speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS) + speed = max(0, round(speed)) + except (TypeError, ValueError): + speed = STATE_UNKNOWN + + # Make driving attribute True if it isn't and we can derive that it + # should be True from other data. + if (driving in (STATE_UNKNOWN, False) + and self._driving_speed is not None + and speed != STATE_UNKNOWN): + driving = speed >= self._driving_speed + + attrs = { + ATTR_ADDRESS: address, + ATTR_AT_LOC_SINCE: _dt_attr_from_ts(loc.get('since')), + ATTR_BATTERY_CHARGING: _bool_attr_from_int(loc.get('charge')), + ATTR_DRIVING: driving, + ATTR_LAST_SEEN: last_seen, + ATTR_MOVING: moving, + ATTR_PLACE: place, + ATTR_RAW_SPEED: raw_speed, + ATTR_SPEED: speed, + ATTR_WIFI_ON: _bool_attr_from_int(loc.get('wifiState')), + } + + self._see(dev_id=dev_id, gps=(lat, lon), gps_accuracy=gps_accuracy, + battery=battery, attributes=attrs, + picture=member.get('avatar')) + + def _update_members(self, members, members_updated): + for member in members: + member_id = member['id'] + if member_id in members_updated: + continue + members_updated.append(member_id) + err_key = 'Member data' + try: + first = member.get('firstName') + last = member.get('lastName') + if first and last: + full_name = ' '.join([first, last]) + else: + full_name = first or last + slug_name = cv.slugify(full_name) + include_member = _include_name(self._members_filter, slug_name) + dev_id = self._dev_id(slug_name) + if member_id not in self._members_logged: + self._members_logged.add(member_id) + _LOGGER.debug( + '%s -> %s: will%s be tracked, id=%s', full_name, + dev_id, '' if include_member else ' NOT', member_id) + sharing = bool(int(member['features']['shareLocation'])) + except (KeyError, TypeError, ValueError, vol.Invalid): + self._err(err_key, member) + continue + self._ok(err_key) + + if include_member and sharing: + self._update_member(member, dev_id) + + def _update_life360(self, now=None): + circles_updated = [] + members_updated = [] + + for api in self._apis: + err_key = 'get_circles' + try: + circles = api.get_circles() + except Life360Error as exc: + self._exc(err_key, exc) + continue + self._ok(err_key) + + for circle in circles: + circle_id = circle['id'] + if circle_id in circles_updated: + continue + circles_updated.append(circle_id) + circle_name = circle['name'] + incl_circle = _include_name(self._circles_filter, circle_name) + if circle_id not in self._circles_logged: + self._circles_logged.add(circle_id) + _LOGGER.debug( + '%s Circle: will%s be included, id=%s', circle_name, + '' if incl_circle else ' NOT', circle_id) + try: + places = api.get_circle_places(circle_id) + place_data = "Circle's Places:" + for place in places: + place_data += '\n- name: {}'.format(place['name']) + place_data += '\n latitude: {}'.format( + place['latitude']) + place_data += '\n longitude: {}'.format( + place['longitude']) + place_data += '\n radius: {}'.format( + place['radius']) + if not places: + place_data += ' None' + _LOGGER.debug(place_data) + except (Life360Error, KeyError): + pass + if incl_circle: + err_key = 'get_circle_members "{}"'.format(circle_name) + try: + members = api.get_circle_members(circle_id) + except Life360Error as exc: + self._exc(err_key, exc) + continue + self._ok(err_key) + + self._update_members(members, members_updated) diff --git a/homeassistant/components/life360/helpers.py b/homeassistant/components/life360/helpers.py new file mode 100644 index 00000000000000..0eb215743df3ce --- /dev/null +++ b/homeassistant/components/life360/helpers.py @@ -0,0 +1,7 @@ +"""Life360 integration helpers.""" +from life360 import Life360 + + +def get_api(authorization=None): + """Create Life360 api object.""" + return Life360(timeout=3.05, max_retries=2, authorization=authorization) diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json new file mode 100644 index 00000000000000..27d1b1f4c93b99 --- /dev/null +++ b/homeassistant/components/life360/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "life360", + "name": "Life360", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/life360", + "dependencies": [], + "codeowners": [ + "@pnbruckner" + ], + "requirements": [ + "life360==4.0.0" + ] +} diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json new file mode 100644 index 00000000000000..cff3f39e5d5850 --- /dev/null +++ b/homeassistant/components/life360/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "Life360", + "step": { + "user": { + "title": "Life360 Account Info", + "data": { + "username": "Username", + "password": "Password" + }, + "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts." + } + }, + "error": { + "invalid_username": "Invalid username", + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + }, + "create_entry": { + "default": "To set advanced options, see [Life360 documentation]({docs_url})." + }, + "abort": { + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 955cdf3c8c4dcc..9b789af473e9bc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -26,6 +26,7 @@ "ios", "ipma", "iqvia", + "life360", "lifx", "locative", "logi_circle", diff --git a/requirements_all.txt b/requirements_all.txt index 64040c61060c8f..8f5056e2e659b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -678,6 +678,9 @@ librouteros==2.2.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 +# homeassistant.components.life360 +life360==4.0.0 + # homeassistant.components.lifx_legacy liffylights==0.9.4 From 2c341f2a651896e558b6588ed5042e138bf22c2b Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 6 Jun 2019 19:25:14 -0400 Subject: [PATCH 120/319] Refactor Waze Travel Time & Update Requirements (#22428) * Refactor Waze Travel Time & Update Requirements Refactored Waze Travel Time to contain a data object. Changed error retrieving data to a warning. Added distance conversion depending on region. Removed dependency on TRACKABLE_DOMAINS list. Update to use WazeRouteCalculator 0.10 3rd time's a charm. Deleted fork, caused last PR to screw up. So here we are. * Update requirements_all.txt * Revert package upgrade. * Revert package upgrade. --- .../components/waze_travel_time/sensor.py | 163 ++++++++++++------ 1 file changed, 107 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 282637b15076b7..af0014d24b3835 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -1,17 +1,18 @@ """Support for Waze travel time sensor.""" from datetime import timedelta import logging +import re import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, - ATTR_LATITUDE, ATTR_LONGITUDE) + ATTR_LATITUDE, ATTR_LONGITUDE, CONF_UNIT_SYSTEM_METRIC, + CONF_UNIT_SYSTEM_IMPERIAL) import homeassistant.helpers.config_validation as cv from homeassistant.helpers import location from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -26,18 +27,21 @@ CONF_INCL_FILTER = 'incl_filter' CONF_EXCL_FILTER = 'excl_filter' CONF_REALTIME = 'realtime' +CONF_UNITS = 'units' +CONF_VEHICLE_TYPE = 'vehicle_type' DEFAULT_NAME = 'Waze Travel Time' DEFAULT_REALTIME = True +DEFAULT_VEHICLE_TYPE = 'car' ICON = 'mdi:car' +UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] + REGIONS = ['US', 'NA', 'EU', 'IL', 'AU'] +VEHICLE_TYPES = ['car', 'taxi', 'motorcycle'] SCAN_INTERVAL = timedelta(minutes=5) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - -TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone', 'person'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ORIGIN): cv.string, @@ -47,6 +51,9 @@ vol.Optional(CONF_INCL_FILTER): cv.string, vol.Optional(CONF_EXCL_FILTER): cv.string, vol.Optional(CONF_REALTIME, default=DEFAULT_REALTIME): cv.boolean, + vol.Optional(CONF_VEHICLE_TYPE, + default=DEFAULT_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), + vol.Optional(CONF_UNITS): vol.In(UNITS) }) @@ -59,9 +66,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): incl_filter = config.get(CONF_INCL_FILTER) excl_filter = config.get(CONF_EXCL_FILTER) realtime = config.get(CONF_REALTIME) + vehicle_type = config.get(CONF_VEHICLE_TYPE) + units = config.get(CONF_UNITS, hass.config.units.name) + + data = WazeTravelTimeData(None, None, region, incl_filter, + excl_filter, realtime, units, + vehicle_type) - sensor = WazeTravelTime(name, origin, destination, region, - incl_filter, excl_filter, realtime) + sensor = WazeTravelTime(name, origin, destination, data) add_entities([sensor]) @@ -79,27 +91,28 @@ def _get_location_from_attributes(state): class WazeTravelTime(Entity): """Representation of a Waze travel time sensor.""" - def __init__(self, name, origin, destination, region, - incl_filter, excl_filter, realtime): + def __init__(self, name, origin, destination, waze_data): """Initialize the Waze travel time sensor.""" self._name = name - self._region = region - self._incl_filter = incl_filter - self._excl_filter = excl_filter - self._realtime = realtime + self._waze_data = waze_data self._state = None self._origin_entity_id = None self._destination_entity_id = None - if origin.split('.', 1)[0] in TRACKABLE_DOMAINS: + # Attempt to find entity_id without finding address with period. + pattern = "(? Date: Fri, 7 Jun 2019 12:46:47 +0200 Subject: [PATCH 121/319] Update pyhomematic (#24368) --- homeassistant/components/homematic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index 7c80806cae585b..ea012ceeb27def 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -3,7 +3,7 @@ "name": "Homematic", "documentation": "https://www.home-assistant.io/components/homematic", "requirements": [ - "pyhomematic==0.1.58" + "pyhomematic==0.1.59" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 8f5056e2e659b4..b547735156638f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1139,7 +1139,7 @@ pyhik==0.2.2 pyhiveapi==0.2.17 # homeassistant.components.homematic -pyhomematic==0.1.58 +pyhomematic==0.1.59 # homeassistant.components.homeworks pyhomeworks==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3aed5ca77d8d1d..8d39983ac90594 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ pydispatcher==2.0.5 pyheos==0.5.2 # homeassistant.components.homematic -pyhomematic==0.1.58 +pyhomematic==0.1.59 # homeassistant.components.iqvia pyiqvia==0.2.1 From 7c5da67d74356a7f1d5fcf0437d18e1112664243 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 07:09:26 -0700 Subject: [PATCH 122/319] Add service to update core location (#24328) * Add service to update core location * Update test_init.py --- .../components/homeassistant/__init__.py | 22 ++++++++++++++++--- tests/components/homeassistant/test_init.py | 20 +++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 93a197969ca721..2bcacb48bd1fed 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, - RESTART_EXIT_CODE) + RESTART_EXIT_CODE, ATTR_LATITUDE, ATTR_LONGITUDE) from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -22,6 +22,7 @@ SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' SERVICE_CHECK_CONFIG = 'check_config' SERVICE_UPDATE_ENTITY = 'update_entity' +SERVICE_SET_LOCATION = 'set_location' SCHEMA_UPDATE_ENTITY = vol.Schema({ ATTR_ENTITY_ID: cv.entity_ids }) @@ -131,7 +132,22 @@ async def async_handle_reload_config(call): await conf_util.async_process_ha_core_config( hass, conf.get(ha.DOMAIN) or {}) - hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config + ) + + async def async_set_location(call): + """Service handler to set location.""" + await hass.config.async_update( + latitude=call.data[ATTR_LATITUDE], + longitude=call.data[ATTR_LONGITUDE], + ) + + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, SERVICE_SET_LOCATION, async_set_location, vol.Schema({ + ATTR_LATITUDE: cv.latitude, + ATTR_LONGITUDE: cv.longitude, + }) + ) return True diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index b72589e60e3238..0eeabd252fdcef 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -10,7 +10,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE) + SERVICE_TOGGLE, EVENT_CORE_CONFIG_UPDATE) import homeassistant.components as comps from homeassistant.setup import async_setup_component from homeassistant.components.homeassistant import ( @@ -22,7 +22,7 @@ from tests.common import ( get_test_home_assistant, mock_service, patch_yaml_files, mock_coro, - async_mock_service) + async_mock_service, async_capture_events) def turn_on(hass, entity_id=None, **service_data): @@ -371,3 +371,19 @@ async def test_entity_update(hass): assert len(mock_update.mock_calls) == 1 assert mock_update.mock_calls[0][1][1] == 'light.kitchen' + + +async def test_setting_location(hass): + """Test setting the location.""" + await async_setup_component(hass, 'homeassistant', {}) + events = async_capture_events(hass, EVENT_CORE_CONFIG_UPDATE) + # Just to make sure that we are updating values. + assert hass.config.latitude != 30 + assert hass.config.longitude != 40 + await hass.services.async_call('homeassistant', 'set_location', { + 'latitude': 30, + 'longitude': 40, + }, blocking=True) + assert len(events) == 1 + assert hass.config.latitude == 30 + assert hass.config.longitude == 40 From a79224aba8b939d6acdc705c5d1c00f06b7b6b65 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 7 Jun 2019 11:02:35 -0400 Subject: [PATCH 123/319] Fix ZHA battery level when value is reported via signal (#24371) * fix battery signal * review comment --- homeassistant/components/zha/device_entity.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index 94fe598b6ec231..b3cb19f2c5ac73 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -1,6 +1,7 @@ """Device entity for Zigbee Home Automation.""" import logging +import numbers import time from homeassistant.core import callback @@ -101,6 +102,18 @@ async def async_added_to_hass(self): # only do this on add to HA because it is static await self._async_init_battery_values() + def async_update_state_attribute(self, key, value): + """Update a single device state attribute.""" + if key == 'battery_level': + if not isinstance(value, numbers.Number) or value == -1: + return + value = value / 2 + value = int(round(value)) + self._device_state_attributes.update({ + key: value + }) + self.async_schedule_update_ha_state() + async def async_update(self): """Handle polling.""" if self._zha_device.last_seen is None: From 592d30d495c6ce567a16e97a6a9bb0882c67353e Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 7 Jun 2019 11:13:55 -0400 Subject: [PATCH 124/319] Remove binary sensors for ZHA remotes and controllers (#24370) * remove remote binary sensor profiles * fix contact sensors --- homeassistant/components/zha/binary_sensor.py | 66 +++++-------------- .../components/zha/core/registries.py | 16 +---- tests/components/zha/test_binary_sensor.py | 62 +---------------- 3 files changed, 21 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index e9fa25c2577897..c3e6208d824971 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -1,27 +1,31 @@ """Binary sensors on Zigbee Home Automation networks.""" import logging -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN, BinarySensorDevice, DEVICE_CLASS_MOVING, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OPENING, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_GAS, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_OCCUPANCY +) from homeassistant.const import STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, - LEVEL_CHANNEL, ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, - SIGNAL_SET_LEVEL, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING, ZONE, OCCUPANCY, - ATTR_LEVEL, SENSOR_TYPE, ACCELERATION) + ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING, + ZONE, OCCUPANCY, SENSOR_TYPE, ACCELERATION +) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) # Zigbee Cluster Library Zone Type to Home Assistant device class CLASS_MAPPING = { - 0x000d: 'motion', - 0x0015: 'opening', - 0x0028: 'smoke', - 0x002a: 'moisture', - 0x002b: 'gas', - 0x002d: 'vibration', + 0x000d: DEVICE_CLASS_MOTION, + 0x0015: DEVICE_CLASS_OPENING, + 0x0028: DEVICE_CLASS_SMOKE, + 0x002a: DEVICE_CLASS_MOISTURE, + 0x002b: DEVICE_CLASS_GAS, + 0x002d: DEVICE_CLASS_VIBRATION, } @@ -33,10 +37,10 @@ async def get_ias_device_class(channel): DEVICE_CLASS_REGISTRY = { UNKNOWN: None, - OPENING: OPENING, + OPENING: DEVICE_CLASS_OPENING, ZONE: get_ias_device_class, - OCCUPANCY: OCCUPANCY, - ACCELERATION: 'moving', + OCCUPANCY: DEVICE_CLASS_OCCUPANCY, + ACCELERATION: DEVICE_CLASS_MOVING, } @@ -85,10 +89,8 @@ def __init__(self, **kwargs): self._device_state_attributes = {} self._zone_channel = self.cluster_channels.get(ZONE_CHANNEL) self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL) - self._level_channel = self.cluster_channels.get(LEVEL_CHANNEL) self._attr_channel = self.cluster_channels.get(ATTRIBUTE_CHANNEL) self._zha_sensor_type = kwargs[SENSOR_TYPE] - self._level = None async def _determine_device_class(self): """Determine the device class for this binary sensor.""" @@ -105,11 +107,6 @@ async def async_added_to_hass(self): """Run when about to be added to hass.""" self._device_class = await self._determine_device_class() await super().async_added_to_hass() - if self._level_channel: - await self.async_accept_signal( - self._level_channel, SIGNAL_SET_LEVEL, self.set_level) - await self.async_accept_signal( - self._level_channel, SIGNAL_MOVE_LEVEL, self.move_level) if self._on_off_channel: await self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, @@ -126,8 +123,6 @@ def async_restore_last_state(self, last_state): """Restore previous state.""" super().async_restore_last_state(last_state) self._state = last_state.state == STATE_ON - if 'level' in last_state.attributes: - self._level = last_state.attributes['level'] @property def is_on(self) -> bool: @@ -146,36 +141,9 @@ def async_set_state(self, state): self._state = bool(state) self.async_schedule_update_ha_state() - def move_level(self, change): - """Increment the level, setting state if appropriate.""" - level = self._level or 0 - if not self._state and change > 0: - level = 0 - self._level = min(254, max(0, level + change)) - self._state = bool(self._level) - self.async_schedule_update_ha_state() - - def set_level(self, level): - """Set the level, setting state if appropriate.""" - self._level = level - self._state = bool(level) - self.async_schedule_update_ha_state() - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if self._level_channel is not None: - self._device_state_attributes.update({ - ATTR_LEVEL: self._state and self._level or 0 - }) - return self._device_state_attributes - async def async_update(self): """Attempt to retrieve on off state from the binary sensor.""" await super().async_update() - if self._level_channel: - self._level = await self._level_channel.get_attribute_value( - 'current_level') if self._on_off_channel: self._state = await self._on_off_channel.get_attribute_value( 'on_off') diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index b585ce5f48a679..af483c1f79511f 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -110,17 +110,11 @@ def get_deconz_radio(): BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ - zha.DeviceType.ON_OFF_SWITCH: BINARY_SENSOR, - zha.DeviceType.LEVEL_CONTROL_SWITCH: BINARY_SENSOR, - zha.DeviceType.REMOTE_CONTROL: BINARY_SENSOR, zha.DeviceType.SMART_PLUG: SWITCH, zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT, zha.DeviceType.ON_OFF_LIGHT: LIGHT, zha.DeviceType.DIMMABLE_LIGHT: LIGHT, - zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, - zha.DeviceType.ON_OFF_LIGHT_SWITCH: BINARY_SENSOR, - zha.DeviceType.DIMMER_SWITCH: BINARY_SENSOR, - zha.DeviceType.COLOR_DIMMER_SWITCH: BINARY_SENSOR, + zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT }) DEVICE_CLASS[zll.PROFILE_ID].update({ @@ -130,12 +124,7 @@ def get_deconz_radio(): zll.DeviceType.DIMMABLE_PLUGIN_UNIT: LIGHT, zll.DeviceType.COLOR_LIGHT: LIGHT, zll.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT, - zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, - zll.DeviceType.COLOR_CONTROLLER: BINARY_SENSOR, - zll.DeviceType.COLOR_SCENE_CONTROLLER: BINARY_SENSOR, - zll.DeviceType.CONTROLLER: BINARY_SENSOR, - zll.DeviceType.SCENE_CONTROLLER: BINARY_SENSOR, - zll.DeviceType.ON_OFF_SENSOR: BINARY_SENSOR, + zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT }) SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ @@ -285,7 +274,6 @@ def get_deconz_radio(): }) BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) - BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id) BINARY_SENSOR_CLUSTERS.add(zcl.clusters.security.IasZone.cluster_id) BINARY_SENSOR_CLUSTERS.add( zcl.clusters.measurement.OccupancySensing.cluster_id) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 1d6b4fd3e01eb2..1a7ec667472d16 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -11,8 +11,7 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): """Test zha binary_sensor platform.""" from zigpy.zcl.clusters.security import IasZone from zigpy.zcl.clusters.measurement import OccupancySensing - from zigpy.zcl.clusters.general import OnOff, LevelControl, Basic - from zigpy.profiles.zha import DeviceType + from zigpy.zcl.clusters.general import Basic # create zigpy devices zigpy_device_zone = await async_init_zigpy_device( @@ -23,17 +22,6 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): zha_gateway ) - zigpy_device_remote = await async_init_zigpy_device( - hass, - [Basic.cluster_id], - [OnOff.cluster_id, LevelControl.cluster_id], - DeviceType.LEVEL_CONTROL_SWITCH, - zha_gateway, - ieee="00:0d:6f:11:0a:90:69:e7", - manufacturer="FakeManufacturer", - model="FakeRemoteModel" - ) - zigpy_device_occupancy = await async_init_zigpy_device( hass, [OccupancySensing.cluster_id, Basic.cluster_id], @@ -63,46 +51,20 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): DOMAIN, zigpy_device_occupancy, occupancy_cluster) occupancy_zha_device = zha_gateway.get_device(zigpy_device_occupancy.ieee) - # dimmable binary_sensor - remote_on_off_cluster = zigpy_device_remote.endpoints.get( - 1).out_clusters[OnOff.cluster_id] - remote_level_cluster = zigpy_device_remote.endpoints.get( - 1).out_clusters[LevelControl.cluster_id] - remote_entity_id = make_entity_id(DOMAIN, zigpy_device_remote, - remote_on_off_cluster, - use_suffix=False) - remote_zha_device = zha_gateway.get_device(zigpy_device_remote.ieee) - # test that the sensors exist and are in the unavailable state assert hass.states.get(zone_entity_id).state == STATE_UNAVAILABLE - assert hass.states.get(remote_entity_id).state == STATE_UNAVAILABLE assert hass.states.get(occupancy_entity_id).state == STATE_UNAVAILABLE await async_enable_traffic(hass, zha_gateway, - [zone_zha_device, remote_zha_device, - occupancy_zha_device]) + [zone_zha_device, occupancy_zha_device]) # test that the sensors exist and are in the off state assert hass.states.get(zone_entity_id).state == STATE_OFF - assert hass.states.get(remote_entity_id).state == STATE_OFF assert hass.states.get(occupancy_entity_id).state == STATE_OFF # test getting messages that trigger and reset the sensors await async_test_binary_sensor_on_off(hass, occupancy_cluster, occupancy_entity_id) - await async_test_binary_sensor_on_off(hass, remote_on_off_cluster, - remote_entity_id) - - # test changing the level attribute for dimming remotes - await async_test_remote_level( - hass, remote_level_cluster, remote_entity_id, 150, STATE_ON) - await async_test_remote_level( - hass, remote_level_cluster, remote_entity_id, 0, STATE_OFF) - await async_test_remote_level( - hass, remote_level_cluster, remote_entity_id, 255, STATE_ON) - - await async_test_remote_move_level( - hass, remote_level_cluster, remote_entity_id, 20, STATE_ON) # test IASZone binary sensors await async_test_iaszone_on_off(hass, zone_cluster, zone_entity_id) @@ -127,26 +89,6 @@ async def async_test_binary_sensor_on_off(hass, cluster, entity_id): assert hass.states.get(entity_id).state == STATE_OFF -async def async_test_remote_level(hass, cluster, entity_id, level, - expected_state): - """Test dimmer functionality from the remote.""" - attr = make_attribute(0, level) - cluster.handle_message(False, 1, 0x0a, [[attr]]) - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == expected_state - assert hass.states.get(entity_id).attributes.get('level') == level - - -async def async_test_remote_move_level(hass, cluster, entity_id, change, - expected_state): - """Test move to level command.""" - level = hass.states.get(entity_id).attributes.get('level') - cluster.listener_event('cluster_command', 1, 1, [1, change]) - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == expected_state - assert hass.states.get(entity_id).attributes.get('level') == level - change - - async def async_test_iaszone_on_off(hass, cluster, entity_id): """Test getting on and off messages for iaszone binary sensors.""" # binary sensor on From cb460a85ba0726efca569c8462a9f94b2b28ded3 Mon Sep 17 00:00:00 2001 From: presslab-us Date: Fri, 7 Jun 2019 11:16:34 -0400 Subject: [PATCH 125/319] Add support for ZHA door locks #2 (#24344) * Add support for DoorLock cluster * Add test for zha lock * Change lock_state report to REPORT_CONFIG_IMMEDIATE * Update channel command wrapper to return the entire result This allows for return values other than result[1] * Fix tests * Fix lint * Update DoorLock test to work with updated zigpy schema * Fix lint * Fix unlock test --- .../components/zha/core/channels/__init__.py | 8 +- .../components/zha/core/channels/closures.py | 39 +++++ .../components/zha/core/channels/registry.py | 4 +- homeassistant/components/zha/core/const.py | 3 + .../components/zha/core/registries.py | 8 +- homeassistant/components/zha/light.py | 34 ++--- homeassistant/components/zha/lock.py | 134 ++++++++++++++++++ homeassistant/components/zha/switch.py | 9 +- tests/components/zha/test_light.py | 8 +- tests/components/zha/test_lock.py | 88 ++++++++++++ tests/components/zha/test_switch.py | 4 +- 11 files changed, 305 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/zha/lock.py create mode 100644 tests/components/zha/test_lock.py diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 3eb24050195103..83ade5894652df 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -44,7 +44,6 @@ def decorate_command(channel, command): """Wrap a cluster command to make it safe.""" @wraps(command) async def wrapper(*args, **kwds): - from zigpy.zcl.foundation import Status from zigpy.exceptions import DeliveryError try: result = await command(*args, **kwds) @@ -54,9 +53,8 @@ async def wrapper(*args, **kwds): "{}: {}".format("with args", args), "{}: {}".format("with kwargs", kwds), "{}: {}".format("and result", result)) - if isinstance(result, bool): - return result - return result[1] is Status.SUCCESS + return result + except (DeliveryError, Timeout) as ex: _LOGGER.debug( "%s: command failed: %s exception: %s", @@ -64,7 +62,7 @@ async def wrapper(*args, **kwds): command.__name__, str(ex) ) - return False + return ex return wrapper diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index ba3b6b2e71617f..f2f8d07fde9299 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -5,5 +5,44 @@ https://home-assistant.io/components/zha/ """ import logging +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from . import ZigbeeChannel +from ..const import SIGNAL_ATTR_UPDATED _LOGGER = logging.getLogger(__name__) + + +class DoorLockChannel(ZigbeeChannel): + """Door lock channel.""" + + _value_attribute = 0 + + async def async_update(self): + """Retrieve latest state.""" + result = await self.get_attribute_value('lock_state', from_cache=True) + + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + result + ) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute update from lock cluster.""" + attr_name = self.cluster.attributes.get(attrid, [attrid])[0] + _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", + self.unique_id, self.cluster.name, attr_name, value) + if attrid == self._value_attribute: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.get_attribute_value( + self._value_attribute, from_cache=from_cache) + await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/registry.py b/homeassistant/components/zha/core/channels/registry.py index 8f7335d82a9cd8..8b50ff4149731c 100644 --- a/homeassistant/components/zha/core/channels/registry.py +++ b/homeassistant/components/zha/core/channels/registry.py @@ -5,6 +5,8 @@ https://home-assistant.io/components/zha/ """ from . import ZigbeeChannel + +from .closures import DoorLockChannel from .general import ( OnOffChannel, LevelControlChannel, PowerConfigurationChannel, BasicChannel ) @@ -13,7 +15,6 @@ from .lighting import ColorChannel from .security import IASZoneChannel - ZIGBEE_CHANNEL_REGISTRY = {} @@ -44,4 +45,5 @@ def populate_channel_registry(): zcl.clusters.security.IasZone.cluster_id: IASZoneChannel, zcl.clusters.hvac.Fan.cluster_id: FanChannel, zcl.clusters.lightlink.LightLink.cluster_id: ZigbeeChannel, + zcl.clusters.closures.DoorLock.cluster_id: DoorLockChannel, }) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9e42f6343a150e..97e2364619aa5c 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.light import DOMAIN as LIGHT +from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH @@ -27,6 +28,7 @@ BINARY_SENSOR, FAN, LIGHT, + LOCK, SENSOR, SWITCH, ) @@ -92,6 +94,7 @@ ELECTRICAL_MEASUREMENT_CHANNEL = 'electrical_measurement' POWER_CONFIGURATION_CHANNEL = 'power' EVENT_RELAY_CHANNEL = 'event_relay' +DOORLOCK_CHANNEL = 'door_lock' SIGNAL_ATTR_UPDATED = 'attribute_updated' SIGNAL_MOVE_LEVEL = "move_level" diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index af483c1f79511f..00c2dd22740de1 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.light import DOMAIN as LIGHT +from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH @@ -143,7 +144,8 @@ def get_deconz_radio(): zcl.clusters.hvac.Fan: FAN, SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, - zcl.clusters.general.AnalogInput.cluster_id: SENSOR + zcl.clusters.general.AnalogInput.cluster_id: SENSOR, + zcl.clusters.closures.DoorLock: LOCK }) SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ @@ -271,6 +273,10 @@ def get_deconz_radio(): 'attr': 'fan_mode', 'config': REPORT_CONFIG_OP }], + zcl.clusters.closures.DoorLock.cluster_id: [{ + 'attr': 'lock_state', + 'config': REPORT_CONFIG_IMMEDIATE + }], }) BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index c3aa0e50f44228..64c515b06b0919 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from zigpy.zcl.foundation import Status from homeassistant.components import light from homeassistant.const import STATE_ON from homeassistant.core import callback @@ -14,7 +15,6 @@ ) from .entity import ZhaEntity - _LOGGER = logging.getLogger(__name__) DEFAULT_DURATION = 5 @@ -173,12 +173,12 @@ async def async_turn_on(self, **kwargs): level = min(254, brightness) else: level = self._brightness or 254 - success = await self._level_channel.move_to_level_with_on_off( + result = await self._level_channel.move_to_level_with_on_off( level, duration ) - t_log['move_to_level_with_on_off'] = success - if not success: + t_log['move_to_level_with_on_off'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._state = bool(level) @@ -186,9 +186,9 @@ async def async_turn_on(self, **kwargs): self._brightness = level if brightness is None or brightness: - success = await self._on_off_channel.on() - t_log['on_off'] = success - if not success: + result = await self._on_off_channel.on() + t_log['on_off'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._state = True @@ -196,10 +196,10 @@ async def async_turn_on(self, **kwargs): if light.ATTR_COLOR_TEMP in kwargs and \ self.supported_features & light.SUPPORT_COLOR_TEMP: temperature = kwargs[light.ATTR_COLOR_TEMP] - success = await self._color_channel.move_to_color_temp( + result = await self._color_channel.move_to_color_temp( temperature, duration) - t_log['move_to_color_temp'] = success - if not success: + t_log['move_to_color_temp'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._color_temp = temperature @@ -208,13 +208,13 @@ async def async_turn_on(self, **kwargs): self.supported_features & light.SUPPORT_COLOR: hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*hs_color) - success = await self._color_channel.move_to_color( + result = await self._color_channel.move_to_color( int(xy_color[0] * 65535), int(xy_color[1] * 65535), duration, ) - t_log['move_to_color'] = success - if not success: + t_log['move_to_color'] = result + if not isinstance(result, list) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._hs_color = hs_color @@ -227,14 +227,14 @@ async def async_turn_off(self, **kwargs): duration = kwargs.get(light.ATTR_TRANSITION) supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS if duration and supports_level: - success = await self._level_channel.move_to_level_with_on_off( + result = await self._level_channel.move_to_level_with_on_off( 0, duration*10 ) else: - success = await self._on_off_channel.off() - self.debug("turned off: %s", success) - if not success: + result = await self._on_off_channel.off() + self.debug("turned off: %s", result) + if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py new file mode 100644 index 00000000000000..5ac4a0c2e30825 --- /dev/null +++ b/homeassistant/components/zha/lock.py @@ -0,0 +1,134 @@ +"""Locks on Zigbee Home Automation networks.""" +import logging + +from zigpy.zcl.foundation import Status +from homeassistant.core import callback +from homeassistant.components.lock import ( + DOMAIN, STATE_UNLOCKED, STATE_LOCKED, LockDevice) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, DOORLOCK_CHANNEL, + SIGNAL_ATTR_UPDATED +) +from .entity import ZhaEntity + +_LOGGER = logging.getLogger(__name__) + +""" The first state is Zigbee 'Not fully locked' """ + +STATE_LIST = [ + STATE_UNLOCKED, + STATE_LOCKED, + STATE_UNLOCKED +] + +VALUE_TO_STATE = {i: state for i, state in enumerate(STATE_LIST)} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up Zigbee Home Automation locks.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation Door Lock from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + locks = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if locks is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + locks.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA locks.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(ZhaDoorLock(**discovery_info)) + + async_add_entities(entities, update_before_add=True) + + +class ZhaDoorLock(ZhaEntity, LockDevice): + """Representation of a ZHA lock.""" + + _domain = DOMAIN + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._doorlock_channel = self.cluster_channels.get(DOORLOCK_CHANNEL) + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = VALUE_TO_STATE.get(last_state.state, last_state.state) + + @property + def is_locked(self) -> bool: + """Return true if entity is locked.""" + if self._state is None: + return False + return self._state == STATE_LOCKED + + @property + def device_state_attributes(self): + """Return state attributes.""" + return self.state_attributes + + async def async_lock(self, **kwargs): + """Lock the lock.""" + result = await self._doorlock_channel.lock_door() + if not isinstance(result, list) or result[0] is not Status.SUCCESS: + _LOGGER.error("Error with lock_door: %s", result) + return + self.async_schedule_update_ha_state() + + async def async_unlock(self, **kwargs): + """Unlock the lock.""" + result = await self._doorlock_channel.unlock_door() + if not isinstance(result, list) or result[0] is not Status.SUCCESS: + _LOGGER.error("Error with unlock_door: %s", result) + return + self.async_schedule_update_ha_state() + + async def async_update(self): + """Attempt to retrieve state from the lock.""" + await super().async_update() + await self.async_get_state() + + def async_set_state(self, state): + """Handle state update from channel.""" + self._state = VALUE_TO_STATE.get(state, self._state) + self.async_schedule_update_ha_state() + + async def async_get_state(self, from_cache=True): + """Attempt to retrieve state from the lock.""" + if self._doorlock_channel: + state = await self._doorlock_channel.get_attribute_value( + 'lock_state', from_cache=from_cache) + if state is not None: + self._state = VALUE_TO_STATE.get(state, self._state) + + async def refresh(self, time): + """Call async_get_state at an interval.""" + await self.async_get_state(from_cache=False) + + def debug(self, msg, *args): + """Log debug message.""" + _LOGGER.debug('%s: ' + msg, self.entity_id, *args) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 7efcbabd74e1be..89452f00d9f2f7 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -1,6 +1,7 @@ """Switches on Zigbee Home Automation networks.""" import logging +from zigpy.zcl.foundation import Status from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import STATE_ON from homeassistant.core import callback @@ -66,16 +67,16 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs): """Turn the entity on.""" - success = await self._on_off_channel.on() - if not success: + result = await self._on_off_channel.on() + if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = True self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - success = await self._on_off_channel.off() - if not success: + result = await self._on_off_channel.off() + if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = False self.async_schedule_update_ha_state() diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index e9d6370575b7a8..02a0eba46a389d 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -57,9 +57,9 @@ async def test_light(hass, config_entry, zha_gateway, monkeypatch): level_device_on_off_cluster = zigpy_device_level.endpoints.get(1).on_off level_device_level_cluster = zigpy_device_level.endpoints.get(1).level on_off_mock = MagicMock(side_effect=asyncio.coroutine(MagicMock( - return_value=(sentinel.data, Status.SUCCESS)))) + return_value=[sentinel.data, Status.SUCCESS]))) level_mock = MagicMock(side_effect=asyncio.coroutine(MagicMock( - return_value=(sentinel.data, Status.SUCCESS)))) + return_value=[sentinel.data, Status.SUCCESS]))) monkeypatch.setattr(level_device_on_off_cluster, 'request', on_off_mock) monkeypatch.setattr(level_device_level_cluster, 'request', level_mock) level_entity_id = make_entity_id(DOMAIN, zigpy_device_level, @@ -137,7 +137,7 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): from zigpy.zcl.foundation import Status with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x00, Status.SUCCESS])): # turn on via UI await hass.services.async_call(DOMAIN, 'turn_on', { 'entity_id': entity_id @@ -154,7 +154,7 @@ async def async_test_off_from_hass(hass, cluster, entity_id): from zigpy.zcl.foundation import Status with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x01, Status.SUCCESS])): # turn off via UI await hass.services.async_call(DOMAIN, 'turn_off', { 'entity_id': entity_id diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py new file mode 100644 index 00000000000000..4951c3537a0c4c --- /dev/null +++ b/tests/components/zha/test_lock.py @@ -0,0 +1,88 @@ +"""Test zha lock.""" +from unittest.mock import patch +from homeassistant.const import ( + STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE) +from homeassistant.components.lock import DOMAIN +from tests.common import mock_coro +from .common import ( + async_init_zigpy_device, make_attribute, make_entity_id, + async_enable_traffic) + +LOCK_DOOR = 0 +UNLOCK_DOOR = 1 + + +async def test_lock(hass, config_entry, zha_gateway): + """Test zha lock platform.""" + from zigpy.zcl.clusters.closures import DoorLock + from zigpy.zcl.clusters.general import Basic + + # create zigpy device + zigpy_device = await async_init_zigpy_device( + hass, [DoorLock.cluster_id, Basic.cluster_id], [], None, zha_gateway) + + # load up lock domain + await hass.config_entries.async_forward_entry_setup( + config_entry, DOMAIN) + await hass.async_block_till_done() + + cluster = zigpy_device.endpoints.get(1).door_lock + entity_id = make_entity_id(DOMAIN, zigpy_device, cluster) + zha_device = zha_gateway.get_device(zigpy_device.ieee) + + # test that the lock was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, zha_gateway, [zha_device]) + + # test that the state has changed from unavailable to unlocked + assert hass.states.get(entity_id).state == STATE_UNLOCKED + + # set state to locked + attr = make_attribute(0, 1) + cluster.handle_message(False, 1, 0x0a, [[attr]]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_LOCKED + + # set state to unlocked + attr.value.value = 2 + cluster.handle_message(False, 0, 0x0a, [[attr]]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNLOCKED + + # lock from HA + await async_lock(hass, cluster, entity_id) + + # unlock from HA + await async_unlock(hass, cluster, entity_id) + + +async def async_lock(hass, cluster, entity_id): + """Test lock functionality from hass.""" + from zigpy.zcl.foundation import Status + with patch( + 'zigpy.zcl.Cluster.request', + return_value=mock_coro([Status.SUCCESS, ])): + # lock via UI + await hass.services.async_call(DOMAIN, 'lock', { + 'entity_id': entity_id + }, blocking=True) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == LOCK_DOOR + + +async def async_unlock(hass, cluster, entity_id): + """Test lock functionality from hass.""" + from zigpy.zcl.foundation import Status + with patch( + 'zigpy.zcl.Cluster.request', + return_value=mock_coro([Status.SUCCESS, ])): + # lock via UI + await hass.services.async_call(DOMAIN, 'unlock', { + 'entity_id': entity_id + }, blocking=True) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == UNLOCK_DOOR diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index b0bbc103a9e3a0..2120bd6baf550c 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -54,7 +54,7 @@ async def test_switch(hass, config_entry, zha_gateway): # turn on from HA with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x00, Status.SUCCESS])): # turn on via UI await hass.services.async_call(DOMAIN, 'turn_on', { 'entity_id': entity_id @@ -66,7 +66,7 @@ async def test_switch(hass, config_entry, zha_gateway): # turn off from HA with patch( 'zigpy.zcl.Cluster.request', - return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): + return_value=mock_coro([0x01, Status.SUCCESS])): # turn off via UI await hass.services.async_call(DOMAIN, 'turn_off', { 'entity_id': entity_id From 846d31c4f1b9a4b13a5856816abe891ab795086d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 18:38:05 +0200 Subject: [PATCH 126/319] Create azure-pipelines-ci.yml --- azure-pipelines-ci.yml | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 azure-pipelines-ci.yml diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml new file mode 100644 index 00000000000000..02ac3c7f571e28 --- /dev/null +++ b/azure-pipelines-ci.yml @@ -0,0 +1,54 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev +pr: none + + +jobs: + +- job: 'Pip_Install_Demo' + pool: + vmImage: 'ubuntu-latest' + strategy: + matrix: + Python35: + python.version: '3.5' + Python36: + python.version: '3.6' + Python37: + python.version: '3.7' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python $(python.version)' + inputs: + versionSpec: '$(python.version)' + - script: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev + displayName: 'Set up docker prerequisite requirement' + + - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 + displayName: 'Restore artifacts based on Requirements' + inputs: + keyfile: 'requirements_all.txt' + targetfolder: ./venv + vstsFeed: '$(ArtifactFeed)' + + - script: | + python3 -m venv venv + . venv/bin/activate + pip install -q -U pip setuptools + pip3 install -q -r requirements_all.txt -c homeassistant/package_constraints.txt + displayName: 'Create Virtual Environment & Install Requirements' + + - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 + displayName: 'Save artifacts based on Requirements' + inputs: + keyfile: 'requirements_all.txt' + targetfolder: ./venv + vstsFeed: '$(ArtifactFeed)' From 203c3a51757e13eb295461d7f3714fd326c2366e Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 18:42:47 +0200 Subject: [PATCH 127/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 02ac3c7f571e28..860d04d9c91ba2 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -14,6 +14,7 @@ jobs: pool: vmImage: 'ubuntu-latest' strategy: + maxParallel: 1 matrix: Python35: python.version: '3.5' From 85f4cecc643c974d5be70f81b90551520b24bbc6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 18:54:28 +0200 Subject: [PATCH 128/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 860d04d9c91ba2..952c1cf1559cc8 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -28,6 +28,7 @@ jobs: inputs: versionSpec: '$(python.version)' - script: | + sudo add-apt-repository ppa:jonathonf/ffmpeg-4 sudo apt-get update sudo apt-get install -y --no-install-recommends \ libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev From ae1511d8f6f86e2e8ce79248ea9c80159254b8fa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 21:16:39 +0200 Subject: [PATCH 129/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 952c1cf1559cc8..1925f7af754c4c 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -8,6 +8,11 @@ trigger: pr: none +variables: + - name: ArtifactFeed + value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d' + + jobs: - job: 'Pip_Install_Demo' @@ -44,8 +49,8 @@ jobs: - script: | python3 -m venv venv . venv/bin/activate - pip install -q -U pip setuptools - pip3 install -q -r requirements_all.txt -c homeassistant/package_constraints.txt + pip install -U pip setuptools + pip3 install -r requirements_all.txt -c homeassistant/package_constraints.txt displayName: 'Create Virtual Environment & Install Requirements' - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 From 1647ebaf31cd931bb9c4dca6bfbc7e4571625dfa Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 7 Jun 2019 21:22:02 +0200 Subject: [PATCH 130/319] Bump dependency (#24376) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index dc64e90ba9a51a..2b1bef9081e9cd 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -3,7 +3,7 @@ "name": "Axis", "config_flow": true, "documentation": "https://www.home-assistant.io/components/axis", - "requirements": ["axis==24"], + "requirements": ["axis==25"], "dependencies": [], "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] diff --git a/requirements_all.txt b/requirements_all.txt index b547735156638f..b8b259f43f798b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==24 +axis==25 # homeassistant.components.azure_event_hub azure-eventhub==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d39983ac90594..3a4d0ddb6ec24d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -73,7 +73,7 @@ apns2==0.3.0 av==6.1.2 # homeassistant.components.axis -axis==24 +axis==25 # homeassistant.components.zha bellows-homeassistant==0.8.0 From 3d802afecb91ea97333c6751978b4361baf97b81 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 7 Jun 2019 21:22:19 +0200 Subject: [PATCH 131/319] Upgrade discord.py to 1.1.1 (#24375) --- homeassistant/components/discord/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 05b2a3c8e06215..fd496b3402bcc9 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -3,7 +3,7 @@ "name": "Discord", "documentation": "https://www.home-assistant.io/components/discord", "requirements": [ - "discord.py==1.0.1" + "discord.py==1.1.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index b8b259f43f798b..35d8baa6f3d1ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ directpy==0.5 discogs_client==2.2.1 # homeassistant.components.discord -discord.py==1.0.1 +discord.py==1.1.1 # homeassistant.components.updater distro==1.4.0 From 888c5172bf18eb6a4c566d6f7a922368c8899947 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 7 Jun 2019 21:22:37 +0200 Subject: [PATCH 132/319] Upgrade Mastodon.py to 1.4.3 (#24374) --- homeassistant/components/mastodon/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 6db3791c519b2c..b49aa735b05396 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -3,7 +3,7 @@ "name": "Mastodon", "documentation": "https://www.home-assistant.io/components/mastodon", "requirements": [ - "Mastodon.py==1.4.2" + "Mastodon.py==1.4.3" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 35d8baa6f3d1ae..d8332696999152 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -37,7 +37,7 @@ Adafruit-SHT31==1.0.2 HAP-python==2.5.0 # homeassistant.components.mastodon -Mastodon.py==1.4.2 +Mastodon.py==1.4.3 # homeassistant.components.orangepi_gpio OPi.GPIO==0.3.6 From 1acd34313b103f7d2975ec4e16d5d3f6e4c3a0b6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 22:06:27 +0200 Subject: [PATCH 133/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 1925f7af754c4c..7377c8092df7d4 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -36,26 +36,39 @@ jobs: sudo add-apt-repository ppa:jonathonf/ffmpeg-4 sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev + libudev libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter + + echo "$(python.version)" > .python displayName: 'Set up docker prerequisite requirement' - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 displayName: 'Restore artifacts based on Requirements' inputs: - keyfile: 'requirements_all.txt' - targetfolder: ./venv + keyfile: 'requirements_test_all.txt, .python' + targetfolder: './venv' vstsFeed: '$(ArtifactFeed)' - script: | + # Install build env + sudo apt-get install -y --no-install-recommends \ + libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev + + # Setup venv python3 -m venv venv . venv/bin/activate pip install -U pip setuptools - pip3 install -r requirements_all.txt -c homeassistant/package_constraints.txt + pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt displayName: 'Create Virtual Environment & Install Requirements' + condition: ne(variables['CacheRestored'], 'true') - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 displayName: 'Save artifacts based on Requirements' inputs: - keyfile: 'requirements_all.txt' - targetfolder: ./venv - vstsFeed: '$(ArtifactFeed)' + keyfile: 'requirements_test_all.txt, .python' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + ./venv/bin/py.test --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar + ./script/check_dirty + displayName: 'Run py.test with python $(python.version)' From 3664f61e2dd0dd6bf1e9fae67b5bc7fd9f47411e Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 22:24:43 +0200 Subject: [PATCH 134/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 7377c8092df7d4..51573610538fda 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -38,13 +38,13 @@ jobs: sudo apt-get install -y --no-install-recommends \ libudev libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter - echo "$(python.version)" > .python + echo "$(python.version)" > .cache displayName: 'Set up docker prerequisite requirement' - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 displayName: 'Restore artifacts based on Requirements' inputs: - keyfile: 'requirements_test_all.txt, .python' + keyfile: 'requirements_test_all.txt, .cache' targetfolder: './venv' vstsFeed: '$(ArtifactFeed)' @@ -55,6 +55,7 @@ jobs: # Setup venv python3 -m venv venv + . venv/bin/activate pip install -U pip setuptools pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt @@ -64,11 +65,13 @@ jobs: - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 displayName: 'Save artifacts based on Requirements' inputs: - keyfile: 'requirements_test_all.txt, .python' + keyfile: 'requirements_test_all.txt, .cache' targetfolder: './venv' vstsFeed: '$(ArtifactFeed)' - script: | - ./venv/bin/py.test --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar + + . venv/bin/activate + pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar ./script/check_dirty - displayName: 'Run py.test with python $(python.version)' + displayName: 'Run pytest for python $(python.version)' From 337cd40cb6ddab05b80bfb247398d4a091442c84 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 22:34:44 +0200 Subject: [PATCH 135/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 51573610538fda..d2314bc40dd491 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -67,11 +67,14 @@ jobs: inputs: keyfile: 'requirements_test_all.txt, .cache' targetfolder: './venv' - vstsFeed: '$(ArtifactFeed)' + vstsFeed: '$(ArtifactFeed)' + + - script: | + . venv/bin/activate + pip install -e . + displayName: 'Install Home Assistant for python $(python.version)' - - script: | - + - script: | . venv/bin/activate pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar - ./script/check_dirty displayName: 'Run pytest for python $(python.version)' From e93fbcf7015d2b15e13459842c93a12171722efd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 22:36:42 +0200 Subject: [PATCH 136/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index d2314bc40dd491..da7c827b50b6e4 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -15,11 +15,11 @@ variables: jobs: -- job: 'Pip_Install_Demo' +- job: 'Check_Python' pool: vmImage: 'ubuntu-latest' strategy: - maxParallel: 1 + maxParallel: 2 matrix: Python35: python.version: '3.5' From ceac04b82d85d1c0f7d4d8e7d59f84832d7c801c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 22:40:32 +0200 Subject: [PATCH 137/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index da7c827b50b6e4..30dff6ed216891 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -15,7 +15,7 @@ variables: jobs: -- job: 'Check_Python' +- job: 'Check' pool: vmImage: 'ubuntu-latest' strategy: @@ -76,5 +76,5 @@ jobs: - script: | . venv/bin/activate - pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar + pytest --timeout=9 --durations=10 tests displayName: 'Run pytest for python $(python.version)' From 8af0747f95a80446a64b0571422d347e5acd5bbb Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Jun 2019 22:43:07 +0200 Subject: [PATCH 138/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 30dff6ed216891..3c46734009c2df 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -76,5 +76,5 @@ jobs: - script: | . venv/bin/activate - pytest --timeout=9 --durations=10 tests + pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar tests displayName: 'Run pytest for python $(python.version)' From eb3e53e2d3bf2fc50ae3ab44069a1551de1fb68f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 8 Jun 2019 00:54:14 +0200 Subject: [PATCH 139/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 3c46734009c2df..9eefcb895a51c2 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -36,7 +36,7 @@ jobs: sudo add-apt-repository ppa:jonathonf/ffmpeg-4 sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libudev libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter + libudev libsqlite3 libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter echo "$(python.version)" > .cache displayName: 'Set up docker prerequisite requirement' @@ -51,7 +51,7 @@ jobs: - script: | # Install build env sudo apt-get install -y --no-install-recommends \ - libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev + libudev-dev libsqlite3-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # Setup venv python3 -m venv venv From 17b59cd4100bd4717294ec0204bb77311d11d6da Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 8 Jun 2019 00:55:03 +0200 Subject: [PATCH 140/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 9eefcb895a51c2..f045c52c1c55c5 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -54,7 +54,7 @@ jobs: libudev-dev libsqlite3-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # Setup venv - python3 -m venv venv + python -m venv venv . venv/bin/activate pip install -U pip setuptools From 5a9db70d24e4fcdf22e28a9799f5affb514791f1 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 8 Jun 2019 00:56:10 +0200 Subject: [PATCH 141/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index f045c52c1c55c5..22675aa6e1a672 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -19,7 +19,7 @@ jobs: pool: vmImage: 'ubuntu-latest' strategy: - maxParallel: 2 + maxParallel: 1 matrix: Python35: python.version: '3.5' From 952d72fdd39540236f2ec2aa7d2993781c91823f Mon Sep 17 00:00:00 2001 From: kvanhoorn Date: Sat, 8 Jun 2019 02:05:08 +0200 Subject: [PATCH 142/319] Add shuffle support for itunes component (#24319) * added shuffle support for itunes component * fixed pylint errors * more pylint * final pylint (have my own dev env now) --- .../components/itunes/media_player.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 8451d751954cc4..04e4c3f09e63f2 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -10,7 +10,7 @@ MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_SET) + SUPPORT_VOLUME_SET, SUPPORT_SHUFFLE_SET) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) @@ -26,7 +26,7 @@ SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_TURN_OFF + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_TURN_OFF | SUPPORT_SHUFFLE_SET SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_TURN_OFF @@ -96,6 +96,11 @@ def set_muted(self, muted): """Mute and returns the current state, muted True or False.""" return self._request('PUT', '/mute', {'muted': muted}) + def set_shuffle(self, shuffle): + """Set the shuffle mode, shuffle True or False.""" + return self._request('PUT', '/shuffle', + {'mode': ('songs' if shuffle else 'off')}) + def play(self): """Set playback to play and returns the current state.""" return self._command('play') @@ -183,6 +188,7 @@ def __init__(self, name, host, port, use_ssl, add_entities): self.current_volume = None self.muted = None + self.shuffled = None self.current_title = None self.current_album = None self.current_artist = None @@ -207,6 +213,9 @@ def update_state(self, state_hash): self.current_playlist = state_hash.get('playlist', None) self.content_id = state_hash.get('id', None) + _shuffle = state_hash.get('shuffle', None) + self.shuffled = (_shuffle == 'songs') + @property def name(self): """Return the name of the device.""" @@ -306,6 +315,11 @@ def media_playlist(self): """Title of the currently playing playlist.""" return self.current_playlist + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + return self.shuffled + @property def supported_features(self): """Flag media player features that are supported.""" @@ -321,6 +335,11 @@ def mute_volume(self, mute): response = self.client.set_muted(mute) self.update_state(response) + def set_shuffle(self, shuffle): + """Shuffle (true) or no shuffle (false) media player.""" + response = self.client.set_shuffle(shuffle) + self.update_state(response) + def media_play(self): """Send media_play command to media player.""" response = self.client.play() From a3794b3241916ade45ae657f0e1d939b069af867 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 8 Jun 2019 02:29:51 +0200 Subject: [PATCH 143/319] Fixed wrong setpoint value on startup when climate was previously turned off (#24377) --- homeassistant/components/lcn/climate.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 67ba6d90c53178..7cf4f700b41974 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -51,7 +51,7 @@ def __init__(self, config, address_connection): self._current_temperature = None self._target_temperature = None - self._is_on = True + self._is_on = None self.support = const.SUPPORT_TARGET_TEMPERATURE if self.is_lockable: @@ -130,10 +130,12 @@ def input_received(self, input_obj): return if input_obj.get_var() == self.variable: - self._current_temperature = ( - input_obj.get_value().to_var_unit(self.unit)) - elif self._is_on and input_obj.get_var() == self.setpoint: - self._target_temperature = ( - input_obj.get_value().to_var_unit(self.unit)) + self._current_temperature = \ + input_obj.get_value().to_var_unit(self.unit) + elif input_obj.get_var() == self.setpoint: + self._is_on = not input_obj.get_value().is_locked_regulator() + if self.is_on: + self._target_temperature = \ + input_obj.get_value().to_var_unit(self.unit) self.async_schedule_update_ha_state() From b5ada3bf106d4da68372adaa96856ebe91d60d4a Mon Sep 17 00:00:00 2001 From: rolfberkenbosch <30292281+rolfberkenbosch@users.noreply.github.com> Date: Sat, 8 Jun 2019 02:31:57 +0200 Subject: [PATCH 144/319] Add ATTR_FORECAST_PRECIPITATION option (#24308) * Add ATTR_FORECAST_PRECIPITATION option * Remove blank line --- homeassistant/components/buienradar/weather.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 7d77bec7cca05a..a8e4f9d424d752 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -5,7 +5,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity, + ATTR_FORECAST_PRECIPITATION) from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) from homeassistant.helpers import config_validation as cv @@ -149,7 +150,7 @@ def temperature_unit(self): @property def forecast(self): """Return the forecast array.""" - from buienradar.buienradar import (CONDITION, CONDCODE, DATETIME, + from buienradar.buienradar import (CONDITION, CONDCODE, RAIN, DATETIME, MIN_TEMP, MAX_TEMP) if self._forecast: @@ -166,6 +167,7 @@ def forecast(self): data_out[ATTR_FORECAST_CONDITION] = cond[condcode] data_out[ATTR_FORECAST_TEMP_LOW] = data_in.get(MIN_TEMP) data_out[ATTR_FORECAST_TEMP] = data_in.get(MAX_TEMP) + data_out[ATTR_FORECAST_PRECIPITATION] = data_in.get(RAIN) fcdata_out.append(data_out) From 4c3f39be02ef98f5d01d9e2c76915d317e718c59 Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Fri, 7 Jun 2019 19:45:58 -0500 Subject: [PATCH 145/319] Provide an option for the DD-WRT device tracker to include non-wireless devices (#24259) * Use LAN status instead of wireless status for DD-WRT device tracking * Use the previous DD-WRT device tracker behavior unless specified in the configuration --- homeassistant/components/ddwrt/device_tracker.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index a97fe340f927ed..e412e33fa17ad9 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -18,6 +18,8 @@ DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True +CONF_WIRELESS_ONLY = 'wireless_only' +DEFAULT_WIRELESS_ONLY = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -25,6 +27,7 @@ vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_WIRELESS_ONLY, default=DEFAULT_WIRELESS_ONLY): cv.boolean }) @@ -46,6 +49,7 @@ def __init__(self, config): self.host = config[CONF_HOST] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] + self.wireless_only = config[CONF_WIRELESS_ONLY] self.last_results = {} self.mac2name = {} @@ -103,8 +107,9 @@ def _update_info(self): """ _LOGGER.info("Checking ARP") - url = '{}://{}/Status_Wireless.live.asp'.format( - self.protocol, self.host) + endpoint = 'Wireless' if self.wireless_only else 'Lan' + url = '{}://{}/Status_{}.live.asp'.format( + self.protocol, self.host, endpoint) data = self.get_ddwrt_data(url) if not data: @@ -112,7 +117,10 @@ def _update_info(self): self.last_results = [] - active_clients = data.get('active_wireless', None) + if self.wireless_only: + active_clients = data.get('active_wireless', None) + else: + active_clients = data.get('arp_table', None) if not active_clients: return False From d858e1be056c369e38cb15cad87cce61c934a437 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 7 Jun 2019 23:29:31 -0400 Subject: [PATCH 146/319] Updated pubnubsub-handler to 1.0.7 to fix crash on slow startup (#24388) --- homeassistant/components/wink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wink/manifest.json b/homeassistant/components/wink/manifest.json index 118f7a19733d5a..a878b08416953f 100644 --- a/homeassistant/components/wink/manifest.json +++ b/homeassistant/components/wink/manifest.json @@ -3,7 +3,7 @@ "name": "Wink", "documentation": "https://www.home-assistant.io/components/wink", "requirements": [ - "pubnubsub-handler==1.0.6", + "pubnubsub-handler==1.0.7", "python-wink==1.10.5" ], "dependencies": ["configurator"], diff --git a/requirements_all.txt b/requirements_all.txt index d8332696999152..65c92377da7b37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,7 +930,7 @@ psutil==5.6.2 ptvsd==4.2.8 # homeassistant.components.wink -pubnubsub-handler==1.0.6 +pubnubsub-handler==1.0.7 # homeassistant.components.pushbullet pushbullet.py==0.11.0 From 61dabae6abc96b44bd6fc26ff93e1745ab86c156 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 7 Jun 2019 23:45:37 -0500 Subject: [PATCH 147/319] Add for option for template triggers (#24330) --- .../components/automation/template.py | 54 ++++++++--- tests/components/automation/test_template.py | 93 ++++++++++++++++++- 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 6371be2802102d..96075e9bd1c195 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -4,8 +4,10 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM -from homeassistant.helpers.event import async_track_template +from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_FOR +from homeassistant.helpers import condition +from homeassistant.helpers.event import ( + async_track_same_state, async_track_template) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -13,6 +15,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'template', vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }) @@ -20,17 +23,44 @@ async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass + time_delta = config.get(CONF_FOR) + unsub_track_same = None @callback def template_listener(entity_id, from_s, to_s): """Listen for state changes and calls action.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'template', - 'entity_id': entity_id, - 'from_state': from_s, - 'to_state': to_s, - }, - }, context=(to_s.context if to_s else None))) - - return async_track_template(hass, value_template, template_listener) + nonlocal unsub_track_same + + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action({ + 'trigger': { + 'platform': 'template', + 'entity_id': entity_id, + 'from_state': from_s, + 'to_state': to_s, + }, + }, context=(to_s.context if to_s else None))) + + if not time_delta: + call_action() + return + + unsub_track_same = async_track_same_state( + hass, time_delta, call_action, + lambda _, _2, _3: condition.async_template(hass, value_template), + value_template.extract_entities()) + + unsub = async_track_template( + hass, value_template, template_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if unsub_track_same: + # pylint: disable=not-callable + unsub_track_same() + + return async_remove diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index 25f32ac193942f..815c5e440b4a4c 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -1,11 +1,15 @@ """The tests for the Template automation.""" +from datetime import timedelta + import pytest from homeassistant.core import Context from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation -from tests.common import (assert_setup_component, mock_component) +from tests.common import ( + async_fire_time_changed, assert_setup_component, mock_component) from tests.components.automation import common from tests.common import async_mock_service @@ -434,3 +438,90 @@ async def test_wait_template_with_trigger(hass, calls): assert 1 == len(calls) assert 'template - test.entity - hello - world' == \ calls[0].data['some'] + + +async def test_if_fires_on_change_with_for(hass, calls): + """Test for firing on change with for.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': "{{ is_state('test.entity', 'world') }}", + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + hass.states.async_set('test.entity', 'world') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert 1 == len(calls) + + +async def test_if_not_fires_on_change_with_for(hass, calls): + """Test for firing on change with for.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': "{{ is_state('test.entity', 'world') }}", + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + hass.states.async_set('test.entity', 'world') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4)) + await hass.async_block_till_done() + assert 0 == len(calls) + hass.states.async_set('test.entity', 'hello') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert 0 == len(calls) + + +async def test_if_not_fires_when_turned_off_with_for(hass, calls): + """Test for firing on change with for.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': "{{ is_state('test.entity', 'world') }}", + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + hass.states.async_set('test.entity', 'world') + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4)) + await hass.async_block_till_done() + assert 0 == len(calls) + await common.async_turn_off(hass) + await hass.async_block_till_done() + assert 0 == len(calls) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert 0 == len(calls) From 233bc1a1080149b9abd09e663293d63146b14025 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 7 Jun 2019 23:46:49 -0500 Subject: [PATCH 148/319] Improve amcrest error handling and bump amcrest package to 1.5.3 (#24262) * Improve amcrest error handling and bump amcrest package to 1.5.3 amcrest package update fixes command retry, especially with Digest Authentication, and allows sending snapshot command without channel parameter. Get rid of persistent_notification. Errors at startup, other than login errors, are no longer fatal. Display debug messages about how many times an error has occurred in a row. Remove initial communications test. If camera is off line at startup this just delays the component setup. Handle urllib3 errors when getting data from commands that were sent with stream=True. If errors occur during camera update, try repeating until it works or the camera is determined to be off line. Drop channel parameter in snapshot command which allows camera to use its default channel, which is different in different camera models and firmware versions. Make entities unavailable if too many errors occur in a row. Add new configuration variables to control how many errors in a row should be interpreted as camera being offline, and how frequently to "ping" camera to see when it becomes available again. Add online binary_sensor option to indicate if camera is available (i.e., responding to commands.) * Update per review comments Remove max_errors and recheck_interval configuration variables and used fixed values instead. Move definition of AmcrestChecker class to module level. Change should_poll in camera.py to return a fixed value of True and move logic to update method. --- homeassistant/components/amcrest/__init__.py | 120 ++++++++++---- .../components/amcrest/binary_sensor.py | 61 +++++-- homeassistant/components/amcrest/camera.py | 151 +++++++++++------- homeassistant/components/amcrest/const.py | 4 + homeassistant/components/amcrest/helpers.py | 15 +- .../components/amcrest/manifest.json | 2 +- homeassistant/components/amcrest/sensor.py | 90 +++++++---- homeassistant/components/amcrest/switch.py | 81 +++++++--- requirements_all.txt | 2 +- 9 files changed, 368 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 58df1d8e504763..1c9303b2c52355 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,9 +1,10 @@ """Support for Amcrest IP cameras.""" import logging from datetime import timedelta +import threading import aiohttp -from amcrest import AmcrestCamera, AmcrestError +from amcrest import AmcrestError, Http, LoginError import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_CONTROL @@ -18,12 +19,14 @@ from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, dispatcher_send) +from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.service import async_extract_entity_ids -from .binary_sensor import BINARY_SENSORS +from .binary_sensor import BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSORS from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST -from .const import DOMAIN, DATA_AMCREST +from .const import CAMERAS, DOMAIN, DATA_AMCREST, DEVICES, SERVICE_UPDATE from .helpers import service_signal from .sensor import SENSOR_MOTION_DETECTOR, SENSORS from .switch import SWITCHES @@ -39,6 +42,8 @@ DEFAULT_PORT = 80 DEFAULT_RESOLUTION = 'high' DEFAULT_ARGUMENTS = '-pred 1' +MAX_ERRORS = 5 +RECHECK_INTERVAL = timedelta(minutes=1) NOTIFICATION_ID = 'amcrest_notification' NOTIFICATION_TITLE = 'Amcrest Camera Setup' @@ -58,20 +63,21 @@ def _deprecated_sensor_values(sensors): if SENSOR_MOTION_DETECTOR in sensors: _LOGGER.warning( - "The 'sensors' option value '%s' is deprecated, " + "The '%s' option value '%s' is deprecated, " "please remove it from your configuration and use " - "the 'binary_sensors' option with value 'motion_detected' " - "instead.", SENSOR_MOTION_DETECTOR) + "the '%s' option with value '%s' instead", + CONF_SENSORS, SENSOR_MOTION_DETECTOR, CONF_BINARY_SENSORS, + BINARY_SENSOR_MOTION_DETECTED) return sensors def _deprecated_switches(config): if CONF_SWITCHES in config: _LOGGER.warning( - "The 'switches' option (with value %s) is deprecated, " + "The '%s' option (with value %s) is deprecated, " "please remove it from your configuration and use " - "camera services and attributes instead.", - config[CONF_SWITCHES]) + "services and attributes instead", + CONF_SWITCHES, config[CONF_SWITCHES]) return config @@ -115,33 +121,81 @@ def _has_unique_names(devices): }, extra=vol.ALLOW_EXTRA) +# pylint: disable=too-many-ancestors +class AmcrestChecker(Http): + """amcrest.Http wrapper for catching errors.""" + + def __init__(self, hass, name, host, port, user, password): + """Initialize.""" + self._hass = hass + self._wrap_name = name + self._wrap_errors = 0 + self._wrap_lock = threading.Lock() + self._unsub_recheck = None + super().__init__(host, port, user, password, retries_connection=1, + timeout_protocol=3.05) + + @property + def available(self): + """Return if camera's API is responding.""" + return self._wrap_errors <= MAX_ERRORS + + def command(self, cmd, retries=None, timeout_cmd=None, stream=False): + """amcrest.Http.command wrapper to catch errors.""" + try: + ret = super().command(cmd, retries, timeout_cmd, stream) + except AmcrestError: + with self._wrap_lock: + was_online = self.available + self._wrap_errors += 1 + _LOGGER.debug('%s camera errs: %i', self._wrap_name, + self._wrap_errors) + offline = not self.available + if offline and was_online: + _LOGGER.error( + '%s camera offline: Too many errors', self._wrap_name) + dispatcher_send( + self._hass, + service_signal(SERVICE_UPDATE, self._wrap_name)) + self._unsub_recheck = track_time_interval( + self._hass, self._wrap_test_online, RECHECK_INTERVAL) + raise + with self._wrap_lock: + was_offline = not self.available + self._wrap_errors = 0 + if was_offline: + self._unsub_recheck() + self._unsub_recheck = None + _LOGGER.error('%s camera back online', self._wrap_name) + dispatcher_send( + self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) + return ret + + def _wrap_test_online(self, now): + """Test if camera is back online.""" + try: + self.current_time + except AmcrestError: + pass + + def setup(hass, config): """Set up the Amcrest IP Camera component.""" - hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []}) - devices = config[DOMAIN] + hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) - for device in devices: + for device in config[DOMAIN]: name = device[CONF_NAME] username = device[CONF_USERNAME] password = device[CONF_PASSWORD] try: - api = AmcrestCamera(device[CONF_HOST], - device[CONF_PORT], - username, - password).camera - # pylint: disable=pointless-statement - # Test camera communications. - api.current_time - - except AmcrestError as ex: - _LOGGER.error("Unable to connect to %s camera: %s", name, str(ex)) - hass.components.persistent_notification.create( - 'Error: {}
' - 'You will need to restart hass after fixing.' - ''.format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + api = AmcrestChecker( + hass, name, + device[CONF_HOST], device[CONF_PORT], + username, password) + + except LoginError as ex: + _LOGGER.error("Login error for %s camera: %s", name, ex) continue ffmpeg_arguments = device[CONF_FFMPEG_ARGUMENTS] @@ -159,7 +213,7 @@ def setup(hass, config): else: authentication = None - hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice( + hass.data[DATA_AMCREST][DEVICES][name] = AmcrestDevice( api, authentication, ffmpeg_arguments, stream_source, resolution, control_light) @@ -189,7 +243,7 @@ def setup(hass, config): CONF_SWITCHES: switches }, config) - if not hass.data[DATA_AMCREST]['devices']: + if not hass.data[DATA_AMCREST][DEVICES]: return False def have_permission(user, entity_id): @@ -207,13 +261,13 @@ async def async_extract_from_service(call): if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: # Return all entity_ids user has permission to control. return [ - entity_id for entity_id in hass.data[DATA_AMCREST]['cameras'] + entity_id for entity_id in hass.data[DATA_AMCREST][CAMERAS] if have_permission(user, entity_id) ] call_ids = await async_extract_entity_ids(hass, call) entity_ids = [] - for entity_id in hass.data[DATA_AMCREST]['cameras']: + for entity_id in hass.data[DATA_AMCREST][CAMERAS]: if entity_id not in call_ids: continue if not have_permission(user, entity_id): diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index fe4eb25b3db152..9489fc60d4daa1 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -5,17 +5,24 @@ from amcrest import AmcrestError from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASS_MOTION) + BinarySensorDevice, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOTION) from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST +from .const import ( + BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, SERVICE_UPDATE) +from .helpers import log_update_error, service_signal _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) +BINARY_SENSOR_MOTION_DETECTED = 'motion_detected' +BINARY_SENSOR_ONLINE = 'online' +# Binary sensor types are defined like: Name, device class BINARY_SENSORS = { - 'motion_detected': 'Motion Detected' + BINARY_SENSOR_MOTION_DETECTED: ('Motion Detected', DEVICE_CLASS_MOTION), + BINARY_SENSOR_ONLINE: ('Online', DEVICE_CLASS_CONNECTIVITY), } @@ -26,7 +33,7 @@ async def async_setup_platform(hass, config, async_add_entities, return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities( [AmcrestBinarySensor(name, device, sensor_type) for sensor_type in discovery_info[CONF_BINARY_SENSORS]], @@ -38,10 +45,18 @@ class AmcrestBinarySensor(BinarySensorDevice): def __init__(self, name, device, sensor_type): """Initialize entity.""" - self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type]) + self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type][0]) + self._signal_name = name self._api = device.api self._sensor_type = sensor_type self._state = None + self._device_class = BINARY_SENSORS[sensor_type][1] + self._unsub_dispatcher = None + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return self._sensor_type != BINARY_SENSOR_ONLINE @property def name(self): @@ -56,15 +71,39 @@ def is_on(self): @property def device_class(self): """Return device class.""" - return DEVICE_CLASS_MOTION + return self._device_class + + @property + def available(self): + """Return True if entity is available.""" + return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available def update(self): """Update entity.""" - _LOGGER.debug('Pulling data from %s binary sensor', self._name) + if not self.available: + return + _LOGGER.debug('Updating %s binary sensor', self._name) try: - self._state = self._api.is_motion_detected + if self._sensor_type == BINARY_SENSOR_MOTION_DETECTED: + self._state = self._api.is_motion_detected + + elif self._sensor_type == BINARY_SENSOR_ONLINE: + self._state = self._api.available except AmcrestError as error: - _LOGGER.error( - 'Could not update %s binary sensor due to error: %s', - self.name, error) + log_update_error( + _LOGGER, 'update', self.name, 'binary sensor', error) + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 3b8c8f38f8bc57..685d92d5ae6be5 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,6 +1,8 @@ """Support for Amcrest IP cameras.""" import asyncio +from datetime import timedelta import logging +from urllib3.exceptions import HTTPError from amcrest import AmcrestError import voluptuous as vol @@ -15,11 +17,14 @@ async_get_clientsession) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import CAMERA_WEB_SESSION_TIMEOUT, DATA_AMCREST -from .helpers import service_signal +from .const import ( + CAMERA_WEB_SESSION_TIMEOUT, CAMERAS, DATA_AMCREST, DEVICES, SERVICE_UPDATE) +from .helpers import log_update_error, service_signal _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) + STREAM_SOURCE_LIST = [ 'snapshot', 'mjpeg', @@ -77,7 +82,7 @@ async def async_setup_platform(hass, config, async_add_entities, return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities([ AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) @@ -106,23 +111,25 @@ def __init__(self, name, device, ffmpeg): self._rtsp_url = None self._snapshot_lock = asyncio.Lock() self._unsub_dispatcher = [] + self._update_succeeded = False async def async_camera_image(self): """Return a still image response from the camera.""" - if not self.is_on: - _LOGGER.error( - 'Attempt to take snaphot when %s camera is off', self.name) + available = self.available + if not available or not self.is_on: + _LOGGER.warning( + 'Attempt to take snaphot when %s camera is %s', self.name, + 'offline' if not available else 'off') return None async with self._snapshot_lock: try: # Send the request to snap a picture and return raw jpg data response = await self.hass.async_add_executor_job( - self._api.snapshot, self._resolution) + self._api.snapshot) return response.data - except AmcrestError as error: - _LOGGER.error( - 'Could not get image from %s camera due to error: %s', - self.name, error) + except (AmcrestError, HTTPError) as error: + log_update_error( + _LOGGER, 'get image from', self.name, 'camera', error) return None async def handle_async_mjpeg_stream(self, request): @@ -131,6 +138,12 @@ async def handle_async_mjpeg_stream(self, request): if self._stream_source == 'snapshot': return await super().handle_async_mjpeg_stream(request) + if not self.available: + _LOGGER.warning( + 'Attempt to stream %s when %s camera is offline', + self._stream_source, self.name) + return None + if self._stream_source == 'mjpeg': # stream an MJPEG image stream directly from the camera websession = async_get_clientsession(self.hass) @@ -160,6 +173,14 @@ async def handle_async_mjpeg_stream(self, request): # Entity property overrides + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return True + @property def name(self): """Return the name of this camera.""" @@ -178,6 +199,11 @@ def device_state_attributes(self): attr[_ATTR_COLOR_BW] = self._color_bw return attr + @property + def available(self): + """Return True if entity is available.""" + return self._api.available + @property def supported_features(self): """Return supported features.""" @@ -216,6 +242,10 @@ def is_on(self): # Other Entity method overrides + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + async def async_added_to_hass(self): """Subscribe to signals and add camera to list.""" for service, params in CAMERA_SERVICES.items(): @@ -223,38 +253,37 @@ async def async_added_to_hass(self): self.hass, service_signal(service, self.entity_id), getattr(self, params[1]))) - self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id) + self._unsub_dispatcher.append(async_dispatcher_connect( + self.hass, service_signal(SERVICE_UPDATE, self._name), + self.async_on_demand_update)) + self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id) async def async_will_remove_from_hass(self): """Remove camera from list and disconnect from signals.""" - self.hass.data[DATA_AMCREST]['cameras'].remove(self.entity_id) + self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() def update(self): """Update entity status.""" - _LOGGER.debug('Pulling data from %s camera', self.name) - if self._brand is None: - try: + if not self.available or self._update_succeeded: + if not self.available: + self._update_succeeded = False + return + _LOGGER.debug('Updating %s camera', self.name) + try: + if self._brand is None: resp = self._api.vendor_information.strip() if resp.startswith('vendor='): self._brand = resp.split('=')[-1] else: self._brand = 'unknown' - except AmcrestError as error: - _LOGGER.error( - 'Could not get %s camera brand due to error: %s', - self.name, error) - self._brand = 'unknwown' - if self._model is None: - try: - self._model = self._api.device_type.split('=')[-1].strip() - except AmcrestError as error: - _LOGGER.error( - 'Could not get %s camera model due to error: %s', - self.name, error) - self._model = 'unknown' - try: + if self._model is None: + resp = self._api.device_type.strip() + if resp.startswith('type='): + self._model = resp.split('=')[-1] + else: + self._model = 'unknown' self.is_streaming = self._api.video_enabled self._is_recording = self._api.record_mode == 'Manual' self._motion_detection_enabled = ( @@ -265,9 +294,11 @@ def update(self): self._color_bw = _CBW[self._api.day_night_color] self._rtsp_url = self._api.rtsp_url(typeno=self._resolution) except AmcrestError as error: - _LOGGER.error( - 'Could not get %s camera attributes due to error: %s', - self.name, error) + log_update_error( + _LOGGER, 'get', self.name, 'camera attributes', error) + self._update_succeeded = False + else: + self._update_succeeded = True # Other Camera method overrides @@ -343,9 +374,9 @@ def _enable_video_stream(self, enable): try: self._api.video_enabled = enable except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera video stream due to error: %s', - 'enable' if enable else 'disable', self.name, error) + log_update_error( + _LOGGER, 'enable' if enable else 'disable', self.name, + 'camera video stream', error) else: self.is_streaming = enable self.schedule_update_ha_state() @@ -364,9 +395,9 @@ def _enable_recording(self, enable): self._api.record_mode = rec_mode[ 'Manual' if enable else 'Automatic'] except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera recording due to error: %s', - 'enable' if enable else 'disable', self.name, error) + log_update_error( + _LOGGER, 'enable' if enable else 'disable', self.name, + 'camera recording', error) else: self._is_recording = enable self.schedule_update_ha_state() @@ -376,9 +407,9 @@ def _enable_motion_detection(self, enable): try: self._api.motion_detection = str(enable).lower() except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera motion detection due to error: %s', - 'enable' if enable else 'disable', self.name, error) + log_update_error( + _LOGGER, 'enable' if enable else 'disable', self.name, + 'camera motion detection', error) else: self._motion_detection_enabled = enable self.schedule_update_ha_state() @@ -388,9 +419,9 @@ def _enable_audio(self, enable): try: self._api.audio_enabled = enable except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera audio stream due to error: %s', - 'enable' if enable else 'disable', self.name, error) + log_update_error( + _LOGGER, 'enable' if enable else 'disable', self.name, + 'camera audio stream', error) else: self._audio_enabled = enable self.schedule_update_ha_state() @@ -404,18 +435,18 @@ def _enable_light(self, enable): 'configManager.cgi?action=setConfig&LightGlobal[0].Enable={}' .format(str(enable).lower())) except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera indicator light due to error: %s', - 'enable' if enable else 'disable', self.name, error) + log_update_error( + _LOGGER, 'enable' if enable else 'disable', self.name, + 'indicator light', error) def _enable_motion_recording(self, enable): """Enable or disable motion recording.""" try: self._api.motion_recording = str(enable).lower() except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera motion recording due to error: %s', - 'enable' if enable else 'disable', self.name, error) + log_update_error( + _LOGGER, 'enable' if enable else 'disable', self.name, + 'camera motion recording', error) else: self._motion_recording_enabled = enable self.schedule_update_ha_state() @@ -426,18 +457,18 @@ def _goto_preset(self, preset): self._api.go_to_preset( action='start', preset_point_number=preset) except AmcrestError as error: - _LOGGER.error( - 'Could not move %s camera to preset %i due to error: %s', - self.name, preset, error) + log_update_error( + _LOGGER, 'move', self.name, + 'camera to preset {}'.format(preset), error) def _set_color_bw(self, cbw): """Set camera color mode.""" try: self._api.day_night_color = _CBW.index(cbw) except AmcrestError as error: - _LOGGER.error( - 'Could not set %s camera color mode to %s due to error: %s', - self.name, cbw, error) + log_update_error( + _LOGGER, 'set', self.name, + 'camera color mode to {}'.format(cbw), error) else: self._color_bw = cbw self.schedule_update_ha_state() @@ -447,6 +478,6 @@ def _start_tour(self, start): try: self._api.tour(start=start) except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera tour due to error: %s', - 'start' if start else 'stop', self.name, error) + log_update_error( + _LOGGER, 'start' if start else 'stop', self.name, + 'camera tour', error) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index a0230937e95b81..fe07659b48af8d 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -1,7 +1,11 @@ """Constants for amcrest component.""" DOMAIN = 'amcrest' DATA_AMCREST = DOMAIN +CAMERAS = 'cameras' +DEVICES = 'devices' BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 CAMERA_WEB_SESSION_TIMEOUT = 10 SENSOR_SCAN_INTERVAL_SECS = 10 + +SERVICE_UPDATE = 'update' diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index 270c969a6cc9fa..69d7f5ef28825c 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -2,9 +2,16 @@ from .const import DOMAIN -def service_signal(service, entity_id=None): - """Encode service and entity_id into signal.""" +def service_signal(service, ident=None): + """Encode service and identifier into signal.""" signal = '{}_{}'.format(DOMAIN, service) - if entity_id: - signal += '_{}'.format(entity_id.replace('.', '_')) + if ident: + signal += '_{}'.format(ident.replace('.', '_')) return signal + + +def log_update_error(logger, action, name, entity_type, error): + """Log an update error.""" + logger.error( + 'Could not %s %s %s due to error: %s', + action, name, entity_type, error.__class__.__name__) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index a2eb8c24e212f2..f79ce34897b92c 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -3,7 +3,7 @@ "name": "Amcrest", "documentation": "https://www.home-assistant.io/components/amcrest", "requirements": [ - "amcrest==1.4.0" + "amcrest==1.5.3" ], "dependencies": [ "ffmpeg" diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 718d08358c4210..1788b9c62b0e13 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -2,21 +2,28 @@ from datetime import timedelta import logging +from amcrest import AmcrestError + from homeassistant.const import CONF_NAME, CONF_SENSORS +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DATA_AMCREST, SENSOR_SCAN_INTERVAL_SECS +from .const import ( + DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE) +from .helpers import log_update_error, service_signal _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) -# Sensor types are defined like: Name, units, icon SENSOR_MOTION_DETECTOR = 'motion_detector' +SENSOR_PTZ_PRESET = 'ptz_preset' +SENSOR_SDCARD = 'sdcard' +# Sensor types are defined like: Name, units, icon SENSORS = { SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], - 'sdcard': ['SD Used', '%', 'mdi:sd'], - 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], + SENSOR_PTZ_PRESET: ['PTZ Preset', None, 'mdi:camera-iris'], + SENSOR_SDCARD: ['SD Used', '%', 'mdi:sd'], } @@ -27,7 +34,7 @@ async def async_setup_platform( return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities( [AmcrestSensor(name, device, sensor_type) for sensor_type in discovery_info[CONF_SENSORS]], @@ -40,12 +47,14 @@ class AmcrestSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize a sensor for Amcrest camera.""" self._name = '{} {}'.format(name, SENSORS[sensor_type][0]) + self._signal_name = name self._api = device.api self._sensor_type = sensor_type self._state = None self._attrs = {} self._unit_of_measurement = SENSORS[sensor_type][1] self._icon = SENSORS[sensor_type][2] + self._unsub_dispatcher = None @property def name(self): @@ -72,28 +81,53 @@ def unit_of_measurement(self): """Return the units of measurement.""" return self._unit_of_measurement + @property + def available(self): + """Return True if entity is available.""" + return self._api.available + def update(self): """Get the latest data and updates the state.""" - _LOGGER.debug("Pulling data from %s sensor.", self._name) - - if self._sensor_type == 'motion_detector': - self._state = self._api.is_motion_detected - self._attrs['Record Mode'] = self._api.record_mode - - elif self._sensor_type == 'ptz_preset': - self._state = self._api.ptz_presets_count - - elif self._sensor_type == 'sdcard': - storage = self._api.storage_all - try: - self._attrs['Total'] = '{:.2f} {}'.format(*storage['total']) - except ValueError: - self._attrs['Total'] = '{} {}'.format(*storage['total']) - try: - self._attrs['Used'] = '{:.2f} {}'.format(*storage['used']) - except ValueError: - self._attrs['Used'] = '{} {}'.format(*storage['used']) - try: - self._state = '{:.2f}'.format(storage['used_percent']) - except ValueError: - self._state = storage['used_percent'] + if not self.available: + return + _LOGGER.debug("Updating %s sensor", self._name) + + try: + if self._sensor_type == SENSOR_MOTION_DETECTOR: + self._state = self._api.is_motion_detected + self._attrs['Record Mode'] = self._api.record_mode + + elif self._sensor_type == SENSOR_PTZ_PRESET: + self._state = self._api.ptz_presets_count + + elif self._sensor_type == SENSOR_SDCARD: + storage = self._api.storage_all + try: + self._attrs['Total'] = '{:.2f} {}'.format( + *storage['total']) + except ValueError: + self._attrs['Total'] = '{} {}'.format(*storage['total']) + try: + self._attrs['Used'] = '{:.2f} {}'.format(*storage['used']) + except ValueError: + self._attrs['Used'] = '{} {}'.format(*storage['used']) + try: + self._state = '{:.2f}'.format(storage['used_percent']) + except ValueError: + self._state = storage['used_percent'] + except AmcrestError as error: + log_update_error(_LOGGER, 'update', self.name, 'sensor', error) + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py index 5989d4daf1e38a..ec286b4f4047e1 100644 --- a/homeassistant/components/amcrest/switch.py +++ b/homeassistant/components/amcrest/switch.py @@ -1,17 +1,23 @@ """Support for toggling Amcrest IP camera settings.""" import logging +from amcrest import AmcrestError + from homeassistant.const import CONF_NAME, CONF_SWITCHES +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import ToggleEntity -from .const import DATA_AMCREST +from .const import DATA_AMCREST, DEVICES, SERVICE_UPDATE +from .helpers import log_update_error, service_signal _LOGGER = logging.getLogger(__name__) +MOTION_DETECTION = 'motion_detection' +MOTION_RECORDING = 'motion_recording' # Switch types are defined like: Name, icon SWITCHES = { - 'motion_detection': ['Motion Detection', 'mdi:run-fast'], - 'motion_recording': ['Motion Recording', 'mdi:record-rec'] + MOTION_DETECTION: ['Motion Detection', 'mdi:run-fast'], + MOTION_RECORDING: ['Motion Recording', 'mdi:record-rec'] } @@ -22,7 +28,7 @@ async def async_setup_platform( return name = discovery_info[CONF_NAME] - device = hass.data[DATA_AMCREST]['devices'][name] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities( [AmcrestSwitch(name, device, setting) for setting in discovery_info[CONF_SWITCHES]], @@ -35,10 +41,12 @@ class AmcrestSwitch(ToggleEntity): def __init__(self, name, device, setting): """Initialize the Amcrest switch.""" self._name = '{} {}'.format(name, SWITCHES[setting][0]) + self._signal_name = name self._api = device.api self._setting = setting self._state = False self._icon = SWITCHES[setting][1] + self._unsub_dispatcher = None @property def name(self): @@ -52,30 +60,63 @@ def is_on(self): def turn_on(self, **kwargs): """Turn setting on.""" - if self._setting == 'motion_detection': - self._api.motion_detection = 'true' - elif self._setting == 'motion_recording': - self._api.motion_recording = 'true' + if not self.available: + return + try: + if self._setting == MOTION_DETECTION: + self._api.motion_detection = 'true' + elif self._setting == MOTION_RECORDING: + self._api.motion_recording = 'true' + except AmcrestError as error: + log_update_error(_LOGGER, 'turn on', self.name, 'switch', error) def turn_off(self, **kwargs): """Turn setting off.""" - if self._setting == 'motion_detection': - self._api.motion_detection = 'false' - elif self._setting == 'motion_recording': - self._api.motion_recording = 'false' + if not self.available: + return + try: + if self._setting == MOTION_DETECTION: + self._api.motion_detection = 'false' + elif self._setting == MOTION_RECORDING: + self._api.motion_recording = 'false' + except AmcrestError as error: + log_update_error(_LOGGER, 'turn off', self.name, 'switch', error) + + @property + def available(self): + """Return True if entity is available.""" + return self._api.available def update(self): """Update setting state.""" - _LOGGER.debug("Polling state for setting: %s ", self._name) - - if self._setting == 'motion_detection': - detection = self._api.is_motion_detector_on() - elif self._setting == 'motion_recording': - detection = self._api.is_record_on_motion_detection() - - self._state = detection + if not self.available: + return + _LOGGER.debug("Updating %s switch", self._name) + + try: + if self._setting == MOTION_DETECTION: + detection = self._api.is_motion_detector_on() + elif self._setting == MOTION_RECORDING: + detection = self._api.is_record_on_motion_detection() + self._state = detection + except AmcrestError as error: + log_update_error(_LOGGER, 'update', self.name, 'switch', error) @property def icon(self): """Return the icon for the switch.""" return self._icon + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Subscribe to update signal.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self._unsub_dispatcher() diff --git a/requirements_all.txt b/requirements_all.txt index 65c92377da7b37..a93427b23c2569 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -178,7 +178,7 @@ alpha_vantage==2.1.0 ambiclimate==0.1.2 # homeassistant.components.amcrest -amcrest==1.4.0 +amcrest==1.5.3 # homeassistant.components.androidtv androidtv==0.0.15 From b30f4b8fc0cd88ce018244d779ef421a883f0425 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 8 Jun 2019 13:18:02 +0800 Subject: [PATCH 149/319] Improve boolean validator (#24294) * Improve boolean validator * Remove extra throw * Remove None test as discussed * Fix for tests depending on None == False --- homeassistant/helpers/config_validation.py | 10 +++++++--- tests/components/demo/test_climate.py | 6 ++++-- tests/components/demo/test_media_player.py | 3 ++- tests/components/demo/test_water_heater.py | 3 ++- tests/helpers/test_config_validation.py | 7 +++++-- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 7ec6d177178245..bd5d85230c59cf 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -6,6 +6,7 @@ from datetime import (timedelta, datetime as datetime_sys, time as time_sys, date as date_sys) from socket import _GLOBAL_DEFAULT_TIMEOUT +from numbers import Number from typing import Any, Union, TypeVar, Callable, Sequence, Dict, Optional from urllib.parse import urlparse from uuid import UUID @@ -81,14 +82,17 @@ def validate(obj: Dict) -> Dict: def boolean(value: Any) -> bool: """Validate and coerce a boolean value.""" + if isinstance(value, bool): + return value if isinstance(value, str): - value = value.lower() + value = value.lower().strip() if value in ('1', 'true', 'yes', 'on', 'enable'): return True if value in ('0', 'false', 'no', 'off', 'disable'): return False - raise vol.Invalid('invalid boolean value {}'.format(value)) - return bool(value) + elif isinstance(value, Number): + return value != 0 + raise vol.Invalid('invalid boolean value {}'.format(value)) def isdevice(value): diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 3166b2d3158643..444b053fc1958e 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -203,7 +203,8 @@ def test_set_away_mode_bad_attr(self): """Test setting the away mode without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 'on' == state.attributes.get('away_mode') - common.set_away_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_away_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() assert 'on' == state.attributes.get('away_mode') @@ -246,7 +247,8 @@ def test_set_aux_heat_bad_attr(self): """Test setting the auxiliary heater without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 'off' == state.attributes.get('aux_heat') - common.set_aux_heat(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_aux_heat(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() assert 'off' == state.attributes.get('aux_heat') diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 808e3ee2102a4b..fae4215f954e99 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -90,7 +90,8 @@ def test_volume_services(self): assert False is state.attributes.get('is_volume_muted') - common.mute_volume(self.hass, None, entity_id) + with pytest.raises(vol.Invalid): + common.mute_volume(self.hass, None, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert False is state.attributes.get('is_volume_muted') diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index d8c9c71935b05d..e336e879f91553 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -95,7 +95,8 @@ def test_set_away_mode_bad_attr(self): """Test setting the away mode without required attribute.""" state = self.hass.states.get(ENTITY_WATER_HEATER) assert 'off' == state.attributes.get('away_mode') - common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER) + with pytest.raises(vol.Invalid): + common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER) self.hass.block_till_done() assert 'off' == state.attributes.get('away_mode') diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 4b65904b8b2651..6124699d88e347 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -17,11 +17,14 @@ def test_boolean(): """Test boolean validation.""" schema = vol.Schema(cv.boolean) - for value in ('T', 'negative', 'lock'): + for value in ( + None, 'T', 'negative', 'lock', 'tr ue', + [], [1, 2], {'one': 'two'}, test_boolean): with pytest.raises(vol.MultipleInvalid): schema(value) - for value in ('true', 'On', '1', 'YES', 'enable', 1, True): + for value in ('true', 'On', '1', 'YES', ' true ', + 'enable', 1, 50, True, 0.1): assert schema(value) for value in ('false', 'Off', '0', 'NO', 'disable', 0, False): From 0dc0706eb20657ea7069fa5a7dcbca0b1d305aaa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 22:59:51 -0700 Subject: [PATCH 150/319] Add more HomeKit models for discovery (#24391) * Add more HomeKit models for discovery * Discover Tradfri with HomeKit * Add Wemo device info * Allow full match for HomeKit model * Fix tests --- .../homekit_controller/config_flow.py | 2 - homeassistant/components/hue/config_flow.py | 16 ++++++++ homeassistant/components/hue/manifest.json | 5 +++ .../components/tradfri/config_flow.py | 2 + .../components/tradfri/manifest.json | 5 +++ homeassistant/components/wemo/manifest.json | 5 +++ homeassistant/components/wemo/switch.py | 10 ++++- homeassistant/components/zeroconf/__init__.py | 2 +- homeassistant/generated/zeroconf.py | 5 ++- script/hassfest/ssdp.py | 2 +- script/hassfest/zeroconf.py | 7 +--- .../homekit_controller/test_config_flow.py | 2 +- tests/components/hue/test_config_flow.py | 35 +++++++++++++++++ tests/components/zeroconf/test_init.py | 39 +++++++++++++++---- 14 files changed, 118 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 2ce8c0db6b785a..9ddb144ec9ae08 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -13,9 +13,7 @@ HOMEKIT_IGNORE = [ - 'BSB002', 'Home Assistant Bridge', - 'TRADFRI gateway', ] HOMEKIT_DIR = '.homekit' PAIRING_FILE = 'pairing.json' diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 9c81d144d1c210..d57706f7ac87ee 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -175,6 +175,22 @@ async def async_step_ssdp(self, discovery_info): 'path': 'phue-{}.conf'.format(serial) }) + async def async_step_homekit(self, homekit_info): + """Handle HomeKit discovery.""" + # pylint: disable=unsupported-assignment-operation + host = self.context['host'] = homekit_info.get('host') + + if any(host == flow['context']['host'] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + + if host in configured_hosts(self.hass): + return self.async_abort(reason='already_configured') + + return await self.async_step_import({ + 'host': host, + }) + async def async_step_import(self, import_info): """Import a new bridge as a config entry. diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index d16988529b18d9..c0c7c462f905a7 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,11 @@ "Royal Philips Electronics" ] }, + "homekit": { + "models": [ + "BSB002" + ] + }, "dependencies": [], "codeowners": [ "@balloob" diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 76f6a8f5764c92..bfabf4fd12a933 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -87,6 +87,8 @@ async def async_step_zeroconf(self, user_input): self._host = user_input['host'] return await self.async_step_auth() + async_step_homekit = async_step_zeroconf + async def async_step_import(self, user_input): """Import a config entry.""" for entry in self._async_current_entries(): diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index aba3805a4aaf5c..ba6b21e00283ab 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -6,6 +6,11 @@ "requirements": [ "pytradfri[async]==6.0.1" ], + "homekit": { + "models": [ + "TRADFRI" + ] + }, "dependencies": [], "zeroconf": ["_coap._udp.local."], "codeowners": [ diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index c610c28da394f0..1902df1060b317 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -11,6 +11,11 @@ "Belkin International Inc." ] }, + "homekit": { + "models": [ + "Wemo" + ] + }, "dependencies": [], "codeowners": [ "@sqldiablo" diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index b8967cead3b03b..79f941d8bcf668 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN) -from . import SUBSCRIPTION_REGISTRY +from . import SUBSCRIPTION_REGISTRY, DOMAIN as WEMO_DOMAIN SCAN_INTERVAL = timedelta(seconds=10) @@ -93,6 +93,14 @@ def name(self): """Return the name of the switch if any.""" return self._name + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._name, + 'identifiers': {(WEMO_DOMAIN, self._serialnumber)}, + } + @property def device_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 289aba6ef56292..6011712c2f9789 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -112,7 +112,7 @@ def handle_homekit(hass, info) -> bool: return False for test_model in HOMEKIT: - if not model.startswith(test_model): + if model != test_model and not model.startswith(test_model + " "): continue hass.add_job( diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 716b212e4c623e..1bc00d08314e15 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -20,5 +20,8 @@ } HOMEKIT = { - "LIFX ": "lifx" + "BSB002": "hue", + "LIFX": "lifx", + "TRADFRI": "tradfri", + "Wemo": "wemo" } diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index 9c745e5b033687..308491dfa35575 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -44,7 +44,7 @@ def generate_and_validate(integrations: Dict[str, Integration]): try: with open(str(integration.path / "config_flow.py")) as fp: content = fp.read() - if (' async_step_ssdp(' not in content and + if (' async_step_ssdp' not in content and 'register_discovery_flow' not in content): integration.add_error( 'ssdp', 'Config flow has no async_step_ssdp') diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 895ae4ab790d6c..ad2b5b4e295784 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -42,13 +42,13 @@ def generate_and_validate(integrations: Dict[str, Integration]): uses_discovery_flow = 'register_discovery_flow' in content if (service_types and not uses_discovery_flow and - ' async_step_zeroconf(' not in content): + ' async_step_zeroconf' not in content): integration.add_error( 'zeroconf', 'Config flow has no async_step_zeroconf') continue if (homekit_models and not uses_discovery_flow and - ' async_step_homekit(' not in content): + ' async_step_homekit' not in content): integration.add_error( 'zeroconf', 'Config flow has no async_step_homekit') continue @@ -64,9 +64,6 @@ def generate_and_validate(integrations: Dict[str, Integration]): service_type_dict[service_type].append(domain) for model in homekit_models: - # We add a space, as we want to test for it to be model + space. - model += " " - if model in homekit_dict: integration.add_error( 'zeroconf', diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index b5f923dd55ee1b..99562f60045930 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -283,7 +283,7 @@ async def test_discovery_ignored_model(hass): 'host': '127.0.0.1', 'port': 8080, 'properties': { - 'md': 'BSB002', + 'md': config_flow.HOMEKIT_IGNORE[0], 'id': '00:00:00:00:00:00', 'c#': 1, 'sf': 1, diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index b7736e62390ea3..a4524dfd48df54 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -371,3 +371,38 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): # We did not process the result of this entry but already removed the old # ones. So we should have 0 entries. assert len(hass.config_entries.async_entries('hue')) == 0 + + +async def test_bridge_homekit(hass): + """Test a bridge being discovered via HomeKit.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + flow.context = {} + + with patch.object(config_flow, 'get_bridge', + side_effect=errors.AuthenticationRequired): + result = await flow.async_step_homekit({ + 'host': '0.0.0.0', + 'serial': '1234', + 'manufacturerURL': config_flow.HUE_MANUFACTURERURL + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_homekit_already_configured(hass): + """Test if a HomeKit discovered bridge has already been configured.""" + MockConfigEntry(domain='hue', data={ + 'host': '0.0.0.0' + }).add_to_hass(hass) + + flow = config_flow.HueFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_homekit({ + 'host': '0.0.0.0', + }) + + assert result['type'] == 'abort' diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 27c1dc757492d9..e67d9063b0ae32 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -31,12 +31,15 @@ def get_service_info_mock(service_type, name): properties={b'macaddress': b'ABCDEF012345'}) -def get_homekit_info_mock(service_type, name): +def get_homekit_info_mock(model): """Return homekit info for get_service_info.""" - return ServiceInfo( - service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, - priority=0, server='name.local.', - properties={b'md': b'LIFX Bulb'}) + def mock_homekit_info(service_type, name): + return ServiceInfo( + service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0, + priority=0, server='name.local.', + properties={b'md': model.encode()}) + + return mock_homekit_info async def test_setup(hass, mock_zeroconf): @@ -54,7 +57,7 @@ async def test_setup(hass, mock_zeroconf): assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2 -async def test_homekit(hass, mock_zeroconf): +async def test_homekit_match_partial(hass, mock_zeroconf): """Test configured options for a device are loaded via config entry.""" with patch.dict( zc_gen.ZEROCONF, { @@ -65,10 +68,32 @@ async def test_homekit(hass, mock_zeroconf): ) as mock_config_flow, patch.object( zeroconf, 'ServiceBrowser', side_effect=service_update_mock ) as mock_service_browser: - mock_zeroconf.get_service_info.side_effect = get_homekit_info_mock + mock_zeroconf.get_service_info.side_effect = \ + get_homekit_info_mock("LIFX bulb") assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 2 assert mock_config_flow.mock_calls[0][1][0] == 'lifx' + + +async def test_homekit_match_full(hass, mock_zeroconf): + """Test configured options for a device are loaded via config entry.""" + with patch.dict( + zc_gen.ZEROCONF, { + zeroconf.HOMEKIT_TYPE: ["homekit_controller"] + }, clear=True + ), patch.object( + hass.config_entries, 'flow' + ) as mock_config_flow, patch.object( + zeroconf, 'ServiceBrowser', side_effect=service_update_mock + ) as mock_service_browser: + mock_zeroconf.get_service_info.side_effect = \ + get_homekit_info_mock("BSB002") + assert await async_setup_component( + hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[0][1][0] == 'hue' From 7887d6d6e4e2dfe027c62cc113440e16eeba3497 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 23:08:22 -0700 Subject: [PATCH 151/319] Fix automation failing to restore state (#24390) * Fix automation off * Fix tests --- .../components/automation/__init__.py | 71 +++++++++++-------- .../automation/test_homeassistant.py | 4 +- tests/components/automation/test_init.py | 22 +++--- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index beca5cd236c4ad..90b5857b13c563 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -190,6 +190,7 @@ def __init__(self, automation_id, name, async_attach_triggers, cond_func, self._last_triggered = None self._hidden = hidden self._initial_state = initial_state + self._is_enabled = False @property def name(self): @@ -216,7 +217,8 @@ def hidden(self) -> bool: @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._async_detach_triggers is not None + return (self._async_detach_triggers is not None or + self._is_enabled) async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" @@ -239,37 +241,16 @@ async def async_added_to_hass(self) -> None: "initial state", self.entity_id, enable_automation) - if not enable_automation: - return - - # HomeAssistant is starting up - if self.hass.state == CoreState.not_running: - async def async_enable_automation(event): - """Start automation on startup.""" - await self.async_enable() - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_enable_automation) - - # HomeAssistant is running - else: + if enable_automation: await self.async_enable() async def async_turn_on(self, **kwargs) -> None: """Turn the entity on and update the state.""" - if self.is_on: - return - await self.async_enable() async def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" - if not self.is_on: - return - - self._async_detach_triggers() - self._async_detach_triggers = None - await self.async_update_ha_state() + await self.async_disable() async def async_trigger(self, variables, skip_condition=False, context=None): @@ -296,19 +277,51 @@ async def async_trigger(self, variables, skip_condition=False, async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" await super().async_will_remove_from_hass() - await self.async_turn_off() + await self.async_disable() async def async_enable(self): """Enable this automation entity. This method is a coroutine. """ - if self.is_on: + if self._is_enabled: return - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger) - await self.async_update_ha_state() + self._is_enabled = True + + # HomeAssistant is starting up + if self.hass.state != CoreState.not_running: + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger) + self.async_write_ha_state() + return + + async def async_enable_automation(event): + """Start automation on startup.""" + # Don't do anything if no longer enabled or already attached + if (not self._is_enabled or + self._async_detach_triggers is not None): + return + + self._async_detach_triggers = await self._async_attach_triggers( + self.async_trigger) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_enable_automation) + self.async_write_ha_state() + + async def async_disable(self): + """Disable the automation entity.""" + if not self._is_enabled: + return + + self._is_enabled = False + + if self._async_detach_triggers is not None: + self._async_detach_triggers() + self._async_detach_triggers = None + + self.async_write_ha_state() @property def device_state_attributes(self): diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/automation/test_homeassistant.py index d9cb5313c3e8bc..742a2aa857ca24 100644 --- a/tests/components/automation/test_homeassistant.py +++ b/tests/components/automation/test_homeassistant.py @@ -29,7 +29,7 @@ def test_if_fires_on_hass_start(hass): res = yield from async_setup_component(hass, automation.DOMAIN, config) assert res - assert not automation.is_on(hass, 'automation.hello') + assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 yield from hass.async_start() @@ -64,7 +64,7 @@ def test_if_fires_on_hass_shutdown(hass): } }) assert res - assert not automation.is_on(hass, 'automation.hello') + assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 yield from hass.async_start() diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 179c5f848951ac..81d7a8b257f236 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -696,12 +696,12 @@ def test_initial_value_off(hass): assert len(calls) == 0 -@asyncio.coroutine -def test_initial_value_on(hass): +async def test_initial_value_on(hass): """Test initial value on.""" + hass.state = CoreState.not_running calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'on', @@ -715,23 +715,23 @@ def test_initial_value_on(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') + await hass.async_start() hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_initial_value_off_but_restore_on(hass): +async def test_initial_value_off_but_restore_on(hass): """Test initial value off and restored state is turned on.""" + hass.state = CoreState.not_running calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_ON), )) - res = yield from async_setup_component(hass, automation.DOMAIN, { + await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'off', @@ -745,11 +745,11 @@ def test_initial_value_off_but_restore_on(hass): } } }) - assert res assert not automation.is_on(hass, 'automation.hello') + await hass.async_start() hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 @@ -858,7 +858,7 @@ def test_automation_not_trigger_on_bootstrap(hass): } }) assert res - assert not automation.is_on(hass, 'automation.hello') + assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') yield from hass.async_block_till_done() From f77514c6f284a7d9b59e667bb97478acb3318cf8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 23:08:55 -0700 Subject: [PATCH 152/319] Check cloud trusted proxies (#24395) --- homeassistant/components/cloud/const.py | 4 ++ homeassistant/components/cloud/http_api.py | 8 +++- homeassistant/components/cloud/prefs.py | 22 ++++++++- homeassistant/components/http/__init__.py | 1 + tests/components/cloud/test_http_api.py | 53 +++++++++++++++++++++- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index e2f4b9c078513b..65062213a630d2 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -38,3 +38,7 @@ class InvalidTrustedNetworks(Exception): """Raised when invalid trusted networks config.""" + + +class InvalidTrustedProxies(Exception): + """Raised when invalid trusted proxies config.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index e6151a917afa59..9908268b252556 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -18,7 +18,8 @@ from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks) + PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks, + InvalidTrustedProxies) _LOGGER = logging.getLogger(__name__) @@ -52,7 +53,10 @@ _CLOUD_ERRORS = { InvalidTrustedNetworks: (500, 'Remote UI not compatible with 127.0.0.1/::1' - ' as a trusted network.') + ' as a trusted network.'), + InvalidTrustedProxies: + (500, 'Remote UI not compatible with 127.0.0.1/::1' + ' as trusted proxies.'), } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 0f45f25c49bb9a..9f2579134e506a 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -6,7 +6,7 @@ PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA, PREF_ALIASES, PREF_SHOULD_EXPOSE, - InvalidTrustedNetworks) + InvalidTrustedNetworks, InvalidTrustedProxies) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -59,6 +59,9 @@ async def async_update(self, *, google_enabled=_UNDEF, if remote_enabled is True and self._has_local_trusted_network: raise InvalidTrustedNetworks + if remote_enabled is True and self._has_local_trusted_proxies: + raise InvalidTrustedProxies + await self._store.async_save(self._prefs) async def async_update_google_entity_config( @@ -112,7 +115,7 @@ def remote_enabled(self): if not enabled: return False - if self._has_local_trusted_network: + if self._has_local_trusted_network or self._has_local_trusted_proxies: return False return True @@ -162,3 +165,18 @@ def _has_local_trusted_network(self) -> bool: return True return False + + @property + def _has_local_trusted_proxies(self) -> bool: + """Return if we allow localhost to be a proxy and use its data.""" + if not hasattr(self._hass, 'http'): + return False + + local4 = ip_address('127.0.0.1') + local6 = ip_address('::1') + + if any(local4 in nwk or local6 in nwk + for nwk in self._hass.http.trusted_proxies): + return True + + return False diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index ad64b38200af52..a21fb2ab632039 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -228,6 +228,7 @@ def __init__(self, hass, self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port + self.trusted_proxies = trusted_proxies self.is_ban_enabled = is_ban_enabled self.ssl_profile = ssl_profile self._handler = None diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5ccaba14be639f..24bd647405a631 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,6 +1,7 @@ """Tests for the HTTP API for the cloud component.""" import asyncio from unittest.mock import patch, MagicMock +from ipaddress import ip_network import pytest from jose import jwt @@ -672,7 +673,7 @@ async def test_enabling_remote_trusted_networks_local6( async def test_enabling_remote_trusted_networks_other( hass, hass_ws_client, setup_api, mock_cloud_login): - """Test we cannot enable remote UI when trusted networks active.""" + """Test we can enable remote UI when trusted networks active.""" hass.auth._providers[('trusted_networks', None)] = \ tn_auth.TrustedNetworksAuthProvider( hass, None, tn_auth.CONFIG_SCHEMA({ @@ -749,3 +750,53 @@ async def test_update_google_entity( 'aliases': ['lefty', 'righty'], 'disable_2fa': False, } + + +async def test_enabling_remote_trusted_proxies_local4( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.http.trusted_proxies.append(ip_network('127.0.0.1')) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.' + + assert len(mock_connect.mock_calls) == 0 + + +async def test_enabling_remote_trusted_proxies_local6( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.http.trusted_proxies.append(ip_network('::1')) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.' + + assert len(mock_connect.mock_calls) == 0 From 787bd755873c9e3acbd8b9361b7a5a37e2544177 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 8 Jun 2019 02:19:52 -0400 Subject: [PATCH 153/319] add device class to sensors (#24373) --- homeassistant/components/zha/sensor.py | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b2d246c30959f0..fb4b577ff61617 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -2,7 +2,10 @@ import logging from homeassistant.core import callback -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import ( + DOMAIN, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_POWER +) from homeassistant.const import ( TEMP_CELSIUS, POWER_WATT, ATTR_UNIT_OF_MEASUREMENT ) @@ -11,13 +14,12 @@ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, GENERIC, SENSOR_TYPE, ATTRIBUTE_CHANNEL, ELECTRICAL_MEASUREMENT_CHANNEL, - SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) + SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR, UNKNOWN) from .entity import ZhaEntity PARALLEL_UPDATES = 5 _LOGGER = logging.getLogger(__name__) - # Formatter functions def pass_through_formatter(value): """No op update function.""" @@ -91,6 +93,16 @@ def pressure_formatter(value): ELECTRICAL_MEASUREMENT: False } +DEVICE_CLASS_REGISTRY = { + UNKNOWN: None, + HUMIDITY: DEVICE_CLASS_HUMIDITY, + TEMPERATURE: DEVICE_CLASS_TEMPERATURE, + PRESSURE: DEVICE_CLASS_PRESSURE, + ILLUMINANCE: DEVICE_CLASS_ILLUMINANCE, + METERING: DEVICE_CLASS_POWER, + ELECTRICAL_MEASUREMENT: DEVICE_CLASS_POWER +} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -155,6 +167,10 @@ def __init__(self, unique_id, zha_device, channels, **kwargs): self._channel = self.cluster_channels.get( CHANNEL_REGISTRY.get(self._sensor_type, ATTRIBUTE_CHANNEL) ) + self._device_class = DEVICE_CLASS_REGISTRY.get( + self._sensor_type, + None + ) async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -165,6 +181,11 @@ async def async_added_to_hass(self): self._channel, SIGNAL_STATE_ATTR, self.async_update_state_attribute) + @property + def device_class(self) -> str: + """Return device class from component DEVICE_CLASSES.""" + return self._device_class + @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" From 4cb1d7778327c052ff9180ac736aca8ed6dc92c1 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 8 Jun 2019 14:21:41 +0800 Subject: [PATCH 154/319] Fix for sun issues (#24309) --- homeassistant/components/sun/__init__.py | 21 ++++++++++++++------- tests/components/sun/test_init.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index dda692a8d804e4..edb2549164bb80 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -3,7 +3,8 @@ from datetime import timedelta from homeassistant.const import ( - CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) + CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + EVENT_CORE_CONFIG_UPDATE) from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time @@ -70,7 +71,7 @@ async def async_setup(hass, config): _LOGGER.warning( "Elevation is now configured in home assistant core. " "See https://home-assistant.io/docs/configuration/basic/") - Sun(hass, get_astral_location(hass)) + Sun(hass) return True @@ -79,18 +80,23 @@ class Sun(Entity): entity_id = ENTITY_ID - def __init__(self, hass, location): + def __init__(self, hass): """Initialize the sun.""" self.hass = hass - self.location = location + self.location = None self._state = self.next_rising = self.next_setting = None self.next_dawn = self.next_dusk = None self.next_midnight = self.next_noon = None self.solar_elevation = self.solar_azimuth = None self.rising = self.phase = None - self._next_change = None - self.update_events(dt_util.utcnow()) + + def update_location(event): + self.location = get_astral_location(self.hass) + self.update_events(dt_util.utcnow()) + update_location(None) + self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, update_location) @property def name(self): @@ -100,7 +106,8 @@ def name(self): @property def state(self): """Return the state of the sun.""" - if self.next_rising > self.next_setting: + # 0.8333 is the same value as astral uses + if self.solar_elevation > -0.833: return STATE_ABOVE_HORIZON return STATE_BELOW_HORIZON diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 374527e2c8ae67..26d6bd73fedf85 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -119,6 +119,14 @@ async def test_state_change(hass): assert sun.STATE_ABOVE_HORIZON == \ hass.states.get(sun.ENTITY_ID).state + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=now): + await hass.config.async_update(longitude=hass.config.longitude+90) + await hass.async_block_till_done() + + assert sun.STATE_ABOVE_HORIZON == \ + hass.states.get(sun.ENTITY_ID).state + async def test_norway_in_june(hass): """Test location in Norway where the sun doesn't set in summer.""" @@ -142,6 +150,8 @@ async def test_norway_in_june(hass): state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \ datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC) + assert state.state == sun.STATE_ABOVE_HORIZON + @mark.skip async def test_state_change_count(hass): From 48276b041c82a45db4f9cdc73a7ce649f7ff6fe3 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 8 Jun 2019 08:43:18 +0200 Subject: [PATCH 155/319] deCONZ - properly identify configured bridge (#24378) --- .../components/deconz/config_flow.py | 22 +++++++++++-------- tests/components/deconz/test_config_flow.py | 13 ++++++++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index cf172ad799133a..ea93cc590e21f8 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -18,6 +18,7 @@ DECONZ_MANUFACTURERURL = 'http://www.dresden-elektronik.de' CONF_SERIAL = 'serial' +ATTR_UUID = 'udn' @callback @@ -156,25 +157,28 @@ async def async_step_ssdp(self, discovery_info): if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL: return self.async_abort(reason='not_deconz_bridge') - bridgeid = discovery_info[ATTR_SERIAL] - gateway_entries = configured_gateways(self.hass) + uuid = discovery_info[ATTR_UUID].replace('uuid:', '') + gateways = { + gateway.api.config.uuid: gateway + for gateway in self.hass.data.get(DOMAIN, {}).values() + } - if bridgeid in gateway_entries: - entry = gateway_entries[bridgeid] + if uuid in gateways: + entry = gateways[uuid].config_entry await self._update_entry(entry, discovery_info[CONF_HOST]) return self.async_abort(reason='updated_instance') - # pylint: disable=unsupported-assignment-operation - self.context[ATTR_SERIAL] = bridgeid - - if any(bridgeid == flow['context'][ATTR_SERIAL] + bridgeid = discovery_info[ATTR_SERIAL] + if any(bridgeid == flow['context'][CONF_BRIDGEID] for flow in self._async_in_progress()): return self.async_abort(reason='already_in_progress') + # pylint: disable=unsupported-assignment-operation + self.context[CONF_BRIDGEID] = bridgeid + deconz_config = { CONF_HOST: discovery_info[CONF_HOST], CONF_PORT: discovery_info[CONF_PORT], - CONF_BRIDGEID: bridgeid } return await self.async_step_import(deconz_config) diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 2b9f2c013b0e1b..ac22c964151349 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -1,5 +1,5 @@ """Tests for deCONZ config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import asyncio @@ -177,7 +177,8 @@ async def test_bridge_ssdp_discovery(hass): config_flow.CONF_PORT: 80, config_flow.ATTR_SERIAL: 'id', config_flow.ATTR_MANUFACTURERURL: - config_flow.DECONZ_MANUFACTURERURL + config_flow.DECONZ_MANUFACTURERURL, + config_flow.ATTR_UUID: 'uuid:1234' }, context={'source': 'ssdp'} ) @@ -207,13 +208,19 @@ async def test_bridge_discovery_update_existing_entry(hass): }) entry.add_to_hass(hass) + gateway = Mock() + gateway.config_entry = entry + gateway.api.config.uuid = '1234' + hass.data[config_flow.DOMAIN] = {'id': gateway} + result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, data={ config_flow.CONF_HOST: 'mock-deconz', config_flow.ATTR_SERIAL: 'id', config_flow.ATTR_MANUFACTURERURL: - config_flow.DECONZ_MANUFACTURERURL + config_flow.DECONZ_MANUFACTURERURL, + config_flow.ATTR_UUID: 'uuid:1234' }, context={'source': 'ssdp'} ) From 3ac8c6d1fec325c6aafc81abdc3907e4d4b835be Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Jun 2019 23:47:13 -0700 Subject: [PATCH 156/319] Bump version to 0.95.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1dcea3e2dafb4f..c7c7bd9bc1ef93 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 94 +MINOR_VERSION = 95 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 9924dd7aca1fb5841a1eb6d829cd1b973ccbd134 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 8 Jun 2019 11:05:27 +0200 Subject: [PATCH 157/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 22675aa6e1a672..6b44e98217f9a1 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -23,20 +23,23 @@ jobs: matrix: Python35: python.version: '3.5' + python.container: 'python:3.5' Python36: python.version: '3.6' + python.container: 'python:3.6' Python37: python.version: '3.7' + python.container: 'python:3.7' + container: $[ variables['python.container'] ] steps: - task: UsePythonVersion@0 displayName: 'Use Python $(python.version)' inputs: versionSpec: '$(python.version)' - script: | - sudo add-apt-repository ppa:jonathonf/ffmpeg-4 sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libudev libsqlite3 libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter + libudev libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter echo "$(python.version)" > .cache displayName: 'Set up docker prerequisite requirement' @@ -51,7 +54,7 @@ jobs: - script: | # Install build env sudo apt-get install -y --no-install-recommends \ - libudev-dev libsqlite3-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev + libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # Setup venv python -m venv venv From 67e87f90488b72e110a7d9ff0db1cfcdfca3cb2b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 8 Jun 2019 11:43:33 +0200 Subject: [PATCH 158/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 6b44e98217f9a1..7cafd14e80dafc 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -32,10 +32,6 @@ jobs: python.container: 'python:3.7' container: $[ variables['python.container'] ] steps: - - task: UsePythonVersion@0 - displayName: 'Use Python $(python.version)' - inputs: - versionSpec: '$(python.version)' - script: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ From 95d460c8bda69ffca9f0998adf25e91c865c497e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Jun 2019 13:29:36 +0200 Subject: [PATCH 159/319] Fixes linter warning in ZHA sensor (#24406) --- homeassistant/components/zha/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index fb4b577ff61617..15ef922bd98664 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -20,6 +20,7 @@ PARALLEL_UPDATES = 5 _LOGGER = logging.getLogger(__name__) + # Formatter functions def pass_through_formatter(value): """No op update function.""" From 929f3c25949ecd090e532de46281186f260ac4ea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Jun 2019 08:19:00 -0700 Subject: [PATCH 160/319] Use loose version (#24394) --- homeassistant/config.py | 10 +++++----- tests/test_config.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 7e8bcec08a51a5..3443e98e92816b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,7 +1,7 @@ """Module to help with parsing and generating configuration files.""" from collections import OrderedDict # pylint: disable=no-name-in-module -from distutils.version import StrictVersion # pylint: disable=import-error +from distutils.version import LooseVersion # pylint: disable=import-error import logging import os import re @@ -334,15 +334,15 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.info("Upgrading configuration directory from %s to %s", conf_version, __version__) - version_obj = StrictVersion(conf_version) + version_obj = LooseVersion(conf_version) - if version_obj < StrictVersion('0.50'): + if version_obj < LooseVersion('0.50'): # 0.50 introduced persistent deps dir. lib_path = hass.config.path('deps') if os.path.isdir(lib_path): shutil.rmtree(lib_path) - if version_obj < StrictVersion('0.92'): + if version_obj < LooseVersion('0.92'): # 0.92 moved google/tts.py to google_translate/tts.py config_path = find_config_file(hass.config.config_dir) assert config_path is not None @@ -360,7 +360,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.exception("Migrating to google_translate tts failed") pass - if version_obj < StrictVersion('0.94.0b6') and is_docker_env(): + if version_obj < LooseVersion('0.94') and is_docker_env(): # In 0.94 we no longer install packages inside the deps folder when # running inside a Docker container. lib_path = hass.config.path('deps') diff --git a/tests/test_config.py b/tests/test_config.py index 8e983c673c5ae1..1adb127cfb02eb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -281,7 +281,7 @@ def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass): @mock.patch('homeassistant.config.is_docker_env', return_value=True) def test_remove_lib_on_upgrade_94(mock_docker, mock_os, mock_shutil, hass): """Test removal of library on upgrade from before 0.94 and in Docker.""" - ha_version = '0.94.0b5' + ha_version = '0.93.0.dev0' mock_os.path.isdir = mock.Mock(return_value=True) mock_open = mock.mock_open() with mock.patch('homeassistant.config.open', mock_open, create=True): From 3fa84039f8e79e91a781db5113463f03614ee713 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 8 Jun 2019 17:45:10 +0200 Subject: [PATCH 161/319] deCONZ fix retry set state(#24410) --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 56ea52b7693d52..1c7c0ac8aea39b 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/deconz", "requirements": [ - "pydeconz==59" + "pydeconz==60" ], "ssdp": { "manufacturer": [ diff --git a/requirements_all.txt b/requirements_all.txt index a93427b23c2569..61e8ed814a75a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1057,7 +1057,7 @@ pydaikin==1.4.6 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==59 +pydeconz==60 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a4d0ddb6ec24d..c1a6f9b16546a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ pyHS100==0.3.5 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==59 +pydeconz==60 # homeassistant.components.zwave pydispatcher==2.0.5 From 9235b5282860abc4136c45f3794d84b7b6971d25 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Jun 2019 21:48:37 +0200 Subject: [PATCH 162/319] Restore automation last_triggered with initial_state override (#24400) * Restore automation last_triggered with initial_state override * Made test async/await * Fixes linter warning * Update test_init.py --- .../components/automation/__init__.py | 32 +++++------ tests/components/automation/test_init.py | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 90b5857b13c563..6c230089990cfc 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -223,23 +223,25 @@ def is_on(self) -> bool: async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state: + enable_automation = state.state == STATE_ON + self._last_triggered = state.attributes.get('last_triggered') + _LOGGER.debug("Loaded automation %s with state %s from state " + " storage last state %s", self.entity_id, + enable_automation, state) + else: + enable_automation = DEFAULT_INITIAL_STATE + _LOGGER.debug("Automation %s not in state storage, state %s from " + "default is used.", self.entity_id, + enable_automation) + if self._initial_state is not None: enable_automation = self._initial_state - _LOGGER.debug("Automation %s initial state %s from config " - "initial_state", self.entity_id, enable_automation) - else: - state = await self.async_get_last_state() - if state: - enable_automation = state.state == STATE_ON - self._last_triggered = state.attributes.get('last_triggered') - _LOGGER.debug("Automation %s initial state %s from recorder " - "last state %s", self.entity_id, - enable_automation, state) - else: - enable_automation = DEFAULT_INITIAL_STATE - _LOGGER.debug("Automation %s initial state %s from default " - "initial state", self.entity_id, - enable_automation) + _LOGGER.debug("Automation %s initial state %s overridden from " + "config initial_state", self.entity_id, + enable_automation) if enable_automation: await self.async_enable() diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 81d7a8b257f236..f8748b20efb0d8 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -894,3 +894,57 @@ async def test_automation_with_error_in_script(hass, caplog): hass.bus.async_fire('test_event') await hass.async_block_till_done() assert 'Service not found' in caplog.text + + +async def test_automation_restore_last_triggered_with_initial_state(hass): + """Ensure last_triggered is restored, even when initial state is set.""" + time = dt_util.utcnow() + + mock_restore_cache(hass, ( + State('automation.hello', STATE_ON), + State('automation.bye', STATE_ON, {'last_triggered': time}), + State('automation.solong', STATE_OFF, {'last_triggered': time}), + )) + + config = {automation.DOMAIN: [{ + 'alias': 'hello', + 'initial_state': 'off', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'service': 'test.automation'} + }, { + 'alias': 'bye', + 'initial_state': 'off', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'service': 'test.automation'} + }, { + 'alias': 'solong', + 'initial_state': 'on', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'service': 'test.automation'} + }]} + + await async_setup_component(hass, automation.DOMAIN, config) + + state = hass.states.get('automation.hello') + assert state + assert state.state == STATE_OFF + assert state.attributes['last_triggered'] is None + + state = hass.states.get('automation.bye') + assert state + assert state.state == STATE_OFF + assert state.attributes['last_triggered'] == time + + state = hass.states.get('automation.solong') + assert state + assert state.state == STATE_ON + assert state.attributes['last_triggered'] == time From 848a2a95a874c7f830f6d7da72f61e6d5d0123ea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Jun 2019 16:18:29 -0700 Subject: [PATCH 163/319] Fix recorder defaults (#24399) * Fix recorder defaults * Address comment --- homeassistant/components/recorder/__init__.py | 4 ++-- tests/components/recorder/test_init.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 528f6f4a8a3b43..bad6d5ca0f44eb 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -62,7 +62,7 @@ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: FILTER_SCHEMA.extend({ + vol.Optional(DOMAIN, default=dict): FILTER_SCHEMA.extend({ vol.Optional(CONF_PURGE_KEEP_DAYS, default=10): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_PURGE_INTERVAL, default=1): @@ -95,7 +95,7 @@ def run_information(hass, point_in_time: Optional[datetime] = None): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" - conf = config.get(DOMAIN, {}) + conf = config[DOMAIN] keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 7460a65b0ce9e3..8f0ec7b39291ba 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -7,6 +7,7 @@ from homeassistant.core import callback from homeassistant.const import MATCH_ALL +from homeassistant.setup import async_setup_component from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.util import session_scope @@ -202,3 +203,22 @@ def test_recorder_setup_failure(): rec.join() hass.stop() + + +async def test_defaults_set(hass): + """Test the config defaults are set.""" + recorder_config = None + + async def mock_setup(hass, config): + """Mock setup.""" + nonlocal recorder_config + recorder_config = config['recorder'] + return True + + with patch('homeassistant.components.recorder.async_setup', + side_effect=mock_setup): + assert await async_setup_component(hass, 'history', {}) + + assert recorder_config is not None + assert recorder_config['purge_keep_days'] == 10 + assert recorder_config['purge_interval'] == 1 From d648eb1e4f4fe9222570fc1247bbb85227a712a9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 9 Jun 2019 02:10:23 +0200 Subject: [PATCH 164/319] Minor improvements to automation test suite (#24424) * Minor improvements to automation test suite * Removes unused asyncio imports * Removes some vars that are not needed --- .../automation/test_homeassistant.py | 29 +++------- tests/components/automation/test_init.py | 57 ++++++++----------- 2 files changed, 31 insertions(+), 55 deletions(-) diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/automation/test_homeassistant.py index 742a2aa857ca24..b8802501d5df78 100644 --- a/tests/components/automation/test_homeassistant.py +++ b/tests/components/automation/test_homeassistant.py @@ -1,5 +1,4 @@ """The tests for the Event automation.""" -import asyncio from unittest.mock import patch, Mock from homeassistant.core import CoreState @@ -9,8 +8,7 @@ from tests.common import async_mock_service, mock_coro -@asyncio.coroutine -def test_if_fires_on_hass_start(hass): +async def test_if_fires_on_hass_start(hass): """Test the firing when HASS starts.""" calls = async_mock_service(hass, 'test', 'automation') hass.state = CoreState.not_running @@ -27,31 +25,29 @@ def test_if_fires_on_hass_start(hass): } } - res = yield from async_setup_component(hass, automation.DOMAIN, config) - assert res + assert await async_setup_component(hass, automation.DOMAIN, config) assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 - yield from hass.async_start() + await hass.async_start() assert automation.is_on(hass, 'automation.hello') assert len(calls) == 1 with patch('homeassistant.config.async_hass_config_yaml', Mock(return_value=mock_coro(config))): - yield from hass.services.async_call( + await hass.services.async_call( automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True) assert automation.is_on(hass, 'automation.hello') assert len(calls) == 1 -@asyncio.coroutine -def test_if_fires_on_hass_shutdown(hass): +async def test_if_fires_on_hass_shutdown(hass): """Test the firing when HASS starts.""" calls = async_mock_service(hass, 'test', 'automation') hass.state = CoreState.not_running - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -63,22 +59,13 @@ def test_if_fires_on_hass_shutdown(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 - yield from hass.async_start() + await hass.async_start() assert automation.is_on(hass, 'automation.hello') assert len(calls) == 0 with patch.object(hass.loop, 'stop'): - yield from hass.async_stop() + await hass.async_stop() assert len(calls) == 1 - - # with patch('homeassistant.config.async_hass_config_yaml', - # Mock(return_value=mock_coro(config))): - # yield from hass.services.async_call( - # automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True) - - # assert automation.is_on(hass, 'automation.hello') - # assert len(calls) == 1 diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index f8748b20efb0d8..7fa658b0064913 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,5 +1,4 @@ """The tests for the automation component.""" -import asyncio from datetime import timedelta from unittest.mock import patch, Mock @@ -616,8 +615,7 @@ async def test_reload_config_handles_load_fails(hass, calls): assert len(calls) == 2 -@asyncio.coroutine -def test_automation_restore_state(hass): +async def test_automation_restore_state(hass): """Ensure states are restored on startup.""" time = dt_util.utcnow() @@ -642,39 +640,39 @@ def test_automation_restore_state(hass): 'action': {'service': 'test.automation'} }]} - assert (yield from async_setup_component(hass, automation.DOMAIN, config)) + assert await async_setup_component(hass, automation.DOMAIN, config) state = hass.states.get('automation.hello') assert state assert state.state == STATE_ON + assert state.attributes['last_triggered'] is None state = hass.states.get('automation.bye') assert state assert state.state == STATE_OFF - assert state.attributes.get('last_triggered') == time + assert state.attributes['last_triggered'] == time calls = async_mock_service(hass, 'test', 'automation') assert automation.is_on(hass, 'automation.bye') is False hass.bus.async_fire('test_event_bye') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event_hello') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_initial_value_off(hass): +async def test_initial_value_off(hass): """Test initial value off.""" calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'off', @@ -688,11 +686,10 @@ def test_initial_value_off(hass): } } }) - assert res assert not automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 @@ -753,15 +750,14 @@ async def test_initial_value_off_but_restore_on(hass): assert len(calls) == 0 -@asyncio.coroutine -def test_initial_value_on_but_restore_off(hass): +async def test_initial_value_on_but_restore_off(hass): """Test initial value on and restored state is turned off.""" calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_OFF), )) - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'initial_state': 'on', @@ -775,23 +771,21 @@ def test_initial_value_on_but_restore_off(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_no_initial_value_and_restore_off(hass): +async def test_no_initial_value_and_restore_off(hass): """Test initial value off and restored state is turned on.""" calls = async_mock_service(hass, 'test', 'automation') mock_restore_cache(hass, ( State('automation.hello', STATE_OFF), )) - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -804,20 +798,18 @@ def test_no_initial_value_and_restore_off(hass): } } }) - assert res assert not automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 -@asyncio.coroutine -def test_automation_is_on_if_no_initial_state_or_restore(hass): +async def test_automation_is_on_if_no_initial_state_or_restore(hass): """Test initial value is on when no initial state or restored state.""" calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -830,21 +822,19 @@ def test_automation_is_on_if_no_initial_state_or_restore(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 -@asyncio.coroutine -def test_automation_not_trigger_on_bootstrap(hass): +async def test_automation_not_trigger_on_bootstrap(hass): """Test if automation is not trigger on bootstrap.""" hass.state = CoreState.not_running calls = async_mock_service(hass, 'test', 'automation') - res = yield from async_setup_component(hass, automation.DOMAIN, { + assert await async_setup_component(hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -857,19 +847,18 @@ def test_automation_not_trigger_on_bootstrap(hass): } } }) - assert res assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert automation.is_on(hass, 'automation.hello') hass.bus.async_fire('test_event') - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 assert ['hello.world'] == calls[0].data.get(ATTR_ENTITY_ID) From 896eaba2d6cc2ca6395eded3f47a2ab98937b0f8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 11:11:29 +0200 Subject: [PATCH 165/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 49 +++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 7cafd14e80dafc..8b186b2ef78df2 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -5,17 +5,42 @@ trigger: branches: include: - dev -pr: none +pr: none + +resources: + containers: + - container: 35 + image: python:3.5 + - container: 36 + image: python:3.6 + - container: 37 + image: python:3.7 variables: - name: ArtifactFeed value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d' + - name: PythonMain + value: '35' jobs: +- job: 'Lint' + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - script: | + pip install flake8 + flake8 homeassistant tests script + displayName: 'Run flake8' + + - job: 'Check' + dependsOn: + - Lint + condition: succeeded() pool: vmImage: 'ubuntu-latest' strategy: @@ -23,18 +48,18 @@ jobs: matrix: Python35: python.version: '3.5' - python.container: 'python:3.5' + python.container: '35' Python36: python.version: '3.6' - python.container: 'python:3.6' + python.container: '36' Python37: python.version: '3.7' - python.container: 'python:3.7' + python.container: '37' container: $[ variables['python.container'] ] steps: - script: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ + apt-get update + apt-get install -y --no-install-recommends \ libudev libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter echo "$(python.version)" > .cache @@ -49,7 +74,7 @@ jobs: - script: | # Install build env - sudo apt-get install -y --no-install-recommends \ + apt-get install -y --no-install-recommends \ libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # Setup venv @@ -57,7 +82,7 @@ jobs: . venv/bin/activate pip install -U pip setuptools - pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt + pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt displayName: 'Create Virtual Environment & Install Requirements' condition: ne(variables['CacheRestored'], 'true') @@ -77,3 +102,11 @@ jobs: . venv/bin/activate pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar tests displayName: 'Run pytest for python $(python.version)' + + - script: | + . venv/bin/activate + pip install -r requirements_all.txt -c homeassistant/package_constraints.txt + + pylint homeassistant + displayName: 'Run pylint' + condition: eq($(python.container), $(PythonMain)) From 4cb0ff1f6338edd5e3e18d5e32f2b18754c73037 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 11:14:46 +0200 Subject: [PATCH 166/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 8b186b2ef78df2..8dfbb2e04c34da 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -32,7 +32,7 @@ jobs: container: $[ variables['PythonMain'] ] steps: - script: | - pip install flake8 + pip install -user flake8 flake8 homeassistant tests script displayName: 'Run flake8' From accfedce87838a6d879ec9c06bed1a5bd87ac32d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 11:20:18 +0200 Subject: [PATCH 167/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 8dfbb2e04c34da..35da6f1c89f79a 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -32,7 +32,7 @@ jobs: container: $[ variables['PythonMain'] ] steps: - script: | - pip install -user flake8 + pip install --user flake8 flake8 homeassistant tests script displayName: 'Run flake8' From 7c27bab3c7d497525e9ffe2ee7717c1d97a606a3 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 11:25:24 +0200 Subject: [PATCH 168/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 35da6f1c89f79a..7e609f350849fb 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -32,7 +32,10 @@ jobs: container: $[ variables['PythonMain'] ] steps: - script: | - pip install --user flake8 + python -m venv lint + + . lint/bin/activate + pip install flake8 flake8 homeassistant tests script displayName: 'Run flake8' From 6a4bf1f81775d1cbf1c995c2ec555561469bcd79 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 12:13:37 +0200 Subject: [PATCH 169/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 7e609f350849fb..45f287570ee70f 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -112,4 +112,4 @@ jobs: pylint homeassistant displayName: 'Run pylint' - condition: eq($(python.container), $(PythonMain)) + condition: eq(variables['python.container'], variables['PythonMain']) From b3b2e8ffb772c24c7d3a0cc80cda54941d9370c1 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 12:28:51 +0200 Subject: [PATCH 170/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 45f287570ee70f..d8a3a50bb126bd 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -7,21 +7,11 @@ trigger: - dev pr: none -resources: - containers: - - container: 35 - image: python:3.5 - - container: 36 - image: python:3.6 - - container: 37 - image: python:3.7 - - variables: - name: ArtifactFeed value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d' - name: PythonMain - value: '35' + value: '3.5' jobs: @@ -29,8 +19,11 @@ jobs: - job: 'Lint' pool: vmImage: 'ubuntu-latest' - container: $[ variables['PythonMain'] ] + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '$(PythonMain)' - script: | python -m venv lint @@ -51,19 +44,19 @@ jobs: matrix: Python35: python.version: '3.5' - python.container: '35' Python36: python.version: '3.6' - python.container: '36' Python37: python.version: '3.7' - python.container: '37' - container: $[ variables['python.container'] ] steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '$(python.version)' - script: | - apt-get update - apt-get install -y --no-install-recommends \ - libudev libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter + sudo add-apt-repository ppa:jonathonf/ffmpeg-4 + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libudev libsqlite3 libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter echo "$(python.version)" > .cache displayName: 'Set up docker prerequisite requirement' @@ -77,8 +70,8 @@ jobs: - script: | # Install build env - apt-get install -y --no-install-recommends \ - libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev + sudo apt-get install -y --no-install-recommends \ + libudev-dev libsqlite3-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # Setup venv python -m venv venv @@ -112,4 +105,4 @@ jobs: pylint homeassistant displayName: 'Run pylint' - condition: eq(variables['python.container'], variables['PythonMain']) + condition: eq(variables['python.version'], variables['PythonMain']) From 795300848c4e1a508aaef40c3c82de7eb01f6ac5 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 12:32:12 +0200 Subject: [PATCH 171/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index d8a3a50bb126bd..19cbeb618def17 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -53,6 +53,8 @@ jobs: inputs: versionSpec: '$(python.version)' - script: | + set -e + sudo add-apt-repository ppa:jonathonf/ffmpeg-4 sudo apt-get update sudo apt-get install -y --no-install-recommends \ @@ -69,13 +71,15 @@ jobs: vstsFeed: '$(ArtifactFeed)' - script: | + set -e + # Install build env sudo apt-get install -y --no-install-recommends \ libudev-dev libsqlite3-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # Setup venv python -m venv venv - + . venv/bin/activate pip install -U pip setuptools pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt From fb3d66e6e1c623c2dfd187f6a16c1dc40aee1d7c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 12:35:08 +0200 Subject: [PATCH 172/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 19cbeb618def17..1dd806cad8bdef 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -19,7 +19,6 @@ jobs: - job: 'Lint' pool: vmImage: 'ubuntu-latest' - steps: - task: UsePythonVersion@0 inputs: From d28672308799334049ee58ae6c00c90784bfeed6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 12:38:01 +0200 Subject: [PATCH 173/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 1dd806cad8bdef..783d44dc66bf48 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -83,7 +83,7 @@ jobs: pip install -U pip setuptools pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt displayName: 'Create Virtual Environment & Install Requirements' - condition: ne(variables['CacheRestored'], 'true') + condition: and(succeeded(), ne(variables['CacheRestored'], 'true')) - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 displayName: 'Save artifacts based on Requirements' @@ -108,4 +108,4 @@ jobs: pylint homeassistant displayName: 'Run pylint' - condition: eq(variables['python.version'], variables['PythonMain']) + condition: and(succeeded(), eq(variables['python.version'], variables['PythonMain'])) From 628264be4e8d072ef13c0e7e8f3ed41ca8f0c764 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 12:41:14 +0200 Subject: [PATCH 174/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 783d44dc66bf48..0b4d61a35b89f1 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -56,8 +56,6 @@ jobs: sudo add-apt-repository ppa:jonathonf/ffmpeg-4 sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libudev libsqlite3 libavformat libavcodec libavdevice libavutil libswscale libswresample libavfilter echo "$(python.version)" > .cache displayName: 'Set up docker prerequisite requirement' From 0db27f1ceff0f894484ad98062eb0ac14473a2de Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 14:26:42 +0200 Subject: [PATCH 175/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 0b4d61a35b89f1..f9dda5b41f3328 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -7,11 +7,21 @@ trigger: - dev pr: none +resources: + containers: + - container: 35 + image: homeassistant/ci-azure:3.5 + - container: 36 + image: homeassistant/ci-azure:3.6 + - container: 37 + image: homeassistant/ci-azure:3.7 + + variables: - name: ArtifactFeed value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d' - name: PythonMain - value: '3.5' + value: '35' jobs: @@ -19,10 +29,8 @@ jobs: - job: 'Lint' pool: vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(PythonMain)' - script: | python -m venv lint @@ -43,20 +51,16 @@ jobs: matrix: Python35: python.version: '3.5' + python.container: '35' Python36: python.version: '3.6' + python.container: '36' Python37: python.version: '3.7' + python.container: '37' + container: $[ variables['python.container'] ] steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - script: | - set -e - - sudo add-apt-repository ppa:jonathonf/ffmpeg-4 - sudo apt-get update - echo "$(python.version)" > .cache displayName: 'Set up docker prerequisite requirement' @@ -69,14 +73,8 @@ jobs: - script: | set -e - - # Install build env - sudo apt-get install -y --no-install-recommends \ - libudev-dev libsqlite3-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev - - # Setup venv python -m venv venv - + . venv/bin/activate pip install -U pip setuptools pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt @@ -106,4 +104,4 @@ jobs: pylint homeassistant displayName: 'Run pylint' - condition: and(succeeded(), eq(variables['python.version'], variables['PythonMain'])) + condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) \ No newline at end of file From 3f6a30a974b7119a14ea4313937d9fac7cc1b6aa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 14:47:03 +0200 Subject: [PATCH 176/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index f9dda5b41f3328..8b0b4e50fa1869 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -62,7 +62,7 @@ jobs: steps: - script: | echo "$(python.version)" > .cache - displayName: 'Set up docker prerequisite requirement' + displayName: 'Set python $(python.version) for requirement cache' - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 displayName: 'Restore artifacts based on Requirements' From 0a5966c283af6bbf82a12441ab53766b55fe7b10 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 15:32:30 +0200 Subject: [PATCH 177/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 8b0b4e50fa1869..d09ce33ca3119e 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -43,7 +43,6 @@ jobs: - job: 'Check' dependsOn: - Lint - condition: succeeded() pool: vmImage: 'ubuntu-latest' strategy: @@ -98,10 +97,50 @@ jobs: pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar tests displayName: 'Run pytest for python $(python.version)' + +- job: 'FullCheck' + dependsOn: + - Check + pool: + vmImage: 'ubuntu-latest' + container: $[ variables['PythonMain'] ] + steps: + - script: | + echo "$(PythonMain)" > .cache + displayName: 'Set python $(python.version) for requirement cache' + - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 + displayName: 'Restore artifacts based on Requirements' + inputs: + keyfile: 'requirements_all.txt, .cache' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools + pip install -r requirements_all.txt -c homeassistant/package_constraints.txt + displayName: 'Create Virtual Environment & Install Requirements' + condition: and(succeeded(), ne(variables['CacheRestored'], 'true')) + + - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 + displayName: 'Save artifacts based on Requirements' + inputs: + keyfile: 'requirements_all.txt, .cache' + targetfolder: './venv' + vstsFeed: '$(ArtifactFeed)' + + - script: | + . venv/bin/activate + pip install -e . + displayName: 'Install Home Assistant for python $(python.version)' + - script: | . venv/bin/activate pip install -r requirements_all.txt -c homeassistant/package_constraints.txt pylint homeassistant displayName: 'Run pylint' - condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) \ No newline at end of file + From 4e6b133a17e0841ca5526e19a943da8c63cd3328 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 9 Jun 2019 17:41:15 +0200 Subject: [PATCH 178/319] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index d09ce33ca3119e..729d07704adf82 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -111,7 +111,7 @@ jobs: - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1 displayName: 'Restore artifacts based on Requirements' inputs: - keyfile: 'requirements_all.txt, .cache' + keyfile: 'requirements_all.txt, requirements_test.txt, .cache' targetfolder: './venv' vstsFeed: '$(ArtifactFeed)' @@ -122,13 +122,14 @@ jobs: . venv/bin/activate pip install -U pip setuptools pip install -r requirements_all.txt -c homeassistant/package_constraints.txt + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt displayName: 'Create Virtual Environment & Install Requirements' condition: and(succeeded(), ne(variables['CacheRestored'], 'true')) - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1 displayName: 'Save artifacts based on Requirements' inputs: - keyfile: 'requirements_all.txt, .cache' + keyfile: 'requirements_all.txt, requirements_test.txt, .cache' targetfolder: './venv' vstsFeed: '$(ArtifactFeed)' @@ -139,8 +140,6 @@ jobs: - script: | . venv/bin/activate - pip install -r requirements_all.txt -c homeassistant/package_constraints.txt - pylint homeassistant displayName: 'Run pylint' From cebb146e7c124dc519b10e03d8b1453c2e97e7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 10 Jun 2019 17:39:12 +0200 Subject: [PATCH 179/319] Upgrade broadlink library (#24450) --- homeassistant/components/broadlink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index b730413d0ce050..45ed2003026fd2 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -3,7 +3,7 @@ "name": "Broadlink", "documentation": "https://www.home-assistant.io/components/broadlink", "requirements": [ - "broadlink==0.11.0" + "broadlink==0.11.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 61e8ed814a75a0..efb30fddcd3193 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -275,7 +275,7 @@ boto3==1.9.16 braviarc-homeassistant==0.3.7.dev0 # homeassistant.components.broadlink -broadlink==0.11.0 +broadlink==0.11.1 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 From 4921d35e709bf01d7beb367960d096b932ff0d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 10 Jun 2019 17:39:36 +0200 Subject: [PATCH 180/319] Upgrade ambiclimate library (#24449) --- homeassistant/components/ambiclimate/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index 70c0570487318e..1bae147ae27ef8 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambiclimate", "requirements": [ - "ambiclimate==0.1.2" + "ambiclimate==0.1.3" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index efb30fddcd3193..dde24259ef56eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.ambiclimate -ambiclimate==0.1.2 +ambiclimate==0.1.3 # homeassistant.components.amcrest amcrest==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1a6f9b16546a5..959b630317cf95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -64,7 +64,7 @@ aioswitcher==2019.3.21 aiounifi==6 # homeassistant.components.ambiclimate -ambiclimate==0.1.2 +ambiclimate==0.1.3 # homeassistant.components.apns apns2==0.3.0 From 84e6813779e3145cee3951cd118b7df7589c6c50 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Tue, 11 Jun 2019 00:10:44 +0800 Subject: [PATCH 181/319] Rename via_hub to via_device (#24360) * Rename via_hub to via_device * Fixed registry interactions --- .../components/config/device_registry.py | 2 +- .../components/deconz/deconz_device.py | 2 +- .../components/homekit_controller/__init__.py | 7 ++-- .../components/homematicip_cloud/device.py | 3 +- homeassistant/components/hue/light.py | 2 +- homeassistant/components/hue/sensor_base.py | 2 +- homeassistant/components/mqtt/__init__.py | 32 +++++++++------- homeassistant/components/point/__init__.py | 2 +- homeassistant/components/tellduslive/entry.py | 2 +- homeassistant/components/toon/__init__.py | 16 ++++---- homeassistant/components/tradfri/light.py | 2 +- homeassistant/components/tradfri/switch.py | 2 +- homeassistant/components/zha/entity.py | 3 +- homeassistant/components/zwave/__init__.py | 4 +- homeassistant/components/zwave/node_entity.py | 2 +- homeassistant/helpers/device_registry.py | 25 ++++++------ homeassistant/helpers/entity_platform.py | 2 +- .../components/config/test_device_registry.py | 6 +-- .../specific_devices/test_aqara_gateway.py | 2 +- .../specific_devices/test_ecobee3.py | 4 +- .../specific_devices/test_koogeek_ls1.py | 2 +- .../specific_devices/test_lennox_e30.py | 2 +- tests/components/mqtt/test_init.py | 4 +- tests/components/mqtt/test_sensor.py | 4 +- tests/helpers/test_device_registry.py | 38 +++++++++---------- tests/helpers/test_entity_platform.py | 10 ++--- 26 files changed, 96 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index d9e55bbe67e73c..61b00bf6726753 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -69,7 +69,7 @@ def _entry_dict(entry): 'name': entry.name, 'sw_version': entry.sw_version, 'id': entry.id, - 'hub_device_id': entry.hub_device_id, + 'via_device_id': entry.via_device_id, 'area_id': entry.area_id, 'name_by_user': entry.name_by_user, } diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 90a5c8a3ddebef..8745cb2141a1a8 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -72,5 +72,5 @@ def device_info(self): 'model': self._device.modelid, 'name': self._device.name, 'sw_version': self._device.swversion, - 'via_hub': (DECONZ_DOMAIN, bridgeid), + 'via_device': (DECONZ_DOMAIN, bridgeid), } diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index f1ddf1faacfc77..9651e497ccc7f6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -156,11 +156,12 @@ def device_info(self): 'sw_version': self._accessory_info.get('firmware.revision', ''), } - # Some devices only have a single accessory - we don't add a via_hub - # otherwise it would be self referential. + # Some devices only have a single accessory - we don't add a + # via_device otherwise it would be self referential. bridge_serial = self._accessory.connection_info['serial-number'] if accessory_serial != bridge_serial: - device_info['via_hub'] = (DOMAIN, 'serial-number', bridge_serial) + device_info['via_device'] = ( + DOMAIN, 'serial-number', bridge_serial) return device_info diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 57e04d1f32cd87..3cd84791c6778a 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -44,7 +44,8 @@ def device_info(self): 'manufacturer': self._device.oem, 'model': self._device.modelType, 'sw_version': self._device.firmwareVersion, - 'via_hub': (homematicip_cloud.DOMAIN, self._device.homeId), + 'via_device': ( + homematicip_cloud.DOMAIN, self._device.homeId), } return None diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index c517184b62a8e8..100b26b0b78819 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -334,7 +334,7 @@ def device_info(self): 'model': self.light.productname or self.light.modelid, # Not yet exposed as properties in aiohue 'sw_version': self.light.raw['swversion'], - 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), + 'via_device': (hue.DOMAIN, self.bridge.api.config.bridgeid), } async def async_turn_on(self, **kwargs): diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 9dca6e31b1df82..60ddfac1a95ab9 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -269,7 +269,7 @@ def device_info(self): self.primary_sensor.productname or self.primary_sensor.modelid), 'sw_version': self.primary_sensor.swversion, - 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), + 'via_device': (hue.DOMAIN, self.bridge.api.config.bridgeid), } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 4ba8f1a5cc5273..d31ea150acac88 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -79,7 +79,8 @@ CONF_MANUFACTURER = 'manufacturer' CONF_MODEL = 'model' CONF_SW_VERSION = 'sw_version' -CONF_VIA_HUB = 'via_hub' +CONF_VIA_DEVICE = 'via_device' +CONF_DEPRECATED_VIA_HUB = 'via_hub' PROTOCOL_31 = '3.1' PROTOCOL_311 = '3.1.1' @@ -229,17 +230,20 @@ def embedded_broker_deprecated(value): default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) -MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(vol.Schema({ - vol.Optional(CONF_IDENTIFIERS, default=list): - vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONNECTIONS, default=list): - vol.All(cv.ensure_list, [vol.All(vol.Length(2), [cv.string])]), - vol.Optional(CONF_MANUFACTURER): cv.string, - vol.Optional(CONF_MODEL): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_SW_VERSION): cv.string, - vol.Optional(CONF_VIA_HUB): cv.string, -}), validate_device_has_at_least_one_identifier) +MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( + cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), + vol.Schema({ + vol.Optional(CONF_IDENTIFIERS, default=list): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONNECTIONS, default=list): + vol.All(cv.ensure_list, [vol.All(vol.Length(2), [cv.string])]), + vol.Optional(CONF_MANUFACTURER): cv.string, + vol.Optional(CONF_MODEL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_VIA_DEVICE): cv.string, + }), + validate_device_has_at_least_one_identifier) MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, @@ -1098,8 +1102,8 @@ def device_info(self): if CONF_SW_VERSION in self._device_config: info['sw_version'] = self._device_config[CONF_SW_VERSION] - if CONF_VIA_HUB in self._device_config: - info['via_hub'] = (DOMAIN, self._device_config[CONF_VIA_HUB]) + if CONF_VIA_DEVICE in self._device_config: + info['via_device'] = (DOMAIN, self._device_config[CONF_VIA_DEVICE]) return info diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 2ed83fe1d9b06b..ac5a5a4ec918cf 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -300,7 +300,7 @@ def device_info(self): 'model': 'Point v{}'.format(device['hardware_version']), 'name': device['description'], 'sw_version': device['firmware']['installed'], - 'via_hub': (DOMAIN, device['home']), + 'via_device': (DOMAIN, device['home']), } @property diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py index 9255f9da6458b7..c35a484b09d52d 100644 --- a/homeassistant/components/tellduslive/entry.py +++ b/homeassistant/components/tellduslive/entry.py @@ -128,5 +128,5 @@ def device_info(self): device_info['manufacturer'] = protocol.title() client = device.get('client') if client is not None: - device_info['via_hub'] = ('tellduslive', client) + device_info['via_device'] = ('tellduslive', client) return device_info diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index da47285934cca9..ba39462941f0fd 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistantType, }, manufacturer='Eneco', name="Meter Adapter", - via_hub=(DOMAIN, toon.agreement.id) + via_device=(DOMAIN, toon.agreement.id) ) for component in 'binary_sensor', 'climate', 'sensor': @@ -126,7 +126,7 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'electricity'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), + 'via_device': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), } @@ -136,16 +136,16 @@ class ToonGasMeterDeviceEntity(ToonEntity): @property def device_info(self) -> Dict[str, Any]: """Return device information about this entity.""" - via_hub = 'meter_adapter' + via_device = 'meter_adapter' if self.toon.gas.is_smart: - via_hub = 'electricity' + via_device = 'electricity' return { 'name': 'Gas Meter', 'identifiers': { (DOMAIN, self.toon.agreement.id, 'gas'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, via_hub), + 'via_device': (DOMAIN, self.toon.agreement.id, via_device), } @@ -160,7 +160,7 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'solar'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), + 'via_device': (DOMAIN, self.toon.agreement.id, 'meter_adapter'), } @@ -176,7 +176,7 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'boiler_module'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id), + 'via_device': (DOMAIN, self.toon.agreement.id), } @@ -191,5 +191,5 @@ def device_info(self) -> Dict[str, Any]: 'identifiers': { (DOMAIN, self.toon.agreement.id, 'boiler'), }, - 'via_hub': (DOMAIN, self.toon.agreement.id, 'boiler_module'), + 'via_device': (DOMAIN, self.toon.agreement.id, 'boiler_module'), } diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a2b2cdc7c49bd7..06530f6bad4faf 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -175,7 +175,7 @@ def device_info(self): 'manufacturer': info.manufacturer, 'model': info.model_number, 'sw_version': info.firmware_version, - 'via_hub': (TRADFRI_DOMAIN, self._gateway_id), + 'via_device': (TRADFRI_DOMAIN, self._gateway_id), } @property diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index b7826624f525c0..6b1372c8d98525 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -61,7 +61,7 @@ def device_info(self): 'manufacturer': info.manufacturer, 'model': info.model_number, 'sw_version': info.firmware_version, - 'via_hub': (TRADFRI_DOMAIN, self._gateway_id), + 'via_device': (TRADFRI_DOMAIN, self._gateway_id), } async def async_added_to_hass(self): diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 36df8aada2bc11..338a9db278deb0 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -108,7 +108,8 @@ def device_info(self): ATTR_MANUFACTURER: zha_device_info[ATTR_MANUFACTURER], MODEL: zha_device_info[MODEL], NAME: zha_device_info[NAME], - 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + 'via_device': ( + DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), } @property diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 65dd551ebc1f36..fdc00903f09165 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -1079,14 +1079,14 @@ def device_info(self): info['identifiers'] = { (DOMAIN, self.node_id, self.values.primary.instance, ), } - info['via_hub'] = (DOMAIN, self.node_id, ) + info['via_device'] = (DOMAIN, self.node_id, ) else: info['name'] = node_name(self.node) info['identifiers'] = { (DOMAIN, self.node_id), } if self.node_id > 1: - info['via_hub'] = (DOMAIN, 1, ) + info['via_device'] = (DOMAIN, 1, ) return info @property diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 86f5ae345203e1..3bba18f5c02058 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -133,7 +133,7 @@ def device_info(self): 'name': node_name(self.node) } if self.node_id > 1: - info['via_hub'] = (DOMAIN, 1) + info['via_device'] = (DOMAIN, 1) return info def network_node_changed(self, node=None, value=None, args=None): diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index d090e571a8b970..13a013522fb3e3 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -38,7 +38,7 @@ class DeviceEntry: model = attr.ib(type=str, default=None) name = attr.ib(type=str, default=None) sw_version = attr.ib(type=str, default=None) - hub_device_id = attr.ib(type=str, default=None) + via_device_id = attr.ib(type=str, default=None) area_id = attr.ib(type=str, default=None) name_by_user = attr.ib(type=str, default=None) id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) @@ -93,7 +93,7 @@ def async_get_device(self, identifiers: set, connections: set): def async_get_or_create(self, *, config_entry_id, connections=None, identifiers=None, manufacturer=_UNDEF, model=_UNDEF, name=_UNDEF, sw_version=_UNDEF, - via_hub=None): + via_device=None): """Get device. Create if it doesn't exist.""" if not identifiers and not connections: return None @@ -116,16 +116,16 @@ def async_get_or_create(self, *, config_entry_id, connections=None, device = DeviceEntry(is_new=True) self.devices[device.id] = device - if via_hub is not None: - hub_device = self.async_get_device({via_hub}, set()) - hub_device_id = hub_device.id if hub_device else _UNDEF + if via_device is not None: + via = self.async_get_device({via_device}, set()) + via_device_id = via.id if via else _UNDEF else: - hub_device_id = _UNDEF + via_device_id = _UNDEF return self._async_update_device( device.id, add_config_entry_id=config_entry_id, - hub_device_id=hub_device_id, + via_device_id=via_device_id, merge_connections=connections or _UNDEF, merge_identifiers=identifiers or _UNDEF, manufacturer=manufacturer, @@ -153,7 +153,7 @@ def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF, model=_UNDEF, name=_UNDEF, sw_version=_UNDEF, - hub_device_id=_UNDEF, + via_device_id=_UNDEF, area_id=_UNDEF, name_by_user=_UNDEF): """Update device attributes.""" @@ -191,7 +191,7 @@ def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF, ('model', model), ('name', name), ('sw_version', sw_version), - ('hub_device_id', hub_device_id), + ('via_device_id', via_device_id), ): if value is not _UNDEF and value != getattr(old, attr_name): changes[attr_name] = value @@ -247,7 +247,10 @@ async def async_load(self): sw_version=device['sw_version'], id=device['id'], # Introduced in 0.79 - hub_device_id=device.get('hub_device_id'), + # renamed in 0.95 + via_device_id=( + device.get('via_device_id') + or device.get('hub_device_id')), # Introduced in 0.87 area_id=device.get('area_id'), name_by_user=device.get('name_by_user') @@ -275,7 +278,7 @@ def _data_to_save(self): 'name': entry.name, 'sw_version': entry.sw_version, 'id': entry.id, - 'hub_device_id': entry.hub_device_id, + 'via_device_id': entry.via_device_id, 'area_id': entry.area_id, 'name_by_user': entry.name_by_user } for entry in self.devices.values() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 30868c33f9df60..8b1b850258696b 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -296,7 +296,7 @@ async def _async_add_entity(self, entity, update_before_add, 'model', 'name', 'sw_version', - 'via_hub', + 'via_device', ): if key in device_info: processed_dev_info[key] = device_info[key] diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index de603707ae2071..9f346343f72ba2 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -29,7 +29,7 @@ async def test_list_devices(hass, client, registry): config_entry_id='1234', identifiers={('bridgeid', '1234')}, manufacturer='manufacturer', model='model', - via_hub=('bridgeid', '0123')) + via_device=('bridgeid', '0123')) await client.send_json({ 'id': 5, @@ -47,7 +47,7 @@ async def test_list_devices(hass, client, registry): 'model': 'model', 'name': None, 'sw_version': None, - 'hub_device_id': None, + 'via_device_id': None, 'area_id': None, 'name_by_user': None, }, @@ -58,7 +58,7 @@ async def test_list_devices(hass, client, registry): 'model': 'model', 'name': None, 'sw_version': None, - 'hub_device_id': dev1, + 'via_device_id': dev1, 'area_id': None, 'name_by_user': None, } diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py index 0c77aa37196eb5..59b5be938d3268 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -51,4 +51,4 @@ async def test_aqara_gateway_setup(hass): assert device.name == 'Aqara Hub-1563' assert device.model == 'ZHWA11LM' assert device.sw_version == '1.4.7' - assert device.hub_device_id is None + assert device.via_device_id is None diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 10e01437cda8a3..7848ddaacb8d78 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -74,7 +74,7 @@ async def test_ecobee3_setup(hass): assert climate_device.name == 'HomeW' assert climate_device.model == 'ecobee3' assert climate_device.sw_version == '4.2.394' - assert climate_device.hub_device_id is None + assert climate_device.via_device_id is None # Check that an attached sensor has its own device entity that # is linked to the bridge @@ -83,7 +83,7 @@ async def test_ecobee3_setup(hass): assert sensor_device.name == 'Kitchen' assert sensor_device.model == 'REMOTE SENSOR' assert sensor_device.sw_version == '1.0.0' - assert sensor_device.hub_device_id == climate_device.id + assert sensor_device.via_device_id == climate_device.id async def test_ecobee3_setup_from_cache(hass, hass_storage): diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 8de3d1587b658c..4f18392948bcd4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -45,7 +45,7 @@ async def test_koogeek_ls1_setup(hass): assert device.name == 'Koogeek-LS1-20833F' assert device.model == 'LS1' assert device.sw_version == '2.2.15' - assert device.hub_device_id is None + assert device.via_device_id is None @pytest.mark.parametrize('failure_cls', [ diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index 9825e1ab4abd32..eb8abbd8f7d2ea 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -38,4 +38,4 @@ async def test_lennox_e30_setup(hass): # The fixture contains a single accessory - so its a single device # and no bridge - assert device.hub_device_id is None + assert device.via_device_id is None diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b0d1de36efea0b..a9310894019d98 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -232,7 +232,7 @@ def test_entity_device_info_schema(self): 'model': 'Glass', 'sw_version': '0.1-beta', }) - # full device info with via_hub + # full device info with via_device mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ 'identifiers': ['helloworld', 'hello'], 'connections': [ @@ -243,7 +243,7 @@ def test_entity_device_info_schema(self): 'name': 'Beer', 'model': 'Glass', 'sw_version': '0.1-beta', - 'via_hub': 'test-hub', + 'via_device': 'test-hub', }) # no identifiers with pytest.raises(vol.Invalid): diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index bcd70b82a2493f..e99b7abe22efc6 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -693,7 +693,7 @@ async def test_entity_device_info_with_hub(hass, mqtt_mock): 'state_topic': 'test-topic', 'device': { 'identifiers': ['helloworld'], - 'via_hub': 'hub-id', + 'via_device': 'hub-id', }, 'unique_id': 'veryunique' }) @@ -702,4 +702,4 @@ async def test_entity_device_info_with_hub(hass, mqtt_mock): device = registry.async_get_device({('mqtt', 'helloworld')}, set()) assert device is not None - assert device.hub_device_id == hub.id + assert device.via_device_id == hub.id diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 444bd44133bcf7..80f617e654330a 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -250,71 +250,71 @@ async def test_removing_area_id(registry): assert entry_w_area != entry_wo_area -async def test_specifying_hub_device_create(registry): - """Test specifying a hub and updating.""" - hub = registry.async_get_or_create( +async def test_specifying_via_device_create(registry): + """Test specifying a via_device and updating.""" + via = registry.async_get_or_create( config_entry_id='123', connections={ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') }, identifiers={('hue', '0123')}, - manufacturer='manufacturer', model='hub') + manufacturer='manufacturer', model='via') light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) - assert light.hub_device_id == hub.id + assert light.via_device_id == via.id -async def test_specifying_hub_device_update(registry): - """Test specifying a hub and updating.""" +async def test_specifying_via_device_update(registry): + """Test specifying a via_device and updating.""" light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) - assert light.hub_device_id is None + assert light.via_device_id is None - hub = registry.async_get_or_create( + via = registry.async_get_or_create( config_entry_id='123', connections={ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') }, identifiers={('hue', '0123')}, - manufacturer='manufacturer', model='hub') + manufacturer='manufacturer', model='via') light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) - assert light.hub_device_id == hub.id + assert light.via_device_id == via.id async def test_loading_saving_data(hass, registry): """Test that we load/save data correctly.""" - orig_hub = registry.async_get_or_create( + orig_via = registry.async_get_or_create( config_entry_id='123', connections={ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') }, identifiers={('hue', '0123')}, - manufacturer='manufacturer', model='hub') + manufacturer='manufacturer', model='via') orig_light = registry.async_get_or_create( config_entry_id='456', connections=set(), identifiers={('hue', '456')}, manufacturer='manufacturer', model='light', - via_hub=('hue', '0123')) + via_device=('hue', '0123')) assert len(registry.devices) == 2 @@ -326,10 +326,10 @@ async def test_loading_saving_data(hass, registry): # Ensure same order assert list(registry.devices) == list(registry2.devices) - new_hub = registry2.async_get_device({('hue', '0123')}, set()) + new_via = registry2.async_get_device({('hue', '0123')}, set()) new_light = registry2.async_get_device({('hue', '456')}, set()) - assert orig_hub == new_hub + assert orig_via == new_via assert orig_light == new_light diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 95e1af403d4bb2..e1e3d16c914266 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -706,11 +706,11 @@ async def test_entity_registry_updates_invalid_entity_id(hass): async def test_device_info_called(hass): """Test device info is forwarded correctly.""" registry = await hass.helpers.device_registry.async_get_registry() - hub = registry.async_get_or_create( + via = registry.async_get_or_create( config_entry_id='123', connections=set(), - identifiers={('hue', 'hub-id')}, - manufacturer='manufacturer', model='hub' + identifiers={('hue', 'via-id')}, + manufacturer='manufacturer', model='via' ) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -726,7 +726,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): 'model': 'test-model', 'name': 'test-name', 'sw_version': 'test-sw', - 'via_hub': ('hue', 'hub-id'), + 'via_device': ('hue', 'via-id'), }), ]) return True @@ -754,7 +754,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): assert device.model == 'test-model' assert device.name == 'test-name' assert device.sw_version == 'test-sw' - assert device.hub_device_id == hub.id + assert device.via_device_id == via.id async def test_device_info_not_overrides(hass): From fadfb89b4c292d7041bc68859d89b699ffe740ae Mon Sep 17 00:00:00 2001 From: Julien Brochet <556303+aerialls@users.noreply.github.com> Date: Mon, 10 Jun 2019 18:11:07 +0200 Subject: [PATCH 182/319] Load the SSDP component only when it's needed (#24420) * fix(hue): Load the SSDP component only when it's needed * fix(deconz): Don't load the SSDP component when it's not needed * Update config_flow.py * Update test_config_flow.py --- homeassistant/components/deconz/config_flow.py | 4 +++- homeassistant/components/hue/config_flow.py | 3 ++- tests/components/deconz/test_config_flow.py | 11 ++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index ea93cc590e21f8..1e5eabd0a99495 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -9,7 +9,6 @@ async_discovery, async_get_api_key, async_get_bridgeid) from homeassistant import config_entries -from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -154,6 +153,9 @@ async def _update_entry(self, entry, host): async def async_step_ssdp(self, discovery_info): """Handle a discovered deCONZ bridge.""" + from homeassistant.components.ssdp import ( + ATTR_MANUFACTURERURL, ATTR_SERIAL) + if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL: return self.async_abort(reason='not_deconz_bridge') diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index d57706f7ac87ee..76a46d13ed5438 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.ssdp import ATTR_MANUFACTURERURL from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -146,6 +145,8 @@ async def async_step_ssdp(self, discovery_info): This flow is triggered by the SSDP component. It will check if the host is already configured and delegate to the import step if not. """ + from homeassistant.components.ssdp import ATTR_MANUFACTURERURL + if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL: return self.async_abort(reason='not_hue_bridge') diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ac22c964151349..398ea19f1641d3 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -4,6 +4,7 @@ import asyncio from homeassistant.components.deconz import config_flow +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL from tests.common import MockConfigEntry import pydeconz @@ -175,8 +176,8 @@ async def test_bridge_ssdp_discovery(hass): data={ config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_PORT: 80, - config_flow.ATTR_SERIAL: 'id', - config_flow.ATTR_MANUFACTURERURL: + ATTR_SERIAL: 'id', + ATTR_MANUFACTURERURL: config_flow.DECONZ_MANUFACTURERURL, config_flow.ATTR_UUID: 'uuid:1234' }, @@ -192,7 +193,7 @@ async def test_bridge_ssdp_discovery_not_deconz_bridge(hass): result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, data={ - config_flow.ATTR_MANUFACTURERURL: 'not deconz bridge' + ATTR_MANUFACTURERURL: 'not deconz bridge' }, context={'source': 'ssdp'} ) @@ -217,8 +218,8 @@ async def test_bridge_discovery_update_existing_entry(hass): config_flow.DOMAIN, data={ config_flow.CONF_HOST: 'mock-deconz', - config_flow.ATTR_SERIAL: 'id', - config_flow.ATTR_MANUFACTURERURL: + ATTR_SERIAL: 'id', + ATTR_MANUFACTURERURL: config_flow.DECONZ_MANUFACTURERURL, config_flow.ATTR_UUID: 'uuid:1234' }, From 34e3d2f99792e31d96bc179a6bd12ac8c6bdd57f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 10 Jun 2019 18:12:17 +0200 Subject: [PATCH 183/319] Axis discovery MAC filter (#24442) * Make sure to abort if the MAC is not from Axis * Fix tests * Andrew Sayre suggestion Co-Authored-By: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- homeassistant/components/axis/config_flow.py | 8 ++++- homeassistant/components/axis/strings.json | 5 +-- tests/components/axis/test_config_flow.py | 34 +++++++++++++++----- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 2aa5c4de16e147..410fb62c13909a 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -14,6 +14,8 @@ from .device import get_device from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect +AXIS_OUI = {'00408C', 'ACCC8E', 'B8A44F'} + CONFIG_FILE = 'axis.conf' EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', @@ -151,10 +153,14 @@ async def async_step_zeroconf(self, discovery_info): This flow is triggered by the discovery component. """ + serialnumber = discovery_info['properties']['macaddress'] + + if serialnumber[:6] not in AXIS_OUI: + return self.async_abort(reason='not_axis_device') + if discovery_info[CONF_HOST].startswith('169.254'): return self.async_abort(reason='link_local_address') - serialnumber = discovery_info['properties']['macaddress'] # pylint: disable=unsupported-assignment-operation self.context['macaddress'] = serialnumber diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index ebefbecf311284..29fe09b7e5bd24 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -21,7 +21,8 @@ "abort": { "already_configured": "Device is already configured", "bad_config_file": "Bad data from config file", - "link_local_address": "Link local addresses are not supported" + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" } } -} \ No newline at end of file +} diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index ebd2062ee0f356..d6f8b7c6042ebb 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -169,7 +169,7 @@ async def test_zeroconf_flow(hass): data={ config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_PORT: 80, - 'properties': {'macaddress': '1234'} + 'properties': {'macaddress': '00408C12345'} }, context={'source': 'zeroconf'} ) @@ -184,7 +184,7 @@ async def test_zeroconf_flow_known_device(hass): This is legacy support from devices registered with configurator. """ with patch('homeassistant.components.axis.config_flow.load_json', - return_value={'1234ABCD': { + return_value={'00408C12345': { config_flow.CONF_HOST: '2.3.4.5', config_flow.CONF_USERNAME: 'user', config_flow.CONF_PASSWORD: 'pass', @@ -208,7 +208,7 @@ def mock_constructor( config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_PORT: 80, 'hostname': 'name', - 'properties': {'macaddress': '1234ABCD'} + 'properties': {'macaddress': '00408C12345'} }, context={'source': 'zeroconf'} ) @@ -221,7 +221,7 @@ async def test_zeroconf_flow_already_configured(hass): entry = MockConfigEntry( domain=axis.DOMAIN, data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'}, - axis.config_flow.CONF_MAC: '1234ABCD'} + axis.config_flow.CONF_MAC: '00408C12345'} ) entry.add_to_hass(hass) @@ -233,7 +233,7 @@ async def test_zeroconf_flow_already_configured(hass): config_flow.CONF_PASSWORD: 'pass', config_flow.CONF_PORT: 80, 'hostname': 'name', - 'properties': {'macaddress': '1234ABCD'} + 'properties': {'macaddress': '00408C12345'} }, context={'source': 'zeroconf'} ) @@ -242,11 +242,29 @@ async def test_zeroconf_flow_already_configured(hass): assert result['reason'] == 'already_configured' +async def test_zeroconf_flow_ignore_non_axis_device(hass): + """Test that zeroconf doesn't setup devices with link local addresses.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.CONF_HOST: '169.254.3.4', + 'properties': {'macaddress': '01234567890'} + }, + context={'source': 'zeroconf'} + ) + + assert result['type'] == 'abort' + assert result['reason'] == 'not_axis_device' + + async def test_zeroconf_flow_ignore_link_local_address(hass): """Test that zeroconf doesn't setup devices with link local addresses.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, - data={config_flow.CONF_HOST: '169.254.3.4'}, + data={ + config_flow.CONF_HOST: '169.254.3.4', + 'properties': {'macaddress': '00408C12345'} + }, context={'source': 'zeroconf'} ) @@ -257,7 +275,7 @@ async def test_zeroconf_flow_ignore_link_local_address(hass): async def test_zeroconf_flow_bad_config_file(hass): """Test that zeroconf discovery with bad config files abort.""" with patch('homeassistant.components.axis.config_flow.load_json', - return_value={'1234ABCD': { + return_value={'00408C12345': { config_flow.CONF_HOST: '2.3.4.5', config_flow.CONF_USERNAME: 'user', config_flow.CONF_PASSWORD: 'pass', @@ -268,7 +286,7 @@ async def test_zeroconf_flow_bad_config_file(hass): config_flow.DOMAIN, data={ config_flow.CONF_HOST: '1.2.3.4', - 'properties': {'macaddress': '1234ABCD'} + 'properties': {'macaddress': '00408C12345'} }, context={'source': 'zeroconf'} ) From 20ba80f9346930493c45c20e79be33b05bf44a9e Mon Sep 17 00:00:00 2001 From: jwater7 Date: Mon, 10 Jun 2019 09:16:26 -0700 Subject: [PATCH 184/319] Remove frequest asuswrt log spam (#24448) --- homeassistant/components/asuswrt/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 68641f670aa267..a7b13abbc053c4 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -47,6 +47,6 @@ async def async_update_info(self): Return boolean if scanning successful. """ - _LOGGER.info('Checking Devices') + _LOGGER.debug('Checking Devices') self.last_results = await self.connection.async_get_connected_devices() From af926db21111d187eaa9947a7cbc9a5a2f99aed5 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 10 Jun 2019 21:22:23 +0200 Subject: [PATCH 185/319] Publish test results (#24460) --- azure-pipelines-ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 729d07704adf82..4464050f91934e 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -94,9 +94,14 @@ jobs: - script: | . venv/bin/activate - pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar tests + pytest --timeout=9 --durations=10 --junitxml=junit/test-results.xml -qq -o console_output_style=count -p no:sugar tests displayName: 'Run pytest for python $(python.version)' + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFiles: '**/test-*.xml' + testRunTitle: 'Publish test results for Python $(python.version)' - job: 'FullCheck' dependsOn: From d86837cc4d1b9e3ac0491263935ac3b5eaa0b125 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 10 Jun 2019 14:45:22 -0500 Subject: [PATCH 186/319] Life360: Fix config entry handling for accounts imported from configuration (#24455) Was improperly generating a warning each restart. Was not properly handling a password change in configuration. Was not properly removing config entries for accounts removed from configuration. --- homeassistant/components/life360/__init__.py | 42 +++++++++++++++++-- .../components/life360/config_flow.py | 3 -- .../components/life360/device_tracker.py | 2 +- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 3cb5ad83304130..a42dcf9b72c10b 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -121,11 +121,36 @@ def _thresholds(config): def setup(hass, config): """Set up integration.""" conf = config.get(DOMAIN, LIFE360_SCHEMA({})) - hass.data[DOMAIN] = {'config': conf, 'apis': []} + hass.data[DOMAIN] = {'config': conf, 'apis': {}} discovery.load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) - if CONF_ACCOUNTS in conf: + if CONF_ACCOUNTS not in conf: + return True + + # Check existing config entries. For any that correspond to an entry in + # configuration.yaml, and whose password has not changed, nothing needs to + # be done with that config entry or that account from configuration.yaml. + # But if the config entry was created by import and the account no longer + # exists in configuration.yaml, or if the password has changed, then delete + # that out-of-date config entry. + already_configured = [] + for entry in hass.config_entries.async_entries(DOMAIN): + # Find corresponding configuration.yaml entry and its password. + password = None for account in conf[CONF_ACCOUNTS]: + if account[CONF_USERNAME] == entry.data[CONF_USERNAME]: + password = account[CONF_PASSWORD] + if password == entry.data[CONF_PASSWORD]: + already_configured.append(entry.data[CONF_USERNAME]) + continue + if (not password and entry.source == config_entries.SOURCE_IMPORT + or password and password != entry.data[CONF_PASSWORD]): + hass.async_create_task(hass.config_entries.async_remove( + entry.entry_id)) + + # Create config entries for accounts listed in configuration. + for account in conf[CONF_ACCOUNTS]: + if account[CONF_USERNAME] not in already_configured: hass.async_create_task(hass.config_entries.flow.async_init( DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, data=account)) @@ -134,6 +159,15 @@ def setup(hass, config): async def async_setup_entry(hass, entry): """Set up config entry.""" - hass.data[DOMAIN]['apis'].append( - get_api(entry.data[CONF_AUTHORIZATION])) + hass.data[DOMAIN]['apis'][entry.data[CONF_USERNAME]] = get_api( + entry.data[CONF_AUTHORIZATION]) return True + + +async def async_unload_entry(hass, entry): + """Unload config entry.""" + try: + hass.data[DOMAIN]['apis'].pop(entry.data[CONF_USERNAME]) + return True + except KeyError: + return False diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index 2ec7d34610ec45..4f536b0f60efe5 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -82,9 +82,6 @@ async def async_step_import(self, user_input): """Import a config flow from configuration.""" username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - if username in self.configured_usernames: - _LOGGER.warning('%s already configured', username) - return self.async_abort(reason='user_already_configured') try: authorization = self._api.get_authorization(username, password) except LoginError: diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 1f8574b2d7242e..00201f1aa0d7c6 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -305,7 +305,7 @@ def _update_life360(self, now=None): circles_updated = [] members_updated = [] - for api in self._apis: + for api in self._apis.values(): err_key = 'get_circles' try: circles = api.get_circles() From 1810e459ee215537ed5c6693efb244ab2a034695 Mon Sep 17 00:00:00 2001 From: Andy Kittner Date: Mon, 10 Jun 2019 21:46:38 +0200 Subject: [PATCH 187/319] Remember gpslogger entities across restarts (fixes #24432) (#24444) * Remember gpslogger entities across restarts (fixes #24432) * oops, missed those changes * Remove to do and set defaults to `None` --- .../components/gpslogger/__init__.py | 18 ++--- homeassistant/components/gpslogger/const.py | 8 +++ .../components/gpslogger/device_tracker.py | 71 ++++++++++++++++++- 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 2123421334a82d..869b4b669875af 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -11,19 +11,21 @@ from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER -from .const import DOMAIN +from .const import ( + DOMAIN, + ATTR_ALTITUDE, + ATTR_ACCURACY, + ATTR_ACTIVITY, + ATTR_DEVICE, + ATTR_DIRECTION, + ATTR_PROVIDER, + ATTR_SPEED, +) _LOGGER = logging.getLogger(__name__) TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) -ATTR_ALTITUDE = 'altitude' -ATTR_ACCURACY = 'accuracy' -ATTR_ACTIVITY = 'activity' -ATTR_DEVICE = 'device' -ATTR_DIRECTION = 'direction' -ATTR_PROVIDER = 'provider' -ATTR_SPEED = 'speed' DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 diff --git a/homeassistant/components/gpslogger/const.py b/homeassistant/components/gpslogger/const.py index e37c7f0d77bcc2..870c5310f29d50 100644 --- a/homeassistant/components/gpslogger/const.py +++ b/homeassistant/components/gpslogger/const.py @@ -1,3 +1,11 @@ """Const for GPSLogger.""" DOMAIN = 'gpslogger' + +ATTR_ALTITUDE = 'altitude' +ATTR_ACCURACY = 'accuracy' +ATTR_ACTIVITY = 'activity' +ATTR_DEVICE = 'device' +ATTR_DIRECTION = 'direction' +ATTR_PROVIDER = 'provider' +ATTR_SPEED = 'speed' diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 49d421cbc8c851..d4b6b3c53cc8f8 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -2,14 +2,29 @@ import logging from homeassistant.core import callback +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, +) from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.config_entry import ( DeviceTrackerEntity ) +from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import HomeAssistantType from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE +from .const import ( + ATTR_ACTIVITY, + ATTR_ALTITUDE, + ATTR_DIRECTION, + ATTR_PROVIDER, + ATTR_SPEED, +) _LOGGER = logging.getLogger(__name__) @@ -32,8 +47,27 @@ def _receive_data(device, gps, battery, accuracy, attrs): hass.data[GPL_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \ async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == GPL_DOMAIN + } + if not dev_ids: + return + + entities = [] + for dev_id in dev_ids: + hass.data[GPL_DOMAIN]['devices'].add(dev_id) + entity = GPSLoggerEntity(dev_id, None, None, None, None) + entities.append(entity) -class GPSLoggerEntity(DeviceTrackerEntity): + async_add_entities(entities) + + +class GPSLoggerEntity(DeviceTrackerEntity, RestoreEntity): """Represent a tracked device.""" def __init__( @@ -102,11 +136,46 @@ def source_type(self): async def async_added_to_hass(self): """Register state update callback.""" + await super().async_added_to_hass() self._unsub_dispatcher = async_dispatcher_connect( self.hass, TRACKER_UPDATE, self._async_receive_data) + # don't restore if we got created with data + if self._location is not None: + return + + state = await self.async_get_last_state() + if state is None: + self._location = (None, None) + self._accuracy = None + self._attributes = { + ATTR_ALTITUDE: None, + ATTR_ACTIVITY: None, + ATTR_DIRECTION: None, + ATTR_PROVIDER: None, + ATTR_SPEED: None, + } + self._battery = None + return + + attr = state.attributes + self._location = ( + attr.get(ATTR_LATITUDE), + attr.get(ATTR_LONGITUDE), + ) + self._accuracy = attr.get(ATTR_GPS_ACCURACY) + self._attributes = { + ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), + ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY), + ATTR_DIRECTION: attr.get(ATTR_DIRECTION), + ATTR_PROVIDER: attr.get(ATTR_PROVIDER), + ATTR_SPEED: attr.get(ATTR_SPEED), + } + self._battery = attr.get(ATTR_BATTERY_LEVEL) + async def async_will_remove_from_hass(self): """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() self._unsub_dispatcher() @callback From 168f20bdf496ce25b628c51b0639c3e0d6befa18 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Jun 2019 14:38:14 -0700 Subject: [PATCH 188/319] Add default config to constaint file (#24423) --- homeassistant/package_constraints.txt | 18 ++++++++----- script/gen_requirements_all.py | 39 ++++++++++++++------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5fb81ba93591a8..2e05d38b23ef4a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,21 +1,29 @@ +PyJWT==1.7.1 +PyNaCl==1.3.0 aiohttp==3.5.4 +aiohttp_cors==0.7.0 astral==1.10.1 async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.6 certifi>=2018.04.16 +cryptography==2.6.1 +distro==1.4.0 +hass-nabucasa==0.13 +home-assistant-frontend==20190604.0 importlib-metadata==0.15 jinja2>=2.10 -PyJWT==1.7.1 -cryptography==2.6.1 +netdisco==2.6.0 pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 pyyaml>=3.13,<4 requests==2.22.0 ruamel.yaml==0.15.97 -voluptuous==0.11.5 +sqlalchemy==1.3.3 voluptuous-serialize==2.1.0 +voluptuous==0.11.5 +zeroconf==0.23.0 pycryptodome>=3.6.6 @@ -27,7 +35,3 @@ pycrypto==1000000000.0.0 # Contains code to modify Home Assistant to work around our rules python-systemair-savecair==1000000000.0.0 - -# Newer version causes pylint to take forever -# https://github.com/timothycrosley/isort/issues/848 -isort==4.3.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 33f27a6702188c..7cf9459635c3cc 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 """Generate an updated requirements_all.txt.""" -import fnmatch import importlib import os import pathlib @@ -155,13 +154,6 @@ 'bellows-homeassistant', ) -IGNORE_PACKAGES = ( - 'homeassistant.components.hangouts.hangups_utils', - 'homeassistant.components.cloud.client', - 'homeassistant.components.homekit.*', - 'homeassistant.components.recorder.models', -) - IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') IGNORE_REQ = ( @@ -185,10 +177,6 @@ # Contains code to modify Home Assistant to work around our rules python-systemair-savecair==1000000000.0.0 - -# Newer version causes pylint to take forever -# https://github.com/timothycrosley/isort/issues/848 -isort==4.3.4 """ @@ -218,6 +206,22 @@ def core_requirements(): return re.findall(r"'(.*?)'", reqs_raw) +def gather_recursive_requirements(domain, seen=None): + """Recursively gather requirements from a module.""" + if seen is None: + seen = set() + + seen.add(domain) + integration = Integration(pathlib.Path( + 'homeassistant/components/{}'.format(domain) + )) + integration.load_manifest() + reqs = set(integration.manifest['requirements']) + for dep_domain in integration.manifest['dependencies']: + reqs.update(gather_recursive_requirements(dep_domain, seen)) + return reqs + + def comment_requirement(req): """Comment out requirement. Some don't install on all systems.""" return any(ign in req for ign in COMMENT_REQUIREMENTS) @@ -274,12 +278,8 @@ def gather_requirements_from_modules(errors, reqs): try: module = importlib.import_module(package) except ImportError as err: - for pattern in IGNORE_PACKAGES: - if fnmatch.fnmatch(package, pattern): - break - else: - print("{}: {}".format(package.replace('.', '/') + '.py', err)) - errors.append(package) + print("{}: {}".format(package.replace('.', '/') + '.py', err)) + errors.append(package) continue if getattr(module, 'REQUIREMENTS', None): @@ -347,7 +347,8 @@ def requirements_test_output(reqs): def gather_constraints(): """Construct output for constraint file.""" - return '\n'.join(core_requirements() + ['']) + return '\n'.join(sorted(core_requirements() + list( + gather_recursive_requirements('default_config'))) + ['']) def write_requirements_file(data): From 935240f8c38746f29aaa030a13c23b3919e72034 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Jun 2019 00:36:11 +0200 Subject: [PATCH 189/319] Add websock command to query device for triggers (#24044) * Add websock command to query device for triggers * Lint * Refactor * Add support for domain automations * Make device automation an automation platform * lint * Support device_id in light trigger * Review comments * Add tests * Add tests * lint --- CODEOWNERS | 1 + .../components/automation/__init__.py | 2 +- homeassistant/components/automation/device.py | 18 +++ .../components/device_automation/__init__.py | 80 +++++++++++ .../device_automation/manifest.json | 12 ++ .../components/light/device_automation.py | 80 +++++++++++ homeassistant/const.py | 1 + .../components/device_automation/test_init.py | 67 +++++++++ .../light/test_device_automation.py | 128 ++++++++++++++++++ 9 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/automation/device.py create mode 100644 homeassistant/components/device_automation/__init__.py create mode 100644 homeassistant/components/device_automation/manifest.json create mode 100644 homeassistant/components/light/device_automation.py create mode 100644 tests/components/device_automation/test_init.py create mode 100644 tests/components/light/test_device_automation.py diff --git a/CODEOWNERS b/CODEOWNERS index b17d5a354dc42b..94e01c201dab61 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -61,6 +61,7 @@ homeassistant/components/daikin/* @fredrike @rofrantz homeassistant/components/darksky/* @fabaff homeassistant/components/deconz/* @kane610 homeassistant/components/demo/* @home-assistant/core +homeassistant/components/device_automation/* @home-assistant/core homeassistant/components/digital_ocean/* @fabaff homeassistant/components/discogs/* @thibmaek homeassistant/components/doorbird/* @oblogic7 diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 6c230089990cfc..5238a423181d1d 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -50,7 +50,7 @@ def _platform_validator(config): - """Validate it is a valid platform.""" + """Validate it is a valid platform.""" try: platform = importlib.import_module('.{}'.format(config[CONF_PLATFORM]), __name__) diff --git a/homeassistant/components/automation/device.py b/homeassistant/components/automation/device.py new file mode 100644 index 00000000000000..4e59018b41c8ef --- /dev/null +++ b/homeassistant/components/automation/device.py @@ -0,0 +1,18 @@ +"""Offer device oriented automation.""" +import voluptuous as vol + +from homeassistant.const import CONF_DOMAIN, CONF_PLATFORM +from homeassistant.loader import async_get_integration + + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'device', + vol.Required(CONF_DOMAIN): str, +}, extra=vol.ALLOW_EXTRA) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for trigger.""" + integration = await async_get_integration(hass, config[CONF_DOMAIN]) + platform = integration.get_platform('device_automation') + return await platform.async_trigger(hass, config, action, automation_info) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py new file mode 100644 index 00000000000000..67ad51210dfec5 --- /dev/null +++ b/homeassistant/components/device_automation/__init__.py @@ -0,0 +1,80 @@ +"""Helpers for device automations.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import split_entity_id +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.loader import async_get_integration, IntegrationNotFound + +DOMAIN = 'device_automation' + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up device automation.""" + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_triggers) + return True + + +async def _async_get_device_automation_triggers(hass, domain, device_id): + """List device triggers.""" + integration = None + try: + integration = await async_get_integration(hass, domain) + except IntegrationNotFound: + _LOGGER.warning('Integration %s not found', domain) + return None + + try: + platform = integration.get_platform('device_automation') + except ImportError: + # The domain does not have device automations + return None + + if hasattr(platform, 'async_get_triggers'): + return await platform.async_get_triggers(hass, device_id) + + +async def async_get_device_automation_triggers(hass, device_id): + """List device triggers.""" + device_registry, entity_registry = await asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry()) + + domains = set() + triggers = [] + device = device_registry.async_get(device_id) + for entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(entry_id) + domains.add(config_entry.domain) + + entities = async_entries_for_device(entity_registry, device_id) + for entity in entities: + domains.add(split_entity_id(entity.entity_id)[0]) + + device_triggers = await asyncio.gather(*[ + _async_get_device_automation_triggers(hass, domain, device_id) + for domain in domains + ]) + for device_trigger in device_triggers: + if device_trigger is not None: + triggers.extend(device_trigger) + + return triggers + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'device_automation/list_triggers', + vol.Required('device_id'): str, +}) +async def websocket_device_automation_list_triggers(hass, connection, msg): + """Handle request for device triggers.""" + device_id = msg['device_id'] + triggers = await async_get_device_automation_triggers(hass, device_id) + connection.send_result(msg['id'], {'triggers': triggers}) diff --git a/homeassistant/components/device_automation/manifest.json b/homeassistant/components/device_automation/manifest.json new file mode 100644 index 00000000000000..a95e9c4f68fbb1 --- /dev/null +++ b/homeassistant/components/device_automation/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "device_automation", + "name": "Device automation", + "documentation": "https://www.home-assistant.io/components/device_automation", + "requirements": [], + "dependencies": [ + "webhook" + ], + "codeowners": [ + "@home-assistant/core" + ] +} diff --git a/homeassistant/components/light/device_automation.py b/homeassistant/components/light/device_automation.py new file mode 100644 index 00000000000000..44a9d9887e69b7 --- /dev/null +++ b/homeassistant/components/light/device_automation.py @@ -0,0 +1,80 @@ +"""Provides device automations for lights.""" +import voluptuous as vol + +import homeassistant.components.automation.state as state +from homeassistant.core import split_entity_id +from homeassistant.const import ( + CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_PLATFORM, CONF_TYPE) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device +from . import DOMAIN + +CONF_TURN_OFF = 'turn_off' +CONF_TURN_ON = 'turn_on' + +ENTITY_TRIGGERS = [ + { + # Trigger when light is turned on + CONF_PLATFORM: 'device', + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TURN_OFF, + }, + { + # Trigger when light is turned off + CONF_PLATFORM: 'device', + CONF_DOMAIN: DOMAIN, + CONF_TYPE: CONF_TURN_ON, + }, +] + +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'device', + vol.Optional(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): str, +})) + + +def _is_domain(entity, domain): + return split_entity_id(entity.entity_id)[0] == domain + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + trigger_type = config.get(CONF_TYPE) + if trigger_type == CONF_TURN_ON: + from_state = 'off' + to_state = 'on' + else: + from_state = 'on' + to_state = 'off' + state_config = { + state.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_FROM: from_state, + state.CONF_TO: to_state + } + + return await state.async_trigger(hass, state_config, action, + automation_info) + + +async def async_trigger(hass, config, action, automation_info): + """Temporary so existing automation framework can be used for testing.""" + return await async_attach_trigger(hass, config, action, automation_info) + + +async def async_get_triggers(hass, device_id): + """List device triggers.""" + triggers = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entities = async_entries_for_device(entity_registry, device_id) + domain_entities = [x for x in entities if _is_domain(x, DOMAIN)] + for entity in domain_entities: + for trigger in ENTITY_TRIGGERS: + trigger = dict(trigger) + trigger.update(device_id=device_id, entity_id=entity.entity_id) + triggers.append(trigger) + + return triggers diff --git a/homeassistant/const.py b/homeassistant/const.py index c7c7bd9bc1ef93..258c4d0e4e2edf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -59,6 +59,7 @@ CONF_DELAY_TIME = 'delay_time' CONF_DEVICE = 'device' CONF_DEVICE_CLASS = 'device_class' +CONF_DEVICE_ID = 'device_id' CONF_DEVICES = 'devices' CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' CONF_DISCOVERY = 'discovery' diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py new file mode 100644 index 00000000000000..64b1a7574ae623 --- /dev/null +++ b/tests/components/device_automation/test_init.py @@ -0,0 +1,67 @@ +"""The test for light device automation.""" +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.helpers import device_registry + + +from tests.common import ( + MockConfigEntry, mock_device_registry, mock_registry) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +def _same_triggers(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def test_websocket_get_triggers( + hass, hass_ws_client, device_reg, entity_reg): + """Test we get the expected triggers from a light through websocket.""" + await async_setup_component(hass, 'device_automation', {}) + config_entry = MockConfigEntry(domain='test', data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + entity_reg.async_get_or_create( + 'light', 'test', '5678', device_id=device_entry.id) + expected_triggers = [ + {'platform': 'device', 'domain': 'light', 'type': 'turn_off', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + {'platform': 'device', 'domain': 'light', 'type': 'turn_on', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + ] + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 1, + 'type': 'device_automation/list_triggers', + 'device_id': device_entry.id + }) + msg = await client.receive_json() + + assert msg['id'] == 1 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + triggers = msg['result']['triggers'] + assert _same_triggers(triggers, expected_triggers) diff --git a/tests/components/light/test_device_automation.py b/tests/components/light/test_device_automation.py new file mode 100644 index 00000000000000..31381bfc29b502 --- /dev/null +++ b/tests/components/light/test_device_automation.py @@ -0,0 +1,128 @@ +"""The test for light device automation.""" +import pytest + +from homeassistant.components import light +from homeassistant.const import ( + STATE_ON, STATE_OFF, CONF_PLATFORM) +from homeassistant.setup import async_setup_component +import homeassistant.components.automation as automation +from homeassistant.components.device_automation import ( + async_get_device_automation_triggers) +from homeassistant.helpers import device_registry + + +from tests.common import ( + MockConfigEntry, async_mock_service, mock_device_registry, mock_registry) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') + + +def _same_triggers(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def test_get_triggers(hass, device_reg, entity_reg): + """Test we get the expected triggers from a light.""" + config_entry = MockConfigEntry(domain='test', data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + entity_reg.async_get_or_create( + 'light', 'test', '5678', device_id=device_entry.id) + expected_triggers = [ + {'platform': 'device', 'domain': 'light', 'type': 'turn_off', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + {'platform': 'device', 'domain': 'light', 'type': 'turn_on', + 'device_id': device_entry.id, 'entity_id': 'light.test_5678'}, + ] + triggers = await async_get_device_automation_triggers(hass, + device_entry.id) + assert _same_triggers(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + platform = getattr(hass.components, 'test.light') + + platform.init() + assert await async_setup_component(hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1, dev2, dev3 = platform.DEVICES + + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: [{ + 'trigger': { + 'platform': 'device', + 'domain': light.DOMAIN, + 'entity_id': dev1.entity_id, + 'type': 'turn_on' + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': + 'turn_on {{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'for')) + }, + }}, + {'trigger': { + 'platform': 'device', + 'domain': light.DOMAIN, + 'entity_id': dev1.entity_id, + 'type': 'turn_off' + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': + 'turn_off {{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'for')) + }, + }}, + ] + }) + await hass.async_block_till_done() + assert hass.states.get(dev1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(dev1.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data['some'] == \ + 'turn_off state - {} - on - off - None'.format(dev1.entity_id) + + hass.states.async_set(dev1.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data['some'] == \ + 'turn_on state - {} - off - on - None'.format(dev1.entity_id) From 236c5deeee461167be053d414fd3941d3ddf6ef1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Jun 2019 16:05:32 -0700 Subject: [PATCH 190/319] Sun listener to adapt to core config updates (#24464) * Adaptable sun listener * Lint --- homeassistant/helpers/event.py | 113 ++++++++++++++++++++------------- tests/helpers/test_event.py | 62 ++++++++++++++++++ 2 files changed, 132 insertions(+), 43 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5e262a47565c6c..009c2b1e898ca9 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,15 +1,18 @@ """Helpers for listening to events.""" from datetime import timedelta import functools as ft +from typing import Callable + +import attr from homeassistant.loader import bind_hass from homeassistant.helpers.sun import get_astral_event_next -from ..core import HomeAssistant, callback -from ..const import ( +from homeassistant.core import HomeAssistant, callback +from homeassistant.const import ( ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, - SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) -from ..util import dt as dt_util -from ..util.async_ import run_callback_threadsafe + SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, EVENT_CORE_CONFIG_UPDATE) +from homeassistant.util import dt as dt_util +from homeassistant.util.async_ import run_callback_threadsafe # PyLint does not like the use of threaded_listener_factory # pylint: disable=invalid-name @@ -263,59 +266,83 @@ def remove_listener(): track_time_interval = threaded_listener_factory(async_track_time_interval) -@callback -@bind_hass -def async_track_sunrise(hass, action, offset=None): - """Add a listener that will fire a specified offset from sunrise daily.""" - remove = None +@attr.s +class SunListener: + """Helper class to help listen to sun events.""" + + hass = attr.ib(type=HomeAssistant) + action = attr.ib(type=Callable) + event = attr.ib(type=str) + offset = attr.ib(type=timedelta) + _unsub_sun = attr.ib(default=None) + _unsub_config = attr.ib(default=None) @callback - def sunrise_automation_listener(now): - """Handle points in time to execute actions.""" - nonlocal remove - remove = async_track_point_in_utc_time( - hass, sunrise_automation_listener, get_astral_event_next( - hass, SUN_EVENT_SUNRISE, offset=offset)) - hass.async_run_job(action) + def async_attach(self): + """Attach a sun listener.""" + assert self._unsub_config is None - remove = async_track_point_in_utc_time( - hass, sunrise_automation_listener, get_astral_event_next( - hass, SUN_EVENT_SUNRISE, offset=offset)) + self._unsub_config = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, self._handle_config_event) - def remove_listener(): - """Remove sunset listener.""" - remove() + self._listen_next_sun_event() - return remove_listener + @callback + def async_detach(self): + """Detach the sun listener.""" + assert self._unsub_sun is not None + assert self._unsub_config is not None + self._unsub_sun() + self._unsub_sun = None + self._unsub_config() + self._unsub_config = None -track_sunrise = threaded_listener_factory(async_track_sunrise) + @callback + def _listen_next_sun_event(self): + """Set up the sun event listener.""" + assert self._unsub_sun is None + + self._unsub_sun = async_track_point_in_utc_time( + self.hass, self._handle_sun_event, + get_astral_event_next(self.hass, self.event, offset=self.offset) + ) + + @callback + def _handle_sun_event(self, _now): + """Handle solar event.""" + self._unsub_sun = None + self._listen_next_sun_event() + self.hass.async_run_job(self.action) + + @callback + def _handle_config_event(self, _event): + """Handle core config update.""" + assert self._unsub_sun is not None + self._unsub_sun() + self._unsub_sun = None + self._listen_next_sun_event() @callback @bind_hass -def async_track_sunset(hass, action, offset=None): - """Add a listener that will fire a specified offset from sunset daily.""" - remove = None +def async_track_sunrise(hass, action, offset=None): + """Add a listener that will fire a specified offset from sunrise daily.""" + listener = SunListener(hass, action, SUN_EVENT_SUNRISE, offset) + listener.async_attach() + return listener.async_detach - @callback - def sunset_automation_listener(now): - """Handle points in time to execute actions.""" - nonlocal remove - remove = async_track_point_in_utc_time( - hass, sunset_automation_listener, get_astral_event_next( - hass, SUN_EVENT_SUNSET, offset=offset)) - hass.async_run_job(action) - remove = async_track_point_in_utc_time( - hass, sunset_automation_listener, get_astral_event_next( - hass, SUN_EVENT_SUNSET, offset=offset)) +track_sunrise = threaded_listener_factory(async_track_sunrise) - def remove_listener(): - """Remove sunset listener.""" - remove() - return remove_listener +@callback +@bind_hass +def async_track_sunset(hass, action, offset=None): + """Add a listener that will fire a specified offset from sunset daily.""" + listener = SunListener(hass, action, SUN_EVENT_SUNSET, offset) + listener.async_attach() + return listener.async_detach track_sunset = threaded_listener_factory(async_track_sunset) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 0756bab2eec83e..55900b7c80a23c 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -436,6 +436,68 @@ async def test_track_sunrise(hass): assert len(offset_runs) == 1 +async def test_track_sunrise_update_location(hass): + """Test track the sunrise.""" + # Setup sun component + hass.config.latitude = 32.87336 + hass.config.longitude = 117.22743 + assert await async_setup_component(hass, sun.DOMAIN, { + sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + + # Get next sunrise + astral = Astral() + utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) + utc_today = utc_now.date() + + mod = -1 + while True: + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), + hass.config.latitude, hass.config.longitude)) + if next_rising > utc_now: + break + mod += 1 + + # Track sunrise + runs = [] + with patch('homeassistant.util.dt.utcnow', return_value=utc_now): + async_track_sunrise(hass, lambda: runs.append(1)) + + # Mimick sunrise + _send_time_changed(hass, next_rising) + await hass.async_block_till_done() + assert len(runs) == 1 + + # Move! + with patch('homeassistant.util.dt.utcnow', return_value=utc_now): + await hass.config.async_update( + latitude=40.755931, + longitude=-73.984606, + ) + await hass.async_block_till_done() + + # Mimick sunrise + _send_time_changed(hass, next_rising) + await hass.async_block_till_done() + # Did not increase + assert len(runs) == 1 + + # Get next sunrise + mod = -1 + while True: + next_rising = (astral.sunrise_utc( + utc_today + timedelta(days=mod), + hass.config.latitude, hass.config.longitude)) + if next_rising > utc_now: + break + mod += 1 + + # Mimick sunrise at new location + _send_time_changed(hass, next_rising) + await hass.async_block_till_done() + assert len(runs) == 2 + + async def test_track_sunset(hass): """Test track the sunset.""" latitude = 32.87336 From 820b381a8d4e0cb8165fb77a2dcbfa178862367d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Jun 2019 16:05:43 -0700 Subject: [PATCH 191/319] Update Hass.io when core config is updated (#24461) * Update Hass.io when core config is updated * Lint * Fix tests * Lint sigh --- homeassistant/components/hassio/__init__.py | 12 +++++-- homeassistant/components/hassio/handler.py | 6 ++-- tests/components/hassio/conftest.py | 15 +++++--- tests/components/hassio/test_addon_panel.py | 4 +-- tests/components/hassio/test_init.py | 40 +++++++++++---------- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index c8c0f6c9f19b0c..7e8afdc53124e7 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,7 +9,8 @@ from homeassistant.components.homeassistant import SERVICE_CHECK_CONFIG import homeassistant.config as conf_util from homeassistant.const import ( - ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) + ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + EVENT_CORE_CONFIG_UPDATE) from homeassistant.core import DOMAIN as HASS_DOMAIN, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -194,8 +195,13 @@ async def async_setup(hass, config): await hassio.update_hass_api(config.get('http', {}), refresh_token.token) - if 'homeassistant' in config: - await hassio.update_hass_timezone(config['homeassistant']) + async def push_config(_): + """Push core config to Hass.io.""" + await hassio.update_hass_timezone(str(hass.config.time_zone)) + + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) + + await push_config(None) async def async_service_handler(service): """Handle service calls for Hass.io.""" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 1e6e1c2fffe934..5e7932acbae08f 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -11,7 +11,7 @@ CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, ) -from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT +from homeassistant.const import SERVER_PORT from .const import X_HASSIO @@ -140,13 +140,13 @@ async def update_hass_api(self, http_config, refresh_token): payload=options) @_api_bool - def update_hass_timezone(self, core_config): + def update_hass_timezone(self, timezone): """Update Home-Assistant timezone data on Hass.io. This method return a coroutine. """ return self.send_command("/supervisor/options", payload={ - 'timezone': core_config.get(CONF_TIME_ZONE) + 'timezone': timezone }) async def send_command(self, command, method="post", payload=None, diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index f69be17a9e7576..7f3a9a32dd9595 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -29,11 +29,16 @@ def hassio_env(): @pytest.fixture def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): """Create mock hassio http client.""" - with patch('homeassistant.components.hassio.HassIO.update_hass_api', - Mock(return_value=mock_coro({"result": "ok"}))), \ - patch('homeassistant.components.hassio.HassIO.' - 'get_homeassistant_info', - Mock(side_effect=HassioAPIError())): + with patch( + 'homeassistant.components.hassio.HassIO.update_hass_api', + return_value=mock_coro({"result": "ok"}) + ), patch( + 'homeassistant.components.hassio.HassIO.update_hass_timezone', + return_value=mock_coro({"result": "ok"}) + ), patch( + 'homeassistant.components.hassio.HassIO.get_homeassistant_info', + side_effect=HassioAPIError() + ): hass.state = CoreState.starting hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { 'http': { diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py index 0591521865944c..d765b6ac1734d7 100644 --- a/tests/components/hassio/test_addon_panel.py +++ b/tests/components/hassio/test_addon_panel.py @@ -56,7 +56,7 @@ async def test_hassio_addon_panel_startup(hass, aioclient_mock, hassio_env): }) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 assert mock_panel.called mock_panel.assert_called_with( hass, 'test1', { @@ -98,7 +98,7 @@ async def test_hassio_addon_panel_api(hass, aioclient_mock, hassio_env, }) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 assert mock_panel.called mock_panel.assert_called_with( hass, 'test1', { diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 7b8fad3ec09066..da8360a483477b 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -43,7 +43,7 @@ def test_setup_api_ping(hass, aioclient_mock): result = yield from async_setup_component(hass, 'hassio', {}) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert hass.components.hassio.get_homeassistant_version() == "10.0" assert hass.components.hassio.is_hassio() @@ -82,7 +82,7 @@ def test_setup_api_push_api_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert aioclient_mock.mock_calls[1][2]['watchdog'] @@ -101,7 +101,7 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert not aioclient_mock.mock_calls[1][2]['watchdog'] @@ -117,7 +117,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]['refresh_token'] @@ -177,27 +177,29 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 8123 assert aioclient_mock.mock_calls[1][2]['refresh_token'] == token.token -@asyncio.coroutine -def test_setup_core_push_timezone(hass, aioclient_mock): +async def test_setup_core_push_timezone(hass, aioclient_mock): """Test setup with API push default data.""" + hass.config.time_zone = 'testzone' + with patch.dict(os.environ, MOCK_ENVIRON): - result = yield from async_setup_component(hass, 'hassio', { + result = await async_setup_component(hass, 'hassio', { 'hassio': {}, - 'homeassistant': { - 'time_zone': 'testzone', - }, }) assert result assert aioclient_mock.call_count == 5 assert aioclient_mock.mock_calls[2][2]['timezone'] == "testzone" + await hass.config.async_update(time_zone='America/New_York') + await hass.async_block_till_done() + assert aioclient_mock.mock_calls[-1][2]['timezone'] == "America/New_York" + @asyncio.coroutine def test_setup_hassio_no_additional_data(hass, aioclient_mock): @@ -209,7 +211,7 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert aioclient_mock.mock_calls[-1][3]['X-Hassio-Key'] == "123456" @@ -288,14 +290,14 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert aioclient_mock.mock_calls[-1][2] == 'test' yield from hass.services.async_call('hassio', 'host_shutdown', {}) yield from hass.services.async_call('hassio', 'host_reboot', {}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 8 + assert aioclient_mock.call_count == 9 yield from hass.services.async_call('hassio', 'snapshot_full', {}) yield from hass.services.async_call('hassio', 'snapshot_partial', { @@ -305,7 +307,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 11 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} @@ -321,7 +323,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 12 + assert aioclient_mock.call_count == 13 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, 'password': "123456" @@ -341,12 +343,12 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('homeassistant', 'stop') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 yield from hass.services.async_call('homeassistant', 'check_config') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 with patch( 'homeassistant.config.async_check_ha_config_file', @@ -356,4 +358,4 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 From ae5f284d10f7664f6c4bc61c533bf363212697ef Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 11 Jun 2019 08:32:22 -0700 Subject: [PATCH 192/319] Uber API is going away on June 13, 2019, remove component (#24468) * Uber API is going away on June 13, 2019, remove component * Update CODEOWNERS * Remove Uber component --- CODEOWNERS | 1 - homeassistant/components/uber/__init__.py | 1 - homeassistant/components/uber/manifest.json | 12 - homeassistant/components/uber/sensor.py | 231 -------------------- requirements_all.txt | 3 - 5 files changed, 248 deletions(-) delete mode 100644 homeassistant/components/uber/__init__.py delete mode 100644 homeassistant/components/uber/manifest.json delete mode 100644 homeassistant/components/uber/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 94e01c201dab61..58021038d21cec 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -258,7 +258,6 @@ homeassistant/components/tradfri/* @ggravlingen homeassistant/components/tts/* @robbiet480 homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 -homeassistant/components/uber/* @robbiet480 homeassistant/components/unifi/* @kane610 homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core diff --git a/homeassistant/components/uber/__init__.py b/homeassistant/components/uber/__init__.py deleted file mode 100644 index b555f83fed90e2..00000000000000 --- a/homeassistant/components/uber/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The uber component.""" diff --git a/homeassistant/components/uber/manifest.json b/homeassistant/components/uber/manifest.json deleted file mode 100644 index a7db237ab91444..00000000000000 --- a/homeassistant/components/uber/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "uber", - "name": "Uber", - "documentation": "https://www.home-assistant.io/components/uber", - "requirements": [ - "uber_rides==0.6.0" - ], - "dependencies": [], - "codeowners": [ - "@robbiet480" - ] -} diff --git a/homeassistant/components/uber/sensor.py b/homeassistant/components/uber/sensor.py deleted file mode 100644 index 324124ca960bfb..00000000000000 --- a/homeassistant/components/uber/sensor.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Support for the Uber API.""" -import logging -from datetime import timedelta - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -CONF_END_LATITUDE = 'end_latitude' -CONF_END_LONGITUDE = 'end_longitude' -CONF_PRODUCT_IDS = 'product_ids' -CONF_SERVER_TOKEN = 'server_token' -CONF_START_LATITUDE = 'start_latitude' -CONF_START_LONGITUDE = 'start_longitude' - -ICON = 'mdi:taxi' - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SERVER_TOKEN): cv.string, - vol.Optional(CONF_START_LATITUDE): cv.latitude, - vol.Optional(CONF_START_LONGITUDE): cv.longitude, - vol.Optional(CONF_END_LATITUDE): cv.latitude, - vol.Optional(CONF_END_LONGITUDE): cv.longitude, - vol.Optional(CONF_PRODUCT_IDS): vol.All(cv.ensure_list, [cv.string]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Uber sensor.""" - from uber_rides.session import Session - - session = Session(server_token=config.get(CONF_SERVER_TOKEN)) - start_latitude = config.get(CONF_START_LATITUDE, hass.config.latitude) - start_longitude = config.get(CONF_START_LONGITUDE, hass.config.longitude) - end_latitude = config.get(CONF_END_LATITUDE) - end_longitude = config.get(CONF_END_LONGITUDE) - wanted_product_ids = config.get(CONF_PRODUCT_IDS) - - dev = [] - timeandpriceest = UberEstimate( - session, start_latitude, start_longitude, end_latitude, end_longitude) - - for product_id, product in timeandpriceest.products.items(): - if (wanted_product_ids is not None) and \ - (product_id not in wanted_product_ids): - continue - dev.append(UberSensor('time', timeandpriceest, product_id, product)) - - if product.get('price_details') is not None \ - and product['display_name'] != 'TAXI': - dev.append(UberSensor( - 'price', timeandpriceest, product_id, product)) - - add_entities(dev, True) - - -class UberSensor(Entity): - """Implementation of an Uber sensor.""" - - def __init__(self, sensorType, products, product_id, product): - """Initialize the Uber sensor.""" - self.data = products - self._product_id = product_id - self._product = product - self._sensortype = sensorType - self._name = '{} {}'.format( - self._product['display_name'], self._sensortype) - if self._sensortype == 'time': - self._unit_of_measurement = 'min' - time_estimate = self._product.get('time_estimate_seconds', 0) - self._state = int(time_estimate / 60) - elif self._sensortype == 'price': - if self._product.get('price_details') is not None: - price_details = self._product['price_details'] - self._unit_of_measurement = price_details.get('currency_code') - try: - if price_details.get('low_estimate') is not None: - statekey = 'minimum' - else: - statekey = 'low_estimate' - self._state = int(price_details.get(statekey)) - except TypeError: - self._state = 0 - else: - self._state = 0 - - @property - def name(self): - """Return the name of the sensor.""" - if 'uber' not in self._name.lower(): - self._name = 'Uber{}'.format(self._name) - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def device_state_attributes(self): - """Return the state attributes.""" - time_estimate = self._product.get('time_estimate_seconds') - params = { - 'Product ID': self._product['product_id'], - 'Product short description': self._product['short_description'], - 'Product display name': self._product['display_name'], - 'Product description': self._product['description'], - 'Pickup time estimate (in seconds)': time_estimate, - 'Trip duration (in seconds)': self._product.get('duration'), - 'Vehicle Capacity': self._product['capacity'] - } - - if self._product.get('price_details') is not None: - price_details = self._product['price_details'] - dunit = price_details.get('distance_unit') - distance_key = 'Trip distance (in {}s)'.format(dunit) - distance_val = self._product.get('distance') - params['Cost per minute'] = price_details.get('cost_per_minute') - params['Distance units'] = price_details.get('distance_unit') - params['Cancellation fee'] = price_details.get('cancellation_fee') - cpd = price_details.get('cost_per_distance') - params['Cost per distance'] = cpd - params['Base price'] = price_details.get('base') - params['Minimum price'] = price_details.get('minimum') - params['Price estimate'] = price_details.get('estimate') - params['Price currency code'] = price_details.get('currency_code') - params['High price estimate'] = price_details.get('high_estimate') - params['Low price estimate'] = price_details.get('low_estimate') - params['Surge multiplier'] = price_details.get('surge_multiplier') - else: - distance_key = 'Trip distance (in miles)' - distance_val = self._product.get('distance') - - params[distance_key] = distance_val - - return {k: v for k, v in params.items() if v is not None} - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - def update(self): - """Get the latest data from the Uber API and update the states.""" - self.data.update() - self._product = self.data.products[self._product_id] - if self._sensortype == 'time': - time_estimate = self._product.get('time_estimate_seconds', 0) - self._state = int(time_estimate / 60) - elif self._sensortype == 'price': - price_details = self._product.get('price_details') - if price_details is not None: - min_price = price_details.get('minimum') - self._state = int(price_details.get('low_estimate', min_price)) - else: - self._state = 0 - - -class UberEstimate: - """The class for handling the time and price estimate.""" - - def __init__(self, session, start_latitude, start_longitude, - end_latitude=None, end_longitude=None): - """Initialize the UberEstimate object.""" - self._session = session - self.start_latitude = start_latitude - self.start_longitude = start_longitude - self.end_latitude = end_latitude - self.end_longitude = end_longitude - self.products = None - self.update() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest product info and estimates from the Uber API.""" - from uber_rides.client import UberRidesClient - client = UberRidesClient(self._session) - - self.products = {} - - products_response = client.get_products( - self.start_latitude, self.start_longitude) - - products = products_response.json.get('products') - - for product in products: - self.products[product['product_id']] = product - - if self.end_latitude is not None and self.end_longitude is not None: - price_response = client.get_price_estimates( - self.start_latitude, self.start_longitude, - self.end_latitude, self.end_longitude) - - prices = price_response.json.get('prices', []) - - for price in prices: - product = self.products[price['product_id']] - product['duration'] = price.get('duration', '0') - product['distance'] = price.get('distance', '0') - price_details = product.get('price_details') - if product.get('price_details') is None: - price_details = {} - price_details['estimate'] = price.get('estimate', '0') - price_details['high_estimate'] = price.get( - 'high_estimate', '0') - price_details['low_estimate'] = price.get('low_estimate', '0') - price_details['currency_code'] = price.get('currency_code') - surge_multiplier = price.get('surge_multiplier', '0') - price_details['surge_multiplier'] = surge_multiplier - product['price_details'] = price_details - - estimate_response = client.get_pickup_time_estimates( - self.start_latitude, self.start_longitude) - - estimates = estimate_response.json.get('times') - - for estimate in estimates: - self.products[estimate['product_id']][ - 'time_estimate_seconds'] = estimate.get('estimate', '0') diff --git a/requirements_all.txt b/requirements_all.txt index dde24259ef56eb..886eb7b6302f2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1790,9 +1790,6 @@ tuyapy==0.1.3 # homeassistant.components.twilio twilio==6.19.1 -# homeassistant.components.uber -uber_rides==0.6.0 - # homeassistant.components.upcloud upcloud-api==0.4.3 From 70bbb867f98fc445a9bd41a2babd0f4553dc3d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Tue, 11 Jun 2019 17:34:02 +0200 Subject: [PATCH 193/319] Use met.no instead of yr.no in default config (#24470) --- homeassistant/config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 3443e98e92816b..7d36fb6f7989b4 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -60,10 +60,9 @@ # http: # base_url: example.duckdns.org:8123 -# Sensors -sensor: - # Weather prediction - - platform: yr +# Weather prediction +weather: + - platform: met # Text to speech tts: From 046a4fc4016798425293740a8a4c93f7090b099d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=BDdrale?= Date: Tue, 11 Jun 2019 17:43:59 +0200 Subject: [PATCH 194/319] Bump pyubee to 0.7 to support more models (#24477) * Bump pyubee to 0.7 to support more models * Update requirements_all.txt --- homeassistant/components/ubee/device_tracker.py | 1 + homeassistant/components/ubee/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py index b81a2320b5e87a..c31e3f040aa5a6 100644 --- a/homeassistant/components/ubee/device_tracker.py +++ b/homeassistant/components/ubee/device_tracker.py @@ -22,6 +22,7 @@ vol.Any( 'EVW32C-0N', 'EVW320B', + 'EVW321B', 'EVW3200-Wifi', 'EVW3226@UPC', ), diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json index f9f17e41546a4a..39ffe7686579f6 100644 --- a/homeassistant/components/ubee/manifest.json +++ b/homeassistant/components/ubee/manifest.json @@ -3,7 +3,7 @@ "name": "Ubee", "documentation": "https://www.home-assistant.io/components/ubee", "requirements": [ - "pyubee==0.6" + "pyubee==0.7" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 886eb7b6302f2e..7d640e43f5b584 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1498,7 +1498,7 @@ pytradfri[async]==6.0.1 pytrafikverket==0.1.5.9 # homeassistant.components.ubee -pyubee==0.6 +pyubee==0.7 # homeassistant.components.uptimerobot pyuptimerobot==0.0.5 From 0a7919a279cc99ad834486679dcc5ee83d0a0f9f Mon Sep 17 00:00:00 2001 From: tetienne Date: Tue, 11 Jun 2019 16:45:34 +0100 Subject: [PATCH 195/319] Somfy open api (#19548) * CREATE Somfy component * CREATE cover Somfy platform * USE somfy id as unique id * UPDATE all the devices in one call to limit the number of call * FIX Don't load devices if not yet configured * IMP Replace configurator by a simple notification * ADD log in case state does not match * IMP wording * REMOVE debug stuf * ADD support for tilt position * UPDATE requirements * FIX Use code instead of authorization response - Will allow to setup Somfy without https * HANDLE stateless devices (Somfy RTS) * FIX import locally 3rd party library * UPDATE pymfy to 0.4.3 * ADD missing docstring * FIX For Somfy 100 means closed and 0 opened * FIX position can be None * ENHANCE error management when error 500 occurs at setup * FIX indent * ROLLBACK tilt modification - See https://community.home-assistant.io/t/somfy-tahoma-official-api/61448/90?u=tetienne * FIX Look for capability instead of state * DON'T use exception to test if a feature is available * UPDATE dependency * ADD device_info property * AVOID object creation in each method * REMOVE unused constants * ADD missing doc * IMP Only make one call to add_entities * USE dict[key] instead of get method * IMP Don't pass hass object to the entities * FIX Don't end logging messages with period * USE config entries instead of a cache file * IMPLEMENT async_unload_entry * CONSOLIDATE package - see home-assistant/architecture#124 * UPDATE to pymfy 0.5.1 * SIMPLIFY config flow * ADD French translation * FIX 80 vs 79 max length * ABORT flow asap * FIX A tupple was returned * MIGRATE to manifest.json * ADD a placeholder async_setup_platform coroutine - It's currently required and expected by the platform helper. * FIX codeowner * ADD missing translations file * USE new external step * UPGRADE pymfy version * Close Somfy tab automatically * ADD manufacturer - Somfy only for the moment. * HANDLE missing code or state in Somfy request * REMOVE unused strings * DECLARE somfy component to use config_flow * APPLY static check remarks * FIX async method cannot be called from sync context * FIX only unload what has been loaded during entry setup * DON't catch them all * DON'T log full stacktrace * ABORT conflig flow if configuration missing * OMIT Somfy files for coverage * ADD tests about Somfy config flow * ADD pymfy to the test dependencies --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/somfy/.translations/en.json | 13 ++ .../components/somfy/.translations/fr.json | 13 ++ homeassistant/components/somfy/__init__.py | 160 ++++++++++++++++++ homeassistant/components/somfy/config_flow.py | 146 ++++++++++++++++ homeassistant/components/somfy/const.py | 5 + homeassistant/components/somfy/cover.py | 114 +++++++++++++ homeassistant/components/somfy/manifest.json | 13 ++ homeassistant/components/somfy/strings.json | 13 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/somfy/__init__.py | 1 + tests/components/somfy/test_config_flow.py | 77 +++++++++ 16 files changed, 565 insertions(+) create mode 100644 homeassistant/components/somfy/.translations/en.json create mode 100644 homeassistant/components/somfy/.translations/fr.json create mode 100644 homeassistant/components/somfy/__init__.py create mode 100644 homeassistant/components/somfy/config_flow.py create mode 100644 homeassistant/components/somfy/const.py create mode 100644 homeassistant/components/somfy/cover.py create mode 100644 homeassistant/components/somfy/manifest.json create mode 100644 homeassistant/components/somfy/strings.json create mode 100644 tests/components/somfy/__init__.py create mode 100644 tests/components/somfy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index aea77eb99772b1..fcdcb23809bd5c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -561,6 +561,7 @@ omit = homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py + homeassistant/components/somfy/* homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py homeassistant/components/songpal/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 58021038d21cec..e0756e41932e04 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -223,6 +223,7 @@ homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smtp/* @fabaff homeassistant/components/solaredge_local/* @drobtravels homeassistant/components/solax/* @squishykid +homeassistant/components/somfy/* @tetienne homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff homeassistant/components/spider/* @peternijssen diff --git a/homeassistant/components/somfy/.translations/en.json b/homeassistant/components/somfy/.translations/en.json new file mode 100644 index 00000000000000..d4155915636c96 --- /dev/null +++ b/homeassistant/components/somfy/.translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Somfy account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/fr.json b/homeassistant/components/somfy/.translations/fr.json new file mode 100644 index 00000000000000..6367e41155298d --- /dev/null +++ b/homeassistant/components/somfy/.translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Somfy.", + "authorize_url_timeout": "Durée expirée pour la génération de l'url d'autorisation.", + "missing_configuration": "Le composant Somfy n'est pas configuré. Merci de suivre la documentation." + }, + "create_entry": { + "default": "Authentification réussie avec Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py new file mode 100644 index 00000000000000..c725bb47815dc6 --- /dev/null +++ b/homeassistant/components/somfy/__init__.py @@ -0,0 +1,160 @@ +""" +Support for Somfy hubs. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/somfy/ +""" +import logging +from datetime import timedelta +from functools import partial + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from homeassistant.components.somfy import config_flow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +API = 'api' + +DEVICES = 'devices' + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +DOMAIN = 'somfy' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +SOMFY_AUTH_CALLBACK_PATH = '/auth/somfy/callback' +SOMFY_AUTH_START = '/auth/somfy' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + +SOMFY_COMPONENTS = ['cover'] + + +async def async_setup(hass, config): + """Set up the Somfy component.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = {} + + config_flow.register_flow_implementation( + hass, config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET]) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + )) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Somfy from a config entry.""" + def token_saver(token): + _LOGGER.debug('Saving updated token') + entry.data[CONF_TOKEN] = token + update_entry = partial( + hass.config_entries.async_update_entry, + data={**entry.data} + ) + hass.add_job(update_entry, entry) + + # Force token update. + from pymfy.api.somfy_api import SomfyApi + hass.data[DOMAIN][API] = SomfyApi( + entry.data['refresh_args']['client_id'], + entry.data['refresh_args']['client_secret'], + token=entry.data[CONF_TOKEN], + token_updater=token_saver + ) + + await update_all_devices(hass) + + for component in SOMFY_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component)) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + hass.data[DOMAIN].pop(API, None) + return True + + +class SomfyEntity(Entity): + """Representation of a generic Somfy device.""" + + def __init__(self, device, api): + """Initialize the Somfy device.""" + self.device = device + self.api = api + + @property + def unique_id(self): + """Return the unique id base on the id returned by Somfy.""" + return self.device.id + + @property + def name(self): + """Return the name of the device.""" + return self.device.name + + @property + def device_info(self): + """Return device specific attributes. + + Implemented by platform classes. + """ + return { + 'identifiers': {(DOMAIN, self.unique_id)}, + 'name': self.name, + 'model': self.device.type, + 'via_hub': (DOMAIN, self.device.site_id), + # For the moment, Somfy only returns their own device. + 'manufacturer': 'Somfy' + } + + async def async_update(self): + """Update the device with the latest data.""" + await update_all_devices(self.hass) + devices = self.hass.data[DOMAIN][DEVICES] + self.device = next((d for d in devices if d.id == self.device.id), + self.device) + + def has_capability(self, capability): + """Test if device has a capability.""" + capabilities = self.device.capabilities + return bool([c for c in capabilities if c.name == capability]) + + +@Throttle(MIN_TIME_BETWEEN_UPDATES) +async def update_all_devices(hass): + """Update all the devices.""" + from requests import HTTPError + try: + data = hass.data[DOMAIN] + data[DEVICES] = await hass.async_add_executor_job( + data[API].get_devices) + except HTTPError: + _LOGGER.warning("Cannot update devices") + return False + return True diff --git a/homeassistant/components/somfy/config_flow.py b/homeassistant/components/somfy/config_flow.py new file mode 100644 index 00000000000000..0c29c037ba3dee --- /dev/null +++ b/homeassistant/components/somfy/config_flow.py @@ -0,0 +1,146 @@ +"""Config flow for Somfy.""" +import asyncio +import logging + +import async_timeout + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback +from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN + +AUTH_CALLBACK_PATH = '/auth/somfy/callback' +AUTH_CALLBACK_NAME = 'auth:somfy:callback' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, client_id, client_secret): + """Register a flow implementation. + + client_id: Client id. + client_secret: Client secret. + """ + hass.data[DOMAIN][CLIENT_ID] = client_id + hass.data[DOMAIN][CLIENT_SECRET] = client_secret + + +@config_entries.HANDLERS.register('somfy') +class SomfyFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Instantiate config flow.""" + self.code = None + + async def async_step_import(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + return await self.async_step_auth() + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if DOMAIN not in self.hass.data: + return self.async_abort(reason='missing_configuration') + + return await self.async_step_auth() + + async def async_step_auth(self, user_input=None): + """Create an entry for auth.""" + # Flow has been triggered from Somfy website + if user_input: + return await self.async_step_code(user_input) + + try: + with async_timeout.timeout(10): + url, _ = await self._get_authorization_url() + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + + return self.async_external_step( + step_id='auth', + url=url + ) + + async def _get_authorization_url(self): + """Get Somfy authorization url.""" + from pymfy.api.somfy_api import SomfyApi + client_id = self.hass.data[DOMAIN][CLIENT_ID] + client_secret = self.hass.data[DOMAIN][CLIENT_SECRET] + redirect_uri = '{}{}'.format( + self.hass.config.api.base_url, AUTH_CALLBACK_PATH) + api = SomfyApi(client_id, client_secret, redirect_uri) + + self.hass.http.register_view(SomfyAuthCallbackView()) + # Thanks to the state, we can forward the flow id to Somfy that will + # add it in the callback. + return await self.hass.async_add_executor_job( + api.get_authorization_url, self.flow_id) + + async def async_step_code(self, code): + """Received code for authentication.""" + self.code = code + return self.async_external_step_done(next_step_id="creation") + + async def async_step_creation(self, user_input=None): + """Create Somfy api and entries.""" + client_id = self.hass.data[DOMAIN][CLIENT_ID] + client_secret = self.hass.data[DOMAIN][CLIENT_SECRET] + code = self.code + from pymfy.api.somfy_api import SomfyApi + redirect_uri = '{}{}'.format( + self.hass.config.api.base_url, AUTH_CALLBACK_PATH) + api = SomfyApi(client_id, client_secret, redirect_uri) + token = await self.hass.async_add_executor_job(api.request_token, None, + code) + _LOGGER.info('Successfully authenticated Somfy') + return self.async_create_entry( + title='Somfy', + data={ + 'token': token, + 'refresh_args': { + 'client_id': client_id, + 'client_secret': client_secret + } + }, + ) + + +class SomfyAuthCallbackView(HomeAssistantView): + """Somfy Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + @staticmethod + async def get(request): + """Receive authorization code.""" + from aiohttp import web_response + + if 'code' not in request.query or 'state' not in request.query: + return web_response.Response( + text="Missing code or state parameter in " + request.url + ) + + hass = request.app['hass'] + hass.async_create_task( + hass.config_entries.flow.async_configure( + flow_id=request.query['state'], + user_input=request.query['code'], + )) + + return web_response.Response( + headers={ + 'content-type': 'text/html' + }, + text="" + ) diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py new file mode 100644 index 00000000000000..3d7029d56f69a1 --- /dev/null +++ b/homeassistant/components/somfy/const.py @@ -0,0 +1,5 @@ +"""Define constants for the Somfy component.""" + +DOMAIN = 'somfy' +CLIENT_ID = 'client_id' +CLIENT_SECRET = 'client_secret' diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py new file mode 100644 index 00000000000000..7b4e53f63a79cc --- /dev/null +++ b/homeassistant/components/somfy/cover.py @@ -0,0 +1,114 @@ +""" +Support for Somfy Covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.somfy/ +""" + +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, \ + ATTR_TILT_POSITION +from homeassistant.components.somfy import DOMAIN, SomfyEntity, DEVICES, API + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Somfy cover platform.""" + def get_covers(): + """Retrieve covers.""" + from pymfy.api.devices.category import Category + + categories = {Category.ROLLER_SHUTTER.value, + Category.INTERIOR_BLIND.value, + Category.EXTERIOR_BLIND.value} + + devices = hass.data[DOMAIN][DEVICES] + + return [SomfyCover(cover, hass.data[DOMAIN][API]) for cover in + devices if + categories & set(cover.categories)] + + async_add_entities(await hass.async_add_executor_job(get_covers), True) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up platform. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +class SomfyCover(SomfyEntity, CoverDevice): + """Representation of a Somfy cover device.""" + + def __init__(self, device, api): + """Initialize the Somfy device.""" + from pymfy.api.devices.blind import Blind + super().__init__(device, api) + self.cover = Blind(self.device, self.api) + + async def async_update(self): + """Update the device with the latest data.""" + from pymfy.api.devices.blind import Blind + await super().async_update() + self.cover = Blind(self.device, self.api) + + def close_cover(self, **kwargs): + """Close the cover.""" + self.cover.close() + + def open_cover(self, **kwargs): + """Open the cover.""" + self.cover.open() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.cover.stop() + + def set_cover_position(self, **kwargs): + """Move the cover shutter to a specific position.""" + self.cover.set_position(100 - kwargs[ATTR_POSITION]) + + @property + def current_cover_position(self): + """Return the current position of cover shutter.""" + position = None + if self.has_capability('position'): + position = 100 - self.cover.get_position() + return position + + @property + def is_closed(self): + """Return if the cover is closed.""" + is_closed = None + if self.has_capability('position'): + is_closed = self.cover.is_closed() + return is_closed + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + orientation = None + if self.has_capability('rotation'): + orientation = 100 - self.cover.orientation + return orientation + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + self.cover.orientation = kwargs[ATTR_TILT_POSITION] + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.cover.orientation = 100 + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + self.cover.orientation = 0 + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + self.cover.stop() diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json new file mode 100644 index 00000000000000..02eab03c8bb31d --- /dev/null +++ b/homeassistant/components/somfy/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "somfy", + "name": "Somfy Open API", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/somfy", + "dependencies": [], + "codeowners": [ + "@tetienne" + ], + "requirements": [ + "pymfy==0.5.2" + ] +} \ No newline at end of file diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json new file mode 100644 index 00000000000000..d4155915636c96 --- /dev/null +++ b/homeassistant/components/somfy/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Somfy account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9b789af473e9bc..296c620cd7de31 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -43,6 +43,7 @@ "simplisafe", "smartthings", "smhi", + "somfy", "sonos", "tellduslive", "toon", diff --git a/requirements_all.txt b/requirements_all.txt index 7d640e43f5b584..29bd06de1d81b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1210,6 +1210,9 @@ pymailgunner==1.4 # homeassistant.components.mediaroom pymediaroom==0.6.4 +# homeassistant.components.somfy +pymfy==0.5.2 + # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 959b630317cf95..d7d683ccf6f441 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,6 +250,9 @@ pyiqvia==0.2.1 # homeassistant.components.litejet pylitejet==0.1 +# homeassistant.components.somfy +pymfy==0.5.2 + # homeassistant.components.monoprice pymonoprice==0.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7cf9459635c3cc..05fa9ed3ac6e6c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -107,6 +107,7 @@ 'pyhomematic', 'pyiqvia', 'pylitejet', + 'pymfy', 'pymonoprice', 'pynx584', 'pyopenuv', diff --git a/tests/components/somfy/__init__.py b/tests/components/somfy/__init__.py new file mode 100644 index 00000000000000..05f5cbcf4f0085 --- /dev/null +++ b/tests/components/somfy/__init__.py @@ -0,0 +1 @@ +"""Tests for the Somfy component.""" diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py new file mode 100644 index 00000000000000..4184e984d05ec9 --- /dev/null +++ b/tests/components/somfy/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for the Somfy config flow.""" +import asyncio +from unittest.mock import Mock, patch + +from pymfy.api.somfy_api import SomfyApi + +from homeassistant import data_entry_flow +from homeassistant.components.somfy import config_flow, DOMAIN +from homeassistant.components.somfy.config_flow import \ + register_flow_implementation +from tests.common import MockConfigEntry, mock_coro + +CLIENT_SECRET_VALUE = "5678" + +CLIENT_ID_VALUE = "1234" + +AUTH_URL = 'http://somfy.com' + + +async def test_abort_if_no_configuration(hass): + """Check flow abort when no configuration.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'missing_configuration' + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow(hass): + """Check classic use case.""" + hass.data[DOMAIN] = {} + register_flow_implementation(hass, CLIENT_ID_VALUE, CLIENT_SECRET_VALUE) + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + hass.config.api = Mock(base_url='https://example.com') + flow._get_authorization_url = Mock( + return_value=mock_coro((AUTH_URL, 'state'))) + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result['url'] == AUTH_URL + result = await flow.async_step_auth("my_super_code") + assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE + assert result['step_id'] == 'creation' + assert flow.code == 'my_super_code' + with patch.object(SomfyApi, 'request_token', + return_value={"access_token": "super_token"}): + result = await flow.async_step_creation() + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['refresh_args'] == { + 'client_id': CLIENT_ID_VALUE, + 'client_secret': CLIENT_SECRET_VALUE + } + assert result['title'] == 'Somfy' + assert result['data']['token'] == {"access_token": "super_token"} + + +async def test_abort_if_authorization_timeout(hass): + """Check Somfy authorization timeout.""" + flow = config_flow.SomfyFlowHandler() + flow.hass = hass + flow._get_authorization_url = Mock(side_effect=asyncio.TimeoutError) + result = await flow.async_step_auth() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout' From 8fcfcc40fccb606e7dfe4b79c41ec3dcb408e6c5 Mon Sep 17 00:00:00 2001 From: Philip Rosenberg-Watt Date: Tue, 11 Jun 2019 11:16:13 -0600 Subject: [PATCH 196/319] Add APRS device tracker component (#22469) * Add APRS device tracker component This component keeps open a connection to the APRS-IS infrastructure so messages generated by filtered callsigns can be immediately acted upon. Any messages with certain values for the 'format' key are position reports and are parsed into device tracker entities. * Log errors and return if startup failure * Fix unit tests --- CODEOWNERS | 1 + homeassistant/components/aprs/__init__.py | 1 + .../components/aprs/device_tracker.py | 187 ++++++++++ homeassistant/components/aprs/manifest.json | 11 + requirements_all.txt | 6 + requirements_test_all.txt | 6 + script/gen_requirements_all.py | 2 + tests/components/aprs/__init__.py | 1 + tests/components/aprs/test_device_tracker.py | 351 ++++++++++++++++++ 9 files changed, 566 insertions(+) create mode 100644 homeassistant/components/aprs/__init__.py create mode 100644 homeassistant/components/aprs/device_tracker.py create mode 100644 homeassistant/components/aprs/manifest.json create mode 100644 tests/components/aprs/__init__.py create mode 100644 tests/components/aprs/test_device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index e0756e41932e04..40d41b28790a03 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -25,6 +25,7 @@ homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya homeassistant/components/api/* @home-assistant/core +homeassistant/components/aprs/* @PhilRW homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff homeassistant/components/asuswrt/* @kennedyshead diff --git a/homeassistant/components/aprs/__init__.py b/homeassistant/components/aprs/__init__.py new file mode 100644 index 00000000000000..20a023166aeaed --- /dev/null +++ b/homeassistant/components/aprs/__init__.py @@ -0,0 +1 @@ +"""The APRS component.""" diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py new file mode 100644 index 00000000000000..905eb360bdf4c4 --- /dev/null +++ b/homeassistant/components/aprs/device_tracker.py @@ -0,0 +1,187 @@ +"""Support for APRS device tracking.""" + +import logging +import threading + +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +DOMAIN = 'aprs' + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALTITUDE = 'altitude' +ATTR_COURSE = 'course' +ATTR_COMMENT = 'comment' +ATTR_FROM = 'from' +ATTR_FORMAT = 'format' +ATTR_POS_AMBIGUITY = 'posambiguity' +ATTR_SPEED = 'speed' + +CONF_CALLSIGNS = 'callsigns' + +DEFAULT_HOST = 'rotate.aprs2.net' +DEFAULT_PASSWORD = '-1' +DEFAULT_TIMEOUT = 30.0 + +FILTER_PORT = 14580 + +MSG_FORMATS = ['compressed', 'uncompressed', 'mic-e'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CALLSIGNS): cv.ensure_list, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, + default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_HOST, + default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_TIMEOUT, + default=DEFAULT_TIMEOUT): vol.Coerce(float), +}) + + +def make_filter(callsigns: list) -> str: + """Make a server-side filter from a list of callsigns.""" + return ' '.join('b/{0}'.format(cs.upper()) for cs in callsigns) + + +def gps_accuracy(gps, posambiguity: int) -> int: + """Calculate the GPS accuracy based on APRS posambiguity.""" + import geopy.distance + + pos_a_map = {0: 0, + 1: 1 / 600, + 2: 1 / 60, + 3: 1 / 6, + 4: 1} + if posambiguity in pos_a_map: + degrees = pos_a_map[posambiguity] + + gps2 = (gps[0], gps[1] + degrees) + dist_m = geopy.distance.distance(gps, gps2).m + + accuracy = round(dist_m) + else: + message = "APRS position ambiguity must be 0-4, not '{0}'.".format( + posambiguity) + raise ValueError(message) + + return accuracy + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the APRS tracker.""" + callsigns = config.get(CONF_CALLSIGNS) + server_filter = make_filter(callsigns) + + callsign = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + timeout = config.get(CONF_TIMEOUT) + aprs_listener = AprsListenerThread( + callsign, password, host, server_filter, see) + + def aprs_disconnect(event): + """Stop the APRS connection.""" + aprs_listener.stop() + + aprs_listener.start() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, aprs_disconnect) + + if not aprs_listener.start_event.wait(timeout): + _LOGGER.error("Timeout waiting for APRS to connect.") + return + + if not aprs_listener.start_success: + _LOGGER.error(aprs_listener.start_message) + return + + _LOGGER.debug(aprs_listener.start_message) + return True + + +class AprsListenerThread(threading.Thread): + """APRS message listener.""" + + def __init__(self, callsign: str, password: str, host: str, + server_filter: str, see): + """Initialize the class.""" + super().__init__() + + import aprslib + + self.callsign = callsign + self.host = host + self.start_event = threading.Event() + self.see = see + self.server_filter = server_filter + self.start_message = "" + self.start_success = False + + self.ais = aprslib.IS( + self.callsign, passwd=password, host=self.host, port=FILTER_PORT) + + def start_complete(self, success: bool, message: str): + """Complete startup process.""" + self.start_message = message + self.start_success = success + self.start_event.set() + + def run(self): + """Connect to APRS and listen for data.""" + self.ais.set_filter(self.server_filter) + from aprslib import ConnectionError as AprsConnectionError + from aprslib import LoginError + + try: + _LOGGER.info("Opening connection to %s with callsign %s.", + self.host, self.callsign) + self.ais.connect() + self.start_complete( + True, + "Connected to {0} with callsign {1}.".format( + self.host, self.callsign)) + self.ais.consumer(callback=self.rx_msg, immortal=True) + except (AprsConnectionError, LoginError) as err: + self.start_complete(False, str(err)) + except OSError: + _LOGGER.info("Closing connection to %s with callsign %s.", + self.host, self.callsign) + + def stop(self): + """Close the connection to the APRS network.""" + self.ais.close() + + def rx_msg(self, msg: dict): + """Receive message and process if position.""" + _LOGGER.debug("APRS message received: %s", str(msg)) + if msg[ATTR_FORMAT] in MSG_FORMATS: + dev_id = slugify(msg[ATTR_FROM]) + lat = msg[ATTR_LATITUDE] + lon = msg[ATTR_LONGITUDE] + + attrs = {} + if ATTR_POS_AMBIGUITY in msg: + pos_amb = msg[ATTR_POS_AMBIGUITY] + try: + attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon), + pos_amb) + except ValueError: + _LOGGER.warning( + "APRS message contained invalid posambiguity: %s", + str(pos_amb)) + for attr in [ATTR_ALTITUDE, + ATTR_COMMENT, + ATTR_COURSE, + ATTR_SPEED]: + if attr in msg: + attrs[attr] = msg[attr] + + self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs) diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json new file mode 100644 index 00000000000000..fbe13ca85782c9 --- /dev/null +++ b/homeassistant/components/aprs/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aprs", + "name": "APRS", + "documentation": "https://www.home-assistant.io/components/aprs", + "dependencies": [], + "codeowners": ["@PhilRW"], + "requirements": [ + "aprslib==0.6.46", + "geopy==1.19.0" + ] +} diff --git a/requirements_all.txt b/requirements_all.txt index 29bd06de1d81b1..8607dbef478d24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -195,6 +195,9 @@ apcaccess==0.0.13 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.aprs +aprslib==0.6.46 + # homeassistant.components.aqualogic aqualogic==1.0 @@ -498,6 +501,9 @@ geniushub-client==0.4.11 # homeassistant.components.usgs_earthquakes_feed geojson_client==0.3 +# homeassistant.components.aprs +geopy==1.19.0 + # homeassistant.components.geo_rss_events georss_generic_client==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7d683ccf6f441..d45f6cd70fe9ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -69,6 +69,9 @@ ambiclimate==0.1.3 # homeassistant.components.apns apns2==0.3.0 +# homeassistant.components.aprs +aprslib==0.6.46 + # homeassistant.components.stream av==6.1.2 @@ -123,6 +126,9 @@ gTTS-token==1.1.3 # homeassistant.components.usgs_earthquakes_feed geojson_client==0.3 +# homeassistant.components.aprs +geopy==1.19.0 + # homeassistant.components.geo_rss_events georss_generic_client==0.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 05fa9ed3ac6e6c..4b3e2de3e42cf9 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -52,6 +52,7 @@ 'aiounifi', 'aioswitcher', 'apns2', + 'aprslib', 'av', 'axis', 'caldav', @@ -66,6 +67,7 @@ 'feedparser-homeassistant', 'foobot_async', 'geojson_client', + 'geopy', 'georss_generic_client', 'georss_ign_sismologia_client', 'google-api-python-client', diff --git a/tests/components/aprs/__init__.py b/tests/components/aprs/__init__.py new file mode 100644 index 00000000000000..c3e9dddb37fc5a --- /dev/null +++ b/tests/components/aprs/__init__.py @@ -0,0 +1 @@ +"""Tests for the APRS component.""" diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py new file mode 100644 index 00000000000000..a90f11a01bc8b7 --- /dev/null +++ b/tests/components/aprs/test_device_tracker.py @@ -0,0 +1,351 @@ +"""Test APRS device tracker.""" +from unittest.mock import Mock, patch + +import aprslib + +import homeassistant.components.aprs.device_tracker as device_tracker +from homeassistant.const import EVENT_HOMEASSISTANT_START + +from tests.common import get_test_home_assistant + +DEFAULT_PORT = 14580 + +TEST_CALLSIGN = 'testcall' +TEST_COORDS_NULL_ISLAND = (0, 0) +TEST_FILTER = 'testfilter' +TEST_HOST = 'testhost' +TEST_PASSWORD = 'testpass' + + +def test_make_filter(): + """Test filter.""" + callsigns = [ + 'CALLSIGN1', + 'callsign2' + ] + res = device_tracker.make_filter(callsigns) + assert res == "b/CALLSIGN1 b/CALLSIGN2" + + +def test_gps_accuracy_0(): + """Test GPS accuracy level 0.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 0) + assert acc == 0 + + +def test_gps_accuracy_1(): + """Test GPS accuracy level 1.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 1) + assert acc == 186 + + +def test_gps_accuracy_2(): + """Test GPS accuracy level 2.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 2) + assert acc == 1855 + + +def test_gps_accuracy_3(): + """Test GPS accuracy level 3.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 3) + assert acc == 18553 + + +def test_gps_accuracy_4(): + """Test GPS accuracy level 4.""" + acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 4) + assert acc == 111319 + + +def test_gps_accuracy_invalid_int(): + """Test GPS accuracy with invalid input.""" + level = 5 + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_gps_accuracy_invalid_string(): + """Test GPS accuracy with invalid input.""" + level = "not an int" + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_gps_accuracy_invalid_float(): + """Test GPS accuracy with invalid input.""" + level = 1.2 + + try: + device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level) + assert False, "No exception." + except ValueError: + pass + + +def test_aprs_listener(): + """Test listener thread.""" + with patch('aprslib.IS') as mock_ais: + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + port = DEFAULT_PORT + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + mock_ais.assert_called_with( + callsign, passwd=password, host=host, port=port) + + +def test_aprs_listener_start_fail(): + """Test listener thread start failure.""" + with patch('aprslib.IS.connect', + side_effect=aprslib.ConnectionError("Unable to connect.")): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert not listener.start_success + assert listener.start_message == "Unable to connect." + + +def test_aprs_listener_stop(): + """Test listener thread stop.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.ais.close = Mock() + listener.run() + listener.stop() + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + assert listener.start_success + listener.ais.close.assert_called_with() + + +def test_aprs_listener_rx_msg(): + """Test rx_msg.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_ALTITUDE: 0 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={"altitude": 0}) + + +def test_aprs_listener_rx_msg_ambiguity(): + """Test rx_msg with posambiguity.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_POS_AMBIGUITY: 1 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={device_tracker.ATTR_GPS_ACCURACY: 186}) + + +def test_aprs_listener_rx_msg_ambiguity_invalid(): + """Test rx_msg with invalid posambiguity.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "uncompressed", + device_tracker.ATTR_FROM: "ZZ0FOOBAR-1", + device_tracker.ATTR_LATITUDE: 0.0, + device_tracker.ATTR_LONGITUDE: 0.0, + device_tracker.ATTR_POS_AMBIGUITY: 5 + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_called_with( + dev_id=device_tracker.slugify("ZZ0FOOBAR-1"), + gps=(0.0, 0.0), + attributes={}) + + +def test_aprs_listener_rx_msg_no_position(): + """Test rx_msg with non-position report.""" + with patch('aprslib.IS'): + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = { + device_tracker.ATTR_FORMAT: "invalid" + } + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see) + listener.run() + listener.rx_msg(sample_msg) + + assert listener.callsign == callsign + assert listener.host == host + assert listener.server_filter == server_filter + assert listener.see == see + assert listener.start_event.is_set() + assert listener.start_success + assert listener.start_message == \ + "Connected to testhost with callsign testcall." + see.assert_not_called() + + +def test_setup_scanner(): + """Test setup_scanner.""" + with patch('homeassistant.components.' + 'aprs.device_tracker.AprsListenerThread') as listener: + hass = get_test_home_assistant() + hass.start() + + config = { + 'username': TEST_CALLSIGN, + 'password': TEST_PASSWORD, + 'host': TEST_HOST, + 'callsigns': [ + 'XX0FOO*', + 'YY0BAR-1'] + } + + see = Mock() + res = device_tracker.setup_scanner(hass, config, see) + hass.bus.fire(EVENT_HOMEASSISTANT_START) + hass.stop() + + assert res + listener.assert_called_with( + TEST_CALLSIGN, TEST_PASSWORD, TEST_HOST, + 'b/XX0FOO* b/YY0BAR-1', see) + + +def test_setup_scanner_timeout(): + """Test setup_scanner failure from timeout.""" + hass = get_test_home_assistant() + hass.start() + + config = { + 'username': TEST_CALLSIGN, + 'password': TEST_PASSWORD, + 'host': "localhost", + 'timeout': 0.01, + 'callsigns': [ + 'XX0FOO*', + 'YY0BAR-1'] + } + + see = Mock() + try: + assert not device_tracker.setup_scanner(hass, config, see) + finally: + hass.stop() From 7559e700270a926489733ce5306cffe5f0cca6ae Mon Sep 17 00:00:00 2001 From: Quentame Date: Tue, 11 Jun 2019 19:18:41 +0200 Subject: [PATCH 197/319] Add Linky sensors : yesterday + months + years (#23726) * Add Linky sensors : yesterday + months + years - SCAN_INTERVAL to 4 hours - Always close_session after getting the data - Add username attr - Fix not updating Linky sensor when Enedis API fails * Fix @balloob review: remove monitored_conditions --- CODEOWNERS | 1 + homeassistant/components/linky/manifest.json | 5 +- homeassistant/components/linky/sensor.py | 148 ++++++++++++++----- 3 files changed, 115 insertions(+), 39 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 40d41b28790a03..0069ff34e96e99 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -143,6 +143,7 @@ homeassistant/components/life360/* @pnbruckner homeassistant/components/lifx/* @amelchio homeassistant/components/lifx_cloud/* @amelchio homeassistant/components/lifx_legacy/* @amelchio +homeassistant/components/linky/* @tiste @Quentame homeassistant/components/linux_battery/* @fabaff homeassistant/components/liveboxplaytv/* @pschmitt homeassistant/components/logger/* @home-assistant/core diff --git a/homeassistant/components/linky/manifest.json b/homeassistant/components/linky/manifest.json index 706962b5c4d368..cd4ac4665e2801 100644 --- a/homeassistant/components/linky/manifest.json +++ b/homeassistant/components/linky/manifest.json @@ -6,5 +6,8 @@ "pylinky==0.3.3" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@tiste", + "@Quentame" + ] } diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py index 63f7aaf5423382..263395ab9e77db 100644 --- a/homeassistant/components/linky/sensor.py +++ b/homeassistant/components/linky/sensor.py @@ -1,21 +1,41 @@ """Support for Linky.""" -import logging -import json from datetime import timedelta +import json +import logging +from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient, PyLinkyError import voluptuous as vol -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, - ENERGY_KILO_WATT_HOUR) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, + ENERGY_KILO_WATT_HOUR) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(hours=4) +ICON_ENERGY = "mdi:flash" +CONSUMPTION = "conso" +TIME = "time" +INDEX_CURRENT = -1 +INDEX_LAST = -2 +ATTRIBUTION = "Data provided by Enedis" + DEFAULT_TIMEOUT = 10 +SENSORS = { + "yesterday": ("Linky yesterday", DAILY, INDEX_LAST), + "current_month": ("Linky current month", MONTHLY, INDEX_CURRENT), + "last_month": ("Linky last month", MONTHLY, INDEX_LAST), + "current_year": ("Linky current year", YEARLY, INDEX_CURRENT), + "last_year": ("Linky last year", YEARLY, INDEX_LAST) +} +SENSORS_INDEX_LABEL = 0 +SENSORS_INDEX_SCALE = 1 +SENSORS_INDEX_WHEN = 2 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, @@ -30,28 +50,73 @@ def setup_platform(hass, config, add_entities, discovery_info=None): password = config[CONF_PASSWORD] timeout = config[CONF_TIMEOUT] - from pylinky.client import LinkyClient, PyLinkyError - client = LinkyClient(username, password, None, timeout) - try: - client.login() - client.fetch_data() - except PyLinkyError as exp: - _LOGGER.error(exp) - client.close_session() - return + account = LinkyAccount(hass, add_entities, username, password, timeout) + add_entities(account.sensors, True) + - devices = [LinkySensor('Linky', client)] - add_entities(devices, True) +class LinkyAccount: + """Representation of a Linky account.""" + + def __init__(self, hass, add_entities, username, password, timeout): + """Initialise the Linky account.""" + self._username = username + self.__password = password + self._timeout = timeout + self._data = None + self.sensors = [] + + self.update_linky_data(dt_util.utcnow()) + + self.sensors.append( + LinkySensor("Linky yesterday", self, DAILY, INDEX_LAST)) + self.sensors.append( + LinkySensor("Linky current month", self, MONTHLY, INDEX_CURRENT)) + self.sensors.append( + LinkySensor("Linky last month", self, MONTHLY, INDEX_LAST)) + self.sensors.append( + LinkySensor("Linky current year", self, YEARLY, INDEX_CURRENT)) + self.sensors.append( + LinkySensor("Linky last year", self, YEARLY, INDEX_LAST)) + + track_time_interval(hass, self.update_linky_data, SCAN_INTERVAL) + + def update_linky_data(self, event_time): + """Fetch new state data for the sensor.""" + client = LinkyClient(self._username, self.__password, None, + self._timeout) + try: + client.login() + client.fetch_data() + self._data = client.get_data() + _LOGGER.debug(json.dumps(self._data, indent=2)) + except PyLinkyError as exp: + _LOGGER.error(exp) + finally: + client.close_session() + + @property + def username(self): + """Return the username.""" + return self._username + + @property + def data(self): + """Return the data.""" + return self._data class LinkySensor(Entity): """Representation of a sensor entity for Linky.""" - def __init__(self, name, client): + def __init__(self, name, account: LinkyAccount, scale, when): """Initialize the sensor.""" self._name = name - self._client = client - self._state = None + self.__account = account + self._scale = scale + self.__when = when + self._username = account.username + self.__time = None + self.__consumption = None @property def name(self): @@ -61,28 +126,35 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return self._state + return self.__consumption @property def unit_of_measurement(self): """Return the unit of measurement.""" return ENERGY_KILO_WATT_HOUR - @Throttle(SCAN_INTERVAL) - def update(self): - """Fetch new state data for the sensor.""" - from pylinky.client import PyLinkyError - try: - self._client.fetch_data() - except PyLinkyError as exp: - _LOGGER.error(exp) - self._client.close_session() - return + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON_ENERGY - _LOGGER.debug(json.dumps(self._client.get_data(), indent=2)) + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'time': self.__time, + CONF_USERNAME: self._username + } - if self._client.get_data(): - # get the last past day data - self._state = self._client.get_data()['daily'][-2]['conso'] - else: - self._state = None + def update(self): + """Retreive the new data for the sensor.""" + data = self.__account.data[self._scale][self.__when] + self.__consumption = data[CONSUMPTION] + self.__time = data[TIME] + + if self._scale is not YEARLY: + year_index = INDEX_CURRENT + if self.__time.endswith("Dec"): + year_index = INDEX_LAST + self.__time += ' ' + self.__account.data[YEARLY][year_index][TIME] From a6a35556848ab6d36e0086b67d8eba4b4407ab33 Mon Sep 17 00:00:00 2001 From: Save me Date: Tue, 11 Jun 2019 19:41:20 +0200 Subject: [PATCH 198/319] Add attributs and fix lightlevel inconsistency for LightLevel sensor (#24439) * Add attributs and fix state level * Update sensor.py * Update sensor.py --- homeassistant/components/hue/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 7664bd38d97229..cdc86d2d2800e7 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -40,13 +40,16 @@ def state(self): # scale used because the human eye adjusts to light levels and small # changes at low lux levels are more noticeable than at high lux # levels. - return 10 ** ((self.sensor.lightlevel - 1) / 10000) + return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) @property def device_state_attributes(self): """Return the device state attributes.""" attributes = super().device_state_attributes attributes.update({ + "lightlevel": self.sensor.lightlevel, + "daylight": self.sensor.daylight, + "dark": self.sensor.dark, "threshold_dark": self.sensor.tholddark, "threshold_offset": self.sensor.tholdoffset, }) From b87c541d3ac97d1c9ae38e292c6133ec6a077b3c Mon Sep 17 00:00:00 2001 From: Jurriaan Pruis Date: Tue, 11 Jun 2019 20:28:37 +0000 Subject: [PATCH 199/319] Support ZLO device types to support newer Zigbee devices in ZHA (#24429) * Support ZLO device types Support the device types that are added in https://github.com/zigpy/zigpy/pull/176 so newer Zigbee devices can be supported. * Remove BINARY_SENSOR mappings * Add back ON_OFF_LIGHT_SWITCH, DIMMER_SWITCH and COLOR_DIMMABLE_LIGHT Since they are target devices I've added them as switch and lights, which matches the Zigbee documentation. * Upgrade to zigpy-homeassistant v0.5.0 To be able to use the new DeviceTypes --- homeassistant/components/zha/core/registries.py | 11 ++++++++++- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 00c2dd22740de1..8db60727578543 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -115,7 +115,16 @@ def get_deconz_radio(): zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT, zha.DeviceType.ON_OFF_LIGHT: LIGHT, zha.DeviceType.DIMMABLE_LIGHT: LIGHT, - zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT + zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, + zha.DeviceType.ON_OFF_LIGHT_SWITCH: SWITCH, + zha.DeviceType.DIMMER_SWITCH: LIGHT, + zha.DeviceType.COLOR_DIMMER_SWITCH: LIGHT, + zha.DeviceType.ON_OFF_BALLAST: SWITCH, + zha.DeviceType.DIMMABLE_BALLAST: LIGHT, + zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH, + zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: LIGHT, + zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, + zha.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT }) DEVICE_CLASS[zll.PROFILE_ID].update({ diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d9f17d3f41c241..4e327381902b05 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows-homeassistant==0.8.0", "zha-quirks==0.0.14", "zigpy-deconz==0.1.4", - "zigpy-homeassistant==0.4.2", + "zigpy-homeassistant==0.5.0", "zigpy-xbee-homeassistant==0.3.0" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 8607dbef478d24..565269be9c44dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1914,7 +1914,7 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.1.4 # homeassistant.components.zha -zigpy-homeassistant==0.4.2 +zigpy-homeassistant==0.5.0 # homeassistant.components.zha zigpy-xbee-homeassistant==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d45f6cd70fe9ac..5f333e4026a1d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,4 +364,4 @@ wakeonlan==1.1.6 zeroconf==0.23.0 # homeassistant.components.zha -zigpy-homeassistant==0.4.2 +zigpy-homeassistant==0.5.0 From 0eb387916fefc1367984e88ca05c07072c5b7ce9 Mon Sep 17 00:00:00 2001 From: Ties de Kock Date: Wed, 12 Jun 2019 00:26:04 +0200 Subject: [PATCH 200/319] Camera platform for buienradar imagery (#23358) * Add camera for buienradar radar * Use asyncio.Conditions instead of asyncio.Lock * Add test and fix python 3.5 compatibility * rename interval to delta for consistency with BOM integration * fix linting error introduced during rebase * Improved buienradar.camera documentation and tests * Incorporated one comment on a redundant/cargo cult function * Improved documentation * Increase test coverage by fixing one test by making it a coroutine (to make it actually run), adding another test case, and changing the flow in the implementation. * style changes after review, additional test case * Use python 3.5 style mypy type annotations in __init__ * Remove explicit passing of event loop * Adopt buienradar camera as codeowner * Update manifest.json * Update CODEOWNERS through hassfest Updated CODEOWNERS through hassfest (instead of manually), thanks to @balloob for the hint. --- CODEOWNERS | 1 + homeassistant/components/buienradar/camera.py | 178 +++++++++++++++ .../components/buienradar/manifest.json | 2 +- tests/components/buienradar/test_camera.py | 202 ++++++++++++++++++ 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/buienradar/camera.py create mode 100644 tests/components/buienradar/test_camera.py diff --git a/CODEOWNERS b/CODEOWNERS index 0069ff34e96e99..4af6e742cbb370 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -44,6 +44,7 @@ homeassistant/components/braviatv/* @robbiet480 homeassistant/components/broadlink/* @danielhiversen homeassistant/components/brunt/* @eavanvalkenburg homeassistant/components/bt_smarthub/* @jxwolstenholme +homeassistant/components/buienradar/* @ties homeassistant/components/cisco_ios/* @fbradyirl homeassistant/components/cisco_mobility_express/* @fbradyirl homeassistant/components/cisco_webex_teams/* @fbradyirl diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py new file mode 100644 index 00000000000000..b390a86d622da5 --- /dev/null +++ b/homeassistant/components/buienradar/camera.py @@ -0,0 +1,178 @@ +"""Provide animated GIF loops of Buienradar imagery.""" +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Optional + +import aiohttp +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import CONF_NAME + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from homeassistant.util import dt as dt_util + + +CONF_DIMENSION = 'dimension' +CONF_DELTA = 'delta' + +RADAR_MAP_URL_TEMPLATE = ('https://api.buienradar.nl/image/1.0/' + 'RadarMapNL?w={w}&h={h}') + +_LOG = logging.getLogger(__name__) + +# Maximum range according to docs +DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700)) + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE, + vol.Optional(CONF_DELTA, default=600.0): vol.All(vol.Coerce(float), + vol.Range(min=0)), + vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string, + })) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up buienradar radar-loop camera component.""" + dimension = config[CONF_DIMENSION] + delta = config[CONF_DELTA] + name = config[CONF_NAME] + + async_add_entities([BuienradarCam(name, dimension, delta)]) + + +class BuienradarCam(Camera): + """ + A camera component producing animated buienradar radar-imagery GIFs. + + Rain radar imagery camera based on image URL taken from [0]. + + [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata + """ + + def __init__(self, name: str, dimension: int, delta: float): + """ + Initialize the component. + + This constructor must be run in the event loop. + """ + super().__init__() + + self._name = name + + # dimension (x and y) of returned radar image + self._dimension = dimension + + # time a cached image stays valid for + self._delta = delta + + # Condition that guards the loading indicator. + # + # Ensures that only one reader can cause an http request at the same + # time, and that all readers are notified after this request completes. + # + # invariant: this condition is private to and owned by this instance. + self._condition = asyncio.Condition() + + self._last_image = None # type: Optional[bytes] + # value of the last seen last modified header + self._last_modified = None # type: Optional[str] + # loading status + self._loading = False + # deadline for image refresh - self.delta after last successful load + self._deadline = None # type: Optional[datetime] + + @property + def name(self) -> str: + """Return the component name.""" + return self._name + + def __needs_refresh(self) -> bool: + if not (self._delta and self._deadline and self._last_image): + return True + + return dt_util.utcnow() > self._deadline + + async def __retrieve_radar_image(self) -> bool: + """Retrieve new radar image and return whether this succeeded.""" + session = async_get_clientsession(self.hass) + + url = RADAR_MAP_URL_TEMPLATE.format(w=self._dimension, + h=self._dimension) + + if self._last_modified: + headers = {'If-Modified-Since': self._last_modified} + else: + headers = {} + + try: + async with session.get(url, timeout=5, headers=headers) as res: + res.raise_for_status() + + if res.status == 304: + _LOG.debug("HTTP 304 - success") + return True + + last_modified = res.headers.get('Last-Modified', None) + if last_modified: + self._last_modified = last_modified + + self._last_image = await res.read() + _LOG.debug("HTTP 200 - Last-Modified: %s", last_modified) + + return True + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOG.error("Failed to fetch image, %s", type(err)) + return False + + async def async_camera_image(self) -> Optional[bytes]: + """ + Return a still image response from the camera. + + Uses ayncio conditions to make sure only one task enters the critical + section at the same time. Otherwise, two http requests would start + when two tabs with home assistant are open. + + The condition is entered in two sections because otherwise the lock + would be held while doing the http request. + + A boolean (_loading) is used to indicate the loading status instead of + _last_image since that is initialized to None. + + For reference: + * :func:`asyncio.Condition.wait` releases the lock and acquires it + again before continuing. + * :func:`asyncio.Condition.notify_all` requires the lock to be held. + """ + if not self.__needs_refresh(): + return self._last_image + + # get lock, check iff loading, await notification if loading + async with self._condition: + # can not be tested - mocked http response returns immediately + if self._loading: + _LOG.debug("already loading - waiting for notification") + await self._condition.wait() + return self._last_image + + # Set loading status **while holding lock**, makes other tasks wait + self._loading = True + + try: + now = dt_util.utcnow() + was_updated = await self.__retrieve_radar_image() + # was updated? Set new deadline relative to now before loading + if was_updated: + self._deadline = now + timedelta(seconds=self._delta) + + return self._last_image + finally: + # get lock, unset loading status, notify all waiting tasks + async with self._condition: + self._loading = False + self._condition.notify_all() diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index 98fc5fbdeac458..1ed313348f7100 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -6,5 +6,5 @@ "buienradar==0.91" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@ties"] } diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py new file mode 100644 index 00000000000000..a05cc9413e5fe1 --- /dev/null +++ b/tests/components/buienradar/test_camera.py @@ -0,0 +1,202 @@ +"""The tests for generic camera component.""" +import asyncio +from aiohttp.client_exceptions import ClientResponseError + +from homeassistant.util import dt as dt_util + +from homeassistant.setup import async_setup_component + +# An infinitesimally small time-delta. +EPSILON_DELTA = 0.0000000001 + + +def radar_map_url(dim: int = 512) -> str: + """Build map url, defaulting to 512 wide (as in component).""" + return ("https://api.buienradar.nl/" + "image/1.0/RadarMapNL?w={dim}&h={dim}").format(dim=dim) + + +async def test_fetching_url_and_caching(aioclient_mock, hass, hass_client): + """Test that it fetches the given url.""" + aioclient_mock.get(radar_map_url(), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + resp = await client.get('/api/camera_proxy/camera.config_test') + + assert resp.status == 200 + assert aioclient_mock.call_count == 1 + body = await resp.text() + assert body == 'hello world' + + # default delta is 600s -> should be the same when calling immediately + # afterwards. + + resp = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + + +async def test_expire_delta(aioclient_mock, hass, hass_client): + """Test that the cache expires after delta.""" + aioclient_mock.get(radar_map_url(), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + 'delta': EPSILON_DELTA, + }}) + + client = await hass_client() + + resp = await client.get('/api/camera_proxy/camera.config_test') + + assert resp.status == 200 + assert aioclient_mock.call_count == 1 + body = await resp.text() + assert body == 'hello world' + + await asyncio.sleep(EPSILON_DELTA) + # tiny delta has passed -> should immediately call again + resp = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 2 + + +async def test_only_one_fetch_at_a_time(aioclient_mock, hass, hass_client): + """Test that it fetches with only one request at the same time.""" + aioclient_mock.get(radar_map_url(), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + resp_1 = client.get('/api/camera_proxy/camera.config_test') + resp_2 = client.get('/api/camera_proxy/camera.config_test') + + resp = await resp_1 + resp_2 = await resp_2 + + assert (await resp.text()) == (await resp_2.text()) + + assert aioclient_mock.call_count == 1 + + +async def test_dimension(aioclient_mock, hass, hass_client): + """Test that it actually adheres to the dimension.""" + aioclient_mock.get(radar_map_url(700), text='hello world') + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + 'dimension': 700, + }}) + + client = await hass_client() + + await client.get('/api/camera_proxy/camera.config_test') + + assert aioclient_mock.call_count == 1 + + +async def test_failure_response_not_cached(aioclient_mock, hass, hass_client): + """Test that it does not cache a failure response.""" + aioclient_mock.get(radar_map_url(), text='hello world', status=401) + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + await client.get('/api/camera_proxy/camera.config_test') + await client.get('/api/camera_proxy/camera.config_test') + + assert aioclient_mock.call_count == 2 + + +async def test_last_modified_updates(aioclient_mock, hass, hass_client): + """Test that it does respect HTTP not modified.""" + # Build Last-Modified header value + now = dt_util.utcnow() + last_modified = now.strftime("%a, %d %m %Y %H:%M:%S GMT") + + aioclient_mock.get(radar_map_url(), text='hello world', status=200, + headers={ + 'Last-Modified': last_modified, + }) + + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + 'delta': EPSILON_DELTA, + }}) + + client = await hass_client() + + resp_1 = await client.get('/api/camera_proxy/camera.config_test') + # It is not possible to check if header was sent. + assert aioclient_mock.call_count == 1 + + await asyncio.sleep(EPSILON_DELTA) + + # Content has expired, change response to a 304 NOT MODIFIED, which has no + # text, i.e. old value should be kept + aioclient_mock.clear_requests() + # mock call count is now reset as well: + assert aioclient_mock.call_count == 0 + + aioclient_mock.get(radar_map_url(), text=None, status=304) + + resp_2 = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + + assert (await resp_1.read()) == (await resp_2.read()) + + +async def test_retries_after_error(aioclient_mock, hass, hass_client): + """Test that it does retry after an error instead of caching.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'buienradar', + }}) + + client = await hass_client() + + aioclient_mock.get(radar_map_url(), text=None, status=500) + + # A 404 should not return data and throw: + try: + await client.get('/api/camera_proxy/camera.config_test') + except ClientResponseError: + pass + + assert aioclient_mock.call_count == 1 + + # Change the response to a 200 + aioclient_mock.clear_requests() + aioclient_mock.get(radar_map_url(), text="DEADBEEF") + + assert aioclient_mock.call_count == 0 + + # http error should not be cached, immediate retry. + resp_2 = await client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + + # Binary text can not be added as body to `aioclient_mock.get(text=...)`, + # while `resp.read()` returns bytes, encode the value. + assert (await resp_2.read()) == b"DEADBEEF" From d041c62f557b05203362c32dbff3323b64b79f8b Mon Sep 17 00:00:00 2001 From: kbickar Date: Tue, 11 Jun 2019 18:56:55 -0400 Subject: [PATCH 201/319] Position is reversed for horizontal awnings (#23257) * Position is reversed for awnings * Changed device class function to use map * It wanted more linebreak * Updated defaults * Shortened line * space --- homeassistant/components/tahoma/cover.py | 43 ++++++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tahoma/cover.py b/homeassistant/components/tahoma/cover.py index eeacf7c83b216c..fdeb77dd9901d7 100644 --- a/homeassistant/components/tahoma/cover.py +++ b/homeassistant/components/tahoma/cover.py @@ -2,7 +2,10 @@ from datetime import timedelta import logging -from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homeassistant.components.cover import ( + ATTR_POSITION, DEVICE_CLASS_AWNING, DEVICE_CLASS_BLIND, + DEVICE_CLASS_CURTAIN, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHUTTER, + DEVICE_CLASS_WINDOW, CoverDevice) from homeassistant.util.dt import utcnow from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice @@ -16,6 +19,24 @@ ATTR_LOCK_LEVEL = 'lock_level' ATTR_LOCK_ORIG = 'lock_originator' +TAHOMA_DEVICE_CLASSES = { + 'io:ExteriorVenetianBlindIOComponent': DEVICE_CLASS_BLIND, + 'io:HorizontalAwningIOComponent': DEVICE_CLASS_AWNING, + 'io:RollerShutterGenericIOComponent': DEVICE_CLASS_SHUTTER, + 'io:RollerShutterUnoIOComponent': DEVICE_CLASS_SHUTTER, + 'io:RollerShutterVeluxIOComponent': DEVICE_CLASS_SHUTTER, + 'io:RollerShutterWithLowSpeedManagementIOComponent': DEVICE_CLASS_SHUTTER, + 'io:VerticalExteriorAwningIOComponent': DEVICE_CLASS_AWNING, + 'io:WindowOpenerVeluxIOComponent': DEVICE_CLASS_WINDOW, + 'io:GarageOpenerIOComponent': DEVICE_CLASS_GARAGE, + 'rts:BlindRTSComponent': DEVICE_CLASS_BLIND, + 'rts:CurtainRTSComponent': DEVICE_CLASS_CURTAIN, + 'rts:DualCurtainRTSComponent': DEVICE_CLASS_CURTAIN, + 'rts:ExteriorVenetianBlindRTSComponent': DEVICE_CLASS_BLIND, + 'rts:RollerShutterRTSComponent': DEVICE_CLASS_SHUTTER, + 'rts:VenetianBlindRTSComponent': DEVICE_CLASS_BLIND +} + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tahoma covers.""" @@ -109,12 +130,18 @@ def update(self): # _position: 0 is closed, 100 is fully open. # 'core:ClosureState': 100 is closed, 0 is fully open. if self._closure is not None: - self._position = 100 - self._closure + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self._position = self._closure + else: + self._position = 100 - self._closure if self._position <= 5: self._position = 0 if self._position >= 95: self._position = 100 - self._closed = self._position == 0 + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self._closed = self._position == 0 + else: + self._closed = self._position == 100 else: self._position = None if 'core:OpenClosedState' in self.tahoma_device.active_states: @@ -133,7 +160,11 @@ def current_cover_position(self): def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.apply_action('setPosition', 100 - kwargs.get(ATTR_POSITION)) + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self.apply_action('setPosition', kwargs.get(ATTR_POSITION, 0)) + else: + self.apply_action('setPosition', + 100 - kwargs.get(ATTR_POSITION, 0)) @property def is_closed(self): @@ -143,9 +174,7 @@ def is_closed(self): @property def device_class(self): """Return the class of the device.""" - if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': - return 'window' - return None + return TAHOMA_DEVICE_CLASSES.get(self.tahoma_device.type) @property def device_state_attributes(self): From d7fcb5268ad3c48d4e1a4bd202cd926810ab3e26 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 12 Jun 2019 00:57:29 +0200 Subject: [PATCH 202/319] Fix aprs imports (#24485) --- homeassistant/components/aprs/device_tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index 905eb360bdf4c4..3bde7021d7c45e 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -5,9 +5,9 @@ import voluptuous as vol -from homeassistant.components.device_tracker import ( - ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, PLATFORM_SCHEMA) +from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( + ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv From 5698173c7636b6615e5fb3864f0fb0b434c7549a Mon Sep 17 00:00:00 2001 From: Reinder Reinders Date: Wed, 12 Jun 2019 17:31:55 +0200 Subject: [PATCH 203/319] Version bump for toonapilib to include new API call for fetching thermostat states, which was sometimes missing causing errors in the library (#24459) --- homeassistant/components/toon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index eccaf7df9bcccf..3fd00e88a0c16f 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/toon", "requirements": [ - "toonapilib==3.2.2" + "toonapilib==3.2.4" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 565269be9c44dd..cdd636ffae7436 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1779,7 +1779,7 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonapilib==3.2.2 +toonapilib==3.2.4 # homeassistant.components.totalconnect total_connect_client==0.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f333e4026a1d6..ef558f69f30243 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ srpenergy==1.0.6 statsd==3.2.1 # homeassistant.components.toon -toonapilib==3.2.2 +toonapilib==3.2.4 # homeassistant.components.uvc uvcclient==0.11.0 From 06ca04c1c86632d06cfe12ad3f55ffd8ebd3af9d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Jun 2019 09:29:02 -0700 Subject: [PATCH 204/319] Update hass-nabucasa to 0.14 (#24481) * Update hass-nabucasa to 0.14 * Update owner of cloud * Update codeowners --- CODEOWNERS | 2 +- homeassistant/components/cloud/manifest.json | 4 ++-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4af6e742cbb370..a6b1b44e34cf51 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,7 +49,7 @@ homeassistant/components/cisco_ios/* @fbradyirl homeassistant/components/cisco_mobility_express/* @fbradyirl homeassistant/components/cisco_webex_teams/* @fbradyirl homeassistant/components/ciscospark/* @fbradyirl -homeassistant/components/cloud/* @home-assistant/core +homeassistant/components/cloud/* @home-assistant/cloud homeassistant/components/cloudflare/* @ludeeus homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 982b51133a51cc..1a4511c8c88632 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,13 +3,13 @@ "name": "Cloud", "documentation": "https://www.home-assistant.io/components/cloud", "requirements": [ - "hass-nabucasa==0.13" + "hass-nabucasa==0.14" ], "dependencies": [ "http", "webhook" ], "codeowners": [ - "@home-assistant/core" + "@home-assistant/cloud" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e05d38b23ef4a..a9669ce454f4d9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ bcrypt==3.1.6 certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 -hass-nabucasa==0.13 +hass-nabucasa==0.14 home-assistant-frontend==20190604.0 importlib-metadata==0.15 jinja2>=2.10 diff --git a/requirements_all.txt b/requirements_all.txt index cdd636ffae7436..2be122f81c4a20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -562,7 +562,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.13 +hass-nabucasa==0.14 # homeassistant.components.mqtt hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef558f69f30243..4e635eb21bbfca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -145,7 +145,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.13 +hass-nabucasa==0.14 # homeassistant.components.mqtt hbmqtt==0.9.4 From 24e1a568a2bb5bf42c7525593cfaaa240f6a313f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Jun 2019 09:29:28 -0700 Subject: [PATCH 205/319] remove docs from config entries file [skip ci] (#24488) --- homeassistant/config_entries.py | 122 +------------------------------- 1 file changed, 1 insertion(+), 121 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 299bfe9b407453..a018713dee7be4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1,124 +1,4 @@ -"""The Config Manager is responsible for managing configuration for components. - -The Config Manager allows for creating config entries to be consumed by -components. Each entry is created via a Config Flow Handler, as defined by each -component. - -During startup, Home Assistant will setup the entries during the normal setup -of a component. It will first call the normal setup and then call the method -`async_setup_entry(hass, entry)` for each entry. The same method is called when -Home Assistant is running while a config entry is created. If the version of -the config entry does not match that of the flow handler, setup will -call the method `async_migrate_entry(hass, entry)` with the expectation that -the entry be brought to the current version. Return `True` to indicate -migration was successful, otherwise `False`. - -## Config Flows - -A component needs to define a Config Handler to allow the user to create config -entries for that component. A config flow will manage the creation of entries -from user input, discovery or other sources (like hassio). - -When a config flow is started for a domain, the handler will be instantiated -and receives a unique id. The instance of this handler will be reused for every -interaction of the user with this flow. This makes it possible to store -instance variables on the handler. - -Before instantiating the handler, Home Assistant will make sure to load all -dependencies and install the requirements of the component. - -At a minimum, each config flow will have to define a version number and the -'user' step. - - @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.ConfigFlow): - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - - async def async_step_user(self, user_input=None): - … - -The 'user' step is the first step of a flow and is called when a user -starts a new flow. Each step has three different possible results: "Show Form", -"Abort" and "Create Entry". - -> Note: prior 0.76, the default step is 'init' step, some config flows still -keep 'init' step to avoid break localization. All new config flow should use -'user' step. - -### Show Form - -This will show a form to the user to fill in. You define the current step, -a title, a description and the schema of the data that needs to be returned. - - async def async_step_init(self, user_input=None): - # Use OrderedDict to guarantee order of the form shown to the user - data_schema = OrderedDict() - data_schema[vol.Required('username')] = str - data_schema[vol.Required('password')] = str - - return self.async_show_form( - step_id='user', - title='Account Info', - data_schema=vol.Schema(data_schema) - ) - -After the user has filled in the form, the step method will be called again and -the user input is passed in. If the validation of the user input fails , you -can return a dictionary with errors. Each key in the dictionary refers to a -field name that contains the error. Use the key 'base' if you want to show a -generic error. - - async def async_step_init(self, user_input=None): - errors = None - if user_input is not None: - # Validate user input - if valid: - return self.create_entry(…) - - errors['base'] = 'Unable to reach authentication server.' - - return self.async_show_form(…) - -If the user input passes validation, you can again return one of the three -return values. If you want to navigate the user to the next step, return the -return value of that step: - - return await self.async_step_account() - -### Abort - -When the result is "Abort", a message will be shown to the user and the -configuration flow is finished. - - return self.async_abort( - reason='This device is not supported by Home Assistant.' - ) - -### Create Entry - -When the result is "Create Entry", an entry will be created and stored in Home -Assistant, a success message is shown to the user and the flow is finished. - -## Initializing a config flow from an external source - -You might want to initialize a config flow programmatically. For example, if -we discover a device on the network that requires user interaction to finish -setup. To do so, pass a source parameter and optional user input to the init -method: - - await hass.config_entries.flow.async_init( - 'hue', context={'source': 'discovery'}, data=discovery_info) - -The config flow handler will need to add a step to support the source. The step -should follow the same return values as a normal step. - - async def async_step_discovery(info): - -If the result of the step is to show a form, the user will be able to continue -the flow from the config panel. -""" +"""Manage config entries in Home Assistant.""" import asyncio import logging import functools From 61f4c73aca3f7e28c326a6c30747f88178db49ba Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 12 Jun 2019 18:32:01 +0200 Subject: [PATCH 206/319] Bump adguardhome to 0.2.1 (#24486) Signed-off-by: Franck Nijhof --- homeassistant/components/adguard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index 281a384e21fe94..0063f1ec37064c 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/adguard", "requirements": [ - "adguardhome==0.2.0" + "adguardhome==0.2.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 2be122f81c4a20..d76f1a8c3b902e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -108,7 +108,7 @@ adafruit-blinka==1.2.1 adafruit-circuitpython-mcp230xx==1.1.2 # homeassistant.components.adguard -adguardhome==0.2.0 +adguardhome==0.2.1 # homeassistant.components.frontier_silicon afsapi==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e635eb21bbfca..21cdf12f016b58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyTransportNSW==0.1.1 YesssSMS==0.2.3 # homeassistant.components.adguard -adguardhome==0.2.0 +adguardhome==0.2.1 # homeassistant.components.ambient_station aioambient==0.3.0 From b817609adc3be63f764a226544d93d90eff5fc23 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 12 Jun 2019 13:40:01 -0500 Subject: [PATCH 207/319] Fix owntracks source_type for location messages with default trigger (#24503) Some location update messages do not contain the 't' (trigger) key. Before the change in 0.94 to entity based trackers, these would default to source_type of 'gps' (due to default parameter value in async_see method.) To mirror this behavior in the new entity based tracker, the source_type property should default to SOURCE_TYPE_GPS under the same conditions. --- .../components/owntracks/device_tracker.py | 4 ++-- tests/components/owntracks/test_device_tracker.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 742b7c34435387..b573e390a12fa4 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -9,7 +9,7 @@ ATTR_BATTERY_LEVEL, ) from homeassistant.components.device_tracker.const import ( - ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE) + ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE, SOURCE_TYPE_GPS) from homeassistant.components.device_tracker.config_entry import ( DeviceTrackerEntity ) @@ -127,7 +127,7 @@ def should_poll(self): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return self._data.get('source_type') + return self._data.get('source_type', SOURCE_TYPE_GPS) @property def device_info(self): diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 7d8d48de586c81..5f2bda5957a2fb 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -411,6 +411,19 @@ async def test_location_update(hass, context): """Test the update of a location.""" await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_source_type(hass, 'gps') + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_location_update_no_t_key(hass, context): + """Test the update of a location when message does not contain 't'.""" + message = LOCATION_MESSAGE.copy() + message.pop('t') + await send_message(hass, LOCATION_TOPIC, message) + + assert_location_source_type(hass, 'gps') assert_location_latitude(hass, LOCATION_MESSAGE['lat']) assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) assert_location_state(hass, 'outer') From 6badd83c5d453ea8c3545e8e69fdc89c708cbd53 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Jun 2019 13:54:53 -0700 Subject: [PATCH 208/319] Add Cast discovery to manifest (#24504) --- homeassistant/components/cast/manifest.json | 1 + homeassistant/components/discovery/__init__.py | 4 ++-- homeassistant/generated/zeroconf.py | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 5699f8764cd1aa..ff9e8907ec5aeb 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -7,5 +7,6 @@ "pychromecast==3.2.2" ], "dependencies": [], + "zeroconf": ["_googlecast._tcp.local."], "codeowners": [] } diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index a7c306ad241147..027ba110b5fa66 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -47,7 +47,6 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', - 'google_cast': 'cast', SERVICE_HEOS: 'heos', SERVICE_TELLDUSLIVE: 'tellduslive', 'sonos': 'sonos', @@ -97,8 +96,9 @@ 'axis', 'deconz', 'esphome', - 'ikea_tradfri', + 'google_cast', 'homekit', + 'ikea_tradfri', 'philips_hue', SERVICE_WEMO, ] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1bc00d08314e15..09c1712c061d52 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -14,6 +14,9 @@ "_esphomelib._tcp.local.": [ "esphome" ], + "_googlecast._tcp.local.": [ + "cast" + ], "_hap._tcp.local.": [ "homekit_controller" ] From 1efccf2d90840747faa9cf5c00b082fdac2565f1 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Wed, 12 Jun 2019 23:14:00 +0200 Subject: [PATCH 209/319] Bump pyatmo to 2.00 (#24505) * Bump pyatmo version to 1.13 * Bump to 2.00 * Fix version number --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 91e96e48b5c959..dd72dab576337a 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/components/netatmo", "requirements": [ - "pyatmo==1.12" + "pyatmo==2.0.0" ], "dependencies": [ "webhook" diff --git a/requirements_all.txt b/requirements_all.txt index d76f1a8c3b902e..234e5d21da66ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1012,7 +1012,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==1.12 +pyatmo==2.0.0 # homeassistant.components.apple_tv pyatv==0.3.12 From 4c328e4959cf55d3ca4f3745d91691bbf7a3b5ea Mon Sep 17 00:00:00 2001 From: aidbish <38182386+aidbish@users.noreply.github.com> Date: Thu, 13 Jun 2019 07:21:00 +1000 Subject: [PATCH 210/319] missing comma preventing other voices (#24487) --- homeassistant/components/watson_tts/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index be60908d096730..552083854a2ce4 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -23,7 +23,7 @@ "de-DE_BirgitVoice", "de-DE_BirgitV2Voice", "de-DE_DieterVoice", - "de-DE_DieterV2Voice" + "de-DE_DieterV2Voice", "en-GB_KateVoice", "en-US_AllisonVoice", "en-US_AllisonV2Voice", From f54ad2663090b8c8114e6fea0747e0d3dac58aa0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Jun 2019 16:08:08 -0700 Subject: [PATCH 211/319] Migrate HEOS discovery to manifest (#24508) * Migrate HEOS discovery to manifest * Fix tests --- homeassistant/components/discovery/__init__.py | 2 +- homeassistant/components/heos/config_flow.py | 2 +- homeassistant/components/heos/manifest.json | 5 +++++ homeassistant/generated/ssdp.py | 6 +++++- tests/components/heos/test_config_flow.py | 6 +++--- tests/components/heos/test_init.py | 3 ++- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 027ba110b5fa66..2765757d737939 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -47,7 +47,6 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', - SERVICE_HEOS: 'heos', SERVICE_TELLDUSLIVE: 'tellduslive', 'sonos': 'sonos', SERVICE_IGD: 'upnp', @@ -97,6 +96,7 @@ 'deconz', 'esphome', 'google_cast', + SERVICE_HEOS, 'homekit', 'ikea_tradfri', 'philips_hue', diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 064813a86a7781..8207d40be11b7a 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -22,7 +22,7 @@ class HeosFlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - async def async_step_discovery(self, discovery_info): + async def async_step_ssdp(self, discovery_info): """Handle a discovered Heos device.""" # Store discovered host friendly_name = "{} ({})".format( diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index a1fc803031824c..09833bb729b416 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -6,6 +6,11 @@ "requirements": [ "pyheos==0.5.2" ], + "ssdp": { + "st": [ + "urn:schemas-denon-com:device:ACT-Denon:1" + ] + }, "dependencies": [], "codeowners": [ "@andrewsayre" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 63dbe7616db370..62273bf303b87f 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -15,5 +15,9 @@ "hue" ] }, - "st": {} + "st": { + "urn:schemas-denon-com:device:ACT-Denon:1": [ + "heos" + ] + } } diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index ade0100dbd6c5c..c1c5e308eaee0a 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -76,7 +76,7 @@ async def test_create_entry_when_friendly_name_valid(hass, controller): async def test_discovery_shows_create_form(hass, controller, discovery_data): """Test discovery shows form to confirm setup and subsequent abort.""" await hass.config_entries.flow.async_init( - DOMAIN, context={'source': 'discovery'}, + DOMAIN, context={'source': 'ssdp'}, data=discovery_data) await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 1 @@ -87,7 +87,7 @@ async def test_discovery_shows_create_form(hass, controller, discovery_data): discovery_data[CONF_HOST] = "127.0.0.2" discovery_data[CONF_NAME] = "Bedroom" await hass.config_entries.flow.async_init( - DOMAIN, context={'source': 'discovery'}, + DOMAIN, context={'source': 'ssdp'}, data=discovery_data) await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 1 @@ -103,6 +103,6 @@ async def test_disovery_flow_aborts_already_setup( config_entry.add_to_hass(hass) flow = HeosFlowHandler() flow.hass = hass - result = await flow.async_step_discovery(discovery_data) + result = await flow.async_step_ssdp(discovery_data) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'already_setup' diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index b709c89121a7f0..6d8d9b7e78ef26 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -27,7 +27,8 @@ async def test_async_setup_creates_entry(hass, config): assert entry.data == {CONF_HOST: '127.0.0.1'} -async def test_async_setup_updates_entry(hass, config_entry, config): +async def test_async_setup_updates_entry(hass, config_entry, config, + controller): """Test component setup updates entry from config.""" config[DOMAIN][CONF_HOST] = '127.0.0.2' config_entry.add_to_hass(hass) From 4627d2c1fbbacf104265fb0e11453a20d50dc79e Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Thu, 13 Jun 2019 07:12:56 +0700 Subject: [PATCH 212/319] Bumping Keenetic NDMS2 client to 0.0.8 (#24469) Fixing issue with long strings in Telnet response --- homeassistant/components/keenetic_ndms2/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 91c0c69a4fa56a..42d8d89a021152 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -3,7 +3,7 @@ "name": "Keenetic ndms2", "documentation": "https://www.home-assistant.io/components/keenetic_ndms2", "requirements": [ - "ndms2_client==0.0.7" + "ndms2_client==0.0.8" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 234e5d21da66ca..4dec5228f62d3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -784,7 +784,7 @@ n26==0.2.7 nad_receiver==0.0.11 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.0.7 +ndms2_client==0.0.8 # homeassistant.components.ness_alarm nessclient==0.9.15 From 416ff10ba94a65249227806e8699b4ad043abff7 Mon Sep 17 00:00:00 2001 From: Guy Khmelnitsky Date: Thu, 13 Jun 2019 13:28:44 +0300 Subject: [PATCH 213/319] Update DelugeClient to 1.7.1 (#24518) * Update DelugeClient to 1.7.1 Due to update of Deluge to 2.0.3 * deluge-client==1.7.1 in requirements_all.txt --- homeassistant/components/deluge/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json index 2b3c6d4c05505f..d33a140cedb914 100644 --- a/homeassistant/components/deluge/manifest.json +++ b/homeassistant/components/deluge/manifest.json @@ -3,7 +3,7 @@ "name": "Deluge", "documentation": "https://www.home-assistant.io/components/deluge", "requirements": [ - "deluge-client==1.4.0" + "deluge-client==1.7.1" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 4dec5228f62d3f..dd58748d5126ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -355,7 +355,7 @@ datapoint==0.4.3 defusedxml==0.6.0 # homeassistant.components.deluge -deluge-client==1.4.0 +deluge-client==1.7.1 # homeassistant.components.denonavr denonavr==0.7.9 From 7e2278f1cc865055f749b3b262ebc7637736ecdd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Jun 2019 08:43:57 -0700 Subject: [PATCH 214/319] Clean up Alexa smart home code (#24514) * Clean up Alexa smart home code * lint * Lint * Lint --- homeassistant/components/alexa/__init__.py | 13 +- homeassistant/components/alexa/auth.py | 3 +- .../components/alexa/capabilities.py | 597 +++++ homeassistant/components/alexa/config.py | 13 + homeassistant/components/alexa/const.py | 84 +- homeassistant/components/alexa/entities.py | 445 ++++ homeassistant/components/alexa/errors.py | 87 + homeassistant/components/alexa/handlers.py | 728 ++++++ homeassistant/components/alexa/messages.py | 200 ++ homeassistant/components/alexa/smart_home.py | 2115 +---------------- .../components/alexa/smart_home_http.py | 81 + .../components/alexa/state_report.py | 109 + homeassistant/components/cloud/__init__.py | 8 +- homeassistant/components/cloud/client.py | 13 +- homeassistant/components/cloud/http_api.py | 6 +- tests/components/alexa/__init__.py | 178 ++ tests/components/alexa/test_auth.py | 67 + tests/components/alexa/test_capabilities.py | 340 +++ tests/components/alexa/test_entities.py | 19 + tests/components/alexa/test_smart_home.py | 720 +----- .../components/alexa/test_smart_home_http.py | 46 + tests/components/alexa/test_state_report.py | 40 + tests/components/cloud/test_http_api.py | 2 +- 23 files changed, 3135 insertions(+), 2779 deletions(-) create mode 100644 homeassistant/components/alexa/capabilities.py create mode 100644 homeassistant/components/alexa/config.py create mode 100644 homeassistant/components/alexa/entities.py create mode 100644 homeassistant/components/alexa/errors.py create mode 100644 homeassistant/components/alexa/handlers.py create mode 100644 homeassistant/components/alexa/messages.py create mode 100644 homeassistant/components/alexa/smart_home_http.py create mode 100644 homeassistant/components/alexa/state_report.py create mode 100644 tests/components/alexa/test_auth.py create mode 100644 tests/components/alexa/test_capabilities.py create mode 100644 tests/components/alexa/test_entities.py create mode 100644 tests/components/alexa/test_smart_home_http.py create mode 100644 tests/components/alexa/test_state_report.py diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 862605b64b5700..a15d87175dbcc8 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -5,12 +5,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entityfilter +from homeassistant.const import CONF_NAME -from . import flash_briefings, intent, smart_home +from . import flash_briefings, intent, smart_home_http from .const import ( CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL, CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER, - CONF_ENTITY_CONFIG) + CONF_ENTITY_CONFIG, CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES) _LOGGER = logging.getLogger(__name__) @@ -18,9 +19,9 @@ CONF_SMART_HOME = 'smart_home' ALEXA_ENTITY_SCHEMA = vol.Schema({ - vol.Optional(smart_home.CONF_DESCRIPTION): cv.string, - vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string, - vol.Optional(smart_home.CONF_NAME): cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, }) SMART_HOME_SCHEMA = vol.Schema({ @@ -65,6 +66,6 @@ async def async_setup(hass, config): pass else: smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - await smart_home.async_setup(hass, smart_home_config) + await smart_home_http.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 0717532f64d633..dd61018d739d1b 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -9,7 +9,6 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.util import dt -from .const import DEFAULT_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -97,7 +96,7 @@ async def _async_request_new_token(self, lwa_params): try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(DEFAULT_TIMEOUT): + with async_timeout.timeout(10): response = await session.post(LWA_TOKEN_URI, headers=LWA_HEADERS, data=lwa_params, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py new file mode 100644 index 00000000000000..801005b4b4a74f --- /dev/null +++ b/homeassistant/components/alexa/capabilities.py @@ -0,0 +1,597 @@ +"""Alexa capabilities.""" +from datetime import datetime +import logging + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNLOCKED, +) +import homeassistant.components.climate.const as climate +from homeassistant.components import ( + light, + fan, + cover, +) +import homeassistant.util.color as color_util + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + DATE_FORMAT, + PERCENTAGE_FAN_MAP, +) +from .errors import UnsupportedProperty + + +_LOGGER = logging.getLogger(__name__) + + +class AlexaCapibility: + """Base class for Alexa capability interfaces. + + The Smart Home Skills API defines a number of "capability interfaces", + roughly analogous to domains in Home Assistant. The supported interfaces + describe what actions can be performed on a particular device. + + https://developer.amazon.com/docs/device-apis/message-guide.html + """ + + def __init__(self, entity): + """Initialize an Alexa capibility.""" + self.entity = entity + + def name(self): + """Return the Alexa API name of this interface.""" + raise NotImplementedError + + @staticmethod + def properties_supported(): + """Return what properties this entity supports.""" + return [] + + @staticmethod + def properties_proactively_reported(): + """Return True if properties asynchronously reported.""" + return False + + @staticmethod + def properties_retrievable(): + """Return True if properties can be retrieved.""" + return False + + @staticmethod + def get_property(name): + """Read and return a property. + + Return value should be a dict, or raise UnsupportedProperty. + + Properties can also have a timeOfSample and uncertaintyInMilliseconds, + but returning those metadata is not yet implemented. + """ + raise UnsupportedProperty(name) + + @staticmethod + def supports_deactivation(): + """Applicable only to scenes.""" + return None + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = { + 'type': 'AlexaInterface', + 'interface': self.name(), + 'version': '3', + 'properties': { + 'supported': self.properties_supported(), + 'proactivelyReported': self.properties_proactively_reported(), + 'retrievable': self.properties_retrievable(), + }, + } + + # pylint: disable=assignment-from-none + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop['name'] + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + 'timeOfSample': datetime.now().strftime(DATE_FORMAT), + 'uncertaintyInMilliseconds': 0 + } + + +class AlexaEndpointHealth(AlexaCapibility): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.EndpointHealth' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'connectivity'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return False + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'connectivity': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_UNAVAILABLE: + return {'value': 'UNREACHABLE'} + return {'value': 'OK'} + + +class AlexaPowerController(AlexaCapibility): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PowerController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'powerState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'powerState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_OFF: + return 'OFF' + return 'ON' + + +class AlexaLockController(AlexaCapibility): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.LockController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'lockState'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'lockState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_LOCKED: + return 'LOCKED' + if self.entity.state == STATE_UNLOCKED: + return 'UNLOCKED' + return 'JAMMED' + + +class AlexaSceneController(AlexaCapibility): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + + def __init__(self, entity, supports_deactivation): + """Initialize the entity.""" + super().__init__(entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.SceneController' + + +class AlexaBrightnessController(AlexaCapibility): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.BrightnessController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'brightness'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'brightness': + raise UnsupportedProperty(name) + if 'brightness' in self.entity.attributes: + return round(self.entity.attributes['brightness'] / 255.0 * 100) + return 0 + + +class AlexaColorController(AlexaCapibility): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ColorController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'color'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'color': + raise UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get( + light.ATTR_HS_COLOR, (0, 0)) + + return { + 'hue': hue, + 'saturation': saturation / 100.0, + 'brightness': self.entity.attributes.get( + light.ATTR_BRIGHTNESS, 0) / 255.0, + } + + +class AlexaColorTemperatureController(AlexaCapibility): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ColorTemperatureController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'colorTemperatureInKelvin'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'colorTemperatureInKelvin': + raise UnsupportedProperty(name) + if 'color_temp' in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes['color_temp']) + return 0 + + +class AlexaPercentageController(AlexaCapibility): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PercentageController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'percentage'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'percentage': + raise UnsupportedProperty(name) + + if self.entity.domain == fan.DOMAIN: + speed = self.entity.attributes.get(fan.ATTR_SPEED) + + return PERCENTAGE_FAN_MAP.get(speed, 0) + + if self.entity.domain == cover.DOMAIN: + return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) + + return 0 + + +class AlexaSpeaker(AlexaCapibility): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.Speaker' + + +class AlexaStepSpeaker(AlexaCapibility): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.StepSpeaker' + + +class AlexaPlaybackController(AlexaCapibility): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PlaybackController' + + +class AlexaInputController(AlexaCapibility): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.InputController' + + +class AlexaTemperatureSensor(AlexaCapibility): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.TemperatureSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'temperature'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'temperature': + raise UnsupportedProperty(name) + + unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = self.entity.state + if self.entity.domain == climate.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get( + climate.ATTR_CURRENT_TEMPERATURE) + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } + + +class AlexaContactSensor(AlexaCapibility): + """Implements Alexa.ContactSensor. + + The Alexa.ContactSensor interface describes the properties and events used + to report the state of an endpoint that detects contact between two + surfaces. For example, a contact sensor can report whether a door or window + is open. + + https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ContactSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'detectionState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class AlexaMotionSensor(AlexaCapibility): + """Implements Alexa.MotionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.MotionSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'detectionState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class AlexaThermostatController(AlexaCapibility): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ThermostatController' + + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [] + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_TARGET_TEMPERATURE: + properties.append({'name': 'targetSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: + properties.append({'name': 'lowerSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: + properties.append({'name': 'upperSetpoint'}) + if supported & climate.SUPPORT_OPERATION_MODE: + properties.append({'name': 'thermostatMode'}) + return properties + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name == 'thermostatMode': + ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) + mode = API_THERMOSTAT_MODES.get(ha_mode) + if mode is None: + _LOGGER.error("%s (%s) has unsupported %s value '%s'", + self.entity.entity_id, type(self.entity), + climate.ATTR_OPERATION_MODE, ha_mode) + raise UnsupportedProperty(name) + return mode + + unit = self.hass.config.units.temperature_unit + if name == 'targetSetpoint': + temp = self.entity.attributes.get(ATTR_TEMPERATURE) + elif name == 'lowerSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + elif name == 'upperSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) + else: + raise UnsupportedProperty(name) + + if temp is None: + return None + + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py new file mode 100644 index 00000000000000..df9c9b013dc2ab --- /dev/null +++ b/homeassistant/components/alexa/config.py @@ -0,0 +1,13 @@ +"""Config helpers for Alexa.""" + + +class Config: + """Hold the configuration for Alexa.""" + + def __init__(self, endpoint, async_get_access_token, should_expose, + entity_config=None): + """Initialize the configuration.""" + self.endpoint = endpoint + self.async_get_access_token = async_get_access_token + self.should_expose = should_expose + self.entity_config = entity_config or {} diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 78f7d02f5f03e3..9931406ff0e327 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -1,4 +1,15 @@ """Constants for the Alexa integration.""" +from collections import OrderedDict + +from homeassistant.const import ( + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate import const as climate +from homeassistant.components import fan + + DOMAIN = 'alexa' # Flash briefing constants @@ -25,4 +36,75 @@ DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' -DEFAULT_TIMEOUT = 30 +API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' +API_EVENT = 'event' +API_CONTEXT = 'context' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_SCOPE = 'scope' +API_CHANGE = 'change' + +CONF_DESCRIPTION = 'description' +CONF_DISPLAY_CATEGORIES = 'display_categories' + +AUTH_KEY = "alexa.smart_home.auth" + +API_TEMP_UNITS = { + TEMP_FAHRENHEIT: 'FAHRENHEIT', + TEMP_CELSIUS: 'CELSIUS', +} + +# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a +# reverse mapping of this dict and we want to map the first occurrance of OFF +# back to HA state. +API_THERMOSTAT_MODES = OrderedDict([ + (climate.STATE_HEAT, 'HEAT'), + (climate.STATE_COOL, 'COOL'), + (climate.STATE_AUTO, 'AUTO'), + (climate.STATE_ECO, 'ECO'), + (climate.STATE_MANUAL, 'AUTO'), + (STATE_OFF, 'OFF'), + (climate.STATE_IDLE, 'OFF'), + (climate.STATE_FAN_ONLY, 'OFF'), + (climate.STATE_DRY, 'OFF'), +]) + +PERCENTAGE_FAN_MAP = { + fan.SPEED_LOW: 33, + fan.SPEED_MEDIUM: 66, + fan.SPEED_HIGH: 100, +} + + +class Cause: + """Possible causes for property changes. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object + """ + + # Indicates that the event was caused by a customer interaction with an + # application. For example, a customer switches on a light, or locks a door + # using the Alexa app or an app provided by a device vendor. + APP_INTERACTION = 'APP_INTERACTION' + + # Indicates that the event was caused by a physical interaction with an + # endpoint. For example manually switching on a light or manually locking a + # door lock + PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION' + + # Indicates that the event was caused by the periodic poll of an appliance, + # which found a change in value. For example, you might poll a temperature + # sensor every hour, and send the updated temperature to Alexa. + PERIODIC_POLL = 'PERIODIC_POLL' + + # Indicates that the event was caused by the application of a device rule. + # For example, a customer configures a rule to switch on a light if a + # motion sensor detects motion. In this case, Alexa receives an event from + # the motion sensor, and another event from the light to indicate that its + # state change was caused by the rule. + RULE_TRIGGER = 'RULE_TRIGGER' + + # Indicates that the event was caused by a voice interaction with Alexa. + # For example a user speaking to their Echo device. + VOICE_INTERACTION = 'VOICE_INTERACTION' diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py new file mode 100644 index 00000000000000..30dfbbb88671a6 --- /dev/null +++ b/homeassistant/components/alexa/entities.py @@ -0,0 +1,445 @@ +"""Alexa entity adapters.""" +from typing import List + +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util.decorator import Registry +from homeassistant.components.climate import const as climate +from homeassistant.components import ( + alert, automation, binary_sensor, cover, fan, group, + input_boolean, light, lock, media_player, scene, script, sensor, switch) + +from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES +from .capabilities import ( + AlexaBrightnessController, + AlexaColorController, + AlexaColorTemperatureController, + AlexaContactSensor, + AlexaEndpointHealth, + AlexaInputController, + AlexaLockController, + AlexaMotionSensor, + AlexaPercentageController, + AlexaPlaybackController, + AlexaPowerController, + AlexaSceneController, + AlexaSpeaker, + AlexaStepSpeaker, + AlexaTemperatureSensor, + AlexaThermostatController, +) + +ENTITY_ADAPTERS = Registry() + + +class DisplayCategory: + """Possible display categories for Discovery response. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories + """ + + # Describes a combination of devices set to a specific state, when the + # state change must occur in a specific order. For example, a "watch + # Netflix" scene might require the: 1. TV to be powered on & 2. Input set + # to HDMI1. Applies to Scenes + ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" + + # Indicates media devices with video or photo capabilities. + CAMERA = "CAMERA" + + # Indicates an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Describes a combination of devices set to a specific state, when the + # order of the state change is not important. For example a bedtime scene + # might include turning off lights and lowering the thermostat, but the + # order is unimportant. Applies to Scenes + SCENE_TRIGGER = "SCENE_TRIGGER" + + # Indicates an endpoint that locks. + SMARTLOCK = "SMARTLOCK" + + # Indicates modules that are plugged into an existing electrical outlet. + # Can control a variety of devices. + SMARTPLUG = "SMARTPLUG" + + # Indicates the endpoint is a speaker or speaker system. + SPEAKER = "SPEAKER" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates endpoints that report the temperature only. + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" + + # Indicates endpoints that control temperature, stand-alone air + # conditioners, or heaters with direct temperature control. + THERMOSTAT = "THERMOSTAT" + + # Indicates the endpoint is a television. + TV = "TV" + + +class AlexaEntity: + """An adaptation of an entity, expressed in Alexa's terms. + + The API handlers should manipulate entities only through this interface. + """ + + def __init__(self, hass, config, entity): + """Initialize Alexa Entity.""" + self.hass = hass + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + @property + def entity_id(self): + """Return the Entity ID.""" + return self.entity.entity_id + + def friendly_name(self): + """Return the Alexa API friendly name.""" + return self.entity_conf.get(CONF_NAME, self.entity.name) + + def description(self): + """Return the Alexa API description.""" + return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) + + def alexa_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace('.', '#') + + def display_categories(self): + """Return a list of display categories.""" + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + return [entity_conf[CONF_DISPLAY_CATEGORIES]] + return self.default_display_categories() + + def default_display_categories(self): + """Return a list of default display categories. + + This can be overridden by the user in the Home Assistant configuration. + + See also DisplayCategory. + """ + raise NotImplementedError + + def get_interface(self, capability): + """Return the given AlexaInterface. + + Raises _UnsupportedInterface. + """ + pass + + def interfaces(self): + """Return a list of supported interfaces. + + Used for discovery. The list should contain AlexaInterface instances. + If the list is empty, this entity will not be discovered. + """ + raise NotImplementedError + + def serialize_properties(self): + """Yield each supported property in API format.""" + for interface in self.interfaces(): + for prop in interface.serialize_properties(): + yield prop + + +@callback +def async_get_entities(hass, config) -> List[AlexaEntity]: + """Return all entities that are supported by Alexa.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + if state.domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + + if not list(alexa_entity.interfaces()): + continue + + entities.append(alexa_entity) + + return entities + + +@ENTITY_ADAPTERS.register(alert.DOMAIN) +@ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) +class GenericCapabilities(AlexaEntity): + """A generic, on/off device. + + The choice of last resort. + """ + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class SwitchCapabilities(AlexaEntity): + """Class to represent Switch capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SWITCH] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(climate.DOMAIN) +class ClimateCapabilities(AlexaEntity): + """Class to represent Climate capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.THERMOSTAT] + + def interfaces(self): + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_ON_OFF: + yield AlexaPowerController(self.entity) + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(cover.DOMAIN) +class CoverCapabilities(AlexaEntity): + """Class to represent Cover capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.DOOR] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield AlexaPercentageController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class LightCapabilities(AlexaEntity): + """Class to represent Light capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.LIGHT] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & light.SUPPORT_BRIGHTNESS: + yield AlexaBrightnessController(self.entity) + if supported & light.SUPPORT_COLOR: + yield AlexaColorController(self.entity) + if supported & light.SUPPORT_COLOR_TEMP: + yield AlexaColorTemperatureController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class FanCapabilities(AlexaEntity): + """Class to represent Fan capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.SUPPORT_SET_SPEED: + yield AlexaPercentageController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class LockCapabilities(AlexaEntity): + """Class to represent Lock capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SMARTLOCK] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaLockController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) +class MediaPlayerCapabilities(AlexaEntity): + """Class to represent MediaPlayer capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.TV] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaEndpointHealth(self.hass, self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.const.SUPPORT_VOLUME_SET: + yield AlexaSpeaker(self.entity) + + power_features = (media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF) + if supported & power_features: + yield AlexaPowerController(self.entity) + + step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE | + media_player.const.SUPPORT_VOLUME_STEP) + if supported & step_volume_features: + yield AlexaStepSpeaker(self.entity) + + playback_features = (media_player.const.SUPPORT_PLAY | + media_player.const.SUPPORT_PAUSE | + media_player.const.SUPPORT_STOP | + media_player.const.SUPPORT_NEXT_TRACK | + media_player.const.SUPPORT_PREVIOUS_TRACK) + if supported & playback_features: + yield AlexaPlaybackController(self.entity) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + yield AlexaInputController(self.entity) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class SceneCapabilities(AlexaEntity): + """Class to represent Scene capabilities.""" + + def description(self): + """Return the description of the entity.""" + # Required description as per Amazon Scene docs + scene_fmt = '{} (Scene connected via Home Assistant)' + return scene_fmt.format(AlexaEntity.description(self)) + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaSceneController(self.entity, + supports_deactivation=False)] + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class ScriptCapabilities(AlexaEntity): + """Class to represent Script capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + can_cancel = bool(self.entity.attributes.get('can_cancel')) + return [AlexaSceneController(self.entity, + supports_deactivation=can_cancel)] + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class SensorCapabilities(AlexaEntity): + """Class to represent Sensor capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self): + """Yield the supported interfaces.""" + attrs = self.entity.attributes + if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in ( + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + ): + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class BinarySensorCapabilities(AlexaEntity): + """Class to represent BinarySensor capabilities.""" + + TYPE_CONTACT = 'contact' + TYPE_MOTION = 'motion' + + def default_display_categories(self): + """Return the display categories for this entity.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + return [DisplayCategory.CONTACT_SENSOR] + if sensor_type is self.TYPE_MOTION: + return [DisplayCategory.MOTION_SENSOR] + + def interfaces(self): + """Yield the supported interfaces.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + yield AlexaContactSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_MOTION: + yield AlexaMotionSensor(self.hass, self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + + def get_type(self): + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + 'door', + 'garage_door', + 'opening', + 'window', + ): + return self.TYPE_CONTACT + if attrs.get(ATTR_DEVICE_CLASS) == 'motion': + return self.TYPE_MOTION diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py new file mode 100644 index 00000000000000..651ddc5b187d77 --- /dev/null +++ b/homeassistant/components/alexa/errors.py @@ -0,0 +1,87 @@ +"""Alexa related errors.""" +from homeassistant.exceptions import HomeAssistantError + +from .const import API_TEMP_UNITS + + +class UnsupportedInterface(HomeAssistantError): + """This entity does not support the requested Smart Home API interface.""" + + +class UnsupportedProperty(HomeAssistantError): + """This entity does not support the requested Smart Home API property.""" + + +class AlexaError(Exception): + """Base class for errors that can be serialized by the Alexa API. + + A handler can raise subclasses of this to return an error to the request. + """ + + namespace = None + error_type = None + + def __init__(self, error_message, payload=None): + """Initialize an alexa error.""" + Exception.__init__(self) + self.error_message = error_message + self.payload = None + + +class AlexaInvalidEndpointError(AlexaError): + """The endpoint in the request does not exist.""" + + namespace = 'Alexa' + error_type = 'NO_SUCH_ENDPOINT' + + def __init__(self, endpoint_id): + """Initialize invalid endpoint error.""" + msg = 'The endpoint {} does not exist'.format(endpoint_id) + AlexaError.__init__(self, msg) + self.endpoint_id = endpoint_id + + +class AlexaInvalidValueError(AlexaError): + """Class to represent InvalidValue errors.""" + + namespace = 'Alexa' + error_type = 'INVALID_VALUE' + + +class AlexaUnsupportedThermostatModeError(AlexaError): + """Class to represent UnsupportedThermostatMode errors.""" + + namespace = 'Alexa.ThermostatController' + error_type = 'UNSUPPORTED_THERMOSTAT_MODE' + + +class AlexaTempRangeError(AlexaError): + """Class to represent TempRange errors.""" + + namespace = 'Alexa' + error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + def __init__(self, hass, temp, min_temp, max_temp): + """Initialize TempRange error.""" + unit = hass.config.units.temperature_unit + temp_range = { + 'minimumValue': { + 'value': min_temp, + 'scale': API_TEMP_UNITS[unit], + }, + 'maximumValue': { + 'value': max_temp, + 'scale': API_TEMP_UNITS[unit], + }, + } + payload = {'validRange': temp_range} + msg = 'The requested temperature {} is out of range'.format(temp) + + AlexaError.__init__(self, msg, payload) + + +class AlexaBridgeUnreachableError(AlexaError): + """Class to represent BridgeUnreachable errors.""" + + namespace = 'Alexa' + error_type = 'BRIDGE_UNREACHABLE' diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py new file mode 100644 index 00000000000000..a17381b3e17680 --- /dev/null +++ b/homeassistant/components/alexa/handlers.py @@ -0,0 +1,728 @@ +"""Alexa message handlers.""" +from datetime import datetime +import logging +import math + +from homeassistant import core as ha +from homeassistant.util.decorator import Registry +import homeassistant.util.color as color_util +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_UNLOCK, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate import const as climate +from homeassistant.components import cover, fan, group, light, media_player +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + AUTH_KEY, + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + Cause, +) +from .entities import async_get_entities +from .state_report import async_enable_proactive_mode +from .errors import ( + AlexaInvalidValueError, + AlexaTempRangeError, + AlexaUnsupportedThermostatModeError, +) + +_LOGGER = logging.getLogger(__name__) +HANDLERS = Registry() + + +@HANDLERS.register(('Alexa.Discovery', 'Discover')) +async def async_api_discovery(hass, config, directive, context): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [ + { + 'displayCategories': alexa_entity.display_categories(), + 'cookie': {}, + 'endpointId': alexa_entity.alexa_id(), + 'friendlyName': alexa_entity.friendly_name(), + 'description': alexa_entity.description(), + 'manufacturerName': 'Home Assistant', + 'capabilities': [ + i.serialize_discovery() for i in alexa_entity.interfaces() + ] + } + for alexa_entity in async_get_entities(hass, config) + if config.should_expose(alexa_entity.entity_id) + ] + + return directive.response( + name='Discover.Response', + namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}, + ) + + +@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant')) +async def async_api_accept_grant(hass, config, directive, context): + """Create a API formatted AcceptGrant response. + + Async friendly. + """ + auth_code = directive.payload['grant']['code'] + _LOGGER.debug("AcceptGrant code: %s", auth_code) + + if AUTH_KEY in hass.data: + await hass.data[AUTH_KEY].async_do_auth(auth_code) + await async_enable_proactive_mode(hass, config) + + return directive.response( + name='AcceptGrant.Response', + namespace='Alexa.Authorization', + payload={}) + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) +async def async_api_turn_on(hass, config, directive, context): + """Process a turn on request.""" + entity = directive.entity + domain = entity.domain + if domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_ON + if domain == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) +async def async_api_turn_off(hass, config, directive, context): + """Process a turn off request.""" + entity = directive.entity + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_OFF + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) +async def async_api_set_brightness(hass, config, directive, context): + """Process a set brightness request.""" + entity = directive.entity + brightness = int(directive.payload['brightness']) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) +async def async_api_adjust_brightness(hass, config, directive, context): + """Process an adjust brightness request.""" + entity = directive.entity + brightness_delta = int(directive.payload['brightnessDelta']) + + # read current state + try: + current = math.floor( + int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100) + except ZeroDivisionError: + current = 0 + + # set brightness + brightness = max(0, brightness_delta + current) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.ColorController', 'SetColor')) +async def async_api_set_color(hass, config, directive, context): + """Process a set color request.""" + entity = directive.entity + rgb = color_util.color_hsb_to_RGB( + float(directive.payload['color']['hue']), + float(directive.payload['color']['saturation']), + float(directive.payload['color']['brightness']) + ) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_RGB_COLOR: rgb, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) +async def async_api_set_color_temperature(hass, config, directive, context): + """Process a set color temperature request.""" + entity = directive.entity + kelvin = int(directive.payload['colorTemperatureInKelvin']) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_KELVIN: kelvin, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) +async def async_api_decrease_color_temp(hass, config, directive, context): + """Process a decrease color temperature request.""" + entity = directive.entity + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) + + value = min(max_mireds, current + 50) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) +async def async_api_increase_color_temp(hass, config, directive, context): + """Process an increase color temperature request.""" + entity = directive.entity + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) + + value = max(min_mireds, current - 50) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.SceneController', 'Activate')) +async def async_api_activate(hass, config, directive, context): + """Process an activate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call(domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + payload = { + 'cause': {'type': Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return directive.response( + name='ActivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.SceneController', 'Deactivate')) +async def async_api_deactivate(hass, config, directive, context): + """Process a deactivate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call(domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + payload = { + 'cause': {'type': Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return directive.response( + name='DeactivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) +async def async_api_set_percentage(hass, config, directive, context): + """Process a set percentage request.""" + entity = directive.entity + percentage = int(directive.payload['percentage']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = percentage + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) +async def async_api_adjust_percentage(hass, config, directive, context): + """Process an adjust percentage request.""" + entity = directive.entity + percentage_delta = int(directive.payload['percentageDelta']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = entity.attributes.get(fan.ATTR_SPEED) + + if speed == "off": + current = 0 + elif speed == "low": + current = 33 + elif speed == "medium": + current = 66 + elif speed == "high": + current = 100 + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + + current = entity.attributes.get(cover.ATTR_POSITION) + + data[cover.ATTR_POSITION] = max(0, percentage_delta + current) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.LockController', 'Lock')) +async def async_api_lock(hass, config, directive, context): + """Process a lock request.""" + entity = directive.entity + await hass.services.async_call(entity.domain, SERVICE_LOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + response = directive.response() + response.add_context_property({ + 'name': 'lockState', + 'namespace': 'Alexa.LockController', + 'value': 'LOCKED' + }) + return response + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +async def async_api_unlock(hass, config, directive, context): + """Process an unlock request.""" + entity = directive.entity + await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) +async def async_api_set_volume(hass, config, directive, context): + """Process a set volume request.""" + volume = round(float(directive.payload['volume'] / 100), 2) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.InputController', 'SelectInput')) +async def async_api_select_input(hass, config, directive, context): + """Process a set input request.""" + media_input = directive.payload['input'] + entity = directive.entity + + # attempt to map the ALL UPPERCASE payload name to a source + source_list = entity.attributes[ + media_player.const.ATTR_INPUT_SOURCE_LIST] or [] + for source in source_list: + # response will always be space separated, so format the source in the + # most likely way to find a match + formatted_source = source.lower().replace('-', ' ').replace('_', ' ') + if formatted_source in media_input.lower(): + media_input = source + break + else: + msg = 'failed to map input {} to a media source on {}'.format( + media_input, entity.entity_id) + raise AlexaInvalidValueError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_INPUT_SOURCE: media_input, + } + + await hass.services.async_call( + entity.domain, media_player.SERVICE_SELECT_SOURCE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) +async def async_api_adjust_volume(hass, config, directive, context): + """Process an adjust volume request.""" + volume_delta = int(directive.payload['volume']) + + entity = directive.entity + current_level = entity.attributes.get( + media_player.const.ATTR_MEDIA_VOLUME_LEVEL) + + # read current state + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + + volume = float(max(0, volume_delta + current) / 100) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) +async def async_api_adjust_volume_step(hass, config, directive, context): + """Process an adjust volume step request.""" + # media_player volume up/down service does not support specifying steps + # each component handles it differently e.g. via config. + # For now we use the volumeSteps returned to figure out if we + # should step up/down + volume_step = directive.payload['volumeSteps'] + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + + if volume_step > 0: + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_UP, + data, blocking=False, context=context) + elif volume_step < 0: + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_DOWN, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) +@HANDLERS.register(('Alexa.Speaker', 'SetMute')) +async def async_api_set_mute(hass, config, directive, context): + """Process a set mute request.""" + mute = bool(directive.payload['mute']) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_MUTE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Play')) +async def async_api_play(hass, config, directive, context): + """Process a play request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) +async def async_api_pause(hass, config, directive, context): + """Process a pause request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) +async def async_api_stop(hass, config, directive, context): + """Process a stop request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Next')) +async def async_api_next(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) +async def async_api_previous(hass, config, directive, context): + """Process a previous request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=False, context=context) + + return directive.response() + + +def temperature_from_object(hass, temp_obj, interval=False): + """Get temperature from Temperature object in requested unit.""" + to_unit = hass.config.units.temperature_unit + from_unit = TEMP_CELSIUS + temp = float(temp_obj['value']) + + if temp_obj['scale'] == 'FAHRENHEIT': + from_unit = TEMP_FAHRENHEIT + elif temp_obj['scale'] == 'KELVIN': + # convert to Celsius if absolute temperature + if not interval: + temp -= 273.15 + + return convert_temperature(temp, from_unit, to_unit, interval) + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature')) +async def async_api_set_target_temp(hass, config, directive, context): + """Process a set target temperature request.""" + entity = directive.entity + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit + + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + payload = directive.payload + response = directive.response() + if 'targetSetpoint' in payload: + temp = temperature_from_object(hass, payload['targetSetpoint']) + if temp < min_temp or temp > max_temp: + raise AlexaTempRangeError(hass, temp, min_temp, max_temp) + data[ATTR_TEMPERATURE] = temp + response.add_context_property({ + 'name': 'targetSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]}, + }) + if 'lowerSetpoint' in payload: + temp_low = temperature_from_object(hass, payload['lowerSetpoint']) + if temp_low < min_temp or temp_low > max_temp: + raise AlexaTempRangeError(hass, temp_low, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + response.add_context_property({ + 'name': 'lowerSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]}, + }) + if 'upperSetpoint' in payload: + temp_high = temperature_from_object(hass, payload['upperSetpoint']) + if temp_high < min_temp or temp_high > max_temp: + raise AlexaTempRangeError(hass, temp_high, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + response.add_context_property({ + 'name': 'upperSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]}, + }) + + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, + context=context) + + return response + + +@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) +async def async_api_adjust_target_temp(hass, config, directive, context): + """Process an adjust target temperature request.""" + entity = directive.entity + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit + + temp_delta = temperature_from_object( + hass, directive.payload['targetSetpointDelta'], interval=True) + target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta + + if target_temp < min_temp or target_temp > max_temp: + raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + ATTR_TEMPERATURE: target_temp, + } + + response = directive.response() + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, + context=context) + response.add_context_property({ + 'name': 'targetSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]}, + }) + + return response + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) +async def async_api_set_thermostat_mode(hass, config, directive, context): + """Process a set thermostat mode request.""" + entity = directive.entity + mode = directive.payload['thermostatMode'] + mode = mode if isinstance(mode, str) else mode['value'] + + operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) + ha_mode = next( + (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), + None + ) + if ha_mode not in operation_list: + msg = 'The requested thermostat mode {} is not supported'.format(mode) + raise AlexaUnsupportedThermostatModeError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_OPERATION_MODE: ha_mode, + } + + response = directive.response() + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, + blocking=False, context=context) + response.add_context_property({ + 'name': 'thermostatMode', + 'namespace': 'Alexa.ThermostatController', + 'value': mode, + }) + + return response + + +@HANDLERS.register(('Alexa', 'ReportState')) +async def async_api_reportstate(hass, config, directive, context): + """Process a ReportState request.""" + return directive.response(name='StateReport') diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py new file mode 100644 index 00000000000000..3dd72c11294234 --- /dev/null +++ b/homeassistant/components/alexa/messages.py @@ -0,0 +1,200 @@ +"""Alexa models.""" +import logging +from uuid import uuid4 + +from .const import ( + API_CONTEXT, + API_DIRECTIVE, + API_ENDPOINT, + API_EVENT, + API_HEADER, + API_PAYLOAD, + API_SCOPE, +) +from .entities import ENTITY_ADAPTERS +from .errors import AlexaInvalidEndpointError + +_LOGGER = logging.getLogger(__name__) + + +class AlexaDirective: + """An incoming Alexa directive.""" + + def __init__(self, request): + """Initialize a directive.""" + self._directive = request[API_DIRECTIVE] + self.namespace = self._directive[API_HEADER]['namespace'] + self.name = self._directive[API_HEADER]['name'] + self.payload = self._directive[API_PAYLOAD] + self.has_endpoint = API_ENDPOINT in self._directive + + self.entity = self.entity_id = self.endpoint = None + + def load_entity(self, hass, config): + """Set attributes related to the entity for this request. + + Sets these attributes when self.has_endpoint is True: + + - entity + - entity_id + - endpoint + + Behavior when self.has_endpoint is False is undefined. + + Will raise AlexaInvalidEndpointError if the endpoint in the request is + malformed or nonexistant. + """ + _endpoint_id = self._directive[API_ENDPOINT]['endpointId'] + self.entity_id = _endpoint_id.replace('#', '.') + + self.entity = hass.states.get(self.entity_id) + if not self.entity: + raise AlexaInvalidEndpointError(_endpoint_id) + + self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( + hass, config, self.entity) + + def response(self, + name='Response', + namespace='Alexa', + payload=None): + """Create an API formatted response. + + Async friendly. + """ + response = AlexaResponse(name, namespace, payload) + + token = self._directive[API_HEADER].get('correlationToken') + if token: + response.set_correlation_token(token) + + if self.has_endpoint: + response.set_endpoint(self._directive[API_ENDPOINT].copy()) + + return response + + def error( + self, + namespace='Alexa', + error_type='INTERNAL_ERROR', + error_message="", + payload=None + ): + """Create a API formatted error response. + + Async friendly. + """ + payload = payload or {} + payload['type'] = error_type + payload['message'] = error_message + + _LOGGER.info("Request %s/%s error %s: %s", + self._directive[API_HEADER]['namespace'], + self._directive[API_HEADER]['name'], + error_type, error_message) + + return self.response( + name='ErrorResponse', + namespace=namespace, + payload=payload + ) + + +class AlexaResponse: + """Class to hold a response.""" + + def __init__(self, name, namespace, payload=None): + """Initialize the response.""" + payload = payload or {} + self._response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } + } + + @property + def name(self): + """Return the name of this response.""" + return self._response[API_EVENT][API_HEADER]['name'] + + @property + def namespace(self): + """Return the namespace of this response.""" + return self._response[API_EVENT][API_HEADER]['namespace'] + + def set_correlation_token(self, token): + """Set the correlationToken. + + This should normally mirror the value from a request, and is set by + AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_HEADER]['correlationToken'] = token + + def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None): + """Set the endpoint dictionary. + + This is used to send proactive messages to Alexa. + """ + self._response[API_EVENT][API_ENDPOINT] = { + API_SCOPE: { + 'type': 'BearerToken', + 'token': bearer_token + } + } + + if endpoint_id is not None: + self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id + + if cookie is not None: + self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie + + def set_endpoint(self, endpoint): + """Set the endpoint. + + This should normally mirror the value from a request, and is set by + AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_ENDPOINT] = endpoint + + def _properties(self): + context = self._response.setdefault(API_CONTEXT, {}) + return context.setdefault('properties', []) + + def add_context_property(self, prop): + """Add a property to the response context. + + The Alexa response includes a list of properties which provides + feedback on how states have changed. For example if a user asks, + "Alexa, set theromstat to 20 degrees", the API expects a response with + the new value of the property, and Alexa will respond to the user + "Thermostat set to 20 degrees". + + async_handle_message() will call .merge_context_properties() for every + request automatically, however often handlers will call services to + change state but the effects of those changes are applied + asynchronously. Thus, handlers should call this method to confirm + changes before returning. + """ + self._properties().append(prop) + + def merge_context_properties(self, endpoint): + """Add all properties from given endpoint if not already set. + + Handlers should be using .add_context_property(). + """ + properties = self._properties() + already_set = {(p['namespace'], p['name']) for p in properties} + + for prop in endpoint.serialize_properties(): + if (prop['namespace'], prop['name']) not in already_set: + self.add_context_property(prop) + + def serialize(self): + """Return response as a JSON-able data structure.""" + return self._response diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a69a0cf6ec7a62..f87e6bdee3592f 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,1338 +1,54 @@ """Support for alexa Smart Home Skill API.""" -import asyncio -import json import logging -import math -from collections import OrderedDict -from datetime import datetime -from uuid import uuid4 -import aiohttp -import async_timeout - -import homeassistant.core as ha -import homeassistant.util.color as color_util -from homeassistant.components import ( - alert, automation, binary_sensor, cover, fan, group, http, - input_boolean, light, lock, media_player, scene, script, sensor, switch) -from homeassistant.components.climate import const as climate -from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, - CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, - SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET, - SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, - STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL) -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.event import async_track_state_change -from homeassistant.util.decorator import Registry -from homeassistant.util.temperature import convert as convert_temperature - -from .auth import Auth -from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \ - CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT - -_LOGGER = logging.getLogger(__name__) - -API_DIRECTIVE = 'directive' -API_ENDPOINT = 'endpoint' -API_EVENT = 'event' -API_CONTEXT = 'context' -API_HEADER = 'header' -API_PAYLOAD = 'payload' -API_SCOPE = 'scope' -API_CHANGE = 'change' - -API_TEMP_UNITS = { - TEMP_FAHRENHEIT: 'FAHRENHEIT', - TEMP_CELSIUS: 'CELSIUS', -} - -# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a -# reverse mapping of this dict and we want to map the first occurrance of OFF -# back to HA state. -API_THERMOSTAT_MODES = OrderedDict([ - (climate.STATE_HEAT, 'HEAT'), - (climate.STATE_COOL, 'COOL'), - (climate.STATE_AUTO, 'AUTO'), - (climate.STATE_ECO, 'ECO'), - (climate.STATE_MANUAL, 'AUTO'), - (STATE_OFF, 'OFF'), - (climate.STATE_IDLE, 'OFF'), - (climate.STATE_FAN_ONLY, 'OFF'), - (climate.STATE_DRY, 'OFF'), -]) - -PERCENTAGE_FAN_MAP = { - fan.SPEED_LOW: 33, - fan.SPEED_MEDIUM: 66, - fan.SPEED_HIGH: 100, -} - -SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' - -CONF_DESCRIPTION = 'description' -CONF_DISPLAY_CATEGORIES = 'display_categories' - -HANDLERS = Registry() -ENTITY_ADAPTERS = Registry() -EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' - -AUTH_KEY = "alexa.smart_home.auth" - - -class _DisplayCategory: - """Possible display categories for Discovery response. - - https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories - """ - - # Describes a combination of devices set to a specific state, when the - # state change must occur in a specific order. For example, a "watch - # Netflix" scene might require the: 1. TV to be powered on & 2. Input set - # to HDMI1. Applies to Scenes - ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" - - # Indicates media devices with video or photo capabilities. - CAMERA = "CAMERA" - - # Indicates an endpoint that detects and reports contact. - CONTACT_SENSOR = "CONTACT_SENSOR" - - # Indicates a door. - DOOR = "DOOR" - - # Indicates light sources or fixtures. - LIGHT = "LIGHT" - - # Indicates an endpoint that detects and reports motion. - MOTION_SENSOR = "MOTION_SENSOR" - - # An endpoint that cannot be described in on of the other categories. - OTHER = "OTHER" - - # Describes a combination of devices set to a specific state, when the - # order of the state change is not important. For example a bedtime scene - # might include turning off lights and lowering the thermostat, but the - # order is unimportant. Applies to Scenes - SCENE_TRIGGER = "SCENE_TRIGGER" - - # Indicates an endpoint that locks. - SMARTLOCK = "SMARTLOCK" - - # Indicates modules that are plugged into an existing electrical outlet. - # Can control a variety of devices. - SMARTPLUG = "SMARTPLUG" - - # Indicates the endpoint is a speaker or speaker system. - SPEAKER = "SPEAKER" - - # Indicates in-wall switches wired to the electrical system. Can control a - # variety of devices. - SWITCH = "SWITCH" - - # Indicates endpoints that report the temperature only. - TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" - - # Indicates endpoints that control temperature, stand-alone air - # conditioners, or heaters with direct temperature control. - THERMOSTAT = "THERMOSTAT" - - # Indicates the endpoint is a television. - TV = "TV" - - -def _capability(interface, - version=3, - supports_deactivation=None, - retrievable=None, - properties_supported=None, - cap_type='AlexaInterface'): - """Return a Smart Home API capability object. - - https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object - - There are some additional fields allowed but not implemented here since - we've no use case for them yet: - - - proactively_reported - - `supports_deactivation` applies only to scenes. - """ - result = { - 'type': cap_type, - 'interface': interface, - 'version': version, - } - - if supports_deactivation is not None: - result['supportsDeactivation'] = supports_deactivation - - if retrievable is not None: - result['retrievable'] = retrievable - - if properties_supported is not None: - result['properties'] = {'supported': properties_supported} - - return result - - -class _UnsupportedInterface(Exception): - """This entity does not support the requested Smart Home API interface.""" - - -class _UnsupportedProperty(Exception): - """This entity does not support the requested Smart Home API property.""" - - -class _AlexaError(Exception): - """Base class for errors that can be serialized by the Alexa API. - - A handler can raise subclasses of this to return an error to the request. - """ - - namespace = None - error_type = None - - def __init__(self, error_message, payload=None): - Exception.__init__(self) - self.error_message = error_message - self.payload = None - - -class _AlexaInvalidEndpointError(_AlexaError): - """The endpoint in the request does not exist.""" - - namespace = 'Alexa' - error_type = 'NO_SUCH_ENDPOINT' - - def __init__(self, endpoint_id): - msg = 'The endpoint {} does not exist'.format(endpoint_id) - _AlexaError.__init__(self, msg) - self.endpoint_id = endpoint_id - - -class _AlexaInvalidValueError(_AlexaError): - namespace = 'Alexa' - error_type = 'INVALID_VALUE' - - -class _AlexaUnsupportedThermostatModeError(_AlexaError): - namespace = 'Alexa.ThermostatController' - error_type = 'UNSUPPORTED_THERMOSTAT_MODE' - - -class _AlexaTempRangeError(_AlexaError): - namespace = 'Alexa' - error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE' - - def __init__(self, hass, temp, min_temp, max_temp): - unit = hass.config.units.temperature_unit - temp_range = { - 'minimumValue': { - 'value': min_temp, - 'scale': API_TEMP_UNITS[unit], - }, - 'maximumValue': { - 'value': max_temp, - 'scale': API_TEMP_UNITS[unit], - }, - } - payload = {'validRange': temp_range} - msg = 'The requested temperature {} is out of range'.format(temp) - - _AlexaError.__init__(self, msg, payload) - - -class _AlexaBridgeUnreachableError(_AlexaError): - namespace = 'Alexa' - error_type = 'BRIDGE_UNREACHABLE' - - -class _AlexaEntity: - """An adaptation of an entity, expressed in Alexa's terms. - - The API handlers should manipulate entities only through this interface. - """ - - def __init__(self, hass, config, entity): - self.hass = hass - self.config = config - self.entity = entity - self.entity_conf = config.entity_config.get(entity.entity_id, {}) - - def friendly_name(self): - """Return the Alexa API friendly name.""" - return self.entity_conf.get(CONF_NAME, self.entity.name) - - def description(self): - """Return the Alexa API description.""" - return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) - - def entity_id(self): - """Return the Alexa API entity id.""" - return self.entity.entity_id.replace('.', '#') - - def display_categories(self): - """Return a list of display categories.""" - entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) - if CONF_DISPLAY_CATEGORIES in entity_conf: - return [entity_conf[CONF_DISPLAY_CATEGORIES]] - return self.default_display_categories() - - def default_display_categories(self): - """Return a list of default display categories. - - This can be overridden by the user in the Home Assistant configuration. - - See also _DisplayCategory. - """ - raise NotImplementedError - - def get_interface(self, capability): - """Return the given _AlexaInterface. - - Raises _UnsupportedInterface. - """ - pass - - def interfaces(self): - """Return a list of supported interfaces. - - Used for discovery. The list should contain _AlexaInterface instances. - If the list is empty, this entity will not be discovered. - """ - raise NotImplementedError - - def serialize_properties(self): - """Yield each supported property in API format.""" - for interface in self.interfaces(): - for prop in interface.serialize_properties(): - yield prop - - -class _AlexaInterface: - """Base class for Alexa capability interfaces. - - The Smart Home Skills API defines a number of "capability interfaces", - roughly analogous to domains in Home Assistant. The supported interfaces - describe what actions can be performed on a particular device. - - https://developer.amazon.com/docs/device-apis/message-guide.html - """ - - def __init__(self, entity): - self.entity = entity - - def name(self): - """Return the Alexa API name of this interface.""" - raise NotImplementedError - - @staticmethod - def properties_supported(): - """Return what properties this entity supports.""" - return [] - - @staticmethod - def properties_proactively_reported(): - """Return True if properties asynchronously reported.""" - return False - - @staticmethod - def properties_retrievable(): - """Return True if properties can be retrieved.""" - return False - - @staticmethod - def get_property(name): - """Read and return a property. - - Return value should be a dict, or raise _UnsupportedProperty. - - Properties can also have a timeOfSample and uncertaintyInMilliseconds, - but returning those metadata is not yet implemented. - """ - raise _UnsupportedProperty(name) - - @staticmethod - def supports_deactivation(): - """Applicable only to scenes.""" - return None - - def serialize_discovery(self): - """Serialize according to the Discovery API.""" - result = { - 'type': 'AlexaInterface', - 'interface': self.name(), - 'version': '3', - 'properties': { - 'supported': self.properties_supported(), - 'proactivelyReported': self.properties_proactively_reported(), - 'retrievable': self.properties_retrievable(), - }, - } - - # pylint: disable=assignment-from-none - supports_deactivation = self.supports_deactivation() - if supports_deactivation is not None: - result['supportsDeactivation'] = supports_deactivation - return result - - def serialize_properties(self): - """Return properties serialized for an API response.""" - for prop in self.properties_supported(): - prop_name = prop['name'] - # pylint: disable=assignment-from-no-return - prop_value = self.get_property(prop_name) - if prop_value is not None: - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': prop_value, - 'timeOfSample': datetime.now().strftime(DATE_FORMAT), - 'uncertaintyInMilliseconds': 0 - } - - -class _AlexaEndpointHealth(_AlexaInterface): - """Implements Alexa.EndpointHealth. - - https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it - """ - - def __init__(self, hass, entity): - super().__init__(entity) - self.hass = hass - - def name(self): - return 'Alexa.EndpointHealth' - - def properties_supported(self): - return [{'name': 'connectivity'}] - - def properties_proactively_reported(self): - return False - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'connectivity': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_UNAVAILABLE: - return {'value': 'UNREACHABLE'} - return {'value': 'OK'} - - -class _AlexaPowerController(_AlexaInterface): - """Implements Alexa.PowerController. - - https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html - """ - - def name(self): - return 'Alexa.PowerController' - - def properties_supported(self): - return [{'name': 'powerState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'powerState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_OFF: - return 'OFF' - return 'ON' - - -class _AlexaLockController(_AlexaInterface): - """Implements Alexa.LockController. - - https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html - """ - - def name(self): - return 'Alexa.LockController' - - def properties_supported(self): - return [{'name': 'lockState'}] - - def properties_retrievable(self): - return True - - def properties_proactively_reported(self): - return True - - def get_property(self, name): - if name != 'lockState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_LOCKED: - return 'LOCKED' - if self.entity.state == STATE_UNLOCKED: - return 'UNLOCKED' - return 'JAMMED' - - -class _AlexaSceneController(_AlexaInterface): - """Implements Alexa.SceneController. - - https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html - """ - - def __init__(self, entity, supports_deactivation): - _AlexaInterface.__init__(self, entity) - self.supports_deactivation = lambda: supports_deactivation - - def name(self): - return 'Alexa.SceneController' - - -class _AlexaBrightnessController(_AlexaInterface): - """Implements Alexa.BrightnessController. - - https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html - """ - - def name(self): - return 'Alexa.BrightnessController' - - def properties_supported(self): - return [{'name': 'brightness'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'brightness': - raise _UnsupportedProperty(name) - if 'brightness' in self.entity.attributes: - return round(self.entity.attributes['brightness'] / 255.0 * 100) - return 0 - - -class _AlexaColorController(_AlexaInterface): - """Implements Alexa.ColorController. - - https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html - """ - - def name(self): - return 'Alexa.ColorController' - - def properties_supported(self): - return [{'name': 'color'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'color': - raise _UnsupportedProperty(name) - - hue, saturation = self.entity.attributes.get( - light.ATTR_HS_COLOR, (0, 0)) - - return { - 'hue': hue, - 'saturation': saturation / 100.0, - 'brightness': self.entity.attributes.get( - light.ATTR_BRIGHTNESS, 0) / 255.0, - } - - -class _AlexaColorTemperatureController(_AlexaInterface): - """Implements Alexa.ColorTemperatureController. - - https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html - """ - - def name(self): - return 'Alexa.ColorTemperatureController' - - def properties_supported(self): - return [{'name': 'colorTemperatureInKelvin'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'colorTemperatureInKelvin': - raise _UnsupportedProperty(name) - if 'color_temp' in self.entity.attributes: - return color_util.color_temperature_mired_to_kelvin( - self.entity.attributes['color_temp']) - return 0 - - -class _AlexaPercentageController(_AlexaInterface): - """Implements Alexa.PercentageController. - - https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html - """ - - def name(self): - return 'Alexa.PercentageController' - - def properties_supported(self): - return [{'name': 'percentage'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'percentage': - raise _UnsupportedProperty(name) - - if self.entity.domain == fan.DOMAIN: - speed = self.entity.attributes.get(fan.ATTR_SPEED) - - return PERCENTAGE_FAN_MAP.get(speed, 0) - - if self.entity.domain == cover.DOMAIN: - return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) - - return 0 - - -class _AlexaSpeaker(_AlexaInterface): - """Implements Alexa.Speaker. - - https://developer.amazon.com/docs/device-apis/alexa-speaker.html - """ - - def name(self): - return 'Alexa.Speaker' - - -class _AlexaStepSpeaker(_AlexaInterface): - """Implements Alexa.StepSpeaker. - - https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html - """ - - def name(self): - return 'Alexa.StepSpeaker' - - -class _AlexaPlaybackController(_AlexaInterface): - """Implements Alexa.PlaybackController. - - https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html - """ - - def name(self): - return 'Alexa.PlaybackController' - - -class _AlexaInputController(_AlexaInterface): - """Implements Alexa.InputController. - - https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html - """ - - def name(self): - return 'Alexa.InputController' - - -class _AlexaTemperatureSensor(_AlexaInterface): - """Implements Alexa.TemperatureSensor. - - https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html - """ - - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.TemperatureSensor' - - def properties_supported(self): - return [{'name': 'temperature'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'temperature': - raise _UnsupportedProperty(name) - - unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - temp = self.entity.state - if self.entity.domain == climate.DOMAIN: - unit = self.hass.config.units.temperature_unit - temp = self.entity.attributes.get( - climate.ATTR_CURRENT_TEMPERATURE) - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -class _AlexaContactSensor(_AlexaInterface): - """Implements Alexa.ContactSensor. - - The Alexa.ContactSensor interface describes the properties and events used - to report the state of an endpoint that detects contact between two - surfaces. For example, a contact sensor can report whether a door or window - is open. - - https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html - """ - - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.ContactSensor' - - def properties_supported(self): - return [{'name': 'detectionState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'detectionState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_ON: - return 'DETECTED' - return 'NOT_DETECTED' - - -class _AlexaMotionSensor(_AlexaInterface): - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.MotionSensor' - - def properties_supported(self): - return [{'name': 'detectionState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'detectionState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_ON: - return 'DETECTED' - return 'NOT_DETECTED' - - -class _AlexaThermostatController(_AlexaInterface): - """Implements Alexa.ThermostatController. - - https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html - """ - - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.ThermostatController' - - def properties_supported(self): - properties = [] - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.SUPPORT_TARGET_TEMPERATURE: - properties.append({'name': 'targetSetpoint'}) - if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: - properties.append({'name': 'lowerSetpoint'}) - if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: - properties.append({'name': 'upperSetpoint'}) - if supported & climate.SUPPORT_OPERATION_MODE: - properties.append({'name': 'thermostatMode'}) - return properties - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name == 'thermostatMode': - ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) - mode = API_THERMOSTAT_MODES.get(ha_mode) - if mode is None: - _LOGGER.error("%s (%s) has unsupported %s value '%s'", - self.entity.entity_id, type(self.entity), - climate.ATTR_OPERATION_MODE, ha_mode) - raise _UnsupportedProperty(name) - return mode - - unit = self.hass.config.units.temperature_unit - if name == 'targetSetpoint': - temp = self.entity.attributes.get(ATTR_TEMPERATURE) - elif name == 'lowerSetpoint': - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) - elif name == 'upperSetpoint': - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - else: - raise _UnsupportedProperty(name) - - if temp is None: - return None - - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -@ENTITY_ADAPTERS.register(alert.DOMAIN) -@ENTITY_ADAPTERS.register(automation.DOMAIN) -@ENTITY_ADAPTERS.register(group.DOMAIN) -@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) -class _GenericCapabilities(_AlexaEntity): - """A generic, on/off device. - - The choice of last resort. - """ - - def default_display_categories(self): - return [_DisplayCategory.OTHER] - - def interfaces(self): - return [_AlexaPowerController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(switch.DOMAIN) -class _SwitchCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SWITCH] - - def interfaces(self): - return [_AlexaPowerController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(climate.DOMAIN) -class _ClimateCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.THERMOSTAT] - - def interfaces(self): - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.SUPPORT_ON_OFF: - yield _AlexaPowerController(self.entity) - yield _AlexaThermostatController(self.hass, self.entity) - yield _AlexaTemperatureSensor(self.hass, self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(cover.DOMAIN) -class _CoverCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.DOOR] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & cover.SUPPORT_SET_POSITION: - yield _AlexaPercentageController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(light.DOMAIN) -class _LightCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.LIGHT] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & light.SUPPORT_BRIGHTNESS: - yield _AlexaBrightnessController(self.entity) - if supported & light.SUPPORT_COLOR: - yield _AlexaColorController(self.entity) - if supported & light.SUPPORT_COLOR_TEMP: - yield _AlexaColorTemperatureController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(fan.DOMAIN) -class _FanCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.OTHER] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & fan.SUPPORT_SET_SPEED: - yield _AlexaPercentageController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(lock.DOMAIN) -class _LockCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SMARTLOCK] - - def interfaces(self): - return [_AlexaLockController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) -class _MediaPlayerCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.TV] - - def interfaces(self): - yield _AlexaEndpointHealth(self.hass, self.entity) - - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & media_player.const.SUPPORT_VOLUME_SET: - yield _AlexaSpeaker(self.entity) - - power_features = (media_player.SUPPORT_TURN_ON | - media_player.SUPPORT_TURN_OFF) - if supported & power_features: - yield _AlexaPowerController(self.entity) - - step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE | - media_player.const.SUPPORT_VOLUME_STEP) - if supported & step_volume_features: - yield _AlexaStepSpeaker(self.entity) - - playback_features = (media_player.const.SUPPORT_PLAY | - media_player.const.SUPPORT_PAUSE | - media_player.const.SUPPORT_STOP | - media_player.const.SUPPORT_NEXT_TRACK | - media_player.const.SUPPORT_PREVIOUS_TRACK) - if supported & playback_features: - yield _AlexaPlaybackController(self.entity) - - if supported & media_player.SUPPORT_SELECT_SOURCE: - yield _AlexaInputController(self.entity) - - -@ENTITY_ADAPTERS.register(scene.DOMAIN) -class _SceneCapabilities(_AlexaEntity): - def description(self): - # Required description as per Amazon Scene docs - scene_fmt = '{} (Scene connected via Home Assistant)' - return scene_fmt.format(_AlexaEntity.description(self)) - - def default_display_categories(self): - return [_DisplayCategory.SCENE_TRIGGER] - - def interfaces(self): - return [_AlexaSceneController(self.entity, - supports_deactivation=False)] - - -@ENTITY_ADAPTERS.register(script.DOMAIN) -class _ScriptCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.ACTIVITY_TRIGGER] - - def interfaces(self): - can_cancel = bool(self.entity.attributes.get('can_cancel')) - return [_AlexaSceneController(self.entity, - supports_deactivation=can_cancel)] - - -@ENTITY_ADAPTERS.register(sensor.DOMAIN) -class _SensorCapabilities(_AlexaEntity): - def default_display_categories(self): - # although there are other kinds of sensors, all but temperature - # sensors are currently ignored. - return [_DisplayCategory.TEMPERATURE_SENSOR] - - def interfaces(self): - attrs = self.entity.attributes - if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in ( - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - ): - yield _AlexaTemperatureSensor(self.hass, self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) -class _BinarySensorCapabilities(_AlexaEntity): - TYPE_CONTACT = 'contact' - TYPE_MOTION = 'motion' - - def default_display_categories(self): - sensor_type = self.get_type() - if sensor_type is self.TYPE_CONTACT: - return [_DisplayCategory.CONTACT_SENSOR] - if sensor_type is self.TYPE_MOTION: - return [_DisplayCategory.MOTION_SENSOR] - - def interfaces(self): - sensor_type = self.get_type() - if sensor_type is self.TYPE_CONTACT: - yield _AlexaContactSensor(self.hass, self.entity) - elif sensor_type is self.TYPE_MOTION: - yield _AlexaMotionSensor(self.hass, self.entity) - - yield _AlexaEndpointHealth(self.hass, self.entity) - - def get_type(self): - """Return the type of binary sensor.""" - attrs = self.entity.attributes - if attrs.get(ATTR_DEVICE_CLASS) in ( - 'door', - 'garage_door', - 'opening', - 'window', - ): - return self.TYPE_CONTACT - if attrs.get(ATTR_DEVICE_CLASS) == 'motion': - return self.TYPE_MOTION - - -class _Cause: - """Possible causes for property changes. - - https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object - """ - - # Indicates that the event was caused by a customer interaction with an - # application. For example, a customer switches on a light, or locks a door - # using the Alexa app or an app provided by a device vendor. - APP_INTERACTION = 'APP_INTERACTION' - - # Indicates that the event was caused by a physical interaction with an - # endpoint. For example manually switching on a light or manually locking a - # door lock - PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION' - - # Indicates that the event was caused by the periodic poll of an appliance, - # which found a change in value. For example, you might poll a temperature - # sensor every hour, and send the updated temperature to Alexa. - PERIODIC_POLL = 'PERIODIC_POLL' - - # Indicates that the event was caused by the application of a device rule. - # For example, a customer configures a rule to switch on a light if a - # motion sensor detects motion. In this case, Alexa receives an event from - # the motion sensor, and another event from the light to indicate that its - # state change was caused by the rule. - RULE_TRIGGER = 'RULE_TRIGGER' - - # Indicates that the event was caused by a voice interaction with Alexa. - # For example a user speaking to their Echo device. - VOICE_INTERACTION = 'VOICE_INTERACTION' - - -class Config: - """Hold the configuration for Alexa.""" - - def __init__(self, endpoint, async_get_access_token, should_expose, - entity_config=None): - """Initialize the configuration.""" - self.endpoint = endpoint - self.async_get_access_token = async_get_access_token - self.should_expose = should_expose - self.entity_config = entity_config or {} - - -async def async_setup(hass, config): - """Activate Smart Home functionality of Alexa component. - - This is optional, triggered by having a `smart_home:` sub-section in the - alexa configuration. - - Even if that's disabled, the functionality in this module may still be used - by the cloud component which will call async_handle_message directly. - """ - if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): - hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET]) - - async_get_access_token = \ - hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ - else None - - smart_home_config = Config( - endpoint=config.get(CONF_ENDPOINT), - async_get_access_token=async_get_access_token, - should_expose=config[CONF_FILTER], - entity_config=config.get(CONF_ENTITY_CONFIG), - ) - hass.http.register_view(SmartHomeView(smart_home_config)) - - if AUTH_KEY in hass.data: - await async_enable_proactive_mode(hass, smart_home_config) - - -async def async_enable_proactive_mode(hass, smart_home_config): - """Enable the proactive mode. - - Proactive mode makes this component report state changes to Alexa. - """ - if smart_home_config.async_get_access_token is None: - # no function to call to get token - return - - if await smart_home_config.async_get_access_token() is None: - # not ready yet - return - - async def async_entity_state_listener(changed_entity, old_state, - new_state): - if not smart_home_config.should_expose(changed_entity): - _LOGGER.debug("Not exposing %s because filtered by config", - changed_entity) - return - - if new_state.domain not in ENTITY_ADAPTERS: - return - - alexa_changed_entity = \ - ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config, - new_state) - - for interface in alexa_changed_entity.interfaces(): - if interface.properties_proactively_reported(): - await async_send_changereport_message(hass, smart_home_config, - alexa_changed_entity) - return - - async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) - - -class SmartHomeView(http.HomeAssistantView): - """Expose Smart Home v3 payload interface via HTTP POST.""" - - url = SMART_HOME_HTTP_ENDPOINT - name = 'api:alexa:smart_home' - - def __init__(self, smart_home_config): - """Initialize.""" - self.smart_home_config = smart_home_config - - async def post(self, request): - """Handle Alexa Smart Home requests. - - The Smart Home API requires the endpoint to be implemented in AWS - Lambda, which will need to forward the requests to here and pass back - the response. - """ - hass = request.app['hass'] - user = request[http.KEY_HASS_USER] - message = await request.json() - - _LOGGER.debug("Received Alexa Smart Home request: %s", message) - - response = await async_handle_message( - hass, self.smart_home_config, message, - context=ha.Context(user_id=user.id) - ) - _LOGGER.debug("Sending Alexa Smart Home response: %s", response) - return b'' if response is None else self.json(response) - - -class _AlexaDirective: - def __init__(self, request): - self._directive = request[API_DIRECTIVE] - self.namespace = self._directive[API_HEADER]['namespace'] - self.name = self._directive[API_HEADER]['name'] - self.payload = self._directive[API_PAYLOAD] - self.has_endpoint = API_ENDPOINT in self._directive - - self.entity = self.entity_id = self.endpoint = None - - def load_entity(self, hass, config): - """Set attributes related to the entity for this request. - - Sets these attributes when self.has_endpoint is True: - - - entity - - entity_id - - endpoint - - Behavior when self.has_endpoint is False is undefined. - - Will raise _AlexaInvalidEndpointError if the endpoint in the request is - malformed or nonexistant. - """ - _endpoint_id = self._directive[API_ENDPOINT]['endpointId'] - self.entity_id = _endpoint_id.replace('#', '.') - - self.entity = hass.states.get(self.entity_id) - if not self.entity: - raise _AlexaInvalidEndpointError(_endpoint_id) - - self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( - hass, config, self.entity) - - def response(self, - name='Response', - namespace='Alexa', - payload=None): - """Create an API formatted response. - - Async friendly. - """ - response = _AlexaResponse(name, namespace, payload) - - token = self._directive[API_HEADER].get('correlationToken') - if token: - response.set_correlation_token(token) - - if self.has_endpoint: - response.set_endpoint(self._directive[API_ENDPOINT].copy()) - - return response - - def error( - self, - namespace='Alexa', - error_type='INTERNAL_ERROR', - error_message="", - payload=None - ): - """Create a API formatted error response. - - Async friendly. - """ - payload = payload or {} - payload['type'] = error_type - payload['message'] = error_message - - _LOGGER.info("Request %s/%s error %s: %s", - self._directive[API_HEADER]['namespace'], - self._directive[API_HEADER]['name'], - error_type, error_message) - - return self.response( - name='ErrorResponse', - namespace=namespace, - payload=payload - ) - - -class _AlexaResponse: - def __init__(self, name, namespace, payload=None): - payload = payload or {} - self._response = { - API_EVENT: { - API_HEADER: { - 'namespace': namespace, - 'name': name, - 'messageId': str(uuid4()), - 'payloadVersion': '3', - }, - API_PAYLOAD: payload, - } - } - - @property - def name(self): - """Return the name of this response.""" - return self._response[API_EVENT][API_HEADER]['name'] - - @property - def namespace(self): - """Return the namespace of this response.""" - return self._response[API_EVENT][API_HEADER]['namespace'] - - def set_correlation_token(self, token): - """Set the correlationToken. - - This should normally mirror the value from a request, and is set by - _AlexaDirective.response() usually. - """ - self._response[API_EVENT][API_HEADER]['correlationToken'] = token - - def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None): - """Set the endpoint dictionary. +import homeassistant.core as ha - This is used to send proactive messages to Alexa. - """ - self._response[API_EVENT][API_ENDPOINT] = { - API_SCOPE: { - 'type': 'BearerToken', - 'token': bearer_token - } - } +from .const import API_DIRECTIVE, API_HEADER +from .errors import ( + AlexaError, + AlexaBridgeUnreachableError, +) +from .handlers import HANDLERS +from .messages import AlexaDirective - if endpoint_id is not None: - self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id +_LOGGER = logging.getLogger(__name__) - if cookie is not None: - self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie +EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' - def set_endpoint(self, endpoint): - """Set the endpoint. - This should normally mirror the value from a request, and is set by - _AlexaDirective.response() usually. - """ - self._response[API_EVENT][API_ENDPOINT] = endpoint +# def _capability(interface, +# version=3, +# supports_deactivation=None, +# retrievable=None, +# properties_supported=None, +# cap_type='AlexaInterface'): +# """Return a Smart Home API capability object. - def _properties(self): - context = self._response.setdefault(API_CONTEXT, {}) - return context.setdefault('properties', []) +# https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object - def add_context_property(self, prop): - """Add a property to the response context. +# There are some additional fields allowed but not implemented here since +# we've no use case for them yet: - The Alexa response includes a list of properties which provides - feedback on how states have changed. For example if a user asks, - "Alexa, set theromstat to 20 degrees", the API expects a response with - the new value of the property, and Alexa will respond to the user - "Thermostat set to 20 degrees". +# - proactively_reported - async_handle_message() will call .merge_context_properties() for every - request automatically, however often handlers will call services to - change state but the effects of those changes are applied - asynchronously. Thus, handlers should call this method to confirm - changes before returning. - """ - self._properties().append(prop) +# `supports_deactivation` applies only to scenes. +# """ +# result = { +# 'type': cap_type, +# 'interface': interface, +# 'version': version, +# } - def merge_context_properties(self, endpoint): - """Add all properties from given endpoint if not already set. +# if supports_deactivation is not None: +# result['supportsDeactivation'] = supports_deactivation - Handlers should be using .add_context_property(). - """ - properties = self._properties() - already_set = {(p['namespace'], p['name']) for p in properties} +# if retrievable is not None: +# result['retrievable'] = retrievable - for prop in endpoint.serialize_properties(): - if (prop['namespace'], prop['name']) not in already_set: - self.add_context_property(prop) +# if properties_supported is not None: +# result['properties'] = {'supported': properties_supported} - def serialize(self): - """Return response as a JSON-able data structure.""" - return self._response +# return result async def async_handle_message( @@ -1353,11 +69,11 @@ async def async_handle_message( if context is None: context = ha.Context() - directive = _AlexaDirective(request) + directive = AlexaDirective(request) try: if not enabled: - raise _AlexaBridgeUnreachableError( + raise AlexaBridgeUnreachableError( 'Alexa API not enabled in Home Assistant configuration') if directive.has_endpoint: @@ -1375,7 +91,7 @@ async def async_handle_message( directive.name, ) response = directive.error() - except _AlexaError as err: + except AlexaError as err: response = directive.error( error_type=err.error_type, error_message=err.error_message) @@ -1397,758 +113,3 @@ async def async_handle_message( }, context=context) return response.serialize() - - -async def async_send_changereport_message(hass, config, alexa_entity): - """Send a ChangeReport message for an Alexa entity.""" - token = await config.async_get_access_token() - if not token: - _LOGGER.error("Invalid access token.") - return - - headers = { - "Authorization": "Bearer {}".format(token) - } - - endpoint = alexa_entity.entity_id() - - # this sends all the properties of the Alexa Entity, whether they have - # changed or not. this should be improved, and properties that have not - # changed should be moved to the 'context' object - properties = list(alexa_entity.serialize_properties()) - - payload = { - API_CHANGE: { - 'cause': {'type': _Cause.APP_INTERACTION}, - 'properties': properties - } - } - - message = _AlexaResponse(name='ChangeReport', namespace='Alexa', - payload=payload) - message.set_endpoint_full(token, endpoint) - - message_serialized = message.serialize() - - try: - session = aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(DEFAULT_TIMEOUT): - response = await session.post(config.endpoint, - headers=headers, - json=message_serialized, - allow_redirects=True) - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Timeout calling LWA to get auth token.") - return None - - response_text = await response.text() - - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) - - if response.status != 202: - response_json = json.loads(response_text) - _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", - response_json["payload"]["code"], - response_json["payload"]["description"]) - - -@HANDLERS.register(('Alexa.Discovery', 'Discover')) -async def async_api_discovery(hass, config, directive, context): - """Create a API formatted discovery response. - - Async friendly. - """ - discovery_endpoints = [] - - for entity in hass.states.async_all(): - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - _LOGGER.debug("Not exposing %s because it is never exposed", - entity.entity_id) - continue - - if not config.should_expose(entity.entity_id): - _LOGGER.debug("Not exposing %s because filtered by config", - entity.entity_id) - continue - - if entity.domain not in ENTITY_ADAPTERS: - continue - alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity) - - endpoint = { - 'displayCategories': alexa_entity.display_categories(), - 'cookie': {}, - 'endpointId': alexa_entity.entity_id(), - 'friendlyName': alexa_entity.friendly_name(), - 'description': alexa_entity.description(), - 'manufacturerName': 'Home Assistant', - } - - endpoint['capabilities'] = [ - i.serialize_discovery() for i in alexa_entity.interfaces()] - - if not endpoint['capabilities']: - _LOGGER.debug( - "Not exposing %s because it has no capabilities", - entity.entity_id) - continue - discovery_endpoints.append(endpoint) - - return directive.response( - name='Discover.Response', - namespace='Alexa.Discovery', - payload={'endpoints': discovery_endpoints}, - ) - - -@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant')) -async def async_api_accept_grant(hass, config, directive, context): - """Create a API formatted AcceptGrant response. - - Async friendly. - """ - auth_code = directive.payload['grant']['code'] - _LOGGER.debug("AcceptGrant code: %s", auth_code) - - if AUTH_KEY in hass.data: - await hass.data[AUTH_KEY].async_do_auth(auth_code) - await async_enable_proactive_mode(hass, config) - - return directive.response( - name='AcceptGrant.Response', - namespace='Alexa.Authorization', - payload={}) - - -@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) -async def async_api_turn_on(hass, config, directive, context): - """Process a turn on request.""" - entity = directive.entity - domain = entity.domain - if domain == group.DOMAIN: - domain = ha.DOMAIN - - service = SERVICE_TURN_ON - if domain == cover.DOMAIN: - service = cover.SERVICE_OPEN_COVER - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) -async def async_api_turn_off(hass, config, directive, context): - """Process a turn off request.""" - entity = directive.entity - domain = entity.domain - if entity.domain == group.DOMAIN: - domain = ha.DOMAIN - - service = SERVICE_TURN_OFF - if entity.domain == cover.DOMAIN: - service = cover.SERVICE_CLOSE_COVER - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) -async def async_api_set_brightness(hass, config, directive, context): - """Process a set brightness request.""" - entity = directive.entity - brightness = int(directive.payload['brightness']) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS_PCT: brightness, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) -async def async_api_adjust_brightness(hass, config, directive, context): - """Process an adjust brightness request.""" - entity = directive.entity - brightness_delta = int(directive.payload['brightnessDelta']) - - # read current state - try: - current = math.floor( - int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100) - except ZeroDivisionError: - current = 0 - - # set brightness - brightness = max(0, brightness_delta + current) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS_PCT: brightness, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.ColorController', 'SetColor')) -async def async_api_set_color(hass, config, directive, context): - """Process a set color request.""" - entity = directive.entity - rgb = color_util.color_hsb_to_RGB( - float(directive.payload['color']['hue']), - float(directive.payload['color']['saturation']), - float(directive.payload['color']['brightness']) - ) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_RGB_COLOR: rgb, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) -async def async_api_set_color_temperature(hass, config, directive, context): - """Process a set color temperature request.""" - entity = directive.entity - kelvin = int(directive.payload['colorTemperatureInKelvin']) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_KELVIN: kelvin, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register( - ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) -async def async_api_decrease_color_temp(hass, config, directive, context): - """Process a decrease color temperature request.""" - entity = directive.entity - current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) - max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) - - value = min(max_mireds, current + 50) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_COLOR_TEMP: value, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register( - ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) -async def async_api_increase_color_temp(hass, config, directive, context): - """Process an increase color temperature request.""" - entity = directive.entity - current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) - min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) - - value = max(min_mireds, current - 50) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_COLOR_TEMP: value, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.SceneController', 'Activate')) -async def async_api_activate(hass, config, directive, context): - """Process an activate request.""" - entity = directive.entity - domain = entity.domain - - await hass.services.async_call(domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - payload = { - 'cause': {'type': _Cause.VOICE_INTERACTION}, - 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) - } - - return directive.response( - name='ActivationStarted', - namespace='Alexa.SceneController', - payload=payload, - ) - - -@HANDLERS.register(('Alexa.SceneController', 'Deactivate')) -async def async_api_deactivate(hass, config, directive, context): - """Process a deactivate request.""" - entity = directive.entity - domain = entity.domain - - await hass.services.async_call(domain, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - payload = { - 'cause': {'type': _Cause.VOICE_INTERACTION}, - 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) - } - - return directive.response( - name='DeactivationStarted', - namespace='Alexa.SceneController', - payload=payload, - ) - - -@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) -async def async_api_set_percentage(hass, config, directive, context): - """Process a set percentage request.""" - entity = directive.entity - percentage = int(directive.payload['percentage']) - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_SPEED - speed = "off" - - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - elif percentage <= 100: - speed = "high" - data[fan.ATTR_SPEED] = speed - - elif entity.domain == cover.DOMAIN: - service = SERVICE_SET_COVER_POSITION - data[cover.ATTR_POSITION] = percentage - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) -async def async_api_adjust_percentage(hass, config, directive, context): - """Process an adjust percentage request.""" - entity = directive.entity - percentage_delta = int(directive.payload['percentageDelta']) - service = None - data = {ATTR_ENTITY_ID: entity.entity_id} - - if entity.domain == fan.DOMAIN: - service = fan.SERVICE_SET_SPEED - speed = entity.attributes.get(fan.ATTR_SPEED) - - if speed == "off": - current = 0 - elif speed == "low": - current = 33 - elif speed == "medium": - current = 66 - elif speed == "high": - current = 100 - - # set percentage - percentage = max(0, percentage_delta + current) - speed = "off" - - if percentage <= 33: - speed = "low" - elif percentage <= 66: - speed = "medium" - elif percentage <= 100: - speed = "high" - - data[fan.ATTR_SPEED] = speed - - elif entity.domain == cover.DOMAIN: - service = SERVICE_SET_COVER_POSITION - - current = entity.attributes.get(cover.ATTR_POSITION) - - data[cover.ATTR_POSITION] = max(0, percentage_delta + current) - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.LockController', 'Lock')) -async def async_api_lock(hass, config, directive, context): - """Process a lock request.""" - entity = directive.entity - await hass.services.async_call(entity.domain, SERVICE_LOCK, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - response = directive.response() - response.add_context_property({ - 'name': 'lockState', - 'namespace': 'Alexa.LockController', - 'value': 'LOCKED' - }) - return response - - -# Not supported by Alexa yet -@HANDLERS.register(('Alexa.LockController', 'Unlock')) -async def async_api_unlock(hass, config, directive, context): - """Process an unlock request.""" - entity = directive.entity - await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) -async def async_api_set_volume(hass, config, directive, context): - """Process a set volume request.""" - volume = round(float(directive.payload['volume'] / 100), 2) - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_SET, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.InputController', 'SelectInput')) -async def async_api_select_input(hass, config, directive, context): - """Process a set input request.""" - media_input = directive.payload['input'] - entity = directive.entity - - # attempt to map the ALL UPPERCASE payload name to a source - source_list = entity.attributes[ - media_player.const.ATTR_INPUT_SOURCE_LIST] or [] - for source in source_list: - # response will always be space separated, so format the source in the - # most likely way to find a match - formatted_source = source.lower().replace('-', ' ').replace('_', ' ') - if formatted_source in media_input.lower(): - media_input = source - break - else: - msg = 'failed to map input {} to a media source on {}'.format( - media_input, entity.entity_id) - raise _AlexaInvalidValueError(msg) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_INPUT_SOURCE: media_input, - } - - await hass.services.async_call( - entity.domain, media_player.SERVICE_SELECT_SOURCE, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) -async def async_api_adjust_volume(hass, config, directive, context): - """Process an adjust volume request.""" - volume_delta = int(directive.payload['volume']) - - entity = directive.entity - current_level = entity.attributes.get( - media_player.const.ATTR_MEDIA_VOLUME_LEVEL) - - # read current state - try: - current = math.floor(int(current_level * 100)) - except ZeroDivisionError: - current = 0 - - volume = float(max(0, volume_delta + current) / 100) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_SET, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) -async def async_api_adjust_volume_step(hass, config, directive, context): - """Process an adjust volume step request.""" - # media_player volume up/down service does not support specifying steps - # each component handles it differently e.g. via config. - # For now we use the volumeSteps returned to figure out if we - # should step up/down - volume_step = directive.payload['volumeSteps'] - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - } - - if volume_step > 0: - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_UP, - data, blocking=False, context=context) - elif volume_step < 0: - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_DOWN, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) -@HANDLERS.register(('Alexa.Speaker', 'SetMute')) -async def async_api_set_mute(hass, config, directive, context): - """Process a set mute request.""" - mute = bool(directive.payload['mute']) - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_MUTE, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Play')) -async def async_api_play(hass, config, directive, context): - """Process a play request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PLAY, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) -async def async_api_pause(hass, config, directive, context): - """Process a pause request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PAUSE, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) -async def async_api_stop(hass, config, directive, context): - """Process a stop request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_STOP, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Next')) -async def async_api_next(hass, config, directive, context): - """Process a next request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_NEXT_TRACK, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) -async def async_api_previous(hass, config, directive, context): - """Process a previous request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, - data, blocking=False, context=context) - - return directive.response() - - -def temperature_from_object(hass, temp_obj, interval=False): - """Get temperature from Temperature object in requested unit.""" - to_unit = hass.config.units.temperature_unit - from_unit = TEMP_CELSIUS - temp = float(temp_obj['value']) - - if temp_obj['scale'] == 'FAHRENHEIT': - from_unit = TEMP_FAHRENHEIT - elif temp_obj['scale'] == 'KELVIN': - # convert to Celsius if absolute temperature - if not interval: - temp -= 273.15 - - return convert_temperature(temp, from_unit, to_unit, interval) - - -@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature')) -async def async_api_set_target_temp(hass, config, directive, context): - """Process a set target temperature request.""" - entity = directive.entity - min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) - max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) - unit = hass.config.units.temperature_unit - - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - payload = directive.payload - response = directive.response() - if 'targetSetpoint' in payload: - temp = temperature_from_object(hass, payload['targetSetpoint']) - if temp < min_temp or temp > max_temp: - raise _AlexaTempRangeError(hass, temp, min_temp, max_temp) - data[ATTR_TEMPERATURE] = temp - response.add_context_property({ - 'name': 'targetSetpoint', - 'namespace': 'Alexa.ThermostatController', - 'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]}, - }) - if 'lowerSetpoint' in payload: - temp_low = temperature_from_object(hass, payload['lowerSetpoint']) - if temp_low < min_temp or temp_low > max_temp: - raise _AlexaTempRangeError(hass, temp_low, min_temp, max_temp) - data[climate.ATTR_TARGET_TEMP_LOW] = temp_low - response.add_context_property({ - 'name': 'lowerSetpoint', - 'namespace': 'Alexa.ThermostatController', - 'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]}, - }) - if 'upperSetpoint' in payload: - temp_high = temperature_from_object(hass, payload['upperSetpoint']) - if temp_high < min_temp or temp_high > max_temp: - raise _AlexaTempRangeError(hass, temp_high, min_temp, max_temp) - data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high - response.add_context_property({ - 'name': 'upperSetpoint', - 'namespace': 'Alexa.ThermostatController', - 'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]}, - }) - - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, - context=context) - - return response - - -@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) -async def async_api_adjust_target_temp(hass, config, directive, context): - """Process an adjust target temperature request.""" - entity = directive.entity - min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) - max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) - unit = hass.config.units.temperature_unit - - temp_delta = temperature_from_object( - hass, directive.payload['targetSetpointDelta'], interval=True) - target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta - - if target_temp < min_temp or target_temp > max_temp: - raise _AlexaTempRangeError(hass, target_temp, min_temp, max_temp) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - ATTR_TEMPERATURE: target_temp, - } - - response = directive.response() - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, - context=context) - response.add_context_property({ - 'name': 'targetSetpoint', - 'namespace': 'Alexa.ThermostatController', - 'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]}, - }) - - return response - - -@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) -async def async_api_set_thermostat_mode(hass, config, directive, context): - """Process a set thermostat mode request.""" - entity = directive.entity - mode = directive.payload['thermostatMode'] - mode = mode if isinstance(mode, str) else mode['value'] - - operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) - ha_mode = next( - (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), - None - ) - if ha_mode not in operation_list: - msg = 'The requested thermostat mode {} is not supported'.format(mode) - raise _AlexaUnsupportedThermostatModeError(msg) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - climate.ATTR_OPERATION_MODE: ha_mode, - } - - response = directive.response() - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, - blocking=False, context=context) - response.add_context_property({ - 'name': 'thermostatMode', - 'namespace': 'Alexa.ThermostatController', - 'value': mode, - }) - - return response - - -@HANDLERS.register(('Alexa', 'ReportState')) -async def async_api_reportstate(hass, config, directive, context): - """Process a ReportState request.""" - return directive.response(name='StateReport') diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py new file mode 100644 index 00000000000000..cb70fb8625301e --- /dev/null +++ b/homeassistant/components/alexa/smart_home_http.py @@ -0,0 +1,81 @@ +"""Alexa HTTP interface.""" +import logging + +from homeassistant import core +from homeassistant.components.http.view import HomeAssistantView + +from .auth import Auth +from .config import Config +from .const import ( + AUTH_KEY, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER +) +from .state_report import async_enable_proactive_mode +from .smart_home import async_handle_message + +_LOGGER = logging.getLogger(__name__) +SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' + + +async def async_setup(hass, config): + """Activate Smart Home functionality of Alexa component. + + This is optional, triggered by having a `smart_home:` sub-section in the + alexa configuration. + + Even if that's disabled, the functionality in this module may still be used + by the cloud component which will call async_handle_message directly. + """ + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET]) + + async_get_access_token = \ + hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ + else None + + smart_home_config = Config( + endpoint=config.get(CONF_ENDPOINT), + async_get_access_token=async_get_access_token, + should_expose=config[CONF_FILTER], + entity_config=config.get(CONF_ENTITY_CONFIG), + ) + hass.http.register_view(SmartHomeView(smart_home_config)) + + if AUTH_KEY in hass.data: + await async_enable_proactive_mode(hass, smart_home_config) + + +class SmartHomeView(HomeAssistantView): + """Expose Smart Home v3 payload interface via HTTP POST.""" + + url = SMART_HOME_HTTP_ENDPOINT + name = 'api:alexa:smart_home' + + def __init__(self, smart_home_config): + """Initialize.""" + self.smart_home_config = smart_home_config + + async def post(self, request): + """Handle Alexa Smart Home requests. + + The Smart Home API requires the endpoint to be implemented in AWS + Lambda, which will need to forward the requests to here and pass back + the response. + """ + hass = request.app['hass'] + user = request['hass_user'] + message = await request.json() + + _LOGGER.debug("Received Alexa Smart Home request: %s", message) + + response = await async_handle_message( + hass, self.smart_home_config, message, + context=core.Context(user_id=user.id) + ) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b'' if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py new file mode 100644 index 00000000000000..568502fb6bfe62 --- /dev/null +++ b/homeassistant/components/alexa/state_report.py @@ -0,0 +1,109 @@ +"""Alexa state report code.""" +import asyncio +import json +import logging + +import aiohttp +import async_timeout + +from homeassistant.const import MATCH_ALL + +from .const import API_CHANGE, Cause +from .entities import ENTITY_ADAPTERS +from .messages import AlexaResponse + +_LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEOUT = 10 + + +async def async_enable_proactive_mode(hass, smart_home_config): + """Enable the proactive mode. + + Proactive mode makes this component report state changes to Alexa. + """ + if smart_home_config.async_get_access_token is None: + # no function to call to get token + return + + if await smart_home_config.async_get_access_token() is None: + # not ready yet + return + + async def async_entity_state_listener(changed_entity, old_state, + new_state): + if not smart_home_config.should_expose(changed_entity): + _LOGGER.debug("Not exposing %s because filtered by config", + changed_entity) + return + + if new_state.domain not in ENTITY_ADAPTERS: + return + + alexa_changed_entity = \ + ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config, + new_state) + + for interface in alexa_changed_entity.interfaces(): + if interface.properties_proactively_reported(): + await async_send_changereport_message(hass, smart_home_config, + alexa_changed_entity) + return + + hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) + + +async def async_send_changereport_message(hass, config, alexa_entity): + """Send a ChangeReport message for an Alexa entity.""" + token = await config.async_get_access_token() + if not token: + _LOGGER.error("Invalid access token.") + return + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoint = alexa_entity.alexa_id() + + # this sends all the properties of the Alexa Entity, whether they have + # changed or not. this should be improved, and properties that have not + # changed should be moved to the 'context' object + properties = list(alexa_entity.serialize_properties()) + + payload = { + API_CHANGE: { + 'cause': {'type': Cause.APP_INTERACTION}, + 'properties': properties + } + } + + message = AlexaResponse(name='ChangeReport', namespace='Alexa', + payload=payload) + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + + try: + session = hass.helpers.aiohttp_client.async_get_clientsession() + with async_timeout.timeout(DEFAULT_TIMEOUT): + response = await session.post(config.endpoint, + headers=headers, + json=message_serialized, + allow_redirects=True) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout calling LWA to get auth token.") + return None + + response_text = await response.text() + + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) + + if response.status != 202: + response_json = json.loads(response_text) + _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"]) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d4d443a692d51a..5490a0da156db6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import const as alexa_const from homeassistant.components.google_assistant import const as ga_c from homeassistant.const import ( CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, @@ -33,9 +33,9 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ - vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, - vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, - vol.Optional(alexa_sh.CONF_NAME): cv.string, + vol.Optional(alexa_const.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_const.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, }) GOOGLE_ENTITY_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index eadb1731bd021a..f5edefeee432d1 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -7,7 +7,10 @@ from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import ( + config as alexa_config, + smart_home as alexa_sh, +) from homeassistant.components.google_assistant import ( helpers as ga_h, smart_home as ga) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES @@ -28,12 +31,12 @@ class CloudClient(Interface): def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, websession: aiohttp.ClientSession, - alexa_config: Dict[str, Any], google_config: Dict[str, Any]): + alexa_cfg: Dict[str, Any], google_config: Dict[str, Any]): """Initialize client interface to Cloud.""" self._hass = hass self._prefs = prefs self._websession = websession - self._alexa_user_config = alexa_config + self._alexa_user_config = alexa_cfg self._google_user_config = google_config self._alexa_config = None @@ -75,12 +78,12 @@ def remote_autostart(self) -> bool: return self._prefs.remote_enabled @property - def alexa_config(self) -> alexa_sh.Config: + def alexa_config(self) -> alexa_config.Config: """Return Alexa config.""" if not self._alexa_config: alexa_conf = self._alexa_user_config - self._alexa_config = alexa_sh.Config( + self._alexa_config = alexa_config.Config( endpoint=None, async_get_access_token=None, should_expose=alexa_conf[CONF_FILTER], diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 9908268b252556..9c167d256018a2 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -13,7 +13,7 @@ from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import entities as alexa_entities from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( @@ -421,7 +421,7 @@ def _account_data(cloud): 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, 'alexa_entities': client.alexa_config.should_expose.config, - 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), + 'alexa_domains': list(alexa_entities.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, 'remote_connected': remote.is_connected, 'remote_certificate': certificate, @@ -497,7 +497,7 @@ async def google_assistant_list(hass, connection, msg): vol.Optional('disable_2fa'): bool, }) async def google_assistant_update(hass, connection, msg): - """List all google assistant entities.""" + """Update google assistant config.""" cloud = hass.data[DOMAIN] changes = dict(msg) changes.pop('type') diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 88ecc63d200198..b1c8c6aa8bdddb 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1 +1,179 @@ """Tests for the Alexa integration.""" +from uuid import uuid4 + +from homeassistant.core import Context +from homeassistant.components.alexa import config, smart_home + +from tests.common import async_mock_service + +TEST_URL = "https://api.amazonalexa.com/v3/events" +TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" + + +async def get_access_token(): + """Return a test access token.""" + return "thisisnotanacesstoken" + + +DEFAULT_CONFIG = config.Config( + endpoint=TEST_URL, + async_get_access_token=get_access_token, + should_expose=lambda entity_id: True) + + +def get_new_request(namespace, name, endpoint=None): + """Generate a new API message.""" + raw_msg = { + 'directive': { + 'header': { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'correlationToken': str(uuid4()), + 'payloadVersion': '3', + }, + 'endpoint': { + 'scope': { + 'type': 'BearerToken', + 'token': str(uuid4()), + }, + 'endpointId': endpoint, + }, + 'payload': {}, + } + } + + if not endpoint: + raw_msg['directive'].pop('endpoint') + + return raw_msg + + +async def assert_request_calls_service( + namespace, + name, + endpoint, + service, + hass, + response_type='Response', + payload=None): + """Assert an API request calls a hass service.""" + context = Context() + request = get_new_request(namespace, name, endpoint) + if payload: + request['directive']['payload'] = payload + + domain, service_name = service.split('.') + calls = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request, context) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert 'event' in msg + assert call.data['entity_id'] == endpoint.replace('#', '.') + assert msg['event']['header']['name'] == response_type + assert call.context == context + + return call, msg + + +async def assert_request_fails( + namespace, + name, + endpoint, + service_not_called, + hass, + payload=None): + """Assert an API request returns an ErrorResponse.""" + request = get_new_request(namespace, name, endpoint) + if payload: + request['directive']['payload'] = payload + + domain, service_name = service_not_called.split('.') + call = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert not call + assert 'event' in msg + assert msg['event']['header']['name'] == 'ErrorResponse' + + return msg + + +async def assert_power_controller_works( + endpoint, + on_service, + off_service, + hass +): + """Assert PowerController API requests work.""" + await assert_request_calls_service( + 'Alexa.PowerController', 'TurnOn', endpoint, + on_service, hass) + + await assert_request_calls_service( + 'Alexa.PowerController', 'TurnOff', endpoint, + off_service, hass) + + +async def assert_scene_controller_works( + endpoint, + activate_service, + deactivate_service, + hass): + """Assert SceneController API requests work.""" + _, response = await assert_request_calls_service( + 'Alexa.SceneController', 'Activate', endpoint, + activate_service, hass, + response_type='ActivationStarted') + assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION' + assert 'timestamp' in response['event']['payload'] + + if deactivate_service: + await assert_request_calls_service( + 'Alexa.SceneController', 'Deactivate', endpoint, + deactivate_service, hass, + response_type='DeactivationStarted') + cause_type = response['event']['payload']['cause']['type'] + assert cause_type == 'VOICE_INTERACTION' + assert 'timestamp' in response['event']['payload'] + + +async def reported_properties(hass, endpoint): + """Use ReportState to get properties and return them. + + The result is a ReportedProperties instance, which has methods to make + assertions about the properties. + """ + request = get_new_request('Alexa', 'ReportState', endpoint) + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + return ReportedProperties(msg['context']['properties']) + + +class ReportedProperties: + """Class to help assert reported properties.""" + + def __init__(self, properties): + """Initialize class.""" + self.properties = properties + + def assert_equal(self, namespace, name, value): + """Assert a property is equal to a given value.""" + for prop in self.properties: + if prop['namespace'] == namespace and prop['name'] == name: + assert prop['value'] == value + return prop + + assert False, 'property %s:%s not in %r' % ( + namespace, + name, + self.properties, + ) diff --git a/tests/components/alexa/test_auth.py b/tests/components/alexa/test_auth.py new file mode 100644 index 00000000000000..aefb5e82225c26 --- /dev/null +++ b/tests/components/alexa/test_auth.py @@ -0,0 +1,67 @@ +"""Test Alexa auth endpoints.""" +from homeassistant.components.alexa.auth import Auth +from . import TEST_TOKEN_URL + + +async def run_auth_get_access_token(hass, aioclient_mock, expires_in, + client_id, client_secret, + accept_grant_code, refresh_token): + """Do auth and request a new token for tests.""" + aioclient_mock.post(TEST_TOKEN_URL, + json={'access_token': 'the_access_token', + 'refresh_token': refresh_token, + 'expires_in': expires_in}) + + auth = Auth(hass, client_id, client_secret) + await auth.async_do_auth(accept_grant_code) + await auth.async_get_access_token() + + +async def test_auth_get_access_token_expired(hass, aioclient_mock): + """Test the auth get access token function.""" + client_id = "client123" + client_secret = "shhhhh" + accept_grant_code = "abcdefg" + refresh_token = "refresher" + + await run_auth_get_access_token(hass, aioclient_mock, -5, + client_id, client_secret, + accept_grant_code, refresh_token) + + assert len(aioclient_mock.mock_calls) == 2 + calls = aioclient_mock.mock_calls + + auth_call_json = calls[0][2] + token_call_json = calls[1][2] + + assert auth_call_json["grant_type"] == "authorization_code" + assert auth_call_json["code"] == accept_grant_code + assert auth_call_json["client_id"] == client_id + assert auth_call_json["client_secret"] == client_secret + + assert token_call_json["grant_type"] == "refresh_token" + assert token_call_json["refresh_token"] == refresh_token + assert token_call_json["client_id"] == client_id + assert token_call_json["client_secret"] == client_secret + + +async def test_auth_get_access_token_not_expired(hass, aioclient_mock): + """Test the auth get access token function.""" + client_id = "client123" + client_secret = "shhhhh" + accept_grant_code = "abcdefg" + refresh_token = "refresher" + + await run_auth_get_access_token(hass, aioclient_mock, 555, + client_id, client_secret, + accept_grant_code, refresh_token) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + auth_call_json = call[0][2] + + assert auth_call_json["grant_type"] == "authorization_code" + assert auth_call_json["code"] == accept_grant_code + assert auth_call_json["client_id"] == client_id + assert auth_call_json["client_secret"] == client_secret diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py new file mode 100644 index 00000000000000..c47dae6d3a3d6e --- /dev/null +++ b/tests/components/alexa/test_capabilities.py @@ -0,0 +1,340 @@ +"""Test Alexa capabilities.""" +import pytest + +from homeassistant.const import ( + STATE_LOCKED, + STATE_UNLOCKED, + STATE_UNKNOWN, +) +from homeassistant.components.alexa import smart_home +from tests.common import async_mock_service + +from . import ( + DEFAULT_CONFIG, + get_new_request, + assert_request_calls_service, + assert_request_fails, + reported_properties, +) + + +@pytest.mark.parametrize( + "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) +async def test_api_adjust_brightness(hass, result, adjust): + """Test api adjust brightness process.""" + request = get_new_request( + 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') + + # add payload + request['directive']['payload']['brightnessDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'brightness': '77' + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['brightness_pct'] == result + assert msg['header']['name'] == 'Response' + + +async def test_api_set_color_rgb(hass): + """Test api set color process.""" + request = get_new_request( + 'Alexa.ColorController', 'SetColor', 'light#test') + + # add payload + request['directive']['payload']['color'] = { + 'hue': '120', + 'saturation': '0.612', + 'brightness': '0.342', + } + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", + 'supported_features': 16, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['rgb_color'] == (33, 87, 33) + assert msg['header']['name'] == 'Response' + + +async def test_api_set_color_temperature(hass): + """Test api set color temperature process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'SetColorTemperature', + 'light#test') + + # add payload + request['directive']['payload']['colorTemperatureInKelvin'] = '7500' + + # setup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['kelvin'] == 7500 + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) +async def test_api_decrease_color_temp(hass, result, initial): + """Test api decrease color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', + 'light#test') + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'max_mireds': 500, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) +async def test_api_increase_color_temp(hass, result, initial): + """Test api increase color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', + 'light#test') + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'min_mireds': 142, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize( + "domain,payload,source_list,idx", [ + ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1), + ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0), + ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0), + ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None), + ] +) +async def test_api_select_input(hass, domain, payload, source_list, idx): + """Test api set input process.""" + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", + 'source': 'unknown', + 'source_list': source_list, + }) + + # test where no source matches + if idx is None: + await assert_request_fails( + 'Alexa.InputController', 'SelectInput', 'media_player#test', + 'media_player.select_source', + hass, + payload={'input': payload}) + return + + call, _ = await assert_request_calls_service( + 'Alexa.InputController', 'SelectInput', 'media_player#test', + 'media_player.select_source', + hass, + payload={'input': payload}) + assert call.data['source'] == source_list[idx] + + +async def test_report_lock_state(hass): + """Test LockController implements lockState property.""" + hass.states.async_set( + 'lock.locked', STATE_LOCKED, {}) + hass.states.async_set( + 'lock.unlocked', STATE_UNLOCKED, {}) + hass.states.async_set( + 'lock.unknown', STATE_UNKNOWN, {}) + + properties = await reported_properties(hass, 'lock.locked') + properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED') + + properties = await reported_properties(hass, 'lock.unlocked') + properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED') + + properties = await reported_properties(hass, 'lock.unknown') + properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') + + +async def test_report_dimmable_light_state(hass): + """Test BrightnessController reports brightness correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'brightness': 128, 'supported_features': 1}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 1}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) + + +async def test_report_colored_light_state(hass): + """Test ColorController reports color correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'hs_color': (180, 75), + 'brightness': 128, + 'supported_features': 17}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 17}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 180, + 'saturation': 0.75, + 'brightness': 128 / 255.0, + }) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 0, + 'saturation': 0, + 'brightness': 0, + }) + + +async def test_report_colored_temp_light_state(hass): + """Test ColorTemperatureController reports color temp correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'color_temp': 240, + 'supported_features': 2}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 2}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 4166) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 0) + + +async def test_report_fan_speed_state(hass): + """Test PercentageController reports fan speed correctly.""" + hass.states.async_set( + 'fan.off', 'off', {'friendly_name': "Off fan", + 'speed': "off", + 'supported_features': 1}) + hass.states.async_set( + 'fan.low_speed', 'on', {'friendly_name': "Low speed fan", + 'speed': "low", + 'supported_features': 1}) + hass.states.async_set( + 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan", + 'speed': "medium", + 'supported_features': 1}) + hass.states.async_set( + 'fan.high_speed', 'on', {'friendly_name': "High speed fan", + 'speed': "high", + 'supported_features': 1}) + + properties = await reported_properties(hass, 'fan.off') + properties.assert_equal('Alexa.PercentageController', 'percentage', 0) + + properties = await reported_properties(hass, 'fan.low_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 33) + + properties = await reported_properties(hass, 'fan.medium_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 66) + + properties = await reported_properties(hass, 'fan.high_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 100) + + +async def test_report_cover_percentage_state(hass): + """Test PercentageController reports cover percentage correctly.""" + hass.states.async_set( + 'cover.fully_open', 'open', {'friendly_name': "Fully open cover", + 'current_position': 100, + 'supported_features': 15}) + hass.states.async_set( + 'cover.half_open', 'open', {'friendly_name': "Half open cover", + 'current_position': 50, + 'supported_features': 15}) + hass.states.async_set( + 'cover.closed', 'closed', {'friendly_name': "Closed cover", + 'current_position': 0, + 'supported_features': 15}) + + properties = await reported_properties(hass, 'cover.fully_open') + properties.assert_equal('Alexa.PercentageController', 'percentage', 100) + + properties = await reported_properties(hass, 'cover.half_open') + properties.assert_equal('Alexa.PercentageController', 'percentage', 50) + + properties = await reported_properties(hass, 'cover.closed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 0) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py new file mode 100644 index 00000000000000..a2193b09019f83 --- /dev/null +++ b/tests/components/alexa/test_entities.py @@ -0,0 +1,19 @@ +"""Test Alexa entity representation.""" +from homeassistant.components.alexa import smart_home +from . import get_new_request, DEFAULT_CONFIG + + +async def test_unsupported_domain(hass): + """Discovery ignores entities of unknown domains.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + hass.states.async_set( + 'woz.boop', 'on', {'friendly_name': "Boop Woz"}) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert not msg['payload']['endpoints'] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 20b4495cd1a053..da7063f8acd1be 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,34 +1,27 @@ """Test for smart home alexa support.""" -import json -from uuid import uuid4 - import pytest from homeassistant.core import Context, callback -from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_LOCKED, - STATE_UNLOCKED, STATE_UNKNOWN) -from homeassistant.setup import async_setup_component -from homeassistant.components import alexa -from homeassistant.components.alexa import smart_home -from homeassistant.components.alexa.auth import Auth +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.alexa import ( + config, + smart_home, + messages, +) from homeassistant.helpers import entityfilter from tests.common import async_mock_service - -async def get_access_token(): - """Return a test access token.""" - return "thisisnotanacesstoken" - - -TEST_URL = "https://api.amazonalexa.com/v3/events" -TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" - -DEFAULT_CONFIG = smart_home.Config( - endpoint=TEST_URL, - async_get_access_token=get_access_token, - should_expose=lambda entity_id: True) +from . import ( + get_new_request, + DEFAULT_CONFIG, + assert_request_calls_service, + assert_request_fails, + ReportedProperties, + assert_power_controller_works, + assert_scene_controller_works, + reported_properties, +) @pytest.fixture @@ -42,39 +35,11 @@ def events(hass): yield events -def get_new_request(namespace, name, endpoint=None): - """Generate a new API message.""" - raw_msg = { - 'directive': { - 'header': { - 'namespace': namespace, - 'name': name, - 'messageId': str(uuid4()), - 'correlationToken': str(uuid4()), - 'payloadVersion': '3', - }, - 'endpoint': { - 'scope': { - 'type': 'BearerToken', - 'token': str(uuid4()), - }, - 'endpointId': endpoint, - }, - 'payload': {}, - } - } - - if not endpoint: - raw_msg['directive'].pop('endpoint') - - return raw_msg - - def test_create_api_message_defaults(hass): """Create a API message response of a request with defaults.""" request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy') directive_header = request['directive']['header'] - directive = smart_home._AlexaDirective(request) + directive = messages.AlexaDirective(request) msg = directive.response(payload={'test': 3})._response @@ -101,7 +66,7 @@ def test_create_api_message_special(): request = get_new_request('Alexa.PowerController', 'TurnOn') directive_header = request['directive']['header'] directive_header.pop('correlationToken') - directive = smart_home._AlexaDirective(request) + directive = messages.AlexaDirective(request) msg = directive.response('testName', 'testNameSpace')._response @@ -901,7 +866,7 @@ async def test_thermostat(hass): payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}} ) assert call.data['temperature'] == 69.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 69.0, 'scale': 'FAHRENHEIT'}) @@ -927,7 +892,7 @@ async def test_thermostat(hass): assert call.data['temperature'] == 70.0 assert call.data['target_temp_low'] == 68.0 assert call.data['target_temp_high'] == 86.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 70.0, 'scale': 'FAHRENHEIT'}) @@ -967,7 +932,7 @@ async def test_thermostat(hass): payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}} ) assert call.data['temperature'] == 52.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 52.0, 'scale': 'FAHRENHEIT'}) @@ -988,7 +953,7 @@ async def test_thermostat(hass): payload={'thermostatMode': {'value': 'HEAT'}} ) assert call.data['operation_mode'] == 'heat' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'HEAT') @@ -999,7 +964,7 @@ async def test_thermostat(hass): payload={'thermostatMode': {'value': 'COOL'}} ) assert call.data['operation_mode'] == 'cool' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'COOL') @@ -1011,7 +976,7 @@ async def test_thermostat(hass): payload={'thermostatMode': 'HEAT'} ) assert call.data['operation_mode'] == 'heat' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'HEAT') @@ -1047,7 +1012,7 @@ async def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=entityfilter.generate_filter( @@ -1057,7 +1022,7 @@ async def test_exclude_filters(hass): exclude_entities=['cover.deny'], )) - msg = await smart_home.async_handle_message(hass, config, request) + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1082,7 +1047,7 @@ async def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=entityfilter.generate_filter( @@ -1092,7 +1057,7 @@ async def test_include_filters(hass): exclude_entities=[], )) - msg = await smart_home.async_handle_message(hass, config, request) + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1111,7 +1076,7 @@ async def test_never_exposed_entities(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=entityfilter.generate_filter( @@ -1121,7 +1086,7 @@ async def test_never_exposed_entities(hass): exclude_entities=[], )) - msg = await smart_home.async_handle_message(hass, config, request) + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1162,267 +1127,20 @@ async def test_api_function_not_implemented(hass): assert msg['payload']['type'] == 'INTERNAL_ERROR' -async def assert_request_fails( - namespace, - name, - endpoint, - service_not_called, - hass, - payload=None): - """Assert an API request returns an ErrorResponse.""" - request = get_new_request(namespace, name, endpoint) - if payload: - request['directive']['payload'] = payload - - domain, service_name = service_not_called.split('.') - call = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert not call - assert 'event' in msg - assert msg['event']['header']['name'] == 'ErrorResponse' - - return msg - - -async def assert_request_calls_service( - namespace, - name, - endpoint, - service, - hass, - response_type='Response', - payload=None): - """Assert an API request calls a hass service.""" - context = Context() - request = get_new_request(namespace, name, endpoint) - if payload: - request['directive']['payload'] = payload - - domain, service_name = service.split('.') - calls = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request, context) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert 'event' in msg - assert call.data['entity_id'] == endpoint.replace('#', '.') - assert msg['event']['header']['name'] == response_type - assert call.context == context - - return call, msg - - -async def assert_power_controller_works( - endpoint, - on_service, - off_service, - hass -): - """Assert PowerController API requests work.""" - await assert_request_calls_service( - 'Alexa.PowerController', 'TurnOn', endpoint, - on_service, hass) - - await assert_request_calls_service( - 'Alexa.PowerController', 'TurnOff', endpoint, - off_service, hass) - - -async def assert_scene_controller_works( - endpoint, - activate_service, - deactivate_service, - hass): - """Assert SceneController API requests work.""" - _, response = await assert_request_calls_service( - 'Alexa.SceneController', 'Activate', endpoint, - activate_service, hass, - response_type='ActivationStarted') - assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION' - assert 'timestamp' in response['event']['payload'] - - if deactivate_service: - await assert_request_calls_service( - 'Alexa.SceneController', 'Deactivate', endpoint, - deactivate_service, hass, - response_type='DeactivationStarted') - cause_type = response['event']['payload']['cause']['type'] - assert cause_type == 'VOICE_INTERACTION' - assert 'timestamp' in response['event']['payload'] - - -@pytest.mark.parametrize( - "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) -async def test_api_adjust_brightness(hass, result, adjust): - """Test api adjust brightness process.""" - request = get_new_request( - 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') - - # add payload - request['directive']['payload']['brightnessDelta'] = adjust - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'brightness': '77' - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['brightness_pct'] == result - assert msg['header']['name'] == 'Response' - - -async def test_api_set_color_rgb(hass): - """Test api set color process.""" - request = get_new_request( - 'Alexa.ColorController', 'SetColor', 'light#test') - - # add payload - request['directive']['payload']['color'] = { - 'hue': '120', - 'saturation': '0.612', - 'brightness': '0.342', - } - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", - 'supported_features': 16, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['rgb_color'] == (33, 87, 33) - assert msg['header']['name'] == 'Response' - - -async def test_api_set_color_temperature(hass): - """Test api set color temperature process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'SetColorTemperature', - 'light#test') - - # add payload - request['directive']['payload']['colorTemperatureInKelvin'] = '7500' - - # setup test devices - hass.states.async_set( - 'light.test', 'off', {'friendly_name': "Test light"}) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['kelvin'] == 7500 - assert msg['header']['name'] == 'Response' - - -@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) -async def test_api_decrease_color_temp(hass, result, initial): - """Test api decrease color temp process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', - 'light#test') - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'color_temp': initial, - 'max_mireds': 500, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['color_temp'] == result - assert msg['header']['name'] == 'Response' - - -@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) -async def test_api_increase_color_temp(hass, result, initial): - """Test api increase color temp process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', - 'light#test') - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'color_temp': initial, - 'min_mireds': 142, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['color_temp'] == result - assert msg['header']['name'] == 'Response' - - async def test_api_accept_grant(hass): """Test api AcceptGrant process.""" request = get_new_request("Alexa.Authorization", "AcceptGrant") # add payload request['directive']['payload'] = { - 'grant': { - 'type': 'OAuth2.AuthorizationCode', - 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ==' - }, - 'grantee': { - 'type': 'BearerToken', - 'token': 'access-token-from-skill' - } + 'grant': { + 'type': 'OAuth2.AuthorizationCode', + 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ==' + }, + 'grantee': { + 'type': 'BearerToken', + 'token': 'access-token-from-skill' + } } # setup test devices @@ -1436,174 +1154,6 @@ async def test_api_accept_grant(hass): assert msg['header']['name'] == 'AcceptGrant.Response' -async def test_report_lock_state(hass): - """Test LockController implements lockState property.""" - hass.states.async_set( - 'lock.locked', STATE_LOCKED, {}) - hass.states.async_set( - 'lock.unlocked', STATE_UNLOCKED, {}) - hass.states.async_set( - 'lock.unknown', STATE_UNKNOWN, {}) - - properties = await reported_properties(hass, 'lock.locked') - properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED') - - properties = await reported_properties(hass, 'lock.unlocked') - properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED') - - properties = await reported_properties(hass, 'lock.unknown') - properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') - - -async def test_report_dimmable_light_state(hass): - """Test BrightnessController reports brightness correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'brightness': 128, 'supported_features': 1}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 1}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) - - -async def test_report_colored_light_state(hass): - """Test ColorController reports color correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'hs_color': (180, 75), - 'brightness': 128, - 'supported_features': 17}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 17}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.ColorController', 'color', { - 'hue': 180, - 'saturation': 0.75, - 'brightness': 128 / 255.0, - }) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.ColorController', 'color', { - 'hue': 0, - 'saturation': 0, - 'brightness': 0, - }) - - -async def test_report_colored_temp_light_state(hass): - """Test ColorTemperatureController reports color temp correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'color_temp': 240, - 'supported_features': 2}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 2}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.ColorTemperatureController', - 'colorTemperatureInKelvin', 4166) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.ColorTemperatureController', - 'colorTemperatureInKelvin', 0) - - -async def test_report_fan_speed_state(hass): - """Test PercentageController reports fan speed correctly.""" - hass.states.async_set( - 'fan.off', 'off', {'friendly_name': "Off fan", - 'speed': "off", - 'supported_features': 1}) - hass.states.async_set( - 'fan.low_speed', 'on', {'friendly_name': "Low speed fan", - 'speed': "low", - 'supported_features': 1}) - hass.states.async_set( - 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan", - 'speed': "medium", - 'supported_features': 1}) - hass.states.async_set( - 'fan.high_speed', 'on', {'friendly_name': "High speed fan", - 'speed': "high", - 'supported_features': 1}) - - properties = await reported_properties(hass, 'fan.off') - properties.assert_equal('Alexa.PercentageController', 'percentage', 0) - - properties = await reported_properties(hass, 'fan.low_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 33) - - properties = await reported_properties(hass, 'fan.medium_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 66) - - properties = await reported_properties(hass, 'fan.high_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 100) - - -async def test_report_cover_percentage_state(hass): - """Test PercentageController reports cover percentage correctly.""" - hass.states.async_set( - 'cover.fully_open', 'open', {'friendly_name': "Fully open cover", - 'current_position': 100, - 'supported_features': 15}) - hass.states.async_set( - 'cover.half_open', 'open', {'friendly_name': "Half open cover", - 'current_position': 50, - 'supported_features': 15}) - hass.states.async_set( - 'cover.closed', 'closed', {'friendly_name': "Closed cover", - 'current_position': 0, - 'supported_features': 15}) - - properties = await reported_properties(hass, 'cover.fully_open') - properties.assert_equal('Alexa.PercentageController', 'percentage', 100) - - properties = await reported_properties(hass, 'cover.half_open') - properties.assert_equal('Alexa.PercentageController', 'percentage', 50) - - properties = await reported_properties(hass, 'cover.closed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 0) - - -async def reported_properties(hass, endpoint): - """Use ReportState to get properties and return them. - - The result is a _ReportedProperties instance, which has methods to make - assertions about the properties. - """ - request = get_new_request('Alexa', 'ReportState', endpoint) - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - return _ReportedProperties(msg['context']['properties']) - - -class _ReportedProperties: - def __init__(self, properties): - self.properties = properties - - def assert_equal(self, namespace, name, value): - """Assert a property is equal to a given value.""" - for prop in self.properties: - if prop['namespace'] == namespace and prop['name'] == name: - assert prop['value'] == value - return prop - - assert False, 'property %s:%s not in %r' % ( - namespace, - name, - self.properties, - ) - - async def test_entity_config(hass): """Test that we can configure things via entity config.""" request = get_new_request('Alexa.Discovery', 'Discover') @@ -1611,7 +1161,7 @@ async def test_entity_config(hass): hass.states.async_set( 'light.test_1', 'on', {'friendly_name': "Test light 1"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=lambda entity_id: True, @@ -1625,7 +1175,7 @@ async def test_entity_config(hass): ) msg = await smart_home.async_handle_message( - hass, config, request) + hass, alexa_config, request) assert 'event' in msg msg = msg['event'] @@ -1644,95 +1194,6 @@ async def test_entity_config(hass): ) -async def test_unsupported_domain(hass): - """Discovery ignores entities of unknown domains.""" - request = get_new_request('Alexa.Discovery', 'Discover') - - hass.states.async_set( - 'woz.boop', 'on', {'friendly_name': "Boop Woz"}) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - - assert 'event' in msg - msg = msg['event'] - - assert not msg['payload']['endpoints'] - - -async def do_http_discovery(config, hass, hass_client): - """Submit a request to the Smart Home HTTP API.""" - await async_setup_component(hass, alexa.DOMAIN, config) - http_client = await hass_client() - - request = get_new_request('Alexa.Discovery', 'Discover') - response = await http_client.post( - smart_home.SMART_HOME_HTTP_ENDPOINT, - data=json.dumps(request), - headers={'content-type': 'application/json'}) - return response - - -async def test_http_api(hass, hass_client): - """With `smart_home:` HTTP API is exposed.""" - config = { - 'alexa': { - 'smart_home': None - } - } - - response = await do_http_discovery(config, hass, hass_client) - response_data = await response.json() - - # Here we're testing just the HTTP view glue -- details of discovery are - # covered in other tests. - assert response_data['event']['header']['name'] == 'Discover.Response' - - -async def test_http_api_disabled(hass, hass_client): - """Without `smart_home:`, the HTTP API is disabled.""" - config = { - 'alexa': {} - } - response = await do_http_discovery(config, hass, hass_client) - - assert response.status == 404 - - -@pytest.mark.parametrize( - "domain,payload,source_list,idx", [ - ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1), - ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0), - ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0), - ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None), - ] -) -async def test_api_select_input(hass, domain, payload, source_list, idx): - """Test api set input process.""" - hass.states.async_set( - 'media_player.test', 'off', { - 'friendly_name': "Test media player", - 'source': 'unknown', - 'source_list': source_list, - }) - - # test where no source matches - if idx is None: - await assert_request_fails( - 'Alexa.InputController', 'SelectInput', 'media_player#test', - 'media_player.select_source', - hass, - payload={'input': payload}) - return - - call, _ = await assert_request_calls_service( - 'Alexa.InputController', 'SelectInput', 'media_player#test', - 'media_player.select_source', - hass, - payload={'input': payload}) - assert call.data['source'] == source_list[idx] - - async def test_logging_request(hass, events): """Test that we log requests.""" context = Context() @@ -1834,104 +1295,3 @@ async def test_endpoint_bad_health(hass): properties = await reported_properties(hass, 'binary_sensor#test_contact') properties.assert_equal('Alexa.EndpointHealth', 'connectivity', {'value': 'UNREACHABLE'}) - - -async def test_report_state(hass, aioclient_mock): - """Test proactive state reports.""" - aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) - - hass.states.async_set( - 'binary_sensor.test_contact', - 'on', - { - 'friendly_name': "Test Contact Sensor", - 'device_class': 'door', - } - ) - - await smart_home.async_enable_proactive_mode(hass, DEFAULT_CONFIG) - - hass.states.async_set( - 'binary_sensor.test_contact', - 'off', - { - 'friendly_name': "Test Contact Sensor", - 'device_class': 'door', - } - ) - - # To trigger event listener - await hass.async_block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - call = aioclient_mock.mock_calls - - call_json = call[0][2] - assert call_json["event"]["payload"]["change"]["properties"][0][ - "value"] == "NOT_DETECTED" - assert call_json["event"]["endpoint"][ - "endpointId"] == "binary_sensor#test_contact" - - -async def run_auth_get_access_token(hass, aioclient_mock, expires_in, - client_id, client_secret, - accept_grant_code, refresh_token): - """Do auth and request a new token for tests.""" - aioclient_mock.post(TEST_TOKEN_URL, - json={'access_token': 'the_access_token', - 'refresh_token': refresh_token, - 'expires_in': expires_in}) - - auth = Auth(hass, client_id, client_secret) - await auth.async_do_auth(accept_grant_code) - await auth.async_get_access_token() - - -async def test_auth_get_access_token_expired(hass, aioclient_mock): - """Test the auth get access token function.""" - client_id = "client123" - client_secret = "shhhhh" - accept_grant_code = "abcdefg" - refresh_token = "refresher" - - await run_auth_get_access_token(hass, aioclient_mock, -5, - client_id, client_secret, - accept_grant_code, refresh_token) - - assert len(aioclient_mock.mock_calls) == 2 - calls = aioclient_mock.mock_calls - - auth_call_json = calls[0][2] - token_call_json = calls[1][2] - - assert auth_call_json["grant_type"] == "authorization_code" - assert auth_call_json["code"] == accept_grant_code - assert auth_call_json["client_id"] == client_id - assert auth_call_json["client_secret"] == client_secret - - assert token_call_json["grant_type"] == "refresh_token" - assert token_call_json["refresh_token"] == refresh_token - assert token_call_json["client_id"] == client_id - assert token_call_json["client_secret"] == client_secret - - -async def test_auth_get_access_token_not_expired(hass, aioclient_mock): - """Test the auth get access token function.""" - client_id = "client123" - client_secret = "shhhhh" - accept_grant_code = "abcdefg" - refresh_token = "refresher" - - await run_auth_get_access_token(hass, aioclient_mock, 555, - client_id, client_secret, - accept_grant_code, refresh_token) - - assert len(aioclient_mock.mock_calls) == 1 - call = aioclient_mock.mock_calls - - auth_call_json = call[0][2] - - assert auth_call_json["grant_type"] == "authorization_code" - assert auth_call_json["code"] == accept_grant_code - assert auth_call_json["client_id"] == client_id - assert auth_call_json["client_secret"] == client_secret diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py new file mode 100644 index 00000000000000..fb410e4c4d6204 --- /dev/null +++ b/tests/components/alexa/test_smart_home_http.py @@ -0,0 +1,46 @@ +"""Test Smart Home HTTP endpoints.""" +import json + +from homeassistant.setup import async_setup_component +from homeassistant.components.alexa import DOMAIN, smart_home_http + +from . import get_new_request + + +async def do_http_discovery(config, hass, hass_client): + """Submit a request to the Smart Home HTTP API.""" + await async_setup_component(hass, DOMAIN, config) + http_client = await hass_client() + + request = get_new_request('Alexa.Discovery', 'Discover') + response = await http_client.post( + smart_home_http.SMART_HOME_HTTP_ENDPOINT, + data=json.dumps(request), + headers={'content-type': 'application/json'}) + return response + + +async def test_http_api(hass, hass_client): + """With `smart_home:` HTTP API is exposed.""" + config = { + 'alexa': { + 'smart_home': None + } + } + + response = await do_http_discovery(config, hass, hass_client) + response_data = await response.json() + + # Here we're testing just the HTTP view glue -- details of discovery are + # covered in other tests. + assert response_data['event']['header']['name'] == 'Discover.Response' + + +async def test_http_api_disabled(hass, hass_client): + """Without `smart_home:`, the HTTP API is disabled.""" + config = { + 'alexa': {} + } + response = await do_http_discovery(config, hass, hass_client) + + assert response.status == 404 diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py new file mode 100644 index 00000000000000..c5f95a9621825c --- /dev/null +++ b/tests/components/alexa/test_state_report.py @@ -0,0 +1,40 @@ +"""Test report state.""" +from homeassistant.components.alexa import state_report +from . import TEST_URL, DEFAULT_CONFIG + + +async def test_report_state(hass, aioclient_mock): + """Test proactive state reports.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'off', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \ + == "NOT_DETECTED" + assert call_json["event"]["endpoint"]["endpointId"] \ + == "binary_sensor#test_contact" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 24bd647405a631..68cd7fab891e63 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -343,7 +343,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, with patch.dict( 'homeassistant.components.google_assistant.const.' 'DOMAIN_TO_GOOGLE_TYPES', {'light': None}, clear=True - ), patch.dict('homeassistant.components.alexa.smart_home.ENTITY_ADAPTERS', + ), patch.dict('homeassistant.components.alexa.entities.ENTITY_ADAPTERS', {'switch': None}, clear=True): await client.send_json({ 'id': 5, From 3d03a86b13fc0078f9f0d7b125971169e4628502 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Jun 2019 08:44:43 -0700 Subject: [PATCH 215/319] Remove conversation from default config (#24515) --- homeassistant/components/default_config/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 992cb71c07c57c..6969d9bba7e9a4 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -7,7 +7,6 @@ "automation", "cloud", "config", - "conversation", "frontend", "history", "logbook", From 6d3c3ce449e3aa97604ffce6557dfb450161c418 Mon Sep 17 00:00:00 2001 From: zewelor Date: Thu, 13 Jun 2019 18:42:47 +0200 Subject: [PATCH 216/319] Refactor yeelight code (#22547) * Separate yeelight light classes * Removed not used variable * Allow to create device right away, when model is declared * Lint fixes * Use correct brightness, when nightlight mode is on * Pylint fix * Add power property * Fix imports * Update homeassistant/components/yeelight/light.py Co-Authored-By: Teemu R. * Small PR fixes * Simplify device to yeelight class mapping * Simplify device initialization code * Fix comment --- homeassistant/components/yeelight/__init__.py | 103 ++++++--- homeassistant/components/yeelight/light.py | 212 ++++++++++-------- 2 files changed, 186 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index dabd66751fd362..39dc62eddb0964 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -14,7 +14,8 @@ from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.dispatcher import dispatcher_send, \ + dispatcher_connect from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) @@ -22,6 +23,7 @@ DOMAIN = "yeelight" DATA_YEELIGHT = DOMAIN DATA_UPDATED = 'yeelight_{}_data_updated' +DEVICE_INITIALIZED = '{}_device_initialized'.format(DOMAIN) DEFAULT_NAME = 'Yeelight' DEFAULT_TRANSITION = 350 @@ -41,6 +43,8 @@ ACTION_STAY = 'stay' ACTION_OFF = 'off' +ACTIVE_MODE_NIGHTLIGHT = '1' + SCAN_INTERVAL = timedelta(seconds=30) YEELIGHT_RGB_TRANSITION = 'RGBTransition' @@ -115,7 +119,7 @@ def setup(hass, config): conf = config.get(DOMAIN, {}) yeelight_data = hass.data[DATA_YEELIGHT] = {} - def device_discovered(service, info): + def device_discovered(_, info): _LOGGER.debug("Adding autodetected %s", info['hostname']) device_type = info['device_type'] @@ -132,7 +136,7 @@ def device_discovered(service, info): discovery.listen(hass, SERVICE_YEELIGHT, device_discovered) - def update(event): + def update(_): for device in list(yeelight_data.values()): device.update() @@ -140,6 +144,17 @@ def update(event): hass, update, conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) ) + def load_platforms(ipaddr): + platform_config = hass.data[DATA_YEELIGHT][ipaddr].config.copy() + platform_config[CONF_HOST] = ipaddr + platform_config[CONF_CUSTOM_EFFECTS] = \ + config.get(DOMAIN, {}).get(CONF_CUSTOM_EFFECTS, {}) + load_platform(hass, LIGHT_DOMAIN, DOMAIN, platform_config, config) + load_platform(hass, BINARY_SENSOR_DOMAIN, DOMAIN, platform_config, + config) + + dispatcher_connect(hass, DEVICE_INITIALIZED, load_platforms) + if DOMAIN in config: for ipaddr, device_config in conf[CONF_DEVICES].items(): _LOGGER.debug("Adding configured %s", device_config[CONF_NAME]) @@ -148,7 +163,7 @@ def update(event): return True -def _setup_device(hass, hass_config, ipaddr, device_config): +def _setup_device(hass, _, ipaddr, device_config): devices = hass.data[DATA_YEELIGHT] if ipaddr in devices: @@ -157,15 +172,7 @@ def _setup_device(hass, hass_config, ipaddr, device_config): device = YeelightDevice(hass, ipaddr, device_config) devices[ipaddr] = device - - platform_config = device_config.copy() - platform_config[CONF_HOST] = ipaddr - platform_config[CONF_CUSTOM_EFFECTS] = \ - hass_config.get(DOMAIN, {}).get(CONF_CUSTOM_EFFECTS, {}) - - load_platform(hass, LIGHT_DOMAIN, DOMAIN, platform_config, hass_config) - load_platform(hass, BINARY_SENSOR_DOMAIN, DOMAIN, platform_config, - hass_config) + hass.add_job(device.setup) class YeelightDevice: @@ -178,24 +185,14 @@ def __init__(self, hass, ipaddr, config): self._ipaddr = ipaddr self._name = config.get(CONF_NAME) self._model = config.get(CONF_MODEL) - self._bulb_device = None + self._bulb_device = Bulb(self.ipaddr, model=self._model) + self._device_type = None self._available = False + self._initialized = False @property def bulb(self): """Return bulb device.""" - if self._bulb_device is None: - try: - self._bulb_device = Bulb(self._ipaddr, model=self._model) - # force init for type - self.update() - - self._available = True - except BulbException as ex: - self._available = False - _LOGGER.error("Failed to connect to bulb %s, %s: %s", - self._ipaddr, self._name, ex) - return self._bulb_device @property @@ -218,23 +215,38 @@ def available(self): """Return true is device is available.""" return self._available + @property + def model(self): + """Return configured device model.""" + return self._model + @property def is_nightlight_enabled(self) -> bool: """Return true / false if nightlight is currently enabled.""" if self.bulb is None: return False - return self.bulb.last_properties.get('active_mode') == '1' + return self._active_mode == ACTIVE_MODE_NIGHTLIGHT @property def is_nightlight_supported(self) -> bool: """Return true / false if nightlight is supported.""" - return self.bulb.get_model_specs().get('night_light', False) + if self.model: + return self.bulb.get_model_specs().get('night_light', False) + + return self._active_mode is not None + + @property + def _active_mode(self): + return self.bulb.last_properties.get('active_mode') @property - def is_ambilight_supported(self) -> bool: - """Return true / false if ambilight is supported.""" - return self.bulb.get_model_specs().get('background_light', False) + def type(self): + """Return bulb type.""" + if not self._device_type: + self._device_type = self.bulb.bulb_type + + return self._device_type def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn on device.""" @@ -242,17 +254,16 @@ def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): self.bulb.turn_on(duration=duration, light_type=light_type) except BulbException as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) - return def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: self.bulb.turn_off(duration=duration, light_type=light_type) except BulbException as ex: - _LOGGER.error("Unable to turn the bulb off: %s", ex) - return + _LOGGER.error("Unable to turn the bulb off: %s, %s: %s", + self.ipaddr, self.name, ex) - def update(self): + def _update_properties(self): """Read new properties from the device.""" if not self.bulb: return @@ -260,9 +271,29 @@ def update(self): try: self.bulb.get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True + if not self._initialized: + self._initialize_device() except BulbException as ex: if self._available: # just inform once - _LOGGER.error("Unable to update bulb status: %s", ex) + _LOGGER.error("Unable to update device %s, %s: %s", + self.ipaddr, self.name, ex) self._available = False + return self._available + + def _initialize_device(self): + self._initialized = True + dispatcher_send(self._hass, DEVICE_INITIALIZED, self.ipaddr) + + def update(self): + """Update device properties and send data updated signal.""" + self._update_properties() dispatcher_send(self._hass, DATA_UPDATED.format(self._ipaddr)) + + def setup(self): + """Fetch initial device properties.""" + initial_update = self._update_properties() + + # We can build correct class anyway. + if not initial_update and self.model: + self._initialize_device() diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 33116d973e990d..563c9ab8782e25 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -80,6 +80,19 @@ EFFECT_TWITTER, EFFECT_STOP] +MODEL_TO_DEVICE_TYPE = { + 'mono': BulbType.White, + 'mono1': BulbType.White, + 'color': BulbType.Color, + 'color1': BulbType.Color, + 'color2': BulbType.Color, + 'strip1': BulbType.Color, + 'bslamp1': BulbType.Color, + 'ceiling1': BulbType.WhiteTemp, + 'ceiling2': BulbType.WhiteTemp, + 'ceiling3': BulbType.WhiteTemp, + 'ceiling4': BulbType.WhiteTempMood} + def _transitions_config_parser(transitions): """Parse transitions config into initialized objects.""" @@ -137,11 +150,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): custom_effects = _parse_custom_effects(discovery_info[CONF_CUSTOM_EFFECTS]) - lights = [YeelightLight(device, custom_effects=custom_effects)] - - if device.is_ambilight_supported: - lights.append( - YeelightAmbientLight(device, custom_effects=custom_effects)) + lights = [] + + if device.model: + device_type = MODEL_TO_DEVICE_TYPE.get(device.model, None) + else: + device_type = device.type + + def _lights_setup_helper(klass): + lights.append(klass(device, custom_effects=custom_effects)) + + if device_type == BulbType.White: + _lights_setup_helper(YeelightGenericLight) + elif device_type == BulbType.Color: + _lights_setup_helper(YeelightColorLight) + elif device_type == BulbType.WhiteTemp: + _lights_setup_helper(YeelightWhiteTempLight) + elif device_type == BulbType.WhiteTempMood: + _lights_setup_helper(YeelightWithAmbientLight) + _lights_setup_helper(YeelightAmbientLight) + else: + _LOGGER.error("Cannot determine device type for %s, %s", + device.ipaddr, device.name) hass.data[data_key] += lights add_entities(lights, True) @@ -179,23 +209,21 @@ def service_handler(service): schema=service_schema_start_flow) -class YeelightLight(Light): - """Representation of a Yeelight light.""" +class YeelightGenericLight(Light): + """Representation of a Yeelight generic light.""" def __init__(self, device, custom_effects=None): """Initialize the Yeelight light.""" self.config = device.config self._device = device - self._supported_features = SUPPORT_YEELIGHT - self._brightness = None self._color_temp = None - self._is_on = None self._hs = None - self._min_mireds = None - self._max_mireds = None + model_specs = self._bulb.get_model_specs() + self._min_mireds = kelvin_to_mired(model_specs['color_temp']['max']) + self._max_mireds = kelvin_to_mired(model_specs['color_temp']['min']) self._light_type = LightType.Main @@ -229,7 +257,7 @@ def available(self) -> bool: @property def supported_features(self) -> int: """Flag supported features.""" - return self._supported_features + return SUPPORT_YEELIGHT @property def effect_list(self): @@ -239,7 +267,10 @@ def effect_list(self): @property def color_temp(self) -> int: """Return the color temperature.""" - return self._color_temp + temp = self._get_property('ct') + if temp: + self._color_temp = temp + return kelvin_to_mired(int(self._color_temp)) @property def name(self) -> str: @@ -249,12 +280,15 @@ def name(self) -> str: @property def is_on(self) -> bool: """Return true if device is on.""" - return self._is_on + return self._get_property(self._power_property) == 'on' @property def brightness(self) -> int: """Return the brightness of this light between 1..255.""" - return self._brightness + temp = self._get_property(self._brightness_property) + if temp: + self._brightness = temp + return round(255 * (int(self._brightness) / 100)) @property def min_mireds(self): @@ -281,6 +315,42 @@ def light_type(self): """Return light type.""" return self._light_type + @property + def hs_color(self) -> tuple: + """Return the color property.""" + return self._hs + + # F821: https://github.com/PyCQA/pyflakes/issues/373 + @property + def _bulb(self) -> 'Bulb': # noqa: F821 + return self.device.bulb + + @property + def _properties(self) -> dict: + if self._bulb is None: + return {} + return self._bulb.last_properties + + def _get_property(self, prop, default=None): + return self._properties.get(prop, default) + + @property + def _brightness_property(self): + return 'bright' + + @property + def _power_property(self): + return 'power' + + @property + def device(self): + """Return yeelight device.""" + return self._device + + def update(self): + """Update light properties.""" + self._hs = self._get_hs_from_properties() + def _get_hs_from_properties(self): rgb = self._get_property('rgb') color_mode = self._get_property('color_mode') @@ -290,7 +360,7 @@ def _get_hs_from_properties(self): color_mode = int(color_mode) if color_mode == 2: # color temperature - temp_in_k = mired_to_kelvin(self._color_temp) + temp_in_k = mired_to_kelvin(self.color_temp) return color_util.color_temperature_to_hs(temp_in_k) if color_mode == 3: # hsv hue = int(self._get_property('hue')) @@ -305,34 +375,6 @@ def _get_hs_from_properties(self): return color_util.color_RGB_to_hs(red, green, blue) - @property - def hs_color(self) -> tuple: - """Return the color property.""" - return self._hs - - @property - def _properties(self) -> dict: - if self._bulb is None: - return {} - return self._bulb.last_properties - - def _get_property(self, prop, default=None): - return self._properties.get(prop, default) - - @property - def device(self): - """Return yeelight device.""" - return self._device - - @property - def _is_nightlight_enabled(self): - return self.device.is_nightlight_enabled - - # F821: https://github.com/PyCQA/pyflakes/issues/373 - @property - def _bulb(self) -> 'yeelight.Bulb': # noqa: F821 - return self.device.bulb - def set_music_mode(self, mode) -> None: """Set the music mode on or off.""" if mode: @@ -340,47 +382,6 @@ def set_music_mode(self, mode) -> None: else: self._bulb.stop_music() - def update(self) -> None: - """Update properties from the bulb.""" - bulb_type = self._bulb.bulb_type - - if bulb_type == BulbType.Color: - self._supported_features = SUPPORT_YEELIGHT_RGB - elif self.light_type == LightType.Ambient: - self._supported_features = SUPPORT_YEELIGHT_RGB - elif bulb_type in (BulbType.WhiteTemp, BulbType.WhiteTempMood): - if self._is_nightlight_enabled: - self._supported_features = SUPPORT_YEELIGHT - else: - self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP - - if self.min_mireds is None: - model_specs = self._bulb.get_model_specs() - self._min_mireds = \ - kelvin_to_mired(model_specs['color_temp']['max']) - self._max_mireds = \ - kelvin_to_mired(model_specs['color_temp']['min']) - - if bulb_type == BulbType.WhiteTempMood: - self._is_on = self._get_property('main_power') == 'on' - else: - self._is_on = self._get_property('power') == 'on' - - if self._is_nightlight_enabled: - bright = self._get_property('nl_br') - else: - bright = self._get_property('bright') - - if bright: - self._brightness = round(255 * (int(bright) / 100)) - - temp_in_k = self._get_property('ct') - - if temp_in_k: - self._color_temp = kelvin_to_mired(int(temp_in_k)) - - self._hs = self._get_hs_from_properties() - @_cmd def set_brightness(self, brightness, duration) -> None: """Set bulb brightness.""" @@ -566,12 +567,41 @@ def start_flow(self, transitions, count=0, action=ACTION_RECOVER): _LOGGER.error("Unable to set effect: %s", ex) -class YeelightAmbientLight(YeelightLight): +class YeelightColorLight(YeelightGenericLight): + """Representation of a Color Yeelight light.""" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_YEELIGHT_RGB + + +class YeelightWhiteTempLight(YeelightGenericLight): + """Representation of a Color Yeelight light.""" + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_YEELIGHT_WHITE_TEMP + + @property + def _brightness_property(self): + return 'current_brightness' + + +class YeelightWithAmbientLight(YeelightWhiteTempLight): + """Representation of a Yeelight which has ambilight support.""" + + @ property + def _power_property(self): + return 'main_power' + + +class YeelightAmbientLight(YeelightColorLight): """Representation of a Yeelight ambient light.""" PROPERTIES_MAPPING = { "color_mode": "bg_lmode", - "main_power": "bg_power", } def __init__(self, *args, **kwargs): @@ -587,14 +617,10 @@ def name(self) -> str: """Return the name of the device if any.""" return "{} ambilight".format(self.device.name) - @property - def _is_nightlight_enabled(self): - return False - def _get_property(self, prop, default=None): bg_prop = self.PROPERTIES_MAPPING.get(prop) if not bg_prop: bg_prop = "bg_" + prop - return self._properties.get(bg_prop, default) + return super()._get_property(bg_prop, default) From 08591dae0e1111f9c4b96a05cdbbb6389f529dbc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Jun 2019 11:09:07 -0700 Subject: [PATCH 217/319] Migrate Sonos discovery to manifest (#24507) --- homeassistant/components/discovery/__init__.py | 2 +- homeassistant/components/sonos/manifest.json | 5 +++++ homeassistant/generated/ssdp.py | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 2765757d737939..229e64ad682179 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -48,7 +48,6 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: 'daikin', SERVICE_TELLDUSLIVE: 'tellduslive', - 'sonos': 'sonos', SERVICE_IGD: 'upnp', } @@ -100,6 +99,7 @@ 'homekit', 'ikea_tradfri', 'philips_hue', + 'sonos', SERVICE_WEMO, ] diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b1f4c924fc4d8b..8a7dd60d80a5b8 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -7,6 +7,11 @@ "pysonos==0.0.14" ], "dependencies": [], + "ssdp": { + "st": [ + "urn:schemas-upnp-org:device:ZonePlayer:1" + ] + }, "codeowners": [ "@amelchio" ] diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 62273bf303b87f..28df05a872cfb0 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -18,6 +18,9 @@ "st": { "urn:schemas-denon-com:device:ACT-Denon:1": [ "heos" + ], + "urn:schemas-upnp-org:device:ZonePlayer:1": [ + "sonos" ] } } From 6c5124e12a6dae2b179539a2466e81b1edce35c0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Jun 2019 11:58:08 -0700 Subject: [PATCH 218/319] Cloud: allow managing Alexa entities via UI (#24522) * Clean up Alexa config * Cloud: Manage Alexa entities via UI * Add tests for new cloud APIs --- homeassistant/components/alexa/config.py | 36 +++++++--- homeassistant/components/alexa/const.py | 2 - homeassistant/components/alexa/handlers.py | 5 +- .../components/alexa/smart_home_http.py | 61 ++++++++++++----- homeassistant/components/cloud/__init__.py | 1 - homeassistant/components/cloud/client.py | 61 ++++++++++------- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 54 ++++++++++++++- homeassistant/components/cloud/prefs.py | 40 ++++++++++- tests/components/alexa/__init__.py | 34 ++++++++-- tests/components/alexa/test_smart_home.py | 68 ++++++++----------- tests/components/cloud/test_client.py | 20 +++++- tests/components/cloud/test_http_api.py | 45 ++++++++++++ 13 files changed, 322 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index df9c9b013dc2ab..b5060709ce3210 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,13 +1,33 @@ """Config helpers for Alexa.""" -class Config: +class AbstractConfig: """Hold the configuration for Alexa.""" - def __init__(self, endpoint, async_get_access_token, should_expose, - entity_config=None): - """Initialize the configuration.""" - self.endpoint = endpoint - self.async_get_access_token = async_get_access_token - self.should_expose = should_expose - self.entity_config = entity_config or {} + @property + def supports_auth(self): + """Return if config supports auth.""" + return False + + @property + def endpoint(self): + """Endpoint for report state.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return {} + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + # pylint: disable=no-self-use + return False + + async def async_get_access_token(self): + """Get an access token.""" + raise NotImplementedError + + async def async_accept_grant(self, code): + """Accept a grant.""" + raise NotImplementedError diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 9931406ff0e327..513c4ac43d7151 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -48,8 +48,6 @@ CONF_DESCRIPTION = 'description' CONF_DISPLAY_CATEGORIES = 'display_categories' -AUTH_KEY = "alexa.smart_home.auth" - API_TEMP_UNITS = { TEMP_FAHRENHEIT: 'FAHRENHEIT', TEMP_CELSIUS: 'CELSIUS', diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index a17381b3e17680..5a1a899ea69eff 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -31,7 +31,6 @@ from homeassistant.util.temperature import convert as convert_temperature from .const import ( - AUTH_KEY, API_TEMP_UNITS, API_THERMOSTAT_MODES, Cause, @@ -86,8 +85,8 @@ async def async_api_accept_grant(hass, config, directive, context): auth_code = directive.payload['grant']['code'] _LOGGER.debug("AcceptGrant code: %s", auth_code) - if AUTH_KEY in hass.data: - await hass.data[AUTH_KEY].async_do_auth(auth_code) + if config.supports_auth: + await config.async_accept_grant(auth_code) await async_enable_proactive_mode(hass, config) return directive.response( diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index cb70fb8625301e..d0c4429e6b2f27 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -5,9 +5,8 @@ from homeassistant.components.http.view import HomeAssistantView from .auth import Auth -from .config import Config +from .config import AbstractConfig from .const import ( - AUTH_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, @@ -21,6 +20,47 @@ SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' +class AlexaConfig(AbstractConfig): + """Alexa config.""" + + def __init__(self, hass, config): + """Initialize Alexa config.""" + self._config = config + + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + self._auth = Auth(hass, config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET]) + else: + self._auth = None + + @property + def supports_auth(self): + """Return if config supports auth.""" + return self._auth is not None + + @property + def endpoint(self): + """Endpoint for report state.""" + return self._config.get(CONF_ENDPOINT) + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG, {}) + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + return self._config[CONF_FILTER](entity_id) + + async def async_get_access_token(self): + """Get an access token.""" + return await self._auth.async_get_access_token() + + async def async_accept_grant(self, code): + """Accept a grant.""" + return await self._auth.async_do_auth(code) + + async def async_setup(hass, config): """Activate Smart Home functionality of Alexa component. @@ -30,23 +70,10 @@ async def async_setup(hass, config): Even if that's disabled, the functionality in this module may still be used by the cloud component which will call async_handle_message directly. """ - if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): - hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET]) - - async_get_access_token = \ - hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ - else None - - smart_home_config = Config( - endpoint=config.get(CONF_ENDPOINT), - async_get_access_token=async_get_access_token, - should_expose=config[CONF_FILTER], - entity_config=config.get(CONF_ENTITY_CONFIG), - ) + smart_home_config = AlexaConfig(hass, config) hass.http.register_view(SmartHomeView(smart_home_config)) - if AUTH_KEY in hass.data: + if smart_home_config.supports_auth: await async_enable_proactive_mode(hass, smart_home_config) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 5490a0da156db6..01e2b48559b9e4 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -61,7 +61,6 @@ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV, MODE_PROD]), - # Change to optional when we include real servers vol.Optional(CONF_COGNITO_CLIENT_ID): str, vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f5edefeee432d1..f6d283ee1ebe70 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -26,6 +26,38 @@ from .prefs import CloudPreferences +class AlexaConfig(alexa_config.AbstractConfig): + """Alexa Configuration.""" + + def __init__(self, config, prefs): + """Initialize the Alexa config.""" + self._config = config + self._prefs = prefs + + @property + def endpoint(self): + """Endpoint for report state.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG, {}) + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config[CONF_FILTER].empty_filter: + return self._config[CONF_FILTER](entity_id) + + entity_configs = self._prefs.alexa_entity_configs + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + class CloudClient(Interface): """Interface class for Home Assistant Cloud.""" @@ -36,10 +68,10 @@ def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, self._hass = hass self._prefs = prefs self._websession = websession - self._alexa_user_config = alexa_cfg - self._google_user_config = google_config + self.google_user_config = google_config + self.alexa_user_config = alexa_cfg - self._alexa_config = None + self.alexa_config = AlexaConfig(alexa_cfg, prefs) self._google_config = None @property @@ -77,26 +109,11 @@ def remote_autostart(self) -> bool: """Return true if we want start a remote connection.""" return self._prefs.remote_enabled - @property - def alexa_config(self) -> alexa_config.Config: - """Return Alexa config.""" - if not self._alexa_config: - alexa_conf = self._alexa_user_config - - self._alexa_config = alexa_config.Config( - endpoint=None, - async_get_access_token=None, - should_expose=alexa_conf[CONF_FILTER], - entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), - ) - - return self._alexa_config - @property def google_config(self) -> ga_h.Config: """Return Google config.""" if not self._google_config: - google_conf = self._google_user_config + google_conf = self.google_user_config def should_expose(entity): """If an entity should be exposed.""" @@ -134,14 +151,8 @@ def should_2fa(entity): return self._google_config - @property - def google_user_config(self) -> Dict[str, Any]: - """Return google action user config.""" - return self._google_user_config - async def cleanups(self) -> None: """Cleanup some stuff after logout.""" - self._alexa_config = None self._google_config = None @callback diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 65062213a630d2..505232bfb85848 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -9,6 +9,7 @@ PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUD_USER = 'cloud_user' PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs' +PREF_ALEXA_ENTITY_CONFIGS = 'alexa_entity_configs' PREF_OVERRIDE_NAME = 'override_name' PREF_DISABLE_2FA = 'disable_2fa' PREF_ALIASES = 'aliases' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 9c167d256018a2..eb3b056535144f 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -90,6 +90,9 @@ async def async_setup(hass): hass.components.websocket_api.async_register_command( google_assistant_update) + hass.components.websocket_api.async_register_command(alexa_list) + hass.components.websocket_api.async_register_command(alexa_update) + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -420,7 +423,7 @@ def _account_data(cloud): 'cloud': cloud.iot.state, 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, - 'alexa_entities': client.alexa_config.should_expose.config, + 'alexa_entities': client.alexa_user_config['filter'].config, 'alexa_domains': list(alexa_entities.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, 'remote_connected': remote.is_connected, @@ -508,3 +511,52 @@ async def google_assistant_update(hass, connection, msg): connection.send_result( msg['id'], cloud.client.prefs.google_entity_configs.get(msg['entity_id'])) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/alexa/entities' +}) +async def alexa_list(hass, connection, msg): + """List all alexa entities.""" + cloud = hass.data[DOMAIN] + entities = alexa_entities.async_get_entities( + hass, cloud.client.alexa_config + ) + + result = [] + + for entity in entities: + result.append({ + 'entity_id': entity.entity_id, + 'display_categories': entity.default_display_categories(), + 'interfaces': [ifc.name() for ifc in entity.interfaces()], + }) + + connection.send_result(msg['id'], result) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/alexa/entities/update', + 'entity_id': str, + vol.Optional('should_expose'): bool, +}) +async def alexa_update(hass, connection, msg): + """Update alexa entity config.""" + cloud = hass.data[DOMAIN] + changes = dict(msg) + changes.pop('type') + changes.pop('id') + + await cloud.client.prefs.async_update_alexa_entity_config(**changes) + + connection.send_result( + msg['id'], + cloud.client.prefs.alexa_entity_configs.get(msg['entity_id'])) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 9f2579134e506a..1e4ac754460527 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -5,7 +5,7 @@ DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA, - PREF_ALIASES, PREF_SHOULD_EXPOSE, + PREF_ALIASES, PREF_SHOULD_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, InvalidTrustedNetworks, InvalidTrustedProxies) STORAGE_KEY = DOMAIN @@ -33,6 +33,7 @@ async def async_initialize(self): PREF_ENABLE_REMOTE: False, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_ALEXA_ENTITY_CONFIGS: {}, PREF_CLOUDHOOKS: {}, PREF_CLOUD_USER: None, } @@ -42,7 +43,8 @@ async def async_initialize(self): async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, - cloud_user=_UNDEF, google_entity_configs=_UNDEF): + cloud_user=_UNDEF, google_entity_configs=_UNDEF, + alexa_entity_configs=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), @@ -52,6 +54,7 @@ async def async_update(self, *, google_enabled=_UNDEF, (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), + (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), ): if value is not _UNDEF: self._prefs[key] = value @@ -95,6 +98,33 @@ async def async_update_google_entity_config( } await self.async_update(google_entity_configs=updated_entities) + async def async_update_alexa_entity_config( + self, *, entity_id, should_expose=_UNDEF): + """Update config for an Alexa entity.""" + entities = self.alexa_entity_configs + entity = entities.get(entity_id, {}) + + changes = {} + for key, value in ( + (PREF_SHOULD_EXPOSE, should_expose), + ): + if value is not _UNDEF: + changes[key] = value + + if not changes: + return + + updated_entity = { + **entity, + **changes, + } + + updated_entities = { + **entities, + entity_id: updated_entity, + } + await self.async_update(alexa_entity_configs=updated_entities) + def as_dict(self): """Return dictionary version.""" return { @@ -103,6 +133,7 @@ def as_dict(self): PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, + PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUD_USER: self.cloud_user, } @@ -140,6 +171,11 @@ def google_entity_configs(self): """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def alexa_entity_configs(self): + """Return Alexa Entity configurations.""" + return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property def cloudhooks(self): """Return the published cloud webhooks.""" diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index b1c8c6aa8bdddb..ab273d5e02410a 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -10,15 +10,35 @@ TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" -async def get_access_token(): - """Return a test access token.""" - return "thisisnotanacesstoken" +class MockConfig(config.AbstractConfig): + """Mock Alexa config.""" + entity_config = {} -DEFAULT_CONFIG = config.Config( - endpoint=TEST_URL, - async_get_access_token=get_access_token, - should_expose=lambda entity_id: True) + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @property + def endpoint(self): + """Endpoint for report state.""" + return TEST_URL + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + return True + + async def async_get_access_token(self): + """Get an access token.""" + return "thisisnotanacesstoken" + + async def async_accept_grant(self, code): + """Accept a grant.""" + pass + + +DEFAULT_CONFIG = MockConfig() def get_new_request(namespace, name, endpoint=None): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index da7063f8acd1be..3aa1c7df3667f7 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -4,7 +4,6 @@ from homeassistant.core import Context, callback from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.components.alexa import ( - config, smart_home, messages, ) @@ -14,6 +13,7 @@ from . import ( get_new_request, + MockConfig, DEFAULT_CONFIG, assert_request_calls_service, assert_request_fails, @@ -1012,15 +1012,13 @@ async def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - alexa_config = config.Config( - endpoint=None, - async_get_access_token=None, - should_expose=entityfilter.generate_filter( - include_domains=[], - include_entities=[], - exclude_domains=['script'], - exclude_entities=['cover.deny'], - )) + alexa_config = MockConfig() + alexa_config.should_expose = entityfilter.generate_filter( + include_domains=[], + include_entities=[], + exclude_domains=['script'], + exclude_entities=['cover.deny'], + ) msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -1047,15 +1045,13 @@ async def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - alexa_config = config.Config( - endpoint=None, - async_get_access_token=None, - should_expose=entityfilter.generate_filter( - include_domains=['automation', 'group'], - include_entities=['script.deny'], - exclude_domains=[], - exclude_entities=[], - )) + alexa_config = MockConfig() + alexa_config.should_expose = entityfilter.generate_filter( + include_domains=['automation', 'group'], + include_entities=['script.deny'], + exclude_domains=[], + exclude_entities=[], + ) msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -1076,15 +1072,13 @@ async def test_never_exposed_entities(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - alexa_config = config.Config( - endpoint=None, - async_get_access_token=None, - should_expose=entityfilter.generate_filter( - include_domains=['group'], - include_entities=[], - exclude_domains=[], - exclude_entities=[], - )) + alexa_config = MockConfig() + alexa_config.should_expose = entityfilter.generate_filter( + include_domains=['group'], + include_entities=[], + exclude_domains=[], + exclude_entities=[], + ) msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -1161,18 +1155,14 @@ async def test_entity_config(hass): hass.states.async_set( 'light.test_1', 'on', {'friendly_name': "Test light 1"}) - alexa_config = config.Config( - endpoint=None, - async_get_access_token=None, - should_expose=lambda entity_id: True, - entity_config={ - 'light.test_1': { - 'name': 'Config name', - 'display_categories': 'SWITCH', - 'description': 'Config description' - } + alexa_config = MockConfig() + alexa_config.entity_config = { + 'light.test_1': { + 'name': 'Config name', + 'display_categories': 'SWITCH', + 'description': 'Config description' } - ) + } msg = await smart_home.async_handle_message( hass, alexa_config, request) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index fa1d8cf8b9b5e7..ca82d1e0aba362 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -7,7 +7,8 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import DOMAIN +from homeassistant.components.cloud import ( + DOMAIN, ALEXA_SCHEMA, prefs, client) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa @@ -251,3 +252,20 @@ async def test_google_config_should_2fa( ) assert not cloud_client.google_config.should_2fa(state) + + +async def test_alexa_config_expose_entity_prefs(hass): + """Test Alexa config should expose using prefs.""" + cloud_prefs = prefs.CloudPreferences(hass) + await cloud_prefs.async_initialize() + entity_conf = { + 'should_expose': False + } + await cloud_prefs.async_update(alexa_entity_configs={ + 'light.kitchen': entity_conf + }) + conf = client.AlexaConfig(ALEXA_SCHEMA({}), cloud_prefs) + + assert not conf.should_expose('light.kitchen') + entity_conf['should_expose'] = True + assert conf.should_expose('light.kitchen') diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 68cd7fab891e63..0e4d46672ba0dd 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -15,6 +15,7 @@ DOMAIN) from homeassistant.components.google_assistant.helpers import ( GoogleEntity, Config) +from homeassistant.components.alexa.entities import LightCapabilities from tests.common import mock_coro @@ -361,6 +362,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'google_enabled': True, 'google_entity_configs': {}, 'google_secure_devices_pin': None, + 'alexa_entity_configs': {}, 'remote_enabled': False, }, 'alexa_entities': { @@ -800,3 +802,46 @@ async def test_enabling_remote_trusted_proxies_local6( 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.' assert len(mock_connect.mock_calls) == 0 + + +async def test_list_alexa_entities( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can list Alexa entities.""" + client = await hass_ws_client(hass) + entity = LightCapabilities(hass, MagicMock(entity_config={}), State( + 'light.kitchen', 'on' + )) + with patch('homeassistant.components.alexa.entities' + '.async_get_entities', return_value=[entity]): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/entities', + }) + response = await client.receive_json() + + assert response['success'] + assert len(response['result']) == 1 + assert response['result'][0] == { + 'entity_id': 'light.kitchen', + 'display_categories': ['LIGHT'], + 'interfaces': ['Alexa.PowerController', 'Alexa.EndpointHealth'], + } + + +async def test_update_alexa_entity( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can update config of an Alexa entity.""" + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/entities/update', + 'entity_id': 'light.kitchen', + 'should_expose': False, + }) + response = await client.receive_json() + + assert response['success'] + prefs = hass.data[DOMAIN].client.prefs + assert prefs.alexa_entity_configs['light.kitchen'] == { + 'should_expose': False, + } From 8951c802251a6415cb58991fd633f3a788834644 Mon Sep 17 00:00:00 2001 From: ktnrg45 <38207570+ktnrg45@users.noreply.github.com> Date: Thu, 13 Jun 2019 17:36:17 -0700 Subject: [PATCH 219/319] WIP Ps4 Convert entity to Async / Fix entity name changing (#24101) * Convert ps4 to async * Init client handler. * Add PS4_DATA * Move data class * add handler * add import * Update __init__.py * Change most functions to async * bump 0.8.0 * bump 0.8.0 * bump 0.8.0 * Pylint * whitespace * Rewrite to use asyncio sockets. * Remove unneeded log * Add alias * Update __init__.py * Update config_flow.py * Add alias * Add search_all method * Clean up * whitespace * change comment * 0.8.2 * 0.8.2 * 0.8.2 * Pylint * pylint * faster updates * Avoid scheduling update if state is the same. * Better handling remove search all --- homeassistant/components/ps4/__init__.py | 18 +- homeassistant/components/ps4/config_flow.py | 7 +- homeassistant/components/ps4/const.py | 2 + homeassistant/components/ps4/manifest.json | 2 +- homeassistant/components/ps4/media_player.py | 276 ++++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 195 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index b91e6b239e74be..7f7561304d2ba4 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -7,13 +7,29 @@ from homeassistant.util import location from .config_flow import PlayStation4FlowHandler # noqa: pylint: disable=unused-import -from .const import DOMAIN # noqa: pylint: disable=unused-import +from .const import DOMAIN, PS4_DATA # noqa: pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) +class PS4Data(): + """Init Data Class.""" + + def __init__(self): + """Init Class.""" + self.devices = [] + self.protocol = None + + async def async_setup(hass, config): """Set up the PS4 Component.""" + from pyps4_homeassistant.ddp import async_create_ddp_endpoint + + hass.data[PS4_DATA] = PS4Data() + + transport, protocol = await async_create_ddp_endpoint() + hass.data[PS4_DATA].protocol = protocol + _LOGGER.debug("PS4 DDP endpoint created: %s, %s", transport, protocol) return True diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index b31ba44fbe3dc2..8ef98e12a8fead 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -9,7 +9,7 @@ CONF_CODE, CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_REGION, CONF_TOKEN) from homeassistant.util import location -from .const import DEFAULT_NAME, DOMAIN +from .const import DEFAULT_ALIAS, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -61,7 +61,7 @@ async def async_step_creds(self, user_input=None): if user_input is not None: try: self.creds = await self.hass.async_add_executor_job( - self.helper.get_creds) + self.helper.get_creds, DEFAULT_ALIAS) if self.creds is not None: return await self.async_step_mode() return self.async_abort(reason='credential_error') @@ -143,7 +143,8 @@ async def async_step_link(self, user_input=None): self.host = user_input[CONF_IP_ADDRESS] is_ready, is_login = await self.hass.async_add_executor_job( - self.helper.link, self.host, self.creds, self.pin) + self.helper.link, self.host, + self.creds, self.pin, DEFAULT_ALIAS) if is_ready is False: errors['base'] = 'not_ready' diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index bbf654530b0081..3c0dad6119fbc2 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -1,7 +1,9 @@ """Constants for PlayStation 4.""" DEFAULT_NAME = "PlayStation 4" DEFAULT_REGION = "United States" +DEFAULT_ALIAS = 'Home-Assistant' DOMAIN = 'ps4' +PS4_DATA = 'ps4_data' # Deprecated used for logger/backwards compatibility from 0.89 REGIONS = ['R1', 'R2', 'R3', 'R4', 'R5'] diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 1cf613bf9b9464..a94fcd44082482 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ps4", "requirements": [ - "pyps4-homeassistant==0.7.3" + "pyps4-homeassistant==0.8.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index f5360f491dbc2d..7d51dd4463e176 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -1,29 +1,31 @@ """Support for PlayStation 4 consoles.""" import logging -import socket +import asyncio import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.media_player import ( ENTITY_IMAGE_URL, MediaPlayerDevice) from homeassistant.components.media_player.const import ( MEDIA_TYPE_GAME, MEDIA_TYPE_APP, SUPPORT_SELECT_SOURCE, - SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) + SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) from homeassistant.components.ps4 import format_unique_id from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_REGION, CONF_TOKEN, STATE_IDLE, STATE_OFF, STATE_PLAYING) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import device_registry, entity_registry from homeassistant.util.json import load_json, save_json -from .const import DOMAIN as PS4_DOMAIN, REGIONS as deprecated_regions +from .const import (DEFAULT_ALIAS, DOMAIN as PS4_DOMAIN, PS4_DATA, + REGIONS as deprecated_regions) _LOGGER = logging.getLogger(__name__) SUPPORT_PS4 = SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ - SUPPORT_STOP | SUPPORT_SELECT_SOURCE + SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE -PS4_DATA = 'ps4_data' ICON = 'mdi:playstation' GAMES_FILE = '.ps4-games.json' MEDIA_IMAGE_DEFAULT = None @@ -50,35 +52,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up PS4 from a config entry.""" config = config_entry - - def add_entities(entities, update_before_add=False): - """Sync version of async add devices.""" - hass.add_job(async_add_entities, entities, update_before_add) - - await hass.async_add_executor_job( - setup_platform, hass, config, - add_entities, None) + await async_setup_platform( + hass, config, async_add_entities, discovery_info=None) async def async_service_handle(hass): """Handle for services.""" - def service_command(call): + async def async_service_command(call): entity_ids = call.data[ATTR_ENTITY_ID] command = call.data[ATTR_COMMAND] for device in hass.data[PS4_DATA].devices: if device.entity_id in entity_ids: - device.send_command(command) + await device.async_send_command(command) hass.services.async_register( - PS4_DOMAIN, SERVICE_COMMAND, service_command, + PS4_DOMAIN, SERVICE_COMMAND, async_service_command, schema=PS4_COMMAND_SCHEMA) await async_service_handle(hass) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): """Set up PS4 Platform.""" - import pyps4_homeassistant as pyps4 - hass.data[PS4_DATA] = PS4Data() + import pyps4_homeassistant.ps4 as pyps4 games_file = hass.config.path(GAMES_FILE) creds = config.data[CONF_TOKEN] device_list = [] @@ -86,25 +82,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): host = device[CONF_HOST] region = device[CONF_REGION] name = device[CONF_NAME] - ps4 = pyps4.Ps4(host, creds) + ps4 = pyps4.Ps4Async(host, creds, device_name=DEFAULT_ALIAS) device_list.append(PS4Device( - name, host, region, ps4, creds, games_file)) - add_entities(device_list, True) - - -class PS4Data(): - """Init Data Class.""" - - def __init__(self): - """Init Class.""" - self.devices = [] + config, name, host, region, ps4, creds, games_file)) + async_add_entities(device_list, update_before_add=True) class PS4Device(MediaPlayerDevice): """Representation of a PS4.""" - def __init__(self, name, host, region, ps4, creds, games_file): + def __init__(self, config, name, host, region, ps4, creds, games_file): """Initialize the ps4 device.""" + self._entry_id = config.entry_id self._ps4 = ps4 self._host = host self._name = name @@ -123,56 +112,87 @@ def __init__(self, name, host, region, ps4, creds, games_file): self._disconnected = False self._info = None self._unique_id = None - self._power_on = False + + @callback + def status_callback(self): + """Handle status callback. Parse status.""" + self._parse_status() + + @callback + def schedule_update(self): + """Schedules update with HA.""" + self.async_schedule_update_ha_state() + + @callback + def subscribe_to_protocol(self): + """Notify protocol to callback with update changes.""" + self.hass.data[PS4_DATA].protocol.add_callback( + self._ps4, self.status_callback) + + def check_region(self): + """Display logger msg if region is deprecated.""" + # Non-Breaking although data returned may be inaccurate. + if self._region in deprecated_regions: + _LOGGER.info("""Region: %s has been deprecated. + Please remove PS4 integration + and Re-configure again to utilize + current regions""", self._region) async def async_added_to_hass(self): """Subscribe PS4 events.""" self.hass.data[PS4_DATA].devices.append(self) + self.check_region() - def update(self): + async def async_update(self): """Retrieve the latest data.""" - try: - status = self._ps4.get_status() - if self._info is None: - # Add entity to registry - self.get_device_info(status) - self._games = self.load_games() - if self._games is not None: - self._source_list = list(sorted(self._games.values())) - # Non-Breaking although data returned may be inaccurate. - if self._region in deprecated_regions: - _LOGGER.info("""Region: %s has been deprecated. - Please remove PS4 integration - and Re-configure again to utilize - current regions""", self._region) - except socket.timeout: - status = None + if self._ps4.ddp_protocol is not None: + # Request Status with asyncio transport. + self._ps4.get_status() + if not self._ps4.connected and not self._ps4.is_standby: + await self._ps4.async_connect() + + # Try to ensure correct status is set on startup for device info. + if self._ps4.ddp_protocol is None: + # Use socket.socket. + await self.hass.async_add_executor_job(self._ps4.get_status) + self._ps4.ddp_protocol = self.hass.data[PS4_DATA].protocol + self.subscribe_to_protocol() + + if self._ps4.status is not None: + if self._info is None: + # Add entity to registry. + await self.async_get_device_info(self._ps4.status) + self._parse_status() + + def _parse_status(self): + """Parse status.""" + status = self._ps4.status + if status is not None: + self._games = self.load_games() + if self._games is not None: + self._source_list = list(sorted(self._games.values())) self._retry = 0 self._disconnected = False if status.get('status') == 'Ok': - # Check if only 1 device in Hass. - if len(self.hass.data[PS4_DATA].devices) == 1: - # Enable keep alive feature for PS4 Connection. - # Only 1 device is supported, Since have to use port 997. - self._ps4.keep_alive = True - else: - self._ps4.keep_alive = False - if self._power_on: - # Auto Login after Turn On. - self._ps4.open() - self._power_on = False title_id = status.get('running-app-titleid') name = status.get('running-app-name') if title_id and name is not None: self._state = STATE_PLAYING if self._media_content_id != title_id: self._media_content_id = title_id - self.get_title_data(title_id, name) + self._media_title = name + self._source = self._media_title + self._media_type = None + asyncio.ensure_future( + self.async_get_title_data(title_id, name)) else: - self.idle() + if self._state != STATE_IDLE: + self.idle() else: - self.state_off() + if self._state != STATE_OFF: + self.state_off() + elif self._retry > 5: self.state_unknown() else: @@ -182,11 +202,13 @@ def idle(self): """Set states for state idle.""" self.reset_title() self._state = STATE_IDLE + self.schedule_update() def state_off(self): """Set states for state off.""" self.reset_title() self._state = STATE_OFF + self.schedule_update() def state_unknown(self): """Set states for state unknown.""" @@ -201,32 +223,47 @@ def reset_title(self): """Update if there is no title.""" self._media_title = None self._media_content_id = None + self._media_type = None self._source = None - def get_title_data(self, title_id, name): + async def async_get_title_data(self, title_id, name): """Get PS Store Data.""" from pyps4_homeassistant.errors import PSDataIncomplete app_name = None art = None + media_type = None try: - title = self._ps4.get_ps_store_data( + title = await self._ps4.async_get_ps_store_data( name, title_id, self._region) + except PSDataIncomplete: - _LOGGER.error( - "Could not find data in region: %s for PS ID: %s", - self._region, title_id) + title = None + except asyncio.TimeoutError: + title = None + _LOGGER.error("PS Store Search Timed out") + else: - app_name = title.name - art = title.cover_art + if title is not None: + app_name = title.name + art = title.cover_art + # Also assume media type is game if search fails. + if title.game_type != 'App': + media_type = MEDIA_TYPE_GAME + else: + media_type = MEDIA_TYPE_APP + else: + _LOGGER.error( + "Could not find data in region: %s for PS ID: %s", + self._region, title_id) + finally: self._media_title = app_name or name self._source = self._media_title - self._media_image = art - if title.game_type == 'App': - self._media_type = MEDIA_TYPE_APP - else: - self._media_type = MEDIA_TYPE_GAME + self._media_image = art or None + self._media_type = media_type + self.update_list() + self.schedule_update() def update_list(self): """Update Game List, Correct data if different.""" @@ -234,9 +271,11 @@ def update_list(self): store = self._games[self._media_content_id] if store != self._media_title: self._games.pop(self._media_content_id) + if self._media_content_id not in self._games: self.add_games(self._media_content_id, self._media_title) self._games = self.load_games() + self._source_list = list(sorted(self._games.values())) def load_games(self): @@ -271,28 +310,50 @@ def add_games(self, title_id, app_name): games.update(game) self.save_games(games) - def get_device_info(self, status): + async def async_get_device_info(self, status): """Set device info for registry.""" - _sw_version = status['system-version'] - _sw_version = _sw_version[1:4] - sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:]) - self._info = { - 'name': status['host-name'], - 'model': 'PlayStation 4', - 'identifiers': { - (PS4_DOMAIN, status['host-id']) - }, - 'manufacturer': 'Sony Interactive Entertainment Inc.', - 'sw_version': sw_version - } - - self._unique_id = format_unique_id(self._creds, status['host-id']) + # If cannot get status on startup, assume info from registry. + if status is None: + _LOGGER.info("Assuming status from registry") + e_registry = await entity_registry.async_get_registry(self.hass) + d_registry = await device_registry.async_get_registry(self.hass) + for entity_id, entry in e_registry.entities.items(): + if entry.config_entry_id == self._entry_id: + self._unique_id = entry.unique_id + self.entity_id = entity_id + break + for device in d_registry.devices.values(): + if self._entry_id in device.config_entries: + self._info = { + 'name': device.name, + 'model': device.model, + 'identifiers': device.identifiers, + 'manufacturer': device.manufacturer, + 'sw_version': device.sw_version + } + break + + else: + _sw_version = status['system-version'] + _sw_version = _sw_version[1:4] + sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:]) + self._info = { + 'name': status['host-name'], + 'model': 'PlayStation 4', + 'identifiers': { + (PS4_DOMAIN, status['host-id']) + }, + 'manufacturer': 'Sony Interactive Entertainment Inc.', + 'sw_version': sw_version + } + + self._unique_id = format_unique_id(self._creds, status['host-id']) async def async_will_remove_from_hass(self): """Remove Entity from Hass.""" - # Close TCP Socket + # Close TCP Transport. if self._ps4.connected: - await self.hass.async_add_executor_job(self._ps4.close) + await self._ps4.close() self.hass.data[PS4_DATA].devices.remove(self) @property @@ -367,43 +428,44 @@ def source_list(self): """List of available input sources.""" return self._source_list - def turn_off(self): + async def async_turn_off(self): """Turn off media player.""" - self._ps4.standby() + await self._ps4.standby() - def turn_on(self): + async def async_turn_on(self): """Turn on the media player.""" - self._power_on = True self._ps4.wakeup() - def media_pause(self): + async def async_media_pause(self): """Send keypress ps to return to menu.""" - self.send_remote_control('ps') + await self.async_send_remote_control('ps') - def media_stop(self): + async def async_media_stop(self): """Send keypress ps to return to menu.""" - self.send_remote_control('ps') + await self.async_send_remote_control('ps') - def select_source(self, source): + async def async_select_source(self, source): """Select input source.""" for title_id, game in self._games.items(): if source.lower().encode(encoding='utf-8') == \ game.lower().encode(encoding='utf-8') \ or source == title_id: + _LOGGER.debug( "Starting PS4 game %s (%s) using source %s", game, title_id, source) - self._ps4.start_title( - title_id, running_id=self._media_content_id) + + await self._ps4.start_title(title_id, self._media_content_id) return + _LOGGER.warning( "Could not start title. '%s' is not in source list", source) return - def send_command(self, command): + async def async_send_command(self, command): """Send Button Command.""" - self.send_remote_control(command) + await self.async_send_remote_control(command) - def send_remote_control(self, command): + async def async_send_remote_control(self, command): """Send RC command.""" - self._ps4.remote_control(command) + await self._ps4.remote_control(command) diff --git a/requirements_all.txt b/requirements_all.txt index dd58748d5126ec..1a10980db390ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1294,7 +1294,7 @@ pypjlink2==1.2.0 pypoint==1.1.1 # homeassistant.components.ps4 -pyps4-homeassistant==0.7.3 +pyps4-homeassistant==0.8.2 # homeassistant.components.qwikswitch pyqwikswitch==0.93 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21cdf12f016b58..d8478353580bcc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -274,7 +274,7 @@ pyopenuv==1.0.9 pyotp==2.2.7 # homeassistant.components.ps4 -pyps4-homeassistant==0.7.3 +pyps4-homeassistant==0.8.2 # homeassistant.components.qwikswitch pyqwikswitch==0.93 From 9aeb75f28d2deb132e19db0f8315895b091c1a58 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 14 Jun 2019 20:14:45 +0200 Subject: [PATCH 220/319] deCONZ - Change attribute Watts to W (#24535) --- homeassistant/components/deconz/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index efdb8ad80919b2..cb60998137fcaf 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -100,7 +100,7 @@ def device_state_attributes(self): self._device.dark is not None: attr[ATTR_DARK] = self._device.dark - if self.unit_of_measurement == 'Watts': + if self.unit_of_measurement == 'W': attr[ATTR_CURRENT] = self._device.current attr[ATTR_VOLTAGE] = self._device.voltage From b0e6f349762357692d268d29687daf8d7c23be72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 14 Jun 2019 21:26:02 +0200 Subject: [PATCH 221/319] Improve stability of netatmo sensor (#24190) * Improve stability of netatmo sensor * Improve stability of netatmo sensor * Improve stability of netatmo sensor * netatmo, except timeout * netatmo, except timeout * netatmo, except timeout * netatmo, except timeout * Always release lock --- homeassistant/components/netatmo/sensor.py | 102 ++++++++++----------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index dabfb827aea0e7..48d82eca2f0580 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,20 +1,21 @@ """Support for the Netatmo Weather Service.""" -from datetime import timedelta import logging -from time import time import threading +from datetime import timedelta +from time import time +import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_NAME, CONF_MODE, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_BATTERY) from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import call_later from homeassistant.util import Throttle - from .const import DATA_NETATMO_AUTH _LOGGER = logging.getLogger(__name__) @@ -101,7 +102,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available Netatmo weather sensors.""" dev = [] - not_handled = {} auth = hass.data[DATA_NETATMO_AUTH] if config.get(CONF_AREAS) is not None: @@ -121,45 +121,55 @@ def setup_platform(hass, config, add_entities, discovery_info=None): area[CONF_MODE] )) else: - for data_class in all_product_classes(): + def _retry(_data): + try: + _dev = find_devices(_data) + except requests.exceptions.Timeout: + return call_later(hass, NETATMO_UPDATE_INTERVAL, + lambda _: _retry(_data)) + if _dev: + add_entities(_dev, True) + + import pyatmo + for data_class in [pyatmo.WeatherStationData, pyatmo.HomeCoachData]: data = NetatmoData(auth, data_class, config.get(CONF_STATION)) - module_items = [] # Test if manually configured if CONF_MODULES in config: module_items = config[CONF_MODULES].items() - else: - # otherwise add all modules and conditions - for module_name in data.get_module_names(): - monitored_conditions = \ - data.station_data.monitoredConditions(module_name) - module_items.append( - (module_name, monitored_conditions)) - - for module_name, monitored_conditions in module_items: - # Test if module exists - if module_name not in data.get_module_names(): - not_handled[module_name] = \ - not_handled[module_name]+1 \ - if module_name in not_handled else 1 - else: - # Only create sensors for monitored properties + for module_name, monitored_conditions in module_items: for condition in monitored_conditions: dev.append(NetatmoSensor( data, module_name, condition.lower(), config.get(CONF_STATION))) + continue - for module_name, _ in not_handled.items(): - _LOGGER.error('Module name: "%s" not found', module_name) + # otherwise add all modules and conditions + try: + dev.extend(find_devices(data)) + except requests.exceptions.Timeout: + call_later(hass, NETATMO_UPDATE_INTERVAL, + lambda _: _retry(data)) if dev: add_entities(dev, True) -def all_product_classes(): - """Provide all handled Netatmo product classes.""" - import pyatmo +def find_devices(data): + """Find all devices.""" + dev = [] + not_handled = [] + for module_name in data.get_module_names(): + if (module_name not in data.get_module_names() + and module_name not in not_handled): + not_handled.append(not_handled) + continue + for condition in data.station_data.monitoredConditions(module_name): + dev.append(NetatmoSensor( + data, module_name, condition.lower(), data.station)) - return [pyatmo.WeatherStationData, pyatmo.HomeCoachData] + for module_name in not_handled: + _LOGGER.error('Module name: "%s" not found', module_name) + return dev class NetatmoSensor(Entity): @@ -515,22 +525,6 @@ def get_module_names(self): self.update() return self.data.keys() - def _detect_platform_type(self): - """Return the XXXData object corresponding to the specified platform. - - The return can be a WeatherStationData or a HomeCoachData. - """ - from pyatmo import NoDevice - try: - station_data = self.data_class(self.auth) - _LOGGER.debug("%s detected!", str(self.data_class.__name__)) - return station_data - except NoDevice: - _LOGGER.warning("No Weather or HomeCoach devices found for %s", - str(self.station) - ) - raise - def update(self): """Call the Netatmo API to update the data. @@ -541,14 +535,20 @@ def update(self): if time() < self._next_update or \ not self._update_in_progress.acquire(False): return - - from pyatmo import NoDevice try: - self.station_data = self._detect_platform_type() - except NoDevice: - return + from pyatmo import NoDevice + try: + self.station_data = self.data_class(self.auth) + _LOGGER.debug("%s detected!", str(self.data_class.__name__)) + except NoDevice: + _LOGGER.warning("No Weather or HomeCoach devices found for %s", + str(self.station) + ) + return + except requests.exceptions.Timeout: + _LOGGER.warning("Timed out when connecting to Netatmo server.") + return - try: if self.station is not None: data = self.station_data.lastData( station=self.station, exclude=3600) From d8f5e9b878279891d1a58c4014b2710fc92b7947 Mon Sep 17 00:00:00 2001 From: rolfberkenbosch <30292281+rolfberkenbosch@users.noreply.github.com> Date: Fri, 14 Jun 2019 22:31:07 +0200 Subject: [PATCH 222/319] Update meteoalertapi to 0.1.5 (#24528) --- homeassistant/components/meteoalarm/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json index 015033a0e38a94..2148375621307c 100644 --- a/homeassistant/components/meteoalarm/manifest.json +++ b/homeassistant/components/meteoalarm/manifest.json @@ -3,7 +3,7 @@ "name": "meteoalarm", "documentation": "https://www.home-assistant.io/components/meteoalarm", "requirements": [ - "meteoalertapi==0.1.3" + "meteoalertapi==0.1.5" ], "dependencies": [], "codeowners": ["@rolfberkenbosch"] diff --git a/requirements_all.txt b/requirements_all.txt index 1a10980db390ac..b26186f429982f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -745,7 +745,7 @@ mbddns==0.1.2 messagebird==1.2.0 # homeassistant.components.meteoalarm -meteoalertapi==0.1.3 +meteoalertapi==0.1.5 # homeassistant.components.meteo_france meteofrance==0.3.7 From 9a8b945118ea49b2816ffd34f9d9b13756524e7f Mon Sep 17 00:00:00 2001 From: ktnrg45 <38207570+ktnrg45@users.noreply.github.com> Date: Fri, 14 Jun 2019 13:47:50 -0700 Subject: [PATCH 223/319] PS4 bump to 0.8.3 (#24527) * 0.8.3 * 0.8.3 * 0.8.3 * add unsubscribe method --- homeassistant/components/ps4/manifest.json | 2 +- homeassistant/components/ps4/media_player.py | 8 +++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index a94fcd44082482..aab01d0eda2bf7 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ps4", "requirements": [ - "pyps4-homeassistant==0.8.2" + "pyps4-homeassistant==0.8.3" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 7d51dd4463e176..f1d78564674d4e 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -129,6 +129,12 @@ def subscribe_to_protocol(self): self.hass.data[PS4_DATA].protocol.add_callback( self._ps4, self.status_callback) + @callback + def unsubscribe_to_protocol(self): + """Notify protocol to remove callback.""" + self.hass.data[PS4_DATA].protocol.remove_callback( + self._ps4, self.status_callback) + def check_region(self): """Display logger msg if region is deprecated.""" # Non-Breaking although data returned may be inaccurate. @@ -246,7 +252,7 @@ async def async_get_title_data(self, title_id, name): if title is not None: app_name = title.name art = title.cover_art - # Also assume media type is game if search fails. + # Assume media type is game if not app. if title.game_type != 'App': media_type = MEDIA_TYPE_GAME else: diff --git a/requirements_all.txt b/requirements_all.txt index b26186f429982f..f2b314d8a78de1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1294,7 +1294,7 @@ pypjlink2==1.2.0 pypoint==1.1.1 # homeassistant.components.ps4 -pyps4-homeassistant==0.8.2 +pyps4-homeassistant==0.8.3 # homeassistant.components.qwikswitch pyqwikswitch==0.93 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8478353580bcc..20f8d6660335c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -274,7 +274,7 @@ pyopenuv==1.0.9 pyotp==2.2.7 # homeassistant.components.ps4 -pyps4-homeassistant==0.8.2 +pyps4-homeassistant==0.8.3 # homeassistant.components.qwikswitch pyqwikswitch==0.93 From 92816b57ef2af0b1237d9c93b986070a83fb5034 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 14 Jun 2019 14:53:13 -0700 Subject: [PATCH 224/319] Update translations --- .../adguard/.translations/es-419.json | 27 +++++++++++ .../components/adguard/.translations/it.json | 21 ++++++++ .../components/adguard/.translations/ko.json | 29 +++++++++++ .../components/adguard/.translations/lb.json | 27 +++++++++++ .../components/adguard/.translations/nl.json | 29 +++++++++++ .../components/adguard/.translations/pl.json | 29 +++++++++++ .../components/adguard/.translations/sl.json | 29 +++++++++++ .../ambiclimate/.translations/es-419.json | 12 +++++ .../ambiclimate/.translations/it.json | 8 ++++ .../ambiclimate/.translations/nl.json | 23 +++++++++ .../components/axis/.translations/ca.json | 3 +- .../components/axis/.translations/en.json | 3 +- .../components/axis/.translations/it.json | 4 +- .../components/axis/.translations/ko.json | 3 +- .../components/axis/.translations/lb.json | 4 +- .../components/axis/.translations/nl.json | 14 +++++- .../components/axis/.translations/pl.json | 3 +- .../components/axis/.translations/pt-BR.json | 19 +++++++- .../components/axis/.translations/ru.json | 3 +- .../components/axis/.translations/sl.json | 1 + .../axis/.translations/zh-Hant.json | 3 +- .../components/deconz/.translations/it.json | 3 +- .../components/deconz/.translations/ko.json | 4 +- .../components/deconz/.translations/lb.json | 2 + .../components/deconz/.translations/nl.json | 13 ++++- .../components/deconz/.translations/pl.json | 2 + .../components/deconz/.translations/sl.json | 2 + .../deconz/.translations/zh-Hant.json | 2 + .../dialogflow/.translations/fr.json | 2 +- .../components/ebusd/.translations/pt-BR.json | 6 +++ .../components/esphome/.translations/it.json | 5 ++ .../components/esphome/.translations/lb.json | 1 + .../components/esphome/.translations/nl.json | 1 + .../esphome/.translations/pt-BR.json | 3 ++ .../gpslogger/.translations/nl.json | 7 +++ .../components/hangouts/.translations/it.json | 1 + .../components/hangouts/.translations/nl.json | 1 + .../components/heos/.translations/it.json | 6 ++- .../components/heos/.translations/nl.json | 10 +++- .../homekit_controller/.translations/it.json | 8 +++- .../homekit_controller/.translations/ko.json | 2 +- .../homekit_controller/.translations/lb.json | 1 + .../homekit_controller/.translations/nl.json | 10 ++++ .../.translations/pt-BR.json | 6 +++ .../homekit_controller/.translations/sl.json | 1 + .../homekit_controller/.translations/sv.json | 2 +- .../components/hue/.translations/ko.json | 1 + .../components/hue/.translations/lb.json | 2 + .../components/hue/.translations/nl.json | 2 + .../components/hue/.translations/pl.json | 1 + .../components/hue/.translations/sl.json | 1 + .../components/hue/.translations/zh-Hant.json | 1 + .../components/ipma/.translations/it.json | 2 +- .../components/ipma/.translations/pt-BR.json | 18 +++++++ .../components/iqvia/.translations/it.json | 17 +++++++ .../components/iqvia/.translations/nl.json | 3 +- .../components/life360/.translations/ca.json | 27 +++++++++++ .../components/life360/.translations/en.json | 48 +++++++++---------- .../components/life360/.translations/it.json | 26 ++++++++++ .../components/life360/.translations/ko.json | 27 +++++++++++ .../components/life360/.translations/lb.json | 27 +++++++++++ .../components/life360/.translations/nl.json | 27 +++++++++++ .../components/life360/.translations/pl.json | 27 +++++++++++ .../life360/.translations/pt-BR.json | 27 +++++++++++ .../components/life360/.translations/ru.json | 27 +++++++++++ .../components/life360/.translations/sl.json | 27 +++++++++++ .../life360/.translations/zh-Hant.json | 27 +++++++++++ .../logi_circle/.translations/it.json | 31 ++++++++++++ .../logi_circle/.translations/nl.json | 22 ++++++++- .../logi_circle/.translations/pt-BR.json | 1 + .../components/mailgun/.translations/nl.json | 3 ++ .../mobile_app/.translations/it.json | 4 ++ .../mobile_app/.translations/nl.json | 14 ++++++ .../moon/.translations/sensor.nl.json | 2 +- .../components/mqtt/.translations/ko.json | 2 +- .../components/nest/.translations/lb.json | 8 ++-- .../onboarding/.translations/it.json | 7 +++ .../components/openuv/.translations/it.json | 2 +- .../components/point/.translations/nl.json | 1 + .../components/ps4/.translations/it.json | 2 + .../components/ps4/.translations/nl.json | 11 +++-- .../components/ps4/.translations/pt-BR.json | 27 ++++++++++- .../smartthings/.translations/pt-BR.json | 10 ++++ .../components/smhi/.translations/it.json | 2 +- .../components/somfy/.translations/ca.json | 13 +++++ .../components/somfy/.translations/ko.json | 13 +++++ .../components/somfy/.translations/lb.json | 13 +++++ .../components/somfy/.translations/nl.json | 13 +++++ .../components/somfy/.translations/pl.json | 13 +++++ .../components/somfy/.translations/ru.json | 13 +++++ .../somfy/.translations/zh-Hant.json | 13 +++++ .../tellduslive/.translations/nl.json | 4 ++ .../components/toon/.translations/it.json | 32 +++++++++++++ .../components/toon/.translations/pt-BR.json | 11 +++++ .../components/tplink/.translations/it.json | 15 ++++++ .../tplink/.translations/pt-BR.json | 11 +++++ .../components/twilio/.translations/nl.json | 3 ++ .../components/upnp/.translations/nl.json | 4 ++ .../components/wemo/.translations/it.json | 15 ++++++ .../components/wemo/.translations/ko.json | 15 ++++++ .../components/wemo/.translations/lb.json | 15 ++++++ .../components/wemo/.translations/nl.json | 15 ++++++ .../components/wemo/.translations/pl.json | 15 ++++++ .../components/wemo/.translations/sl.json | 15 ++++++ .../wemo/.translations/zh-Hant.json | 15 ++++++ .../components/zone/.translations/it.json | 2 +- 106 files changed, 1123 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/adguard/.translations/es-419.json create mode 100644 homeassistant/components/adguard/.translations/it.json create mode 100644 homeassistant/components/adguard/.translations/ko.json create mode 100644 homeassistant/components/adguard/.translations/lb.json create mode 100644 homeassistant/components/adguard/.translations/nl.json create mode 100644 homeassistant/components/adguard/.translations/pl.json create mode 100644 homeassistant/components/adguard/.translations/sl.json create mode 100644 homeassistant/components/ambiclimate/.translations/es-419.json create mode 100644 homeassistant/components/ambiclimate/.translations/it.json create mode 100644 homeassistant/components/ambiclimate/.translations/nl.json create mode 100644 homeassistant/components/ebusd/.translations/pt-BR.json create mode 100644 homeassistant/components/ipma/.translations/pt-BR.json create mode 100644 homeassistant/components/iqvia/.translations/it.json create mode 100644 homeassistant/components/life360/.translations/ca.json create mode 100644 homeassistant/components/life360/.translations/it.json create mode 100644 homeassistant/components/life360/.translations/ko.json create mode 100644 homeassistant/components/life360/.translations/lb.json create mode 100644 homeassistant/components/life360/.translations/nl.json create mode 100644 homeassistant/components/life360/.translations/pl.json create mode 100644 homeassistant/components/life360/.translations/pt-BR.json create mode 100644 homeassistant/components/life360/.translations/ru.json create mode 100644 homeassistant/components/life360/.translations/sl.json create mode 100644 homeassistant/components/life360/.translations/zh-Hant.json create mode 100644 homeassistant/components/logi_circle/.translations/it.json create mode 100644 homeassistant/components/mobile_app/.translations/nl.json create mode 100644 homeassistant/components/onboarding/.translations/it.json create mode 100644 homeassistant/components/smartthings/.translations/pt-BR.json create mode 100644 homeassistant/components/somfy/.translations/ca.json create mode 100644 homeassistant/components/somfy/.translations/ko.json create mode 100644 homeassistant/components/somfy/.translations/lb.json create mode 100644 homeassistant/components/somfy/.translations/nl.json create mode 100644 homeassistant/components/somfy/.translations/pl.json create mode 100644 homeassistant/components/somfy/.translations/ru.json create mode 100644 homeassistant/components/somfy/.translations/zh-Hant.json create mode 100644 homeassistant/components/toon/.translations/it.json create mode 100644 homeassistant/components/toon/.translations/pt-BR.json create mode 100644 homeassistant/components/tplink/.translations/it.json create mode 100644 homeassistant/components/tplink/.translations/pt-BR.json create mode 100644 homeassistant/components/wemo/.translations/it.json create mode 100644 homeassistant/components/wemo/.translations/ko.json create mode 100644 homeassistant/components/wemo/.translations/lb.json create mode 100644 homeassistant/components/wemo/.translations/nl.json create mode 100644 homeassistant/components/wemo/.translations/pl.json create mode 100644 homeassistant/components/wemo/.translations/sl.json create mode 100644 homeassistant/components/wemo/.translations/zh-Hant.json diff --git a/homeassistant/components/adguard/.translations/es-419.json b/homeassistant/components/adguard/.translations/es-419.json new file mode 100644 index 00000000000000..c3d57832cf4c3e --- /dev/null +++ b/homeassistant/components/adguard/.translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + }, + "error": { + "connection_error": "Error al conectar." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a la p\u00e1gina principal de AdGuard proporcionada por el complemento Hass.io: {addon}?", + "title": "AdGuard Home a trav\u00e9s del complemento Hass.io" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "AdGuard Home utiliza un certificado SSL", + "username": "Nombre de usuario", + "verify_ssl": "AdGuard Home utiliza un certificado adecuado" + }, + "description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/it.json b/homeassistant/components/adguard/.translations/it.json new file mode 100644 index 00000000000000..6cd8767334dc9f --- /dev/null +++ b/homeassistant/components/adguard/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home." + }, + "error": { + "connection_error": "Impossibile connettersi." + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "ssl": "AdGuard Home utilizza un certificato SSL", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/ko.json b/homeassistant/components/adguard/.translations/ko.json new file mode 100644 index 00000000000000..fe58b5d74d5125 --- /dev/null +++ b/homeassistant/components/adguard/.translations/ko.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 AdGuard Home \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "hassio_confirm": { + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c AdGuard Home \uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Hass.io \uc560\ub4dc\uc628\uc758 AdGuard Home" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "ssl": "AdGuard Home \uc740 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "AdGuard Home \uc740 \uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + }, + "description": "\ubaa8\ub2c8\ud130\ub9c1 \ubc0f \uc81c\uc5b4\uac00 \uac00\ub2a5\ud558\ub3c4\ub85d AdGuard Home \uc778\uc2a4\ud134\uc2a4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "title": "AdGuard Home \uc5f0\uacb0" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/lb.json b/homeassistant/components/adguard/.translations/lb.json new file mode 100644 index 00000000000000..fd837994ebbd58 --- /dev/null +++ b/homeassistant/components/adguard/.translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun AdGuard Home ass erlaabt." + }, + "error": { + "connection_error": "Feeler beim verbannen." + }, + "step": { + "hassio_confirm": { + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Apparat", + "password": "Passwuert", + "port": "Port", + "ssl": "AdGuard Home benotzt een SSL Zertifikat", + "username": "Benotzernumm", + "verify_ssl": "AdGuard Home benotzt een eegenen Zertifikat" + }, + "title": "Verbannt \u00e4ren AdGuard Home" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/nl.json b/homeassistant/components/adguard/.translations/nl.json new file mode 100644 index 00000000000000..e0e61c045255d4 --- /dev/null +++ b/homeassistant/components/adguard/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van AdGuard Home is toegestaan." + }, + "error": { + "connection_error": "Kon niet verbinden." + }, + "step": { + "hassio_confirm": { + "description": "Wilt u Home Assistant configureren om verbinding te maken met AdGuard Home van de Hass.io-add-on: {addon}?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "ssl": "AdGuard Home maakt gebruik van een SSL certificaat", + "username": "Gebruikersnaam", + "verify_ssl": "AdGuard Home maakt gebruik van een goed certificaat" + }, + "description": "Stel uw AdGuard Home-instantie in om toezicht en controle mogelijk te maken.", + "title": "Link uw AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/pl.json b/homeassistant/components/adguard/.translations/pl.json new file mode 100644 index 00000000000000..44d7e6c93ed7af --- /dev/null +++ b/homeassistant/components/adguard/.translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja AdGuard Home." + }, + "error": { + "connection_error": "Nieudane po\u0142\u0105czenie." + }, + "step": { + "hassio_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z AdGuard Home przez dodatek hass.io {addon}?", + "title": "AdGuard Home dzi\u0119ki dodatkowi Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Has\u0142o", + "port": "Port", + "ssl": "AdGuard Home u\u017cywa certyfikatu SSL.", + "username": "Nazwa u\u017cytkownika", + "verify_ssl": "AdGuard Home u\u017cywa odpowiedniego certyfikatu." + }, + "description": "Skonfiguruj swoj\u0105 instancj\u0119 AdGuard Home aby umo\u017cliwi\u0107 monitorowanie i nadz\u00f3r sieci.", + "title": "Po\u0142\u0105cz sw\u00f3j AdGuard Home" + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/sl.json b/homeassistant/components/adguard/.translations/sl.json new file mode 100644 index 00000000000000..c098f382bfd4c5 --- /dev/null +++ b/homeassistant/components/adguard/.translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dovoljena je samo ena konfiguracija AdGuard Home." + }, + "error": { + "connection_error": "Povezava ni uspela." + }, + "step": { + "hassio_confirm": { + "description": "\u017delite konfigurirati Home Assistant-a za povezavo z AdGuard Home, ki ga ponuja hass.io add-on {addon} ?", + "title": "AdGuard Home preko dodatka Hass.io" + }, + "user": { + "data": { + "host": "Gostitelj", + "password": "Geslo", + "port": "Vrata", + "ssl": "AdGuard Home uporablja SSL certifikat", + "username": "Uporabni\u0161ko ime", + "verify_ssl": "AdGuard Home uporablja ustrezen certifikat" + }, + "description": "Nastavite primerek AdGuard Home, da omogo\u010dite spremljanje in nadzor.", + "title": "Pove\u017eite svoj AdGuard Home." + } + }, + "title": "AdGuard Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/es-419.json b/homeassistant/components/ambiclimate/.translations/es-419.json new file mode 100644 index 00000000000000..eaac252d605da5 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "access_token": "Error desconocido al generar un token de acceso.", + "already_setup": "La cuenta de Ambiclimate est\u00e1 configurada.", + "no_config": "Es necesario configurar Ambiclimate antes de poder autenticarse con \u00e9l. Por favor, lea las instrucciones](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3n exitosa con Ambiclimate" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/it.json b/homeassistant/components/ambiclimate/.translations/it.json new file mode 100644 index 00000000000000..b062eb67c1ffea --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/it.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_setup": "L'account Ambiclimate \u00e8 configurato." + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/.translations/nl.json b/homeassistant/components/ambiclimate/.translations/nl.json new file mode 100644 index 00000000000000..ca4d0b912ab7b3 --- /dev/null +++ b/homeassistant/components/ambiclimate/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "access_token": "Onbekende fout bij het genereren van een toegangstoken.", + "already_setup": "Het Ambiclimate-account is geconfigureerd.", + "no_config": "U moet Ambiclimate configureren voordat u zich ermee kunt authenticeren. (Lees de instructies) (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Ambiclimate" + }, + "error": { + "follow_link": "Gelieve de link te volgen en te verifi\u00ebren voordat u op Verzenden drukt.", + "no_token": "Niet geverifieerd met Ambiclimate" + }, + "step": { + "auth": { + "description": "Volg deze [link] ( {authorization_url} ) en Toestaan toegang tot uw Ambiclimate-account, kom dan terug en druk hieronder op Verzenden . \n (Zorg ervoor dat de opgegeven callback-URL {cb_url} )", + "title": "Authenticatie Ambiclimate" + } + }, + "title": "Ambiclimate" + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json index e55d23b2a910ab..75dd89ef9c15b0 100644 --- a/homeassistant/components/axis/.translations/ca.json +++ b/homeassistant/components/axis/.translations/ca.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", "bad_config_file": "Dades incorrectes del fitxer de configuraci\u00f3", - "link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible" + "link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible", + "not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis" }, "error": { "already_configured": "El dispositiu ja est\u00e0 configurat", diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json index 5bf0e31b0b22e3..5fd5d9be5655e9 100644 --- a/homeassistant/components/axis/.translations/en.json +++ b/homeassistant/components/axis/.translations/en.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Device is already configured", "bad_config_file": "Bad data from config file", - "link_local_address": "Link local addresses are not supported" + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" }, "error": { "already_configured": "Device is already configured", diff --git a/homeassistant/components/axis/.translations/it.json b/homeassistant/components/axis/.translations/it.json index 2141bf34942bc8..2498c28ec33aca 100644 --- a/homeassistant/components/axis/.translations/it.json +++ b/homeassistant/components/axis/.translations/it.json @@ -2,10 +2,12 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "bad_config_file": "Dati errati dal file di configurazione" + "bad_config_file": "Dati errati dal file di configurazione", + "not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis" }, "error": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", "device_unavailable": "Il dispositivo non \u00e8 disponibile", "faulty_credentials": "Credenziali utente non valide" }, diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json index d16bd0f6e5e6f6..5ceaa0828103ee 100644 --- a/homeassistant/components/axis/.translations/ko.json +++ b/homeassistant/components/axis/.translations/ko.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" + "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4" }, "error": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", diff --git a/homeassistant/components/axis/.translations/lb.json b/homeassistant/components/axis/.translations/lb.json index 6b0728f4030d86..281eaa7c8818ba 100644 --- a/homeassistant/components/axis/.translations/lb.json +++ b/homeassistant/components/axis/.translations/lb.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "Apparat ass scho konfigur\u00e9iert", "bad_config_file": "Feelerhaft Donn\u00e9e\u00eb aus der Konfiguratioun's Datei", - "link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt" + "link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt", + "not_axis_device": "Entdeckten Apparat ass keen Axis Apparat" }, "error": { "already_configured": "Apparat ass scho konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", "device_unavailable": "Apparat ass net erreechbar", "faulty_credentials": "Ong\u00eblteg Login Informatioune" }, diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json index e46f35aa1f9a48..83395283404040 100644 --- a/homeassistant/components/axis/.translations/nl.json +++ b/homeassistant/components/axis/.translations/nl.json @@ -1,6 +1,14 @@ { "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "bad_config_file": "Slechte gegevens van het configuratiebestand", + "link_local_address": "Link-lokale adressen worden niet ondersteund", + "not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat" + }, "error": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", "device_unavailable": "Apparaat is niet beschikbaar", "faulty_credentials": "Ongeldige gebruikersreferenties" }, @@ -11,8 +19,10 @@ "password": "Wachtwoord", "port": "Poort", "username": "Gebruikersnaam" - } + }, + "title": "Stel het Axis-apparaat in" } - } + }, + "title": "Axis-apparaat" } } \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json index 9d8de4c4a7b1ba..88e803605363de 100644 --- a/homeassistant/components/axis/.translations/pl.json +++ b/homeassistant/components/axis/.translations/pl.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "bad_config_file": "B\u0142\u0119dne dane z pliku konfiguracyjnego", - "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane" + "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane", + "not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis" }, "error": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", diff --git a/homeassistant/components/axis/.translations/pt-BR.json b/homeassistant/components/axis/.translations/pt-BR.json index 53b8079a1ea29a..4126d99e2e21c8 100644 --- a/homeassistant/components/axis/.translations/pt-BR.json +++ b/homeassistant/components/axis/.translations/pt-BR.json @@ -1,7 +1,22 @@ { "config": { + "abort": { + "not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis" + }, "error": { - "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento." - } + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.", + "faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "username": "Nome de usu\u00e1rio" + } + } + }, + "title": "Dispositivo Axis" } } \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/ru.json b/homeassistant/components/axis/.translations/ru.json index dee7876fffcbf0..67d720aa85f06a 100644 --- a/homeassistant/components/axis/.translations/ru.json +++ b/homeassistant/components/axis/.translations/ru.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430", "bad_config_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438", - "link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f" + "link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f", + "not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis" }, "error": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430", diff --git a/homeassistant/components/axis/.translations/sl.json b/homeassistant/components/axis/.translations/sl.json index 41d2994987333f..cf58ed345ce611 100644 --- a/homeassistant/components/axis/.translations/sl.json +++ b/homeassistant/components/axis/.translations/sl.json @@ -7,6 +7,7 @@ }, "error": { "already_configured": "Naprava je \u017ee konfigurirana", + "already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.", "device_unavailable": "Naprava ni na voljo", "faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki" }, diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json index 7b93d2f7243ec8..1457db2b600276 100644 --- a/homeassistant/components/axis/.translations/zh-Hant.json +++ b/homeassistant/components/axis/.translations/zh-Hant.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548", - "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740" + "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740", + "not_axis_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Axis \u88dd\u7f6e" }, "error": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json index dfff5743df7aa2..f15a2ddf26536a 100644 --- a/homeassistant/components/deconz/.translations/it.json +++ b/homeassistant/components/deconz/.translations/it.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", "no_bridges": "Nessun bridge deCONZ rilevato", - "one_instance_only": "Il componente supporto solo un'istanza di deCONZ" + "one_instance_only": "Il componente supporto solo un'istanza di deCONZ", + "updated_instance": "Istanza deCONZ aggiornata con nuovo indirizzo host" }, "error": { "no_key": "Impossibile ottenere una API key" diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index f68b4dc10e9a51..4bf845d50e5fd2 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\ube0c\ub9bf\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "not_deconz_bridge": "deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc544\ub2d9\ub2c8\ub2e4", "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4", "updated_instance": "deCONZ \uc778\uc2a4\ud134\uc2a4\ub97c \uc0c8\ub85c\uc6b4 \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub85c \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4" }, @@ -15,7 +17,7 @@ "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9", "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9" }, - "description": "Hass.io \ubd80\uac00\uae30\ub2a5 {addon} \ub85c(\uc73c\ub85c) deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Hass.io \uc560\ub4dc\uc628\uc758 deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" }, "init": { diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 3308a557d5dfb1..60a27304d78437 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Bridge ass schon konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", "no_bridges": "Keng dECONZ bridges fonnt", + "not_deconz_bridge": "Keng deCONZ Bridge", "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng deCONZ Instanz", "updated_instance": "deCONZ Instanz gouf mat der neier Adress vum Apparat ge\u00e4nnert" }, diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index d4b65f16552a8d..19477bbed3f33c 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -2,13 +2,24 @@ "config": { "abort": { "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "Configuratiestroom voor bridge wordt al ingesteld.", "no_bridges": "Geen deCONZ bruggen ontdekt", - "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance" + "not_deconz_bridge": "Dit is geen deCONZ bridge", + "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance", + "updated_instance": "DeCONZ-instantie bijgewerkt met nieuw host-adres" }, "error": { "no_key": "Kon geen API-sleutel ophalen" }, "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "description": "Wilt u de Home Assistant configureren om verbinding te maken met de deCONZ gateway van de hass.io add-on {addon}?", + "title": "deCONZ Zigbee Gateway via Hass.io add-on" + }, "init": { "data": { "host": "Host", diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index c3eded43341165..e9b5c21f31b806 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Mostek jest ju\u017c skonfigurowany", + "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", + "not_deconz_bridge": "Nie mostek deCONZ", "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ", "updated_instance": "Zaktualizowano instancj\u0119 deCONZ o nowy adres hosta" }, diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index 1a8550ca08fba6..58ecde32a8484d 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Most je \u017ee nastavljen", + "already_in_progress": "Konfiguracijski tok za most je \u017ee v teku.", "no_bridges": "Ni odkritih mostov deCONZ", + "not_deconz_bridge": "Ni deCONZ most", "one_instance_only": "Komponenta podpira le en primerek deCONZ", "updated_instance": "Posodobljen deCONZ z novim naslovom gostitelja" }, diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 06b174f27f53b4..0c9efd8992b873 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "Bridge \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe", + "not_deconz_bridge": "\u975e deCONZ Bridge \u88dd\u7f6e", "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u7269\u4ef6", "updated_instance": "\u4f7f\u7528\u65b0\u4e3b\u6a5f\u7aef\u4f4d\u5740\u66f4\u65b0 deCONZ \u5be6\u4f8b" }, diff --git a/homeassistant/components/dialogflow/.translations/fr.json b/homeassistant/components/dialogflow/.translations/fr.json index 53edb21b8e82c3..e9eabeff6381d9 100644 --- a/homeassistant/components/dialogflow/.translations/fr.json +++ b/homeassistant/components/dialogflow/.translations/fr.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Une seule instance est n\u00e9cessaire." }, "create_entry": { - "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Mailgun] ( {mailgun_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / json \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes." + "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Mailgun] ( {dialogflow_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / json \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes." }, "step": { "user": { diff --git a/homeassistant/components/ebusd/.translations/pt-BR.json b/homeassistant/components/ebusd/.translations/pt-BR.json new file mode 100644 index 00000000000000..9925fdfab9cc34 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/pt-BR.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dia", + "night": "Noite" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json index 47047a95560596..b9088c2eadc34e 100644 --- a/homeassistant/components/esphome/.translations/it.json +++ b/homeassistant/components/esphome/.translations/it.json @@ -8,6 +8,7 @@ "invalid_password": "Password non valida!", "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, impostare un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { @@ -16,6 +17,10 @@ "description": "Inserisci la password per {name} che hai impostato nella tua configurazione.", "title": "Inserisci la password" }, + "discovery_confirm": { + "description": "Vuoi aggiungere il nodo ESPHome ` {name} ` a Home Assistant?", + "title": "Trovato nodo ESPHome" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/lb.json b/homeassistant/components/esphome/.translations/lb.json index a240debfaf5af6..955b050bc5b19d 100644 --- a/homeassistant/components/esphome/.translations/lb.json +++ b/homeassistant/components/esphome/.translations/lb.json @@ -8,6 +8,7 @@ "invalid_password": "Ong\u00ebltegt Passwuert!", "resolve_error": "Kann d'Adresse vum ESP net opl\u00e9isen. Falls d\u00ebse Problem weiderhi besteet dann defin\u00e9iert eng statesch IP Adresse:\nhttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json index aba738f4e0f6fb..a56130b2263980 100644 --- a/homeassistant/components/esphome/.translations/nl.json +++ b/homeassistant/components/esphome/.translations/nl.json @@ -8,6 +8,7 @@ "invalid_password": "Ongeldig wachtwoord!", "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/pt-BR.json b/homeassistant/components/esphome/.translations/pt-BR.json index 80a5c28598c8b9..77a98a875ba34d 100644 --- a/homeassistant/components/esphome/.translations/pt-BR.json +++ b/homeassistant/components/esphome/.translations/pt-BR.json @@ -17,6 +17,9 @@ "description": "Por favor, digite a senha que voc\u00ea definiu em sua configura\u00e7\u00e3o.", "title": "Digite a senha" }, + "discovery_confirm": { + "title": "N\u00f3 ESPHome descoberto" + }, "user": { "data": { "port": "Porta" diff --git a/homeassistant/components/gpslogger/.translations/nl.json b/homeassistant/components/gpslogger/.translations/nl.json index 4956cf52f267d0..f34a21b7897928 100644 --- a/homeassistant/components/gpslogger/.translations/nl.json +++ b/homeassistant/components/gpslogger/.translations/nl.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant-instantie moet via internet toegankelijk zijn om berichten van GPSLogger te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "create_entry": { + "default": "Om evenementen naar Home Assistant te verzenden, moet u de webhook-functie instellen in GPSLogger. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n\n Zie [de documentatie] ( {docs_url} ) voor meer informatie." + }, "step": { "user": { "description": "Weet je zeker dat je de GPSLogger Webhook wilt instellen?", diff --git a/homeassistant/components/hangouts/.translations/it.json b/homeassistant/components/hangouts/.translations/it.json index 76a9adcb40efe4..ad8dafd17ec74a 100644 --- a/homeassistant/components/hangouts/.translations/it.json +++ b/homeassistant/components/hangouts/.translations/it.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", "email": "Indirizzo email", "password": "Password" }, diff --git a/homeassistant/components/hangouts/.translations/nl.json b/homeassistant/components/hangouts/.translations/nl.json index da9bc9edd7b217..9f9b121a7c2a85 100644 --- a/homeassistant/components/hangouts/.translations/nl.json +++ b/homeassistant/components/hangouts/.translations/nl.json @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "Autorisatiecode (vereist voor handmatige authenticatie)", "email": "E-mailadres", "password": "Wachtwoord" }, diff --git a/homeassistant/components/heos/.translations/it.json b/homeassistant/components/heos/.translations/it.json index 32667d0dbe8e41..20a4060add4ba5 100644 --- a/homeassistant/components/heos/.translations/it.json +++ b/homeassistant/components/heos/.translations/it.json @@ -1,12 +1,16 @@ { "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare una singola connessione Heos poich\u00e9 supporta tutti i dispositivi sulla rete." + }, "error": { "connection_failure": "Impossibile connettersi all'host specificato." }, "step": { "user": { "data": { - "access_token": "Host" + "access_token": "Host", + "host": "Host" }, "description": "Inserire il nome host o l'indirizzo IP di un dispositivo Heos (preferibilmente uno connesso alla rete tramite cavo).", "title": "Connetti a Heos" diff --git a/homeassistant/components/heos/.translations/nl.json b/homeassistant/components/heos/.translations/nl.json index d3c91af2c1649d..3e7105e8cb358d 100644 --- a/homeassistant/components/heos/.translations/nl.json +++ b/homeassistant/components/heos/.translations/nl.json @@ -1,10 +1,18 @@ { "config": { + "abort": { + "already_setup": "U kunt alleen een enkele Heos-verbinding configureren, omdat deze alle apparaten in het netwerk ondersteunt." + }, + "error": { + "connection_failure": "Kan geen verbinding maken met de opgegeven host." + }, "step": { "user": { "data": { - "access_token": "Host" + "access_token": "Host", + "host": "Host" }, + "description": "Voer de hostnaam of het IP-adres van een Heos-apparaat in (bij voorkeur een die via een kabel is verbonden met het netwerk).", "title": "Verbinding maken met Heos" } }, diff --git a/homeassistant/components/homekit_controller/.translations/it.json b/homeassistant/components/homekit_controller/.translations/it.json index 6ec1c28344845c..a1d460d12dcfa8 100644 --- a/homeassistant/components/homekit_controller/.translations/it.json +++ b/homeassistant/components/homekit_controller/.translations/it.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller." + "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller.", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", + "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Si prega di resettare l'accessorio e riprovare.", + "ignored_model": "Il supporto di HomeKit per questo modello \u00e8 bloccato poich\u00e9 \u00e8 disponibile un'integrazione nativa con pi\u00f9 funzionalit\u00e0.", + "invalid_config_entry": "Questo dispositivo viene visualizzato come pronto per l'associazione, ma c'\u00e8 gi\u00e0 una voce di configurazione in conflitto in Home Assistant che deve prima essere rimossa.", + "no_devices": "Non \u00e8 stato possibile trovare dispositivi non associati" }, "error": { "authentication_error": "Codice HomeKit errato. Per favore, controllate e riprovate.", "unable_to_pair": "Impossibile abbinare, per favore riprova.", "unknown_error": "Il dispositivo ha riportato un errore sconosciuto. L'abbinamento non \u00e8 riuscito." }, + "flow_title": "Accessorio HomeKit: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json index 6f494120f1da53..8837e501a8aae7 100644 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -7,7 +7,7 @@ "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "invalid_config_entry": "\uc774 \uae30\uae30\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.", - "no_devices": "\ud398\uc5b4\ub9c1\ub418\uc9c0 \uc54a\uc740 \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + "no_devices": "\ud398\uc5b4\ub9c1\uc774 \ud544\uc694\ud55c \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" }, "error": { "authentication_error": "HomeKit \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json index 882a1d3bc3af43..97efd428a0469e 100644 --- a/homeassistant/components/homekit_controller/.translations/lb.json +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "D'Kupplung kann net dob\u00e4igesat ginn, well den Apparat net m\u00e9i siichtbar ass", "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", "already_paired": "D\u00ebsen Accessoire ass schonn mat engem aneren Apparat verbonnen. S\u00ebtzt den Apparat op Wierksastellungen zer\u00e9ck an prob\u00e9iert nach emol w.e.g.", "ignored_model": "HomeKit Support fir d\u00ebse Modell ass block\u00e9iert well eng m\u00e9i komplett nativ Integratioun disponibel ass.", "invalid_config_entry": "D\u00ebsen Apparat mellt sech prett fir ze verbanne mee et g\u00ebtt schonn eng Entr\u00e9e am Home Assistant d\u00e9i ee Konflikt duerstellt welch fir d'\u00e9ischt muss erausgeholl ginn.", diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json index a714934372b77c..30494295f0ea0f 100644 --- a/homeassistant/components/homekit_controller/.translations/nl.json +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -1,20 +1,30 @@ { "config": { "abort": { + "accessory_not_found_error": "Kan geen koppeling toevoegen omdat het apparaat niet langer kan worden gevonden.", + "already_configured": "Accessoire is al geconfigureerd met deze controller.", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", + "already_paired": "Dit accessoire is al gekoppeld aan een ander apparaat. Reset het accessoire en probeer het opnieuw.", + "ignored_model": "HomeKit-ondersteuning voor dit model is geblokkeerd omdat er een meer functie volledige native integratie beschikbaar is.", "invalid_config_entry": "Dit apparaat geeft aan dat het gereed is om te koppelen, maar er is al een conflicterend configuratie-item voor in de Home Assistant dat eerst moet worden verwijderd.", "no_devices": "Er zijn geen gekoppelde apparaten gevonden" }, "error": { "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.", + "busy_error": "Het apparaat weigerde om koppelingen toe te voegen, omdat het al gekoppeld is met een andere controller.", + "max_peers_error": "Apparaat heeft geweigerd om koppelingen toe te voegen omdat het geen vrije koppelingsopslag heeft.", + "max_tries_error": "Apparaat weigerde pairing toe te voegen omdat het meer dan 100 niet-succesvolle authenticatiepogingen heeft ontvangen.", "pairing_failed": "Er deed zich een fout voor tijdens het koppelen met dit apparaat. Dit kan een tijdelijke storing zijn of uw apparaat wordt mogelijk momenteel niet ondersteund.", "unable_to_pair": "Kan niet koppelen, probeer het opnieuw.", "unknown_error": "Apparaat meldde een onbekende fout. Koppelen mislukt." }, + "flow_title": "HomeKit-accessoire: {name}", "step": { "pair": { "data": { "pairing_code": "Koppelingscode" }, + "description": "Voer uw HomeKit pairing code (in het formaat XXX-XX-XXX) om dit accessoire te gebruiken", "title": "Koppel met HomeKit accessoire" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/pt-BR.json b/homeassistant/components/homekit_controller/.translations/pt-BR.json index f13ca355b2e1be..58f12bf595cf00 100644 --- a/homeassistant/components/homekit_controller/.translations/pt-BR.json +++ b/homeassistant/components/homekit_controller/.translations/pt-BR.json @@ -4,6 +4,12 @@ "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento, pois o dispositivo n\u00e3o pode mais ser encontrado.", "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento." }, + "error": { + "busy_error": "O dispositivo recusou-se a adicionar o emparelhamento, uma vez que j\u00e1 est\u00e1 emparelhando com outro controlador.", + "max_peers_error": "O dispositivo recusou-se a adicionar o emparelhamento, pois n\u00e3o tem armazenamento de emparelhamento gratuito.", + "max_tries_error": "O dispositivo recusou-se a adicionar o emparelhamento, uma vez que recebeu mais de 100 tentativas de autentica\u00e7\u00e3o malsucedidas.", + "pairing_failed": "Ocorreu um erro sem tratamento ao tentar emparelhar com este dispositivo. Isso pode ser uma falha tempor\u00e1ria ou o dispositivo pode n\u00e3o ser suportado no momento." + }, "flow_title": "Acess\u00f3rio HomeKit: {name}" } } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/sl.json b/homeassistant/components/homekit_controller/.translations/sl.json index 0404dd7beb5434..2af8a2a7ab5c5a 100644 --- a/homeassistant/components/homekit_controller/.translations/sl.json +++ b/homeassistant/components/homekit_controller/.translations/sl.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "Seznanjanja ni mogo\u010de dodati, ker naprave ni ve\u010d mogo\u010de najti.", "already_configured": "Dodatna oprema je \u017ee konfigurirana s tem krmilnikom.", + "already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.", "already_paired": "Ta dodatna oprema je \u017ee povezana z drugo napravo. Ponastavite dodatno opremo in poskusite znova.", "ignored_model": "Podpora za HomeKit za ta model je blokirana, saj je na voljo ve\u010d funkcij popolne nativne integracije.", "invalid_config_entry": "Ta naprava se prikazuje kot pripravljena za povezavo, vendar je konflikt v nastavitvah Home Assistant, ki ga je treba najprej odstraniti.", diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json index 302f71d4ccfc67..b4b721b7ff91f1 100644 --- a/homeassistant/components/homekit_controller/.translations/sv.json +++ b/homeassistant/components/homekit_controller/.translations/sv.json @@ -18,7 +18,7 @@ "unable_to_pair": "Det g\u00e5r inte att para ihop, f\u00f6rs\u00f6k igen.", "unknown_error": "Enheten rapporterade ett ok\u00e4nt fel. Parning misslyckades." }, - "flow_title": "HomeKit-tillbeh\u00f6r: {namn}", + "flow_title": "HomeKit-tillbeh\u00f6r: {name}", "step": { "pair": { "data": { diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json index 3879eb5b962403..99319f07ce46e6 100644 --- a/homeassistant/components/hue/.translations/ko.json +++ b/homeassistant/components/hue/.translations/ko.json @@ -7,6 +7,7 @@ "cannot_connect": "\ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "discover_timeout": "Hue \ube0c\ub9bf\uc9c0\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", "no_bridges": "\ubc1c\uacac\ub41c \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "not_hue_bridge": "Hue \ube0c\ub9bf\uc9c0\uac00 \uc544\ub2d9\ub2c8\ub2e4", "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { diff --git a/homeassistant/components/hue/.translations/lb.json b/homeassistant/components/hue/.translations/lb.json index 9b245a2a875674..ac83609ff02591 100644 --- a/homeassistant/components/hue/.translations/lb.json +++ b/homeassistant/components/hue/.translations/lb.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "All Philips Hue Bridge si scho\u00a0konfigur\u00e9iert", "already_configured": "Bridge ass scho konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", "cannot_connect": "Keng Verbindung mat der bridge m\u00e9iglech", "discover_timeout": "Keng Hue bridge fonnt", "no_bridges": "Keng Philips Hue Bridge fonnt", + "not_hue_bridge": "Keng Hue Bridge", "unknown": "Onbekannten Feeler opgetrueden" }, "error": { diff --git a/homeassistant/components/hue/.translations/nl.json b/homeassistant/components/hue/.translations/nl.json index bd065bb7506b19..9b84b4a7afce66 100644 --- a/homeassistant/components/hue/.translations/nl.json +++ b/homeassistant/components/hue/.translations/nl.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "Alle Philips Hue bridges zijn al geconfigureerd", "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", "cannot_connect": "Kan geen verbinding maken met bridge", "discover_timeout": "Hue bridges kunnen niet worden gevonden", "no_bridges": "Geen Philips Hue bridges ontdekt", + "not_hue_bridge": "Dit is geen Hue bridge", "unknown": "Onbekende fout opgetreden" }, "error": { diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json index 8eec1aa662aebc..4c89dd151fbf96 100644 --- a/homeassistant/components/hue/.translations/pl.json +++ b/homeassistant/components/hue/.translations/pl.json @@ -7,6 +7,7 @@ "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem", "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue", "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue", + "not_hue_bridge": "Nie mostek Hue", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, "error": { diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json index fc3142ba8201a1..29fc66488eb9ca 100644 --- a/homeassistant/components/hue/.translations/sl.json +++ b/homeassistant/components/hue/.translations/sl.json @@ -7,6 +7,7 @@ "cannot_connect": "Ni mogo\u010de vzpostaviti povezave z mostom", "discover_timeout": "Ni bilo mogo\u010de odkriti Hue mostov", "no_bridges": "Ni odkritih mostov Philips Hue", + "not_hue_bridge": "Ni Hue most", "unknown": "Pri\u0161lo je do neznane napake" }, "error": { diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json index a585cfd38c36d3..3d03aba03a6e94 100644 --- a/homeassistant/components/hue/.translations/zh-Hant.json +++ b/homeassistant/components/hue/.translations/zh-Hant.json @@ -7,6 +7,7 @@ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Bridge", "discover_timeout": "\u7121\u6cd5\u641c\u5c0b\u5230 Hue Bridge", "no_bridges": "\u672a\u641c\u5c0b\u5230 Philips Hue Bridge", + "not_hue_bridge": "\u975e Hue Bridge \u88dd\u7f6e", "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4" }, "error": { diff --git a/homeassistant/components/ipma/.translations/it.json b/homeassistant/components/ipma/.translations/it.json index d751d8a317f2be..954ff6e9ee1e9e 100644 --- a/homeassistant/components/ipma/.translations/it.json +++ b/homeassistant/components/ipma/.translations/it.json @@ -7,7 +7,7 @@ "user": { "data": { "latitude": "Latitudine", - "longitude": "Logitudine", + "longitude": "Longitudine", "name": "Nome" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/pt-BR.json b/homeassistant/components/ipma/.translations/pt-BR.json new file mode 100644 index 00000000000000..4a0d8e0b01bf7a --- /dev/null +++ b/homeassistant/components/ipma/.translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "name_exists": "O nome j\u00e1 existe" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nome" + }, + "description": "Instituto Portugu\u00eas do Mar e Atmosfera", + "title": "Localiza\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/it.json b/homeassistant/components/iqvia/.translations/it.json new file mode 100644 index 00000000000000..37079cf571dbbf --- /dev/null +++ b/homeassistant/components/iqvia/.translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Il CAP \u00e8 gi\u00e0 registrato", + "invalid_zip_code": "Il CAP non \u00e8 valido" + }, + "step": { + "user": { + "data": { + "zip_code": "CAP" + }, + "title": "IQVIA" + } + }, + "title": "IQVIA" + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/.translations/nl.json b/homeassistant/components/iqvia/.translations/nl.json index dccb7348a016f3..e0b3b667de396d 100644 --- a/homeassistant/components/iqvia/.translations/nl.json +++ b/homeassistant/components/iqvia/.translations/nl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Postcode reeds geregistreerd", + "identifier_exists": "Postcode al geregistreerd", "invalid_zip_code": "Postcode is ongeldig" }, "step": { @@ -9,6 +9,7 @@ "data": { "zip_code": "Postcode" }, + "description": "Vul uw Amerikaanse of Canadese postcode in.", "title": "IQVIA" } }, diff --git a/homeassistant/components/life360/.translations/ca.json b/homeassistant/components/life360/.translations/ca.json new file mode 100644 index 00000000000000..a7189d69185220 --- /dev/null +++ b/homeassistant/components/life360/.translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Credencials inv\u00e0lides", + "user_already_configured": "El compte ja ha estat configurat" + }, + "create_entry": { + "default": "Per configurar les opcions avan\u00e7ades mira la [documentaci\u00f3 de Life360]({docs_url})." + }, + "error": { + "invalid_credentials": "Credencials inv\u00e0lides", + "invalid_username": "Nom d'usuari incorrecte", + "user_already_configured": "El compte ja ha estat configurat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Per configurar les opcions avan\u00e7ades mira la [documentaci\u00f3 de Life360]({docs_url}). Pot ser que ho hagis de fer abans d\u2019afegir cap compte.", + "title": "Informaci\u00f3 del compte Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/en.json b/homeassistant/components/life360/.translations/en.json index cff3f39e5d5850..2c187ba0470730 100644 --- a/homeassistant/components/life360/.translations/en.json +++ b/homeassistant/components/life360/.translations/en.json @@ -1,27 +1,27 @@ { - "config": { - "title": "Life360", - "step": { - "user": { - "title": "Life360 Account Info", - "data": { - "username": "Username", - "password": "Password" + "config": { + "abort": { + "invalid_credentials": "Invalid credentials", + "user_already_configured": "Account has already been configured" }, - "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts." - } - }, - "error": { - "invalid_username": "Invalid username", - "invalid_credentials": "Invalid credentials", - "user_already_configured": "Account has already been configured" - }, - "create_entry": { - "default": "To set advanced options, see [Life360 documentation]({docs_url})." - }, - "abort": { - "invalid_credentials": "Invalid credentials", - "user_already_configured": "Account has already been configured" + "create_entry": { + "default": "To set advanced options, see [Life360 documentation]({docs_url})." + }, + "error": { + "invalid_credentials": "Invalid credentials", + "invalid_username": "Invalid username", + "user_already_configured": "Account has already been configured" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts.", + "title": "Life360 Account Info" + } + }, + "title": "Life360" } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/it.json b/homeassistant/components/life360/.translations/it.json new file mode 100644 index 00000000000000..9c4cb1cc4cb15f --- /dev/null +++ b/homeassistant/components/life360/.translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Credenziali non valide", + "user_already_configured": "L'account \u00e8 gi\u00e0 stato configurato" + }, + "create_entry": { + "default": "Per impostare le opzioni avanzate, consultare la [Documentazione Life360] ( {docs_url} )." + }, + "error": { + "invalid_credentials": "Credenziali non valide", + "invalid_username": "Nome utente non valido", + "user_already_configured": "L'account \u00e8 gi\u00e0 stato configurato" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Informazioni sull'account Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/ko.json b/homeassistant/components/life360/.translations/ko.json new file mode 100644 index 00000000000000..b81a6fd059f5ce --- /dev/null +++ b/homeassistant/components/life360/.translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "user_already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "create_entry": { + "default": "\uace0\uae09 \uc635\uc158\uc744 \uc124\uc815\ud558\ub824\uba74 [Life360 \uc124\uba85\uc11c]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + }, + "error": { + "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "user_already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\uace0\uae09 \uc635\uc158\uc744 \uc124\uc815\ud558\ub824\uba74 [Life360 \uc124\uba85\uc11c]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694. \uacc4\uc815\uc744 \ucd94\uac00\ud558\uc2dc\uae30 \uc804\uc5d0 \uc77d\uc5b4\ubcf4\uc2dc\ub294\uac83\uc744 \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.", + "title": "Life360 \uacc4\uc815 \uc815\ubcf4" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/lb.json b/homeassistant/components/life360/.translations/lb.json new file mode 100644 index 00000000000000..bfed5937e24bea --- /dev/null +++ b/homeassistant/components/life360/.translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Ong\u00eblteg Login Informatioune", + "user_already_configured": "Kont ass scho konfigur\u00e9iert" + }, + "create_entry": { + "default": "Fir erweidert Optiounen anzestellen, kuckt [Life360 Dokumentatioun]({docs_url})." + }, + "error": { + "invalid_credentials": "Ong\u00eblteg Login Informatioune", + "invalid_username": "Ong\u00ebltege Benotzernumm", + "user_already_configured": "Kont ass scho konfigur\u00e9iert" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "Fir erweidert Optiounen anzestellen, kuckt [Life360 Dokumentatioun]({docs_url}).\nMaacht dat am beschten ier dir Konte b\u00e4isetzt.", + "title": "Life360 Kont Informatiounen" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/nl.json b/homeassistant/components/life360/.translations/nl.json new file mode 100644 index 00000000000000..ec7a53329503a0 --- /dev/null +++ b/homeassistant/components/life360/.translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Ongeldige gebruikersgegevens", + "user_already_configured": "Account is al geconfigureerd" + }, + "create_entry": { + "default": "Om geavanceerde opties in te stellen, zie [Life360 documentatie]({docs_url})." + }, + "error": { + "invalid_credentials": "Ongeldige gebruikersgegevens", + "invalid_username": "Ongeldige gebruikersnaam", + "user_already_configured": "Account is al geconfigureerd" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Om geavanceerde opties in te stellen, zie [Life360 documentatie]({docs_url}).\nMisschien wilt u dat doen voordat u accounts toevoegt.", + "title": "Life360-accountgegevens" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/pl.json b/homeassistant/components/life360/.translations/pl.json new file mode 100644 index 00000000000000..b9136901e5634d --- /dev/null +++ b/homeassistant/components/life360/.translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce", + "user_already_configured": "Konto ju\u017c zosta\u0142o skonfigurowane." + }, + "create_entry": { + "default": "Aby ustawi\u0107 zaawansowane opcje, udaj si\u0119 do [Dokumentacja Life360] ( {docs_url} ). \n" + }, + "error": { + "invalid_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce", + "invalid_username": "Nieprawid\u0142owa nazwa u\u017cytkownika ", + "user_already_configured": "Konto ju\u017c zosta\u0142o skonfigurowane." + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Aby ustawi\u0107 zaawansowane opcje, udaj si\u0119 do [Dokumentacja Life360] ( {docs_url} ). \n Mo\u017cesz to zrobi\u0107 przed dodaniem kont.", + "title": "Informacje o koncie Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/pt-BR.json b/homeassistant/components/life360/.translations/pt-BR.json new file mode 100644 index 00000000000000..ca4cee896b37ac --- /dev/null +++ b/homeassistant/components/life360/.translations/pt-BR.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Credenciais inv\u00e1lidas", + "user_already_configured": "A conta j\u00e1 foi configurada" + }, + "create_entry": { + "default": "Para definir op\u00e7\u00f5es avan\u00e7adas, consulte [Documenta\u00e7\u00e3o da Life360] ({docs_url})." + }, + "error": { + "invalid_credentials": "Credenciais inv\u00e1lidas", + "invalid_username": "Nome de usu\u00e1rio Inv\u00e1lido", + "user_already_configured": "A conta j\u00e1 foi configurada" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Nome de usu\u00e1rio" + }, + "description": "Para definir op\u00e7\u00f5es avan\u00e7adas, consulte [Documenta\u00e7\u00e3o da Life360] ({docs_url}). \n Voc\u00ea pode querer fazer isso antes de adicionar contas.", + "title": "Informa\u00e7\u00f5es da conta Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/ru.json b/homeassistant/components/life360/.translations/ru.json new file mode 100644 index 00000000000000..0f698457bf799a --- /dev/null +++ b/homeassistant/components/life360/.translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "user_already_configured": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430" + }, + "create_entry": { + "default": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 Life360]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." + }, + "error": { + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "invalid_username": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d", + "user_already_configured": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 Life360]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u044d\u0442\u043e \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430.", + "title": "Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/sl.json b/homeassistant/components/life360/.translations/sl.json new file mode 100644 index 00000000000000..36e4917256bc91 --- /dev/null +++ b/homeassistant/components/life360/.translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "Napa\u010dno geslo", + "user_already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "create_entry": { + "default": "\u010ce \u017eelite nastaviti napredne mo\u017enosti, glejte [Life360 dokumentacija]({docs_url})." + }, + "error": { + "invalid_credentials": "Napa\u010dno geslo", + "invalid_username": "Napa\u010dno uporabni\u0161ko ime", + "user_already_configured": "Ra\u010dun \u017ee nastavljen" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "\u010ce \u017eelite nastaviti napredne mo\u017enosti, glejte [Life360 dokumentacija]({docs_url}). \n To lahko storite pred dodajanjem ra\u010dunov.", + "title": "Podatki ra\u010duna Life360" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/zh-Hant.json b/homeassistant/components/life360/.translations/zh-Hant.json new file mode 100644 index 00000000000000..8ab5dcf536979e --- /dev/null +++ b/homeassistant/components/life360/.translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_credentials": "\u6191\u8b49\u7121\u6548", + "user_already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "create_entry": { + "default": "\u6b32\u8a2d\u5b9a\u9032\u968e\u9078\u9805\uff0c\u8acb\u53c3\u95b1 [Life360 \u6587\u4ef6]({docs_url})\u3002" + }, + "error": { + "invalid_credentials": "\u6191\u8b49\u7121\u6548", + "invalid_username": "\u4f7f\u7528\u8005\u540d\u7a31\u7121\u6548", + "user_already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u6b32\u8a2d\u5b9a\u9032\u968e\u9078\u9805\uff0c\u8acb\u53c3\u95b1 [Life360 \u6587\u4ef6]({docs_url})\u3002\n\u5efa\u8b70\u65bc\u65b0\u589e\u5e33\u865f\u524d\uff0c\u5148\u9032\u884c\u4e86\u89e3\u3002", + "title": "Life360 \u5e33\u865f\u8cc7\u8a0a" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/it.json b/homeassistant/components/logi_circle/.translations/it.json new file mode 100644 index 00000000000000..568bf79a40d207 --- /dev/null +++ b/homeassistant/components/logi_circle/.translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare solo un singolo account Logi Circle.", + "external_error": "Si \u00e8 verificata un'eccezione da un altro flusso.", + "external_setup": "Logi Circle configurato con successo da un altro flusso.", + "no_flows": "Devi configurare Logi Circle prima di poter eseguire l'autenticazione. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/logi_circle/)." + }, + "create_entry": { + "default": "Autenticato con successo con Logi Circle." + }, + "error": { + "auth_error": "Autorizzazione API fallita.", + "auth_timeout": "Timeout dell'autorizzazione durante la richiesta del token di accesso.", + "follow_link": "Segui il link e autenticati prima di premere Invio" + }, + "step": { + "auth": { + "title": "Autenticarsi con Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Scegli tramite quale provider di autenticazione vuoi autenticarti con Logi Circle.", + "title": "Provider di autenticazione" + } + }, + "title": "Logi Circle" + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/nl.json b/homeassistant/components/logi_circle/.translations/nl.json index 84af68e1384dd9..822447f353dbf1 100644 --- a/homeassistant/components/logi_circle/.translations/nl.json +++ b/homeassistant/components/logi_circle/.translations/nl.json @@ -1,13 +1,31 @@ { "config": { "abort": { - "external_error": "Uitzondering opgetreden uit een andere stroom." + "already_setup": "U kunt slechts \u00e9\u00e9n Logi Circle-account configureren.", + "external_error": "Uitzondering opgetreden uit een andere stroom.", + "external_setup": "Logi Circle is met succes geconfigureerd vanuit een andere stroom.", + "no_flows": "U moet Logi Circle configureren voordat u ermee kunt authenticeren. [Lees de instructies] (https://www.home-assistant.io/components/logi_circle/)." }, "create_entry": { "default": "Succesvol geverifieerd met Logi Circle." }, "error": { - "auth_error": "API-autorisatie mislukt." + "auth_error": "API-autorisatie mislukt.", + "auth_timeout": "Er is een time-out opgetreden bij autorisatie bij het aanvragen van toegangstoken.", + "follow_link": "Volg de link en authenticeer voordat u op Verzenden drukt." + }, + "step": { + "auth": { + "description": "Volg de onderstaande link en Accepteer toegang tot uw Logi Circle-account, kom dan terug en druk hieronder op Verzenden . \n\n [Link] ({authorization_url})", + "title": "Authenticeren met Logi Circle" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Kies met welke authenticatieprovider u wilt authenticeren met Logi Circle.", + "title": "Authenticatieprovider" + } }, "title": "Logi Circle" } diff --git a/homeassistant/components/logi_circle/.translations/pt-BR.json b/homeassistant/components/logi_circle/.translations/pt-BR.json index fd742194c6962a..babdba4f9bf190 100644 --- a/homeassistant/components/logi_circle/.translations/pt-BR.json +++ b/homeassistant/components/logi_circle/.translations/pt-BR.json @@ -14,6 +14,7 @@ "data": { "flow_impl": "Provedor" }, + "description": "Escolha atrav\u00e9s de qual provedor de autentica\u00e7\u00e3o voc\u00ea deseja autenticar com o Logi Circle.", "title": "Provedor de Autentica\u00e7\u00e3o" } }, diff --git a/homeassistant/components/mailgun/.translations/nl.json b/homeassistant/components/mailgun/.translations/nl.json index d71c311b7f8ab0..6a1ff24ef2c273 100644 --- a/homeassistant/components/mailgun/.translations/nl.json +++ b/homeassistant/components/mailgun/.translations/nl.json @@ -4,6 +4,9 @@ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Mailgun-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n enkele instantie is nodig." }, + "create_entry": { + "default": "Om evenementen naar Home Assistant te verzenden, moet u [Webhooks with Mailgun] instellen ( {mailgun_url} ). \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhoudstype: application/json \n\n Zie [de documentatie] ( {docs_url} ) voor informatie over het configureren van automatiseringen om binnenkomende gegevens te verwerken." + }, "step": { "user": { "description": "Weet u zeker dat u Mailgun wilt instellen?", diff --git a/homeassistant/components/mobile_app/.translations/it.json b/homeassistant/components/mobile_app/.translations/it.json index 8c083fad17ef07..049e551d19bdfe 100644 --- a/homeassistant/components/mobile_app/.translations/it.json +++ b/homeassistant/components/mobile_app/.translations/it.json @@ -1,7 +1,11 @@ { "config": { + "abort": { + "install_app": "Apri l'app per dispositivi mobili per configurare l'integrazione con Home Assistant. Vedi [i documenti] ( {apps_url} ) per un elenco di app compatibili." + }, "step": { "confirm": { + "description": "Vuoi configurare il componente Mobile App?", "title": "App per dispositivi mobili" } }, diff --git a/homeassistant/components/mobile_app/.translations/nl.json b/homeassistant/components/mobile_app/.translations/nl.json new file mode 100644 index 00000000000000..8140e7df7dc240 --- /dev/null +++ b/homeassistant/components/mobile_app/.translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "install_app": "Open de mobiele app om de integratie met de Home Assistant op te zetten. Zie [de docs]({apps_url}) voor een lijst met compatibele apps." + }, + "step": { + "confirm": { + "description": "Wilt u de Mobile App component instellen?", + "title": "Mobiele app" + } + }, + "title": "Mobiele app" + } +} \ No newline at end of file diff --git a/homeassistant/components/moon/.translations/sensor.nl.json b/homeassistant/components/moon/.translations/sensor.nl.json index 5e78d429b9f079..3eaf470e509b2a 100644 --- a/homeassistant/components/moon/.translations/sensor.nl.json +++ b/homeassistant/components/moon/.translations/sensor.nl.json @@ -7,6 +7,6 @@ "waning_crescent": "Krimpende, sikkelvormige maan", "waning_gibbous": "Krimpende, vooruitspringende maan", "waxing_crescent": "Wassende, sikkelvormige maan", - "waxing_gibbous": "Wassende, vooruitspringende maan" + "waxing_gibbous": "Wassende, sikkelvormige maan" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index e2a1ef6456e0e5..1d50d5bd3c31c4 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -22,7 +22,7 @@ "data": { "discovery": "\uae30\uae30 \uac80\uc0c9 \ud65c\uc131\ud654" }, - "description": "Hass.io \uc560\ub4dc\uc628 {addon} \ub85c(\uc73c\ub85c) MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Hass.io \uc560\ub4dc\uc628\uc758 MQTT \ube0c\ub85c\ucee4" } }, diff --git a/homeassistant/components/nest/.translations/lb.json b/homeassistant/components/nest/.translations/lb.json index 197cc8206d0510..9fdf442c5ff22d 100644 --- a/homeassistant/components/nest/.translations/lb.json +++ b/homeassistant/components/nest/.translations/lb.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen\u00a0Nest Kont\u00a0konfigur\u00e9ieren.", + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Nest Kont konfigur\u00e9ieren.", "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", - "authorize_url_timeout": "Z\u00e4it Iwwerschreidung\u00a0beim gener\u00e9ieren\u00a0vun der Autorisatiouns\u00a0URL.", - "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung\u00a0k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Interne Feeler beim valid\u00e9ieren vum Code", "invalid_code": "Ong\u00ebltege Code", - "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code", + "timeout": "Z\u00e4it Iwwerschreidung beim valid\u00e9ieren vum Code", "unknown": "Onbekannte Feeler beim valid\u00e9ieren vum Code" }, "step": { diff --git a/homeassistant/components/onboarding/.translations/it.json b/homeassistant/components/onboarding/.translations/it.json new file mode 100644 index 00000000000000..1832c80cfcff30 --- /dev/null +++ b/homeassistant/components/onboarding/.translations/it.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Camera da letto", + "kitchen": "Cucina", + "living_room": "Soggiorno" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/it.json b/homeassistant/components/openuv/.translations/it.json index 82dfd63184ab12..1a231d680e693b 100644 --- a/homeassistant/components/openuv/.translations/it.json +++ b/homeassistant/components/openuv/.translations/it.json @@ -10,7 +10,7 @@ "api_key": "API Key di OpenUV", "elevation": "Altitudine", "latitude": "Latitudine", - "longitude": "Logitudine" + "longitude": "Longitudine" }, "title": "Inserisci i tuoi dati" } diff --git a/homeassistant/components/point/.translations/nl.json b/homeassistant/components/point/.translations/nl.json index 50d9c7d45bbf53..1ca54237fd55a0 100644 --- a/homeassistant/components/point/.translations/nl.json +++ b/homeassistant/components/point/.translations/nl.json @@ -23,6 +23,7 @@ "data": { "flow_impl": "Leverancier" }, + "description": "Kies met welke authenticatieprovider u wilt authenticeren met Point.", "title": "Authenticatieleverancier" } }, diff --git a/homeassistant/components/ps4/.translations/it.json b/homeassistant/components/ps4/.translations/it.json index 635fbd7b479cca..afa32056757ccb 100644 --- a/homeassistant/components/ps4/.translations/it.json +++ b/homeassistant/components/ps4/.translations/it.json @@ -29,8 +29,10 @@ }, "mode": { "data": { + "ip_address": "Indirizzo IP (lasciare vuoto se si utilizza la funzione Auto Discovery).", "mode": "Modalit\u00e0 di configurazione" }, + "description": "Seleziona la modalit\u00e0 per la configurazione. L'indirizzo IP pu\u00f2 essere lasciato vuoto se si seleziona Auto Discovery, poich\u00e9 i dispositivi verranno rilevati automaticamente.", "title": "PlayStation 4" } }, diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json index c3cdf03355fc30..8eaa20d76cf8c2 100644 --- a/homeassistant/components/ps4/.translations/nl.json +++ b/homeassistant/components/ps4/.translations/nl.json @@ -4,10 +4,11 @@ "credential_error": "Fout bij ophalen van inloggegevens.", "devices_configured": "Alle gevonden apparaten zijn al geconfigureerd.", "no_devices_found": "Geen PlayStation 4 apparaten gevonden op het netwerk.", - "port_987_bind_error": "Kan niet binden aan poort 987.", - "port_997_bind_error": "Kan niet binden aan poort 997." + "port_987_bind_error": "Kon niet binden aan poort 987. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor meer informatie.", + "port_997_bind_error": "Kon niet binden aan poort 997. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor aanvullende informatie." }, "error": { + "credential_timeout": "Time-out van inlog service. Druk op Submit om opnieuw te starten.", "login_failed": "Kan niet koppelen met PlayStation 4. Controleer of de pincode juist is.", "no_ipaddress": "Voer het IP-adres in van de PlayStation 4 die je wilt configureren.", "not_ready": "PlayStation 4 staat niet aan of is niet verbonden met een netwerk." @@ -24,13 +25,15 @@ "name": "Naam", "region": "Regio" }, - "description": "Voer je PlayStation 4 informatie in. Voor 'PIN', blader naar 'Instellingen' op je PlayStation 4. Blader dan naar 'Mobiele App verbindingsinstellingen' en kies 'Apparaat toevoegen'. Voer de weergegeven PIN-code in.", + "description": "Voer je PlayStation 4-informatie in. Ga voor 'PIN' naar 'Instellingen' op je PlayStation 4-console. Navigeer vervolgens naar 'Verbindingsinstellingen mobiele app' en selecteer 'Apparaat toevoegen'. Voer de pincode in die wordt weergegeven. Raadpleeg de [documentatie] (https://www.home-assistant.io/components/ps4/) voor meer informatie.", "title": "PlayStation 4" }, "mode": { "data": { - "ip_address": "IP-adres (leeg laten als u Auto Discovery gebruikt)." + "ip_address": "IP-adres (leeg laten als u Auto Discovery gebruikt).", + "mode": "Configuratiemodus" }, + "description": "Selecteer modus voor configuratie. Het veld IP-adres kan leeg blijven als Auto Discovery wordt geselecteerd, omdat apparaten automatisch worden gedetecteerd.", "title": "PlayStation 4" } }, diff --git a/homeassistant/components/ps4/.translations/pt-BR.json b/homeassistant/components/ps4/.translations/pt-BR.json index e74254727872a1..7746ed5d9f41cb 100644 --- a/homeassistant/components/ps4/.translations/pt-BR.json +++ b/homeassistant/components/ps4/.translations/pt-BR.json @@ -1,7 +1,30 @@ { "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo PlayStation 4 encontrado na rede." + }, "error": { - "credential_timeout": "Servi\u00e7o de credencial expirou. Pressione Submit para reiniciar." - } + "credential_timeout": "Servi\u00e7o de credencial expirou. Pressione Submit para reiniciar.", + "not_ready": "O PlayStation 4 n\u00e3o est\u00e1 ligado ou conectado \u00e0 rede." + }, + "step": { + "creds": { + "title": "Playstation 4" + }, + "link": { + "data": { + "ip_address": "Endere\u00e7o IP", + "name": "Nome", + "region": "Regi\u00e3o" + }, + "title": "Playstation 4" + }, + "mode": { + "data": { + "mode": "Modo de configura\u00e7\u00e3o" + } + } + }, + "title": "Playstation 4" } } \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/pt-BR.json b/homeassistant/components/smartthings/.translations/pt-BR.json new file mode 100644 index 00000000000000..eee67c4e16fbde --- /dev/null +++ b/homeassistant/components/smartthings/.translations/pt-BR.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "wait_install": { + "title": "Instalar o SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/it.json b/homeassistant/components/smhi/.translations/it.json index b8c228f7e9eb1a..1c886e4f20eb80 100644 --- a/homeassistant/components/smhi/.translations/it.json +++ b/homeassistant/components/smhi/.translations/it.json @@ -8,7 +8,7 @@ "user": { "data": { "latitude": "Latitudine", - "longitude": "Logitudine", + "longitude": "Longitudine", "name": "Nome" }, "title": "Localit\u00e0 in Svezia" diff --git a/homeassistant/components/somfy/.translations/ca.json b/homeassistant/components/somfy/.translations/ca.json new file mode 100644 index 00000000000000..14f707ac046734 --- /dev/null +++ b/homeassistant/components/somfy/.translations/ca.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar un compte de Somfy.", + "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "missing_configuration": "El component Somfy no est\u00e0 configurat. Mira'n la documentaci\u00f3." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/ko.json b/homeassistant/components/somfy/.translations/ko.json new file mode 100644 index 00000000000000..72b234cd98b60e --- /dev/null +++ b/homeassistant/components/somfy/.translations/ko.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Somfy \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "Somfy \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." + }, + "create_entry": { + "default": "Somfy \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/lb.json b/homeassistant/components/somfy/.translations/lb.json new file mode 100644 index 00000000000000..62f588292416fc --- /dev/null +++ b/homeassistant/components/somfy/.translations/lb.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Somfy Kont konfigur\u00e9ieren.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "missing_configuration": "D'Somfy Komponent ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Somfy authentifiz\u00e9iert." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/nl.json b/homeassistant/components/somfy/.translations/nl.json new file mode 100644 index 00000000000000..be50b280c17ad6 --- /dev/null +++ b/homeassistant/components/somfy/.translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n Somfy-account configureren.", + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "Het Somfy-component is niet geconfigureerd. Gelieve de documentatie te volgen." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/pl.json b/homeassistant/components/somfy/.translations/pl.json new file mode 100644 index 00000000000000..ba62014ae44cef --- /dev/null +++ b/homeassistant/components/somfy/.translations/pl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Somfy.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", + "missing_configuration": "Komponent Somfy nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105" + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Somfy" + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/ru.json b/homeassistant/components/somfy/.translations/ru.json new file mode 100644 index 00000000000000..7251bc990e9f46 --- /dev/null +++ b/homeassistant/components/somfy/.translations/ru.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 Somfy \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439." + }, + "create_entry": { + "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/zh-Hant.json b/homeassistant/components/somfy/.translations/zh-Hant.json new file mode 100644 index 00000000000000..6b53e943304383 --- /dev/null +++ b/homeassistant/components/somfy/.translations/zh-Hant.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Somfy \u5e33\u865f\u3002", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "missing_configuration": "Somfy \u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Somfy \u88dd\u7f6e\u3002" + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/nl.json b/homeassistant/components/tellduslive/.translations/nl.json index 609ac51c4defec..a1029d991fe471 100644 --- a/homeassistant/components/tellduslive/.translations/nl.json +++ b/homeassistant/components/tellduslive/.translations/nl.json @@ -2,10 +2,14 @@ "config": { "abort": { "all_configured": "TelldusLive is al geconfigureerd", + "already_setup": "TelldusLive is al geconfigureerd", "authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie url.", "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", "unknown": "Onbekende fout opgetreden" }, + "error": { + "auth_error": "Authenticatiefout, probeer het opnieuw." + }, "step": { "auth": { "description": "Om uw TelldusLive-account te linken: \n 1. Klik op de onderstaande link \n 2. Log in op Telldus Live \n 3. Autoriseer ** {app_name} ** (klik op ** Ja **). \n 4. Kom hier terug en klik op ** VERSTUREN **. \n\n [Link TelldusLive account]({auth_url})", diff --git a/homeassistant/components/toon/.translations/it.json b/homeassistant/components/toon/.translations/it.json new file mode 100644 index 00000000000000..696c770f130952 --- /dev/null +++ b/homeassistant/components/toon/.translations/it.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "client_id": "L'ID client dalla configurazione non \u00e8 valido.", + "no_agreements": "Questo account non ha display Toon.", + "no_app": "\u00c8 necessario configurare Toon prima di poter eseguire l'autenticazione con esso. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Si \u00e8 verificato un errore imprevisto durante l'autenticazione." + }, + "error": { + "credentials": "Le credenziali fornite non sono valide.", + "display_exists": "Il display selezionato \u00e8 gi\u00e0 configurato." + }, + "step": { + "authenticate": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Autenticati con il tuo account Eneco Toon (non l'account sviluppatore).", + "title": "Collega il tuo account Toon" + }, + "display": { + "data": { + "display": "Seleziona il display" + }, + "description": "Seleziona il display Toon con cui connettersi.", + "title": "Seleziona il display" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/pt-BR.json b/homeassistant/components/toon/.translations/pt-BR.json new file mode 100644 index 00000000000000..cb6ef7c41c4394 --- /dev/null +++ b/homeassistant/components/toon/.translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "client_secret": "O segredo do cliente da configura\u00e7\u00e3o \u00e9 inv\u00e1lido.", + "no_agreements": "Esta conta n\u00e3o possui exibi\u00e7\u00f5es Toon." + }, + "error": { + "credentials": "As credenciais fornecidas s\u00e3o inv\u00e1lidas." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/.translations/it.json b/homeassistant/components/tplink/.translations/it.json new file mode 100644 index 00000000000000..4931e2293dd8f8 --- /dev/null +++ b/homeassistant/components/tplink/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo TP-Link trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione." + }, + "step": { + "confirm": { + "description": "Vuoi configurare i dispositivi intelligenti TP-Link?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/.translations/pt-BR.json b/homeassistant/components/tplink/.translations/pt-BR.json new file mode 100644 index 00000000000000..cb74920ff92e82 --- /dev/null +++ b/homeassistant/components/tplink/.translations/pt-BR.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Deseja configurar dispositivos inteligentes TP-Link?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json index fc8b5c08261234..842307c666bbdb 100644 --- a/homeassistant/components/twilio/.translations/nl.json +++ b/homeassistant/components/twilio/.translations/nl.json @@ -4,6 +4,9 @@ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Twillo-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig." }, + "create_entry": { + "default": "Om evenementen naar de Home Assistant te verzenden, moet u [Webhooks with Twilio] ( {twilio_url} ) instellen. \n\n Vul de volgende info in: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhoudstype: application / x-www-form-urlencoded \n\n Zie [de documentatie] ( {docs_url} ) voor informatie over het configureren van automatiseringen om binnenkomende gegevens te verwerken." + }, "step": { "user": { "description": "Weet u zeker dat u Twilio wilt instellen?", diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json index 5d426f2edafee9..a94471bb6102df 100644 --- a/homeassistant/components/upnp/.translations/nl.json +++ b/homeassistant/components/upnp/.translations/nl.json @@ -8,6 +8,10 @@ "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in", "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van UPnP/IGD nodig." }, + "error": { + "one": "Een", + "other": "Ander" + }, "step": { "confirm": { "description": "Wilt u UPnP/IGD instellen?", diff --git a/homeassistant/components/wemo/.translations/it.json b/homeassistant/components/wemo/.translations/it.json new file mode 100644 index 00000000000000..dcfa1954db35fd --- /dev/null +++ b/homeassistant/components/wemo/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Wemo trovato in rete.", + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di Wemo." + }, + "step": { + "confirm": { + "description": "Vuoi configurare Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/ko.json b/homeassistant/components/wemo/.translations/ko.json new file mode 100644 index 00000000000000..57515f2c9708f5 --- /dev/null +++ b/homeassistant/components/wemo/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Wemo \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Wemo \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Wemo \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/lb.json b/homeassistant/components/wemo/.translations/lb.json new file mode 100644 index 00000000000000..cf8a52cef2d469 --- /dev/null +++ b/homeassistant/components/wemo/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Wemo Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Wemo ass erlaabt." + }, + "step": { + "confirm": { + "description": "Soll Wemo konfigur\u00e9iert ginn?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/nl.json b/homeassistant/components/wemo/.translations/nl.json new file mode 100644 index 00000000000000..65fc3865bdd57b --- /dev/null +++ b/homeassistant/components/wemo/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Wemo-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Slechts een enkele configuratie van Wemo is mogelijk." + }, + "step": { + "confirm": { + "description": "Wilt u Wemo instellen?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/pl.json b/homeassistant/components/wemo/.translations/pl.json new file mode 100644 index 00000000000000..bde72d3eb64a59 --- /dev/null +++ b/homeassistant/components/wemo/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w Twojej sieci urz\u0105dze\u0144 Wemo.", + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Wemo." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/sl.json b/homeassistant/components/wemo/.translations/sl.json new file mode 100644 index 00000000000000..61340d9df452a2 --- /dev/null +++ b/homeassistant/components/wemo/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju ni najdenih naprav Wemo.", + "single_instance_allowed": "Mo\u017ena je samo ena konfiguracija Wema." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Wemo?", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/.translations/zh-Hant.json b/homeassistant/components/wemo/.translations/zh-Hant.json new file mode 100644 index 00000000000000..4663cf579f8b06 --- /dev/null +++ b/homeassistant/components/wemo/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Wemo \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 Wemo\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Wemo\uff1f", + "title": "Wemo" + } + }, + "title": "Wemo" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/it.json b/homeassistant/components/zone/.translations/it.json index 4490124510fa4d..24de27a8bbb79c 100644 --- a/homeassistant/components/zone/.translations/it.json +++ b/homeassistant/components/zone/.translations/it.json @@ -8,7 +8,7 @@ "data": { "icon": "Icona", "latitude": "Latitudine", - "longitude": "Logitudine", + "longitude": "Longitudine", "name": "Nome", "passive": "Passiva", "radius": "Raggio" From 970b00b8d68c2280efcdc089bcefdb0b676791c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 14 Jun 2019 14:53:20 -0700 Subject: [PATCH 225/319] Updated frontend to 20190614.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0d517aa6560523..e2a213f3961cfb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190604.0" + "home-assistant-frontend==20190614.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a9669ce454f4d9..619c6fad581a11 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.14 -home-assistant-frontend==20190604.0 +home-assistant-frontend==20190614.0 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index f2b314d8a78de1..b973695cba3a67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -592,7 +592,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190604.0 +home-assistant-frontend==20190614.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20f8d6660335c5..4dd74fc63b00f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -157,7 +157,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190604.0 +home-assistant-frontend==20190614.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 50d282ff3729e102b1191fccdece63f44be2ae5a Mon Sep 17 00:00:00 2001 From: BackSlasher Date: Sat, 15 Jun 2019 01:30:47 +0300 Subject: [PATCH 226/319] Pyyaml5.1 (#24529) * Migrated to PyYAML 5.1 * More intelligent fixing of yaml safe_load Based on https://circleci.com/gh/home-assistant/home-assistant/34831?utm_campaign=vcs-integration-link&utm_medium=referral&utm_source=github-build-link --- homeassistant/components/kira/__init__.py | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- tests/components/apns/test_notify.py | 2 +- tests/components/scene/test_init.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 7cf27d342f51c9..723a13e426a1e3 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -60,7 +60,7 @@ def load_codes(path): codes = [] if os.path.exists(path): with open(path) as code_file: - data = yaml.load(code_file) or [] + data = yaml.safe_load(code_file) or [] for code in data: try: codes.append(CODE_SCHEMA(code)) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 619c6fad581a11..9e83ca6fd0e02e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ netdisco==2.6.0 pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 -pyyaml>=3.13,<4 +pyyaml==5.1 requests==2.22.0 ruamel.yaml==0.15.97 sqlalchemy==1.3.3 diff --git a/requirements_all.txt b/requirements_all.txt index b973695cba3a67..2c4920a2469087 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -12,7 +12,7 @@ cryptography==2.6.1 pip>=8.0.3 python-slugify==3.0.2 pytz>=2019.01 -pyyaml>=3.13,<4 +pyyaml==5.1 requests==2.22.0 ruamel.yaml==0.15.97 voluptuous==0.11.5 diff --git a/setup.py b/setup.py index d9c13524070733..3278ec197d4574 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ 'pip>=8.0.3', 'python-slugify==3.0.2', 'pytz>=2019.01', - 'pyyaml>=3.13,<4', + 'pyyaml==5.1', 'requests==2.22.0', 'ruamel.yaml==0.15.97', 'voluptuous==0.11.5', diff --git a/tests/components/apns/test_notify.py b/tests/components/apns/test_notify.py index 7303f4872e3a04..3f8d00f8f50a25 100644 --- a/tests/components/apns/test_notify.py +++ b/tests/components/apns/test_notify.py @@ -400,7 +400,7 @@ def test_write_device(): device = apns.ApnsDevice('123', 'name', 'track_id', True) apns._write_device(out, device) - data = yaml.load(out.getvalue()) + data = yaml.safe_load(out.getvalue()) assert data == { 123: { 'name': 'name', diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 99364d51e6c5ef..94746cce0f00dc 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -90,7 +90,7 @@ def test_config_yaml_bool(self): self.light_1.entity_id, self.light_2.entity_id) with io.StringIO(config) as file: - doc = yaml_loader.yaml.load(file) + doc = yaml_loader.yaml.safe_load(file) assert setup_component(self.hass, scene.DOMAIN, doc) common.activate(self.hass, 'scene.test') From f9b3ba288766adfb5308914856ccef396d766abd Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Sat, 15 Jun 2019 00:36:38 +0200 Subject: [PATCH 227/319] Added name to sensors (#24525) * Added name to sensors Added name to sensors, should allow for multiple devices. This should fix #22571 and #21591 * Update sensor.py Fixed spelling issue, and line too long * Update sensor.py Removed _ as it is a friendly name --- homeassistant/components/synologydsm/sensor.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py index 2d12dbfe763662..6589c402431e15 100644 --- a/homeassistant/components/synologydsm/sensor.py +++ b/homeassistant/components/synologydsm/sensor.py @@ -7,7 +7,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, CONF_DISKS) from homeassistant.helpers.entity import Entity @@ -68,6 +68,7 @@ list(_STORAGE_DSK_MON_COND.keys()) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=True): cv.boolean, @@ -88,6 +89,7 @@ def run_setup(event): Delay the setup until Home Assistant is fully initialized. This allows any entities to be created already """ + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) @@ -99,21 +101,21 @@ def run_setup(event): api = SynoApi(host, port, username, password, unit, use_ssl) sensors = [SynoNasUtilSensor( - api, variable, _UTILISATION_MON_COND[variable]) + api, name, variable, _UTILISATION_MON_COND[variable]) for variable in monitored_conditions if variable in _UTILISATION_MON_COND] # Handle all volumes for volume in config.get(CONF_VOLUMES, api.storage.volumes): sensors += [SynoNasStorageSensor( - api, variable, _STORAGE_VOL_MON_COND[variable], volume) + api, name, variable, _STORAGE_VOL_MON_COND[variable], volume) for variable in monitored_conditions if variable in _STORAGE_VOL_MON_COND] # Handle all disks for disk in config.get(CONF_DISKS, api.storage.disks): sensors += [SynoNasStorageSensor( - api, variable, _STORAGE_DSK_MON_COND[variable], disk) + api, name, variable, _STORAGE_DSK_MON_COND[variable], disk) for variable in monitored_conditions if variable in _STORAGE_DSK_MON_COND] @@ -150,10 +152,11 @@ def update(self): class SynoNasSensor(Entity): """Representation of a Synology NAS Sensor.""" - def __init__(self, api, variable, variable_info, monitor_device=None): + def __init__(self, api, name, variable, variable_info, + monitor_device=None): """Initialize the sensor.""" self.var_id = variable - self.var_name = variable_info[0] + self.var_name = "{} {}".format(name, variable_info[0]) self.var_units = variable_info[1] self.var_icon = variable_info[2] self.monitor_device = monitor_device From fe8a330a458bcdf17bbd816fb767cba3b56210d4 Mon Sep 17 00:00:00 2001 From: Tomer Figenblat Date: Sat, 15 Jun 2019 01:48:21 +0300 Subject: [PATCH 228/319] Update requirement version and add switcher_kis services (#23477) * Update aioswitcher requirement to 2019.4.26. * Removed unnecessary legacy function call. * Fixed log message capital first letter. * Replaced None argument with empty dict. * Replaced guard. * Added ServiceCallType. * Added set_auto_off and update_device_name services to the component. * Added test cases for service calls. * Conditioned the component services registry with the platform discovery. * Update homeassistant/components/switcher_kis/__init__.py Co-Authored-By: TomerFi * Update homeassistant/components/switcher_kis/__init__.py Co-Authored-By: TomerFi * Resolved change requests. * Added ContextType. * Addes permission verification for service calls. * Added test cases for permision verification and more. * Replaced POLICY_CONTROL with the more suited POLICY_EDIT. * More appropriate function name. * Added domain and entity_id validation for calling services. * Removed service for setting the vendor's device name. --- .../components/switcher_kis/__init__.py | 72 +++++++++++-- .../components/switcher_kis/manifest.json | 2 +- .../components/switcher_kis/services.yaml | 9 ++ .../components/switcher_kis/switch.py | 3 +- homeassistant/helpers/typing.py | 2 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/switcher_kis/conftest.py | 23 +++- tests/components/switcher_kis/consts.py | 3 + tests/components/switcher_kis/test_init.py | 100 +++++++++++++++++- 10 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/switcher_kis/services.yaml diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 8f959369b7b4a6..4fd66a20085589 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -7,19 +7,25 @@ import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_EDIT from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.const import CONF_ENTITY_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback, split_entity_id +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.discovery import (async_listen_platform, + async_load_platform) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import EventType, HomeAssistantType +from homeassistant.helpers.typing import (ContextType, EventType, + HomeAssistantType, ServiceCallType) +from homeassistant.loader import bind_hass _LOGGER = getLogger(__name__) DOMAIN = 'switcher_kis' +CONF_AUTO_OFF = 'auto_off' CONF_DEVICE_ID = 'device_id' CONF_DEVICE_PASSWORD = 'device_password' CONF_PHONE_ID = 'phone_id' @@ -40,6 +46,32 @@ }) }, extra=vol.ALLOW_EXTRA) +SERVICE_SET_AUTO_OFF_NAME = 'set_auto_off' +SERVICE_SET_AUTO_OFF_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_AUTO_OFF): cv.time_period_str +}) + + +@bind_hass +async def _validate_edit_permission( + hass: HomeAssistantType, context: ContextType, + entity_id: str) -> None: + """Use for validating user control permissions.""" + splited = split_entity_id(entity_id) + if splited[0] != SWITCH_DOMAIN or not splited[1].startswith(DOMAIN): + raise Unauthorized( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + + user = await hass.auth.async_get_user(context.user_id) + if user is None: + raise UnknownUser( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + + if not user.permissions.check_entity(entity_id, POLICY_EDIT): + raise Unauthorized( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: """Set up the switcher component.""" @@ -58,13 +90,12 @@ async def async_stop_bridge(event: EventType) -> None: """On homeassistant stop, gracefully stop the bridge if running.""" await v2bridge.stop() - hass.async_add_job(hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_stop_bridge)) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_bridge) try: device_data = await wait_for(v2bridge.queue.get(), timeout=10.0) except (Asyncio_TimeoutError, RuntimeError): - _LOGGER.exception("failed to get response from device") + _LOGGER.exception("Failed to get response from device") await v2bridge.stop() return False @@ -72,8 +103,33 @@ async def async_stop_bridge(event: EventType) -> None: DATA_DEVICE: device_data } + async def async_switch_platform_discovered( + platform: str, discovery_info: Optional[Dict]) -> None: + """Use for registering services after switch platform is discoverd.""" + if platform != DOMAIN: + return + + async def async_set_auto_off_service(service: ServiceCallType) -> None: + """Use for handling setting device auto-off service calls.""" + from aioswitcher.api import SwitcherV2Api + + await _validate_edit_permission( + hass, service.context, service.data[CONF_ENTITY_ID]) + + async with SwitcherV2Api(hass.loop, device_data.ip_addr, phone_id, + device_id, device_password) as swapi: + await swapi.set_auto_shutdown(service.data[CONF_AUTO_OFF]) + + hass.services.async_register( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + async_set_auto_off_service, + schema=SERVICE_SET_AUTO_OFF_SCHEMA) + + async_listen_platform( + hass, SWITCH_DOMAIN, async_switch_platform_discovered) + hass.async_create_task(async_load_platform( - hass, SWITCH_DOMAIN, DOMAIN, None, config)) + hass, SWITCH_DOMAIN, DOMAIN, {}, config)) @callback def device_updates(timestamp: Optional[datetime]) -> None: diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 140caf51936b76..2f3b3b6e84a5bc 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -6,7 +6,7 @@ "@tomerfi" ], "requirements": [ - "aioswitcher==2019.3.21" + "aioswitcher==2019.4.26" ], "dependencies": [] } diff --git a/homeassistant/components/switcher_kis/services.yaml b/homeassistant/components/switcher_kis/services.yaml new file mode 100644 index 00000000000000..5408a20499025e --- /dev/null +++ b/homeassistant/components/switcher_kis/services.yaml @@ -0,0 +1,9 @@ +set_auto_off: + description: 'Update Switcher device auto off setting.' + fields: + entity_id: + description: "Name of the entity id associated with the integration, used for permission validation." + example: "switch.switcher_kis_boiler" + auto_off: + description: 'Time period string containing hours and minutes.' + example: '"02:30"' diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index c66c6b52e0c3d4..a6da7aad4b9c96 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -30,7 +30,8 @@ async def async_setup_platform(hass: HomeAssistantType, config: Dict, async_add_entities: Callable, discovery_info: Dict) -> None: """Set up the switcher platform for the switch component.""" - assert DOMAIN in hass.data + if discovery_info is None: + return async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index e9a8d0749b0a46..f084c5fddbe6a9 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -7,8 +7,10 @@ GPSType = Tuple[float, float] ConfigType = Dict[str, Any] +ContextType = homeassistant.core.Context EventType = homeassistant.core.Event HomeAssistantType = homeassistant.core.HomeAssistant +ServiceCallType = homeassistant.core.ServiceCall ServiceDataType = Dict[str, Any] TemplateVarsType = Optional[Dict[str, Any]] diff --git a/requirements_all.txt b/requirements_all.txt index 2c4920a2469087..426060f990a79e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,7 +160,7 @@ aiolifx_effects==0.2.2 aiopvapi==1.6.14 # homeassistant.components.switcher_kis -aioswitcher==2019.3.21 +aioswitcher==2019.4.26 # homeassistant.components.unifi aiounifi==6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4dd74fc63b00f8..b40047493ce5f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -58,7 +58,7 @@ aiohttp_cors==0.7.0 aiohue==1.9.1 # homeassistant.components.switcher_kis -aioswitcher==2019.3.21 +aioswitcher==2019.4.26 # homeassistant.components.unifi aiounifi==6 diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 9f961f72401a0d..d820f11cea6015 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -98,7 +98,9 @@ async def mock_queue(): patchers = [ patch('aioswitcher.bridge.SwitcherV2Bridge.start', new=mock_bridge), patch('aioswitcher.bridge.SwitcherV2Bridge.stop', new=mock_bridge), - patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue) + patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue), + patch('aioswitcher.bridge.SwitcherV2Bridge.running', + return_value=True) ] for patcher in patchers: @@ -130,3 +132,22 @@ async def mock_queue(): for patcher in patchers: patcher.stop() + + +@fixture(name='mock_api') +def mock_api_fixture() -> Generator[CoroutineMock, Any, None]: + """Fixture for mocking aioswitcher.api.SwitcherV2Api.""" + mock_api = CoroutineMock() + + patchers = [ + patch('aioswitcher.api.SwitcherV2Api.connect', new=mock_api), + patch('aioswitcher.api.SwitcherV2Api.disconnect', new=mock_api) + ] + + for patcher in patchers: + patcher.start() + + yield + + for patcher in patchers: + patcher.stop() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index 47efe8d03c9e9b..852f5e521f7382 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -17,6 +17,9 @@ DUMMY_POWER_CONSUMPTION = 2780 DUMMY_REMAINING_TIME = '01:29:32' +# Adjust if any modification were made to DUMMY_DEVICE_NAME +SWITCH_ENTITY_ID = "switch.switcher_kis_device_name" + MANDATORY_CONFIGURATION = { DOMAIN: { CONF_PHONE_ID: DUMMY_PHONE_ID, diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 33d24903f9435c..b0d98dd6267e4e 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -1,16 +1,32 @@ """Test cases for the switcher_kis component.""" -from typing import Any, Generator +from datetime import timedelta +from typing import Any, Generator, TYPE_CHECKING -from homeassistant.components.switcher_kis import (DOMAIN, DATA_DEVICE) +from pytest import raises + +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.components.switcher_kis import ( + CONF_AUTO_OFF, DOMAIN, DATA_DEVICE, SERVICE_SET_AUTO_OFF_NAME, + SERVICE_SET_AUTO_OFF_SCHEMA, SIGNAL_SWITCHER_DEVICE_UPDATE) +from homeassistant.core import callback, Context +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import async_mock_service, async_fire_time_changed from .consts import ( DUMMY_AUTO_OFF_SET, DUMMY_DEVICE_ID, DUMMY_DEVICE_NAME, DUMMY_DEVICE_STATE, DUMMY_ELECTRIC_CURRENT, DUMMY_IP_ADDRESS, DUMMY_MAC_ADDRESS, DUMMY_PHONE_ID, DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION) + DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION, SWITCH_ENTITY_ID) + +if TYPE_CHECKING: + from tests.common import MockUser + from aioswitcher.devices import SwitcherV2Device async def test_failed_config( @@ -49,3 +65,81 @@ async def test_discovery_data_bucket( assert device.power_consumption == DUMMY_POWER_CONSUMPTION assert device.electric_current == DUMMY_ELECTRIC_CURRENT assert device.phone_id == DUMMY_PHONE_ID + + +async def test_set_auto_off_service( + hass: HomeAssistantType, mock_bridge: Generator[None, Any, None], + mock_api: Generator[None, Any, None], hass_owner_user: 'MockUser', + hass_read_only_user: 'MockUser') -> None: + """Test the set_auto_off service.""" + assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) + + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_SET_AUTO_OFF_NAME) + + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_owner_user.id)) + + with raises(Unauthorized) as unauthorized_read_only_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_read_only_user.id)) + + assert unauthorized_read_only_exc.type is Unauthorized + + with raises(Unauthorized) as unauthorized_wrong_entity_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: "light.not_related_entity", + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_owner_user.id)) + + assert unauthorized_wrong_entity_exc.type is Unauthorized + + with raises(UnknownUser) as unknown_user_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id="not_real_user")) + + assert unknown_user_exc.type is UnknownUser + + service_calls = async_mock_service( + hass, DOMAIN, SERVICE_SET_AUTO_OFF_NAME, SERVICE_SET_AUTO_OFF_SCHEMA) + + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}) + + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert str(service_calls[0].data[CONF_AUTO_OFF]) \ + == DUMMY_AUTO_OFF_SET.lstrip('0') + + +async def test_signal_dispatcher( + hass: HomeAssistantType, + mock_bridge: Generator[None, Any, None]) -> None: + """Test signal dispatcher dispatching device updates every 4 seconds.""" + assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) + + await hass.async_block_till_done() + + @callback + def verify_update_data(device: 'SwitcherV2Device') -> None: + """Use as callback for signal dispatcher.""" + pass + + async_dispatcher_connect( + hass, SIGNAL_SWITCHER_DEVICE_UPDATE, verify_update_data) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=5)) From aa8ddeca34659e097d3e20ddf1129039a565a431 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 15 Jun 2019 13:32:51 +0800 Subject: [PATCH 229/319] Flux switch (#24542) * Updated to pytest * Additional test case --- tests/components/flux/test_switch.py | 1707 +++++++++++++------------- 1 file changed, 885 insertions(+), 822 deletions(-) diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index ee4e2e4e77c861..bfd9e3f755116f 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -1,846 +1,909 @@ """The tests for the Flux switch platform.""" -import unittest -from unittest.mock import patch +from asynctest.mock import patch +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import switch, light from homeassistant.const import ( CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SUN_EVENT_SUNRISE) import homeassistant.util.dt as dt_util from tests.common import ( - assert_setup_component, get_test_home_assistant, fire_time_changed, - mock_service) + assert_setup_component, async_fire_time_changed, + async_mock_service) from tests.components.light import common as common_light from tests.components.switch import common -class TestSwitchFlux(unittest.TestCase): - """Test the Flux switch platform.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +async def test_valid_config(hass): + """Test configuration.""" + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'flux', + 'name': 'flux', + 'lights': ['light.desk', 'light.lamp'], + } + }) + + +async def test_valid_config_with_info(hass): + """Test configuration.""" + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'flux', + 'name': 'flux', + 'lights': ['light.desk', 'light.lamp'], + 'stop_time': '22:59', + 'start_time': '7:22', + 'start_colortemp': '1000', + 'sunset_colortemp': '2000', + 'stop_colortemp': '4000' + } + }) + + +async def test_valid_config_no_name(hass): + """Test configuration.""" + with assert_setup_component(1, 'switch'): + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'flux', + 'lights': ['light.desk', 'light.lamp'] + } + }) - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - def test_valid_config(self): - """Test configuration.""" - assert setup_component(self.hass, 'switch', { +async def test_invalid_config_no_lights(hass): + """Test configuration.""" + with assert_setup_component(0, 'switch'): + assert await async_setup_component(hass, 'switch', { 'switch': { 'platform': 'flux', - 'name': 'flux', - 'lights': ['light.desk', 'light.lamp'], + 'name': 'flux' } }) - def test_valid_config_with_info(self): - """Test configuration.""" - assert setup_component(self.hass, 'switch', { - 'switch': { + +async def test_flux_when_switch_is_off(hass): + """Test the flux switch when it is off.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component( + hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=10, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { 'platform': 'flux', 'name': 'flux', - 'lights': ['light.desk', 'light.lamp'], - 'stop_time': '22:59', - 'start_time': '7:22', + 'lights': [dev1.entity_id] + } + }) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + + assert not turn_on_calls + + +async def test_flux_before_sunrise(hass): + """Test the flux switch before sunrise.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=5) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + await hass.async_block_till_done() + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +async def test_flux_before_sunrise_known_location(hass): + """Test the flux switch before sunrise.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + hass.config.latitude = 55.948372 + hass.config.longitude = -3.199466 + hass.config.elevation = 17 + test_time = dt_util.utcnow().replace( + hour=2, minute=0, second=0, day=21, month=6, year=2019) + + await hass.async_block_till_done() + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + # 'brightness': 255, + # 'disable_brightness_adjust': True, + # 'mode': 'rgb', + # 'interval': 120 + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_after_sunrise_before_sunset(hass): + """Test the flux switch after sunrise and before sunset.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 173 + assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] + + +# pylint: disable=invalid-name +async def test_flux_after_sunset_before_stop(hass): + """Test the flux switch after sunset and before stop.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '22:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 146 + assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] + + +# pylint: disable=invalid-name +async def test_flux_after_stop_before_sunrise(hass): + """Test the flux switch after stop and before sunrise.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_with_custom_start_stop_times(hass): + """Test the flux with custom start and stop times.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'start_time': '6:00', + 'stop_time': '23:30' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 147 + assert call.data[light.ATTR_XY_COLOR] == [0.504, 0.385] + + +async def test_flux_before_sunrise_stop_next_day(hass): + """Test the flux switch before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_after_sunrise_before_sunset_stop_next_day(hass): + """ + Test the flux switch after sunrise and before sunset. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 173 + assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] + + +# pylint: disable=invalid-name +@pytest.mark.parametrize("x", [0, 1]) +async def test_flux_after_sunset_before_midnight_stop_next_day(hass, x): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 119 + assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386] + + +# pylint: disable=invalid-name +async def test_flux_after_sunset_after_midnight_stop_next_day(hass): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=00, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 114 + assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382] + + +# pylint: disable=invalid-name +async def test_flux_after_stop_before_sunrise_stop_next_day(hass): + """Test the flux switch after stop and before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 112 + assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] + + +# pylint: disable=invalid-name +async def test_flux_with_custom_colortemps(hass): + """Test the flux with custom start and stop colortemps.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], 'start_colortemp': '1000', - 'sunset_colortemp': '2000', - 'stop_colortemp': '4000' + 'stop_colortemp': '6000', + 'stop_time': '22:00' } }) - - def test_valid_config_no_name(self): - """Test configuration.""" - with assert_setup_component(1, 'switch'): - assert setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'flux', - 'lights': ['light.desk', 'light.lamp'] - } - }) - - def test_invalid_config_no_lights(self): - """Test configuration.""" - with assert_setup_component(0, 'switch'): - assert setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'flux', - 'name': 'flux' - } - }) - - def test_flux_when_switch_is_off(self): - """Test the flux switch when it is off.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=10, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - assert 0 == len(turn_on_calls) - - def test_flux_before_sunrise(self): - """Test the flux switch before sunrise.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_after_sunrise_before_sunset(self): - """Test the flux switch after sunrise and before sunset.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 173 - assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] - - # pylint: disable=invalid-name - def test_flux_after_sunset_before_stop(self): - """Test the flux switch after sunset and before stop.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 146 - assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] - - # pylint: disable=invalid-name - def test_flux_after_stop_before_sunrise(self): - """Test the flux switch after stop and before sunrise.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_with_custom_start_stop_times(self): - """Test the flux with custom start and stop times.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_time': '6:00', - 'stop_time': '23:30' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 147 - assert call.data[light.ATTR_XY_COLOR] == [0.504, 0.385] - - def test_flux_before_sunrise_stop_next_day(self): - """Test the flux switch before sunrise. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_after_sunrise_before_sunset_stop_next_day(self): - """ - Test the flux switch after sunrise and before sunset. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 173 - assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] - - # pylint: disable=invalid-name - def test_flux_after_sunset_before_midnight_stop_next_day(self): - """Test the flux switch after sunset and before stop. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 119 - assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386] - - # pylint: disable=invalid-name - def test_flux_after_sunset_after_midnight_stop_next_day(self): - """Test the flux switch after sunset and before stop. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=00, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 114 - assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382] - - # pylint: disable=invalid-name - def test_flux_after_stop_before_sunrise_stop_next_day(self): - """Test the flux switch after stop and before sunrise. - - This test has the stop_time on the next day (after midnight). - """ - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 112 - assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] - - # pylint: disable=invalid-name - def test_flux_with_custom_colortemps(self): - """Test the flux with custom start and stop colortemps.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_colortemp': '1000', - 'stop_colortemp': '6000', - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 159 - assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378] - - # pylint: disable=invalid-name - def test_flux_with_custom_brightness(self): - """Test the flux with custom start and stop colortemps.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'brightness': 255, - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 255 - assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] - - def test_flux_with_multiple_lights(self): - """Test the flux switch with multiple light entities.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1, dev2, dev3 = platform.DEVICES - common_light.turn_on(self.hass, entity_id=dev2.entity_id) - self.hass.block_till_done() - common_light.turn_on(self.hass, entity_id=dev3.entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - state = self.hass.states.get(dev2.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - state = self.hass.states.get(dev3.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('xy_color') is None - assert state.attributes.get('brightness') is None - - test_time = dt_util.utcnow().replace(hour=12, minute=0, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - print('sunrise {}'.format(sunrise_time)) - return sunrise_time - print('sunset {}'.format(sunset_time)) - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id, - dev2.entity_id, - dev3.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_BRIGHTNESS] == 163 - assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] - call = turn_on_calls[-2] - assert call.data[light.ATTR_BRIGHTNESS] == 163 - assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] - call = turn_on_calls[-3] - assert call.data[light.ATTR_BRIGHTNESS] == 163 - assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] - - def test_flux_with_mired(self): - """Test the flux switch´s mode mired.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('color_temp') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'mired' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - assert call.data[light.ATTR_COLOR_TEMP] == 269 - - def test_flux_with_rgb(self): - """Test the flux switch´s mode rgb.""" - platform = getattr(self.hass.components, 'test.light') - platform.init() - assert setup_component(self.hass, light.DOMAIN, - {light.DOMAIN: {CONF_PLATFORM: 'test'}}) - - dev1 = platform.DEVICES[0] - - # Verify initial state of light - state = self.hass.states.get(dev1.entity_id) - assert STATE_ON == state.state - assert state.attributes.get('color_temp') is None - - test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) - sunset_time = test_time.replace(hour=17, minute=0, second=0) - sunrise_time = test_time.replace(hour=5, minute=0, second=0) - - def event_date(hass, event, now=None): - if event == SUN_EVENT_SUNRISE: - return sunrise_time - return sunset_time - - with patch('homeassistant.components.flux.switch.dt_utcnow', - return_value=test_time), \ - patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'rgb' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() - call = turn_on_calls[-1] - rgb = (255, 198, 152) - rounded_call = tuple(map(round, call.data[light.ATTR_RGB_COLOR])) - assert rounded_call == rgb + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 159 + assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378] + + +# pylint: disable=invalid-name +async def test_flux_with_custom_brightness(hass): + """Test the flux with custom start and stop colortemps.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'brightness': 255, + 'stop_time': '22:00' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 255 + assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] + + +async def test_flux_with_multiple_lights(hass): + """Test the flux switch with multiple light entities.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1, dev2, dev3 = platform.DEVICES + common_light.turn_on(hass, entity_id=dev2.entity_id) + await hass.async_block_till_done() + common_light.turn_on(hass, entity_id=dev3.entity_id) + await hass.async_block_till_done() + + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + state = hass.states.get(dev2.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + state = hass.states.get(dev3.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('xy_color') is None + assert state.attributes.get('brightness') is None + + test_time = dt_util.utcnow().replace(hour=12, minute=0, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + print('sunrise {}'.format(sunrise_time)) + return sunrise_time + print('sunset {}'.format(sunset_time)) + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [ + dev1.entity_id, + dev2.entity_id, + dev3.entity_id] + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_BRIGHTNESS] == 163 + assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] + call = turn_on_calls[-2] + assert call.data[light.ATTR_BRIGHTNESS] == 163 + assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] + call = turn_on_calls[-3] + assert call.data[light.ATTR_BRIGHTNESS] == 163 + assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] + + +async def test_flux_with_mired(hass): + """Test the flux switch´s mode mired.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('color_temp') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'mired' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + assert call.data[light.ATTR_COLOR_TEMP] == 269 + + +async def test_flux_with_rgb(hass): + """Test the flux switch´s mode rgb.""" + platform = getattr(hass.components, 'test.light') + platform.init() + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: {CONF_PLATFORM: 'test'}}) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = hass.states.get(dev1.entity_id) + assert STATE_ON == state.state + assert state.attributes.get('color_temp') is None + + test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == SUN_EVENT_SUNRISE: + return sunrise_time + return sunset_time + + with patch('homeassistant.components.flux.switch.dt_utcnow', + return_value=test_time), \ + patch('homeassistant.components.flux.switch.get_astral_event_date', + side_effect=event_date): + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'rgb' + } + }) + turn_on_calls = async_mock_service( + hass, light.DOMAIN, SERVICE_TURN_ON) + await common.async_turn_on(hass, 'switch.flux') + await hass.async_block_till_done() + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + call = turn_on_calls[-1] + rgb = (255, 198, 152) + rounded_call = tuple(map(round, call.data[light.ATTR_RGB_COLOR])) + assert rounded_call == rgb From e9b0f54a4301a4e348bf027a33947c5e0bf6bc5f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 15 Jun 2019 17:38:22 +0200 Subject: [PATCH 230/319] UniFi simplify update (#24304) --- homeassistant/components/unifi/config_flow.py | 6 +- homeassistant/components/unifi/const.py | 1 - homeassistant/components/unifi/controller.py | 72 +++++++++++++--- homeassistant/components/unifi/switch.py | 86 +++---------------- tests/components/unifi/test_controller.py | 30 +------ tests/components/unifi/test_init.py | 6 +- tests/components/unifi/test_switch.py | 23 +++-- 7 files changed, 90 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 95af83767736b0..1ffb8d942100d2 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -5,8 +5,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) -from .const import (CONF_CONTROLLER, CONF_POE_CONTROL, CONF_SITE_ID, - DOMAIN, LOGGER) +from .const import CONF_CONTROLLER, CONF_SITE_ID, DOMAIN, LOGGER from .controller import get_controller from .errors import ( AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel) @@ -99,8 +98,7 @@ async def async_step_site(self, user_input=None): raise AlreadyConfigured data = { - CONF_CONTROLLER: self.config, - CONF_POE_CONTROL: True + CONF_CONTROLLER: self.config } return self.async_create_entry( diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index 4d65a0d223a397..7353a9d302b2c3 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -7,5 +7,4 @@ CONTROLLER_ID = '{host}-{site}' CONF_CONTROLLER = 'controller' -CONF_POE_CONTROL = 'poe_control' CONF_SITE_ID = 'site' diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 5105e33f1d6f23..d0600315c013b5 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,11 +5,14 @@ from aiohttp import CookieJar +import aiounifi + from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import CONF_HOST from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_CONTROLLER, CONF_POE_CONTROL, LOGGER +from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID, LOGGER from .errors import AuthenticationRequired, CannotConnect @@ -37,7 +40,57 @@ def mac(self): return client.mac return None - async def async_setup(self, tries=0): + @property + def event_update(self): + """Event specific per UniFi entry to signal new data.""" + return 'unifi-update-{}'.format( + CONTROLLER_ID.format( + host=self.host, + site=self.config_entry.data[CONF_CONTROLLER][CONF_SITE_ID])) + + async def request_update(self): + """Request an update.""" + if self.progress is not None: + return await self.progress + + self.progress = self.hass.async_create_task(self.async_update()) + await self.progress + + self.progress = None + + async def async_update(self): + """Update UniFi controller information.""" + failed = False + + try: + with async_timeout.timeout(4): + await self.api.clients.update() + await self.api.devices.update() + + except aiounifi.LoginRequired: + try: + with async_timeout.timeout(5): + await self.api.login() + + except (asyncio.TimeoutError, aiounifi.AiounifiException): + failed = True + if self.available: + LOGGER.error('Unable to reach controller %s', self.host) + self.available = False + + except (asyncio.TimeoutError, aiounifi.AiounifiException): + failed = True + if self.available: + LOGGER.error('Unable to reach controller %s', self.host) + self.available = False + + if not failed and not self.available: + LOGGER.info('Reconnected to controller %s', self.host) + self.available = True + + async_dispatcher_send(self.hass, self.event_update) + + async def async_setup(self): """Set up a UniFi controller.""" hass = self.hass @@ -54,10 +107,9 @@ async def async_setup(self, tries=0): 'Unknown error connecting with UniFi controller.') return False - if self.config_entry.data[CONF_POE_CONTROL]: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup( - self.config_entry, 'switch')) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + self.config_entry, 'switch')) return True @@ -71,17 +123,13 @@ async def async_reset(self): if self.api is None: return True - if self.config_entry.data[CONF_POE_CONTROL]: - return await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'switch') - return True + return await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'switch') async def get_controller( hass, host, username, password, port, site, verify_ssl): """Create a controller object and verify authentication.""" - import aiounifi - sslcontext = None if verify_ssl: diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 5f33a9c08d35fb..dd6fc1ff1a20d5 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -1,15 +1,13 @@ """Support for devices connected to UniFi POE.""" -import asyncio from datetime import timedelta import logging -import async_timeout - from homeassistant.components import unifi from homeassistant.components.switch import SwitchDevice from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID @@ -36,79 +34,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): controller = hass.data[unifi.DOMAIN][controller_id] switches = {} - progress = None - update_progress = set() - - async def request_update(object_id): - """Request an update.""" - nonlocal progress - update_progress.add(object_id) - - if progress is not None: - return await progress - - progress = asyncio.ensure_future(update_controller()) - result = await progress - progress = None - update_progress.clear() - return result - - async def update_controller(): + @callback + def update_controller(): """Update the values of the controller.""" - tasks = [async_update_items( - controller, async_add_entities, request_update, - switches, update_progress - )] - await asyncio.wait(tasks) + update_items(controller, async_add_entities, switches) - await update_controller() + async_dispatcher_connect(hass, controller.event_update, update_controller) + update_controller() -async def async_update_items(controller, async_add_entities, - request_controller_update, switches, - progress_waiting): - """Update POE port state from the controller.""" - import aiounifi - - @callback - def update_switch_state(): - """Tell switches to reload state.""" - for client_id, client in switches.items(): - if client_id not in progress_waiting: - client.async_schedule_update_ha_state() - - try: - with async_timeout.timeout(4): - await controller.api.clients.update() - await controller.api.devices.update() - - except aiounifi.LoginRequired: - try: - with async_timeout.timeout(5): - await controller.api.login() - except (asyncio.TimeoutError, aiounifi.AiounifiException): - if controller.available: - controller.available = False - update_switch_state() - return - - except (asyncio.TimeoutError, aiounifi.AiounifiException): - if controller.available: - LOGGER.error('Unable to reach controller %s', controller.host) - controller.available = False - update_switch_state() - return - - if not controller.available: - LOGGER.info('Reconnected to controller %s', controller.host) - controller.available = True +@callback +def update_items(controller, async_add_entities, switches): + """Update POE port state from the controller.""" new_switches = [] devices = controller.api.devices - for client_id in controller.api.clients: - if client_id in progress_waiting: - continue + for client_id in controller.api.clients: if client_id in switches: LOGGER.debug("Updating UniFi switch %s (%s)", @@ -137,8 +79,7 @@ def update_switch_state(): if multi_clients_on_port: continue - switches[client_id] = UniFiSwitch( - client, controller, request_controller_update) + switches[client_id] = UniFiSwitch(client, controller) new_switches.append(switches[client_id]) LOGGER.debug("New UniFi switch %s (%s)", client.hostname, client.mac) @@ -149,18 +90,17 @@ def update_switch_state(): class UniFiSwitch(SwitchDevice): """Representation of a client that uses POE.""" - def __init__(self, client, controller, request_controller_update): + def __init__(self, client, controller): """Set up switch.""" self.client = client self.controller = controller self.poe_mode = None if self.port.poe_mode != 'off': self.poe_mode = self.port.poe_mode - self.async_request_controller_update = request_controller_update async def async_update(self): """Synchronize state with controller.""" - await self.async_request_controller_update(self.client.mac) + await self.controller.request_update() @property def name(self): diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index d1db25a23cd4e1..b708a69bb67998 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -4,8 +4,7 @@ import pytest from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.components.unifi.const import ( - CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID) +from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) from homeassistant.components.unifi import controller, errors @@ -22,8 +21,7 @@ } ENTRY_CONFIG = { - CONF_CONTROLLER: CONTROLLER_DATA, - CONF_POE_CONTROL: True + CONF_CONTROLLER: CONTROLLER_DATA } @@ -171,30 +169,6 @@ async def test_reset_unloads_entry_if_setup(): assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 -async def test_reset_unloads_entry_without_poe_control(): - """Calling reset while the entry has been setup.""" - hass = Mock() - entry = Mock() - entry.data = dict(ENTRY_CONFIG) - entry.data[CONF_POE_CONTROL] = False - api = Mock() - api.initialize.return_value = mock_coro(True) - - unifi_controller = controller.UniFiController(hass, entry) - - with patch.object(controller, 'get_controller', - return_value=mock_coro(api)): - assert await unifi_controller.async_setup() is True - - assert not hass.config_entries.async_forward_entry_setup.mock_calls - - hass.config_entries.async_forward_entry_unload.return_value = \ - mock_coro(True) - assert await unifi_controller.async_reset() - - assert not hass.config_entries.async_forward_entry_unload.mock_calls - - async def test_get_controller(hass): """Successful call.""" with patch('aiounifi.Controller.login', return_value=mock_coro()): diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index ec5ab5a577bf08..fffdcb5fb98e35 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -4,8 +4,7 @@ from homeassistant.components import unifi from homeassistant.components.unifi import config_flow from homeassistant.setup import async_setup_component -from homeassistant.components.unifi.const import ( - CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID) +from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) @@ -186,8 +185,7 @@ def mock_constructor( CONF_PORT: 1234, CONF_SITE_ID: 'default', CONF_VERIFY_SSL: True - }, - CONF_POE_CONTROL: True + } } diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 5a04b415f5dc8c..4eba3aca61e700 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -4,22 +4,21 @@ import pytest +from tests.common import mock_coro + import aiounifi from aiounifi.clients import Clients from aiounifi.devices import Devices from homeassistant import config_entries from homeassistant.components import unifi -from homeassistant.components.unifi.const import ( - CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID) +from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID from homeassistant.setup import async_setup_component from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) import homeassistant.components.switch as switch -from tests.common import mock_coro - CLIENT_1 = { 'hostname': 'client_1', 'ip': '10.0.0.1', @@ -180,8 +179,7 @@ } ENTRY_CONFIG = { - CONF_CONTROLLER: CONTROLLER_DATA, - CONF_POE_CONTROL: True + CONF_CONTROLLER: CONTROLLER_DATA } CONTROLLER_ID = unifi.CONTROLLER_ID.format(host='mock-host', site='mock-site') @@ -190,12 +188,9 @@ @pytest.fixture def mock_controller(hass): """Mock a UniFi Controller.""" - controller = Mock( - available=True, - api=Mock(), - spec=unifi.UniFiController - ) - controller.mac = '10:00:00:00:00:01' + controller = unifi.UniFiController(hass, None) + + controller.api = Mock() controller.mock_requests = [] controller.mock_client_responses = deque() @@ -224,6 +219,9 @@ async def setup_controller(hass, mock_controller): config_entry = config_entries.ConfigEntry( 1, unifi.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', config_entries.CONN_CLASS_LOCAL_POLL) + mock_controller.config_entry = config_entry + + await mock_controller.async_update() await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') # To flush out the service call to update the group await hass.async_block_till_done() @@ -242,6 +240,7 @@ async def test_platform_manually_configured(hass): async def test_no_clients(hass, mock_controller): """Test the update_clients function when no clients are found.""" mock_controller.mock_client_responses.append({}) + mock_controller.mock_device_responses.append({}) await setup_controller(hass, mock_controller) assert len(mock_controller.mock_requests) == 2 assert not hass.states.async_all() From 616301f7ee876bbf771b76c9d6af416f0c9d214f Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 15 Jun 2019 20:45:01 +0100 Subject: [PATCH 231/319] Fix incomfort issue #24478 & bump client (#24548) * fix issue #24478 - missing climate entities * bump client --- homeassistant/components/incomfort/climate.py | 5 +---- homeassistant/components/incomfort/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index fa42ced32c28eb..9be7541e922996 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -19,10 +19,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities, client = hass.data[DOMAIN]['client'] heater = hass.data[DOMAIN]['heater'] - rooms = [InComfortClimate(client, r) - for r in heater.rooms if not r.room_temp] - if rooms: - async_add_entities(rooms) + async_add_entities([InComfortClimate(client, r) for r in heater.rooms]) class InComfortClimate(ClimateDevice): diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 1731c8c942f466..13c77cd33fffc8 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -3,7 +3,7 @@ "name": "Intergas InComfort/Intouch Lan2RF gateway", "documentation": "https://www.home-assistant.io/components/incomfort", "requirements": [ - "incomfort-client==0.2.9" + "incomfort-client==0.3.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 426060f990a79e..7a422a7b78bc68 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ iglo==1.2.7 ihcsdk==2.3.0 # homeassistant.components.incomfort -incomfort-client==0.2.9 +incomfort-client==0.3.0 # homeassistant.components.influxdb influxdb==5.2.0 From 6b3c740dc368d3c53e8038a9e28427858cf3c7d2 Mon Sep 17 00:00:00 2001 From: Matt Black Date: Sun, 16 Jun 2019 05:48:55 +1000 Subject: [PATCH 232/319] Handle stacktrace when rtorrent host is unreachable (#24541) --- homeassistant/components/rtorrent/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 8ec6a45b639ca5..d7912e0d6be874 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -99,8 +99,9 @@ def update(self): try: self.data = multicall() self._available = True - except (xmlrpc.client.ProtocolError, ConnectionRefusedError): - _LOGGER.error("Connection to rtorrent lost") + except (xmlrpc.client.ProtocolError, + ConnectionRefusedError, OSError) as ex: + _LOGGER.error("Connection to rtorrent failed (%s)", ex) self._available = False return From c629f24f07cfc0e17b0e8418f77284e647b7b4d2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 15 Jun 2019 13:49:40 -0600 Subject: [PATCH 233/319] Fix a bug with Ambient PWS reconnection (#24540) --- homeassistant/components/ambient_station/__init__.py | 2 +- homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 2c185c3bc71de5..1abdad5e925e00 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -327,7 +327,7 @@ def on_connect(): """Define a handler to fire when the websocket is connected.""" _LOGGER.info('Connected to websocket') _LOGGER.debug('Watchdog starting') - if self._watchdog_listener: + if self._watchdog_listener is not None: self._watchdog_listener() self._watchdog_listener = async_call_later( self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 3e9bbf6a5b8644..510edd540ecca3 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambient_station", "requirements": [ - "aioambient==0.3.0" + "aioambient==0.3.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 7a422a7b78bc68..8695fde6b8b7af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -114,7 +114,7 @@ adguardhome==0.2.1 afsapi==0.0.4 # homeassistant.components.ambient_station -aioambient==0.3.0 +aioambient==0.3.1 # homeassistant.components.asuswrt aioasuswrt==1.1.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b40047493ce5f0..6e4148990fbfdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ YesssSMS==0.2.3 adguardhome==0.2.1 # homeassistant.components.ambient_station -aioambient==0.3.0 +aioambient==0.3.1 # homeassistant.components.automatic aioautomatic==0.6.5 From a0b1b2e254dfa3663b98bc8fab4fd345d206fb90 Mon Sep 17 00:00:00 2001 From: lundan <7068358+lundan@users.noreply.github.com> Date: Sun, 16 Jun 2019 09:44:39 +0200 Subject: [PATCH 234/319] Update __init__.py (#24553) Fix the broken charging status --- homeassistant/components/nissan_leaf/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index f9e7cd7f2d1879..80e5543946cafe 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -301,6 +301,9 @@ async def async_refresh_data(self, now): self.data[DATA_PLUGGED_IN] = ( server_response.is_connected ) + self.data[DATA_CHARGING] = ( + server_response.is_charging + ) async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) self.last_battery_response = utcnow() From b782ed6bbb90f3ac82dadb6541e88d8bc9c8c45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 16 Jun 2019 15:11:53 +0200 Subject: [PATCH 235/319] Update ambiclimate library (#24562) --- homeassistant/components/ambiclimate/climate.py | 9 +++++---- homeassistant/components/ambiclimate/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index ae61163ab0520c..3dc6431bb8c1f3 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -56,14 +56,15 @@ async def async_setup_entry(hass, entry, async_add_entities): websession) try: - _token_info = await oauth.refresh_access_token(token_info) + token_info = await oauth.refresh_access_token(token_info) except ambiclimate.AmbiclimateOauthError: + token_info = None + + if not token_info: _LOGGER.error("Failed to refresh access token") return - if _token_info: - await store.async_save(_token_info) - token_info = _token_info + await store.async_save(token_info) data_connection = ambiclimate.AmbiclimateConnection(oauth, token_info=token_info, diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index 1bae147ae27ef8..e0d4e29a8e568b 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambiclimate", "requirements": [ - "ambiclimate==0.1.3" + "ambiclimate==0.2.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 8695fde6b8b7af..d4ea7e09eae23a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.ambiclimate -ambiclimate==0.1.3 +ambiclimate==0.2.0 # homeassistant.components.amcrest amcrest==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e4148990fbfdf..d84a8cd3e36187 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -64,7 +64,7 @@ aioswitcher==2019.4.26 aiounifi==6 # homeassistant.components.ambiclimate -ambiclimate==0.1.3 +ambiclimate==0.2.0 # homeassistant.components.apns apns2==0.3.0 From c173a3be44f3486a8b1e1329325facf346282b25 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 16 Jun 2019 13:17:53 -0400 Subject: [PATCH 236/319] Misc. ZHA enhancements (#24559) * add nwk to device info * input bind only cluster support * cleanup channel only clusters * dirty hack to correct xiaomi vibration sensor * exclude remaining remote binary sensors * review comments * fix comment --- homeassistant/components/zha/core/device.py | 5 +- .../components/zha/core/discovery.py | 50 ++++++++++++++----- homeassistant/components/zha/core/gateway.py | 6 ++- .../components/zha/core/registries.py | 37 ++++++++++++-- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 85373517aa213d..dcb4fe7ca0e050 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -18,7 +18,7 @@ ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED, QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE, MAINS_POWERED, - BATTERY_OR_UNKNOWN + BATTERY_OR_UNKNOWN, NWK ) from .channels import EventRelayChannel @@ -189,6 +189,7 @@ def device_info(self): ieee = str(self.ieee) return { IEEE: ieee, + NWK: self.nwk, ATTR_MANUFACTURER: self.manufacturer, MODEL: self.model, NAME: self.name or ieee, @@ -390,7 +391,7 @@ async def write_zigbee_attribute(self, endpoint_id, cluster_id, manufacturer=manufacturer ) _LOGGER.debug( - 'set: %s for attr: %s to cluster: %s for entity: %s - res: %s', + 'set: %s for attr: %s to cluster: %s for ept: %s - res: %s', value, attribute, cluster_id, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index e81fa53020da8c..8901726ff88570 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -21,9 +21,10 @@ SENSOR_TYPE, UNKNOWN, GENERIC, POWER_CONFIGURATION_CHANNEL ) from .registries import ( - BINARY_SENSOR_TYPES, NO_SENSOR_CLUSTERS, EVENT_RELAY_CLUSTERS, + BINARY_SENSOR_TYPES, CHANNEL_ONLY_CLUSTERS, EVENT_RELAY_CLUSTERS, SENSOR_TYPES, DEVICE_CLASS, COMPONENT_CLUSTERS, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS + SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + OUTPUT_CHANNEL_ONLY_CLUSTERS, REMOTE_DEVICE_TYPES ) from ..device_entity import ZhaDeviceEntity @@ -87,6 +88,12 @@ def async_process_endpoint( def _async_create_cluster_channel(cluster, zha_device, is_new_join, channels=None, channel_class=None): """Create a cluster channel and attach it to a device.""" + # really ugly hack to deal with xiaomi using the door lock cluster + # incorrectly. + if hasattr(cluster, 'ep_attribute') and \ + cluster.ep_attribute == 'multistate_input': + channel_class = AttributeListeningChannel + # end of ugly hack if channel_class is None: channel_class = ZIGBEE_CHANNEL_REGISTRY.get(cluster.cluster_id, AttributeListeningChannel) @@ -161,17 +168,18 @@ def _async_handle_single_cluster_matches(hass, endpoint, zha_device, profile_clusters, device_key, is_new_join): """Dispatch single cluster matches to HA components.""" + from zigpy.zcl.clusters.general import OnOff cluster_matches = [] cluster_match_results = [] for cluster in endpoint.in_clusters.values(): - # don't let profiles prevent these channels from being created - if cluster.cluster_id in NO_SENSOR_CLUSTERS: + if cluster.cluster_id in CHANNEL_ONLY_CLUSTERS: cluster_match_results.append( _async_handle_channel_only_cluster_match( zha_device, cluster, is_new_join, )) + continue if cluster.cluster_id not in profile_clusters: cluster_match_results.append(_async_handle_single_cluster_match( @@ -184,15 +192,33 @@ def _async_handle_single_cluster_matches(hass, endpoint, zha_device, )) for cluster in endpoint.out_clusters.values(): + if cluster.cluster_id in OUTPUT_CHANNEL_ONLY_CLUSTERS: + cluster_match_results.append( + _async_handle_channel_only_cluster_match( + zha_device, + cluster, + is_new_join, + )) + continue + + device_type = cluster.endpoint.device_type + profile_id = cluster.endpoint.profile_id + if cluster.cluster_id not in profile_clusters: - cluster_match_results.append(_async_handle_single_cluster_match( - hass, - zha_device, - cluster, - device_key, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - is_new_join, - )) + # prevent remotes and controllers from getting entities + if not (cluster.cluster_id == OnOff.cluster_id and profile_id in + REMOTE_DEVICE_TYPES and device_type in + REMOTE_DEVICE_TYPES[profile_id]): + cluster_match_results.append( + _async_handle_single_cluster_match( + hass, + zha_device, + cluster, + device_key, + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + is_new_join, + ) + ) if cluster.cluster_id in EVENT_RELAY_CLUSTERS: _async_create_cluster_channel( diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index f8458848fc2f7c..d1ccaf8265c2da 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -32,7 +32,7 @@ async_create_device_entity, async_dispatch_discovery_info, async_process_endpoint) from .patches import apply_application_controller_patch -from .registries import RADIO_TYPES +from .registries import RADIO_TYPES, INPUT_BIND_ONLY_CLUSTERS from .store import async_get_registry _LOGGER = logging.getLogger(__name__) @@ -274,8 +274,10 @@ async def async_device_initialized(self, device, is_new_join): ) if endpoint_id != 0: for cluster in endpoint.in_clusters.values(): - cluster.bind_only = False + cluster.bind_only = \ + cluster.cluster_id in INPUT_BIND_ONLY_CLUSTERS for cluster in endpoint.out_clusters.values(): + # output clusters are always bind only cluster.bind_only = True else: is_rejoin = is_new_join is True diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 8db60727578543..a7b89362de9369 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -30,11 +30,14 @@ SENSOR_TYPES = {} RADIO_TYPES = {} BINARY_SENSOR_TYPES = {} +REMOTE_DEVICE_TYPES = {} CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} EVENT_RELAY_CLUSTERS = [] -NO_SENSOR_CLUSTERS = [] +CHANNEL_ONLY_CLUSTERS = [] +OUTPUT_CHANNEL_ONLY_CLUSTERS = [] BINDABLE_CLUSTERS = [] +INPUT_BIND_ONLY_CLUSTERS = [] BINARY_SENSOR_CLUSTERS = set() LIGHT_CLUSTERS = set() SWITCH_CLUSTERS = set() @@ -59,6 +62,11 @@ def establish_device_mappings(): if zll.PROFILE_ID not in DEVICE_CLASS: DEVICE_CLASS[zll.PROFILE_ID] = {} + if zha.PROFILE_ID not in REMOTE_DEVICE_TYPES: + REMOTE_DEVICE_TYPES[zha.PROFILE_ID] = [] + if zll.PROFILE_ID not in REMOTE_DEVICE_TYPES: + REMOTE_DEVICE_TYPES[zll.PROFILE_ID] = [] + def get_ezsp_radio(): import bellows.ezsp from bellows.zigbee.application import ControllerApplication @@ -101,15 +109,21 @@ def get_deconz_radio(): EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) - NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) - NO_SENSOR_CLUSTERS.append( + CHANNEL_ONLY_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) + CHANNEL_ONLY_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) - NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + CHANNEL_ONLY_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + + OUTPUT_CHANNEL_ONLY_CLUSTERS.append(zcl.clusters.general.Scenes.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id) + INPUT_BIND_ONLY_CLUSTERS.append( + zcl.clusters.lightlink.LightLink.cluster_id + ) + DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.SMART_PLUG: SWITCH, zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT, @@ -181,6 +195,21 @@ def get_deconz_radio(): SMARTTHINGS_ACCELERATION_CLUSTER: ACCELERATION, }) + zhap = zha.PROFILE_ID + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.NON_COLOR_SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.NON_COLOR_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_CONTROLLER) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.REMOTE_CONTROL) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.SCENE_SELECTOR) + + zllp = zll.PROFILE_ID + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.COLOR_CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.COLOR_SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.SCENE_CONTROLLER) + REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.CONTROL_BRIDGE) + CLUSTER_REPORT_CONFIGS.update({ zcl.clusters.general.Alarms.cluster_id: [], zcl.clusters.general.Basic.cluster_id: [], From 1e248551d56b47e6b8850e6e92352fdf2ce77219 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 16 Jun 2019 19:41:13 +0200 Subject: [PATCH 237/319] Update pysonos to 0.0.15 (#24565) --- homeassistant/components/sonos/manifest.json | 2 +- homeassistant/components/sonos/media_player.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 8a7dd60d80a5b8..3c584f22070c4e 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ - "pysonos==0.0.14" + "pysonos==0.0.15" ], "dependencies": [], "ssdp": { diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 5f86327e88dfac..056eb569538af2 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -227,10 +227,10 @@ def _timespan_secs(timespan): def _is_radio_uri(uri): - """Return whether the URI is a radio stream.""" + """Return whether the URI is a stream (not a playlist).""" radio_schemes = ( 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:', - 'x-sonosapi-hls:', 'hls-radio:') + 'x-sonosapi-hls:', 'hls-radio:', 'x-rincon-stream:') return uri.startswith(radio_schemes) diff --git a/requirements_all.txt b/requirements_all.txt index d4ea7e09eae23a..652c3163db55c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1351,7 +1351,7 @@ pysmarty==0.8 pysnmp==4.4.9 # homeassistant.components.sonos -pysonos==0.0.14 +pysonos==0.0.15 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d84a8cd3e36187..71d2da101ca31e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -286,7 +286,7 @@ pysmartapp==0.3.2 pysmartthings==0.6.8 # homeassistant.components.sonos -pysonos==0.0.14 +pysonos==0.0.15 # homeassistant.components.spc pyspcwebgw==0.4.0 From 08eca4a2373ede0e03a890da98c29698bdc2a12f Mon Sep 17 00:00:00 2001 From: zewelor Date: Sun, 16 Jun 2019 22:38:15 +0200 Subject: [PATCH 238/319] Whitelist yeelight predefined effects per device type (#24544) * Whitelist yeelight predefined effects per device type * Fix support color --- homeassistant/components/yeelight/light.py | 48 +++++++++++++++------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 563c9ab8782e25..1abb05e784ff02 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -28,15 +28,14 @@ SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | - SUPPORT_FLASH) + SUPPORT_FLASH | + SUPPORT_EFFECT) SUPPORT_YEELIGHT_WHITE_TEMP = (SUPPORT_YEELIGHT | SUPPORT_COLOR_TEMP) -SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | - SUPPORT_COLOR | - SUPPORT_EFFECT | - SUPPORT_COLOR_TEMP) +SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT_WHITE_TEMP | + SUPPORT_COLOR) ATTR_MODE = 'mode' @@ -61,24 +60,33 @@ EFFECT_TWITTER = "Twitter" EFFECT_STOP = "Stop" -YEELIGHT_EFFECT_LIST = [ - EFFECT_DISCO, +YEELIGHT_TEMP_ONLY_EFFECT_LIST = [ EFFECT_TEMP, + EFFECT_STOP, +] + +YEELIGHT_MONO_EFFECT_LIST = [ + EFFECT_DISCO, EFFECT_STROBE, - EFFECT_STROBE_COLOR, EFFECT_ALARM, - EFFECT_POLICE, EFFECT_POLICE2, + EFFECT_WHATSAPP, + EFFECT_FACEBOOK, + EFFECT_TWITTER, + *YEELIGHT_TEMP_ONLY_EFFECT_LIST +] + +YEELIGHT_COLOR_EFFECT_LIST = [ + EFFECT_STROBE_COLOR, + EFFECT_POLICE, EFFECT_CHRISTMAS, EFFECT_RGB, EFFECT_RANDOM_LOOP, EFFECT_FAST_RANDOM_LOOP, EFFECT_LSD, EFFECT_SLOWDOWN, - EFFECT_WHATSAPP, - EFFECT_FACEBOOK, - EFFECT_TWITTER, - EFFECT_STOP] + *YEELIGHT_MONO_EFFECT_LIST +] MODEL_TO_DEVICE_TYPE = { 'mono': BulbType.White, @@ -262,7 +270,7 @@ def supported_features(self) -> int: @property def effect_list(self): """Return the list of supported effects.""" - return YEELIGHT_EFFECT_LIST + self.custom_effects_names + return self._predefined_effects + self.custom_effects_names @property def color_temp(self) -> int: @@ -342,6 +350,10 @@ def _brightness_property(self): def _power_property(self): return 'power' + @property + def _predefined_effects(self): + return YEELIGHT_MONO_EFFECT_LIST + @property def device(self): """Return yeelight device.""" @@ -575,6 +587,10 @@ def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_YEELIGHT_RGB + @property + def _predefined_effects(self): + return YEELIGHT_COLOR_EFFECT_LIST + class YeelightWhiteTempLight(YeelightGenericLight): """Representation of a Color Yeelight light.""" @@ -588,6 +604,10 @@ def supported_features(self) -> int: def _brightness_property(self): return 'current_brightness' + @property + def _predefined_effects(self): + return YEELIGHT_TEMP_ONLY_EFFECT_LIST + class YeelightWithAmbientLight(YeelightWhiteTempLight): """Representation of a Yeelight which has ambilight support.""" From ddeb6b6baa7f96de2c8d3fd59b11720bdc8d21ed Mon Sep 17 00:00:00 2001 From: GoNzCiD Date: Mon, 17 Jun 2019 07:44:11 +0200 Subject: [PATCH 239/319] Battery attribute & accuracy filter (#24277) * Extract const to a const file, Add battery as tracker attribute, add accuracy filter option * Update homeassistant/components/traccar/device_tracker.py Co-Authored-By: Otto Winter * Update homeassistant/components/traccar/device_tracker.py Co-Authored-By: Otto Winter * Update homeassistant/components/traccar/device_tracker.py Use [] syntax for keys that are in the validated data. Co-Authored-By: Otto Winter * Fix indentation --- .coveragerc | 1 + homeassistant/components/traccar/const.py | 31 ++++++++ .../components/traccar/device_tracker.py | 72 +++++++++---------- 3 files changed, 68 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/traccar/const.py diff --git a/.coveragerc b/.coveragerc index fcdcb23809bd5c..7e618b9d4b36e3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -631,6 +631,7 @@ omit = homeassistant/components/tplink/switch.py homeassistant/components/tplink_lte/* homeassistant/components/traccar/device_tracker.py + homeassistant/components/traccar/const.py homeassistant/components/trackr/device_tracker.py homeassistant/components/tradfri/* homeassistant/components/tradfri/light.py diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py new file mode 100644 index 00000000000000..755b3dec77b9a1 --- /dev/null +++ b/homeassistant/components/traccar/const.py @@ -0,0 +1,31 @@ +"""Constants for Traccar integration.""" + +CONF_MAX_ACCURACY = 'max_accuracy' +CONF_SKIP_ACCURACY_ON = 'skip_accuracy_filter_on' + +ATTR_ADDRESS = 'address' +ATTR_CATEGORY = 'category' +ATTR_GEOFENCE = 'geofence' +ATTR_MOTION = 'motion' +ATTR_SPEED = 'speed' +ATTR_TRACKER = 'tracker' +ATTR_TRACCAR_ID = 'traccar_id' +ATTR_STATUS = 'status' + +EVENT_DEVICE_MOVING = 'device_moving' +EVENT_COMMAND_RESULT = 'command_result' +EVENT_DEVICE_FUEL_DROP = 'device_fuel_drop' +EVENT_GEOFENCE_ENTER = 'geofence_enter' +EVENT_DEVICE_OFFLINE = 'device_offline' +EVENT_DRIVER_CHANGED = 'driver_changed' +EVENT_GEOFENCE_EXIT = 'geofence_exit' +EVENT_DEVICE_OVERSPEED = 'device_overspeed' +EVENT_DEVICE_ONLINE = 'device_online' +EVENT_DEVICE_STOPPED = 'device_stopped' +EVENT_MAINTENANCE = 'maintenance' +EVENT_ALARM = 'alarm' +EVENT_TEXT_MESSAGE = 'text_message' +EVENT_DEVICE_UNKNOWN = 'device_unknown' +EVENT_IGNITION_OFF = 'ignition_off' +EVENT_IGNITION_ON = 'ignition_on' +EVENT_ALL_EVENTS = 'all_events' diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index d2990e178ab642..08604027273f0a 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -7,44 +7,25 @@ from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, - CONF_PASSWORD, CONF_USERNAME, ATTR_BATTERY_LEVEL, - CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS, - CONF_EVENT) + CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL, + CONF_MONITORED_CONDITIONS, CONF_EVENT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify +from .const import ( + ATTR_ADDRESS, ATTR_CATEGORY, ATTR_GEOFENCE, + ATTR_MOTION, ATTR_SPEED, ATTR_TRACKER, ATTR_TRACCAR_ID, ATTR_STATUS, + EVENT_DEVICE_MOVING, EVENT_COMMAND_RESULT, EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, EVENT_DEVICE_OFFLINE, EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, EVENT_DEVICE_OVERSPEED, EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, EVENT_MAINTENANCE, EVENT_ALARM, EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, EVENT_IGNITION_OFF, EVENT_IGNITION_ON, + EVENT_ALL_EVENTS, CONF_MAX_ACCURACY, CONF_SKIP_ACCURACY_ON) _LOGGER = logging.getLogger(__name__) -ATTR_ADDRESS = 'address' -ATTR_CATEGORY = 'category' -ATTR_GEOFENCE = 'geofence' -ATTR_MOTION = 'motion' -ATTR_SPEED = 'speed' -ATTR_TRACKER = 'tracker' -ATTR_TRACCAR_ID = 'traccar_id' -ATTR_STATUS = 'status' - -EVENT_DEVICE_MOVING = 'device_moving' -EVENT_COMMAND_RESULT = 'command_result' -EVENT_DEVICE_FUEL_DROP = 'device_fuel_drop' -EVENT_GEOFENCE_ENTER = 'geofence_enter' -EVENT_DEVICE_OFFLINE = 'device_offline' -EVENT_DRIVER_CHANGED = 'driver_changed' -EVENT_GEOFENCE_EXIT = 'geofence_exit' -EVENT_DEVICE_OVERSPEED = 'device_overspeed' -EVENT_DEVICE_ONLINE = 'device_online' -EVENT_DEVICE_STOPPED = 'device_stopped' -EVENT_MAINTENANCE = 'maintenance' -EVENT_ALARM = 'alarm' -EVENT_TEXT_MESSAGE = 'text_message' -EVENT_DEVICE_UNKNOWN = 'device_unknown' -EVENT_IGNITION_OFF = 'ignition_off' -EVENT_IGNITION_ON = 'ignition_on' -EVENT_ALL_EVENTS = 'all_events' - DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL @@ -55,6 +36,10 @@ vol.Optional(CONF_PORT, default=8082): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_MAX_ACCURACY, default=0): vol.All(vol.Coerce(int), + vol.Range(min=0)), + vol.Optional(CONF_SKIP_ACCURACY_ON, + default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EVENT, @@ -91,6 +76,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): scanner = TraccarScanner( api, hass, async_see, config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), + config[CONF_MAX_ACCURACY], config[CONF_SKIP_ACCURACY_ON], config[CONF_MONITORED_CONDITIONS], config[CONF_EVENT]) return await scanner.async_init() @@ -99,9 +85,8 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class TraccarScanner: """Define an object to retrieve Traccar data.""" - def __init__(self, api, hass, async_see, scan_interval, - custom_attributes, - event_types): + def __init__(self, api, hass, async_see, scan_interval, max_accuracy, + skip_accuracy_on, custom_attributes, event_types): """Initialize.""" from stringcase import camelcase self._event_types = {camelcase(evt): evt for evt in event_types} @@ -111,6 +96,8 @@ def __init__(self, api, hass, async_see, scan_interval, self._api = api self.connected = False self._hass = hass + self._max_accuracy = max_accuracy + self._skip_accuracy_on = skip_accuracy_on async def async_init(self): """Further initialize connection to Traccar.""" @@ -148,6 +135,8 @@ async def import_device_data(self): device_info = self._api.device_info[device_unique_id] device = None attr = {} + skip_accuracy_filter = False + attr[ATTR_TRACKER] = 'traccar' if device_info.get('address') is not None: attr[ATTR_ADDRESS] = device_info['address'] @@ -157,8 +146,6 @@ async def import_device_data(self): attr[ATTR_CATEGORY] = device_info['category'] if device_info.get('speed') is not None: attr[ATTR_SPEED] = device_info['speed'] - if device_info.get('battery') is not None: - attr[ATTR_BATTERY_LEVEL] = device_info['battery'] if device_info.get('motion') is not None: attr[ATTR_MOTION] = device_info['motion'] if device_info.get('traccar_id') is not None: @@ -172,11 +159,24 @@ async def import_device_data(self): for custom_attr in self._custom_attributes: if device_info.get(custom_attr) is not None: attr[custom_attr] = device_info[custom_attr] + if custom_attr in self._skip_accuracy_on: + skip_accuracy_filter = True + + accuracy = 0.0 + if device_info.get('accuracy') is not None: + accuracy = device_info['accuracy'] + if (not skip_accuracy_filter and self._max_accuracy > 0 and + accuracy > self._max_accuracy): + _LOGGER.debug('Excluded position by accuracy filter: %f (%s)', + accuracy, attr[ATTR_TRACCAR_ID]) + continue + await self._async_see( dev_id=slugify(device_info['device_id']), gps=(device_info.get('latitude'), device_info.get('longitude')), - gps_accuracy=(device_info.get('accuracy')), + gps_accuracy=accuracy, + battery=device_info.get('battery'), attributes=attr) async def import_events(self): From 05bb645263397e18c3cf7705688529268d06af6b Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 17 Jun 2019 18:19:40 +0200 Subject: [PATCH 240/319] Fix zeroconf migration messing up ESPHome discovery (#24578) --- .../components/esphome/config_flow.py | 9 ++++--- tests/components/esphome/test_config_flow.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index ad18e681021d56..b2a96ed53f3a77 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -56,6 +56,7 @@ async def _async_authenticate_or_add(self, user_input, self.context['title_placeholders'] = { 'name': self._name } + self.context['name'] = self._name # Only show authentication step if device uses password if device_info.uses_password: @@ -98,9 +99,11 @@ async def async_step_zeroconf(self, user_input: ConfigType): already_configured = data.device_info.name == node_name if already_configured: - return self.async_abort( - reason='already_configured' - ) + return self.async_abort(reason='already_configured') + + for flow in self._async_in_progress(): + if flow['context']['name'] == node_name: + return self.async_abort(reason='already_configured') return await self._async_authenticate_or_add(user_input={ 'host': address, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f991c36c4f000e..1434302f97ec54 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -281,3 +281,30 @@ async def test_discovery_already_configured_name(hass, mock_client): result = await flow.async_step_zeroconf(user_input=service_info) assert result['type'] == 'abort' assert result['reason'] == 'already_configured' + + +async def test_discovery_duplicate_data(hass, mock_client): + """Test discovery aborts if same mDNS packet arrives.""" + service_info = { + 'host': '192.168.43.183', + 'port': 6053, + 'hostname': 'test8266.local.', + 'properties': { + "address": "test8266.local" + } + } + + mock_client.device_info.return_value = mock_coro( + MockDeviceInfo(False, "test8266")) + + result = await hass.config_entries.flow.async_init( + 'esphome', data=service_info, context={'source': 'zeroconf'} + ) + assert result['type'] == 'form' + assert result['step_id'] == 'discovery_confirm' + + result = await hass.config_entries.flow.async_init( + 'esphome', data=service_info, context={'source': 'zeroconf'} + ) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' From d2022cae280948fabb03efd1a7aff235f286c09e Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Mon, 17 Jun 2019 09:21:21 -0700 Subject: [PATCH 241/319] Bump androidtv to 0.0.16 (#24576) * Bump androidtv to 0.0.16 * Bump androidtv to 0.0.16 --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 841ad299785825..7e23d8e7d59406 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Androidtv", "documentation": "https://www.home-assistant.io/components/androidtv", "requirements": [ - "androidtv==0.0.15" + "androidtv==0.0.16" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 652c3163db55c4..c58b3bcaa45cef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,7 +181,7 @@ ambiclimate==0.2.0 amcrest==1.5.3 # homeassistant.components.androidtv -androidtv==0.0.15 +androidtv==0.0.16 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 From 0a13c47a8c4d46801a00b0dbdfd7ed5501ebe7eb Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Mon, 17 Jun 2019 18:23:14 +0200 Subject: [PATCH 242/319] Added percent to the disk,memory and swap percent labels (#24575) --- homeassistant/components/glances/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 534b4c5cd59c5d..2b35e35669e764 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -26,13 +26,13 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { - 'disk_use_percent': ['Disk used', '%', 'mdi:harddisk'], + 'disk_use_percent': ['Disk used percent', '%', 'mdi:harddisk'], 'disk_use': ['Disk used', 'GiB', 'mdi:harddisk'], 'disk_free': ['Disk free', 'GiB', 'mdi:harddisk'], - 'memory_use_percent': ['RAM used', '%', 'mdi:memory'], + 'memory_use_percent': ['RAM used percent', '%', 'mdi:memory'], 'memory_use': ['RAM used', 'MiB', 'mdi:memory'], 'memory_free': ['RAM free', 'MiB', 'mdi:memory'], - 'swap_use_percent': ['Swap used', '%', 'mdi:memory'], + 'swap_use_percent': ['Swap used percent', '%', 'mdi:memory'], 'swap_use': ['Swap used', 'GiB', 'mdi:memory'], 'swap_free': ['Swap free', 'GiB', 'mdi:memory'], 'processor_load': ['CPU load', '15 min', 'mdi:memory'], From 56155740fe4391845ac75f4bab4ff9e3d4368787 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Mon, 17 Jun 2019 18:26:35 +0200 Subject: [PATCH 243/319] SMA sensor: Add optional path (#24558) --- homeassistant/components/sma/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 9f33e2361868ca..0ef2926eb2d6af 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_SSL, CONF_VERIFY_SSL, - EVENT_HOMEASSISTANT_STOP) + EVENT_HOMEASSISTANT_STOP, CONF_PATH) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -52,6 +52,7 @@ def _check_sensor_schema(conf): vol.All(cv.string, vol.Length(min=13, max=15)), vol.Required(CONF_UNIT): cv.string, vol.Optional(CONF_FACTOR, default=1): vol.Coerce(float), + vol.Optional(CONF_PATH): vol.All(cv.ensure_list, [str]), }) PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ @@ -79,7 +80,8 @@ async def async_setup_platform( sensor_def = pysma.Sensors() # Sensor from the custom config - sensor_def.add([pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR]) + sensor_def.add([pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR], + o.get(CONF_PATH)) for n, o in config[CONF_CUSTOM].items()]) # Use all sensors by default From ffce593cc8bb736cfe451ee34eada7ec65596090 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 17 Jun 2019 17:27:06 +0100 Subject: [PATCH 244/319] Fix geniushub issue #24530 (via a client bump) & handle edge cases (#24546) * bump client library to workaround #24530 * bump client library to workaround #24530 2/2 * bump client library to workaround #24530 * bump client library to workaround #24530 2/2 * bugfix: ghost devices cause TypeError: 'NoneType' object is not subscriptable * bugfix: broken HW zones cause AttributeError: 'GeniusZone' object has no attribute 'temperature' * delint --- homeassistant/components/geniushub/binary_sensor.py | 3 ++- homeassistant/components/geniushub/manifest.json | 2 +- homeassistant/components/geniushub/water_heater.py | 5 ++++- requirements_all.txt | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index cbea4147e73dfb..c0f0d90028dffd 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -18,8 +18,9 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the Genius Hub sensor entities.""" client = hass.data[DOMAIN]['client'] + devices = [d for d in client.hub.device_objs if d.type is not None] switches = [GeniusBinarySensor(client, d) - for d in client.hub.device_objs if d.type[:21] in GH_IS_SWITCH] + for d in devices if d.type[:21] in GH_IS_SWITCH] async_add_entities(switches) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index b2c7286a2d53c2..7c82ceeca44e7b 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -3,7 +3,7 @@ "name": "Genius Hub", "documentation": "https://www.home-assistant.io/components/geniushub", "requirements": [ - "geniushub-client==0.4.11" + "geniushub-client==0.4.12" ], "dependencies": [], "codeowners": ["@zxdavb"] diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index 6efbed514ee666..3b40bafa69913e 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -94,7 +94,10 @@ def should_poll(self) -> bool: @property def current_temperature(self): """Return the current temperature.""" - return self._boiler.temperature + try: + return self._boiler.temperature + except AttributeError: + return None @property def target_temperature(self): diff --git a/requirements_all.txt b/requirements_all.txt index c58b3bcaa45cef..4ba3b0ca939f10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -494,7 +494,7 @@ gearbest_parser==1.0.7 geizhals==0.0.9 # homeassistant.components.geniushub -geniushub-client==0.4.11 +geniushub-client==0.4.12 # homeassistant.components.geo_json_events # homeassistant.components.nsw_rural_fire_service_feed From 5ab1996d3f59d9eb3d86bd5432133b405e01e71a Mon Sep 17 00:00:00 2001 From: Save me Date: Mon, 17 Jun 2019 18:33:56 +0200 Subject: [PATCH 245/319] Add sensitivity and sensitvity_max attributs for binary sensor (#24438) * Add sensitivity ans sensitvity_max attributs for binary sensor * Update binary_sensor.py * Update binary_sensor.py * Update binary_sensor.py * Update binary_sensor.py --- homeassistant/components/hue/binary_sensor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index b9921a9a01fbf5..68f0405856603a 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -26,3 +26,14 @@ async def _async_update_ha_state(self, *args, **kwargs): def is_on(self): """Return true if the binary sensor is on.""" return self.sensor.presence + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = super().device_state_attributes + if 'sensitivity' in self.sensor.config: + attributes['sensitivity'] = self.sensor.config['sensitivity'] + if 'sensitivitymax' in self.sensor.config: + attributes['sensitivity_max'] = \ + self.sensor.config['sensitivitymax'] + return attributes From a02b69db38a610db8dca80c3f64a42e4abb60e1e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Jun 2019 13:50:01 -0700 Subject: [PATCH 246/319] Cloud: Add Alexa report state (#24536) * Cloud: Add Alexa report state * Lint * Lint * Only track state changes when we are logged in --- homeassistant/components/alexa/config.py | 36 +++++++++ homeassistant/components/alexa/handlers.py | 4 +- .../components/alexa/smart_home_http.py | 8 +- .../components/alexa/state_report.py | 17 ++--- homeassistant/components/cloud/client.py | 74 ++++++++++++++++++- homeassistant/components/cloud/const.py | 6 ++ homeassistant/components/cloud/http_api.py | 4 +- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/prefs.py | 28 ++++++- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa/__init__.py | 2 +- tests/components/alexa/test_smart_home.py | 8 +- tests/components/cloud/conftest.py | 10 +++ tests/components/cloud/test_client.py | 33 +++++++-- tests/components/cloud/test_http_api.py | 2 +- 17 files changed, 207 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index b5060709ce3210..36f15735b8b8bd 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,14 +1,26 @@ """Config helpers for Alexa.""" +from .state_report import async_enable_proactive_mode class AbstractConfig: """Hold the configuration for Alexa.""" + _unsub_proactive_report = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + @property def supports_auth(self): """Return if config supports auth.""" return False + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return False + @property def endpoint(self): """Endpoint for report state.""" @@ -19,6 +31,30 @@ def entity_config(self): """Return entity config.""" return {} + @property + def is_reporting_states(self): + """Return if proactive mode is enabled.""" + return self._unsub_proactive_report is not None + + async def async_enable_proactive_mode(self): + """Enable proactive mode.""" + if self._unsub_proactive_report is None: + self._unsub_proactive_report = self.hass.async_create_task( + async_enable_proactive_mode(self.hass, self) + ) + resp = await self._unsub_proactive_report + + # Failed to start reporting. + if resp is None: + self._unsub_proactive_report = None + + async def async_disable_proactive_mode(self): + """Disable proactive mode.""" + unsub_func = await self._unsub_proactive_report + if unsub_func: + unsub_func() + self._unsub_proactive_report = None + def should_expose(self, entity_id): """If an entity should be exposed.""" # pylint: disable=no-self-use diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 5a1a899ea69eff..98fb925946185a 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -87,7 +87,9 @@ async def async_api_accept_grant(hass, config, directive, context): if config.supports_auth: await config.async_accept_grant(auth_code) - await async_enable_proactive_mode(hass, config) + + if config.should_report_state: + await async_enable_proactive_mode(hass, config) return directive.response( name='AcceptGrant.Response', diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index d0c4429e6b2f27..e9437a411d6f36 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -25,6 +25,7 @@ class AlexaConfig(AbstractConfig): def __init__(self, hass, config): """Initialize Alexa config.""" + super().__init__(hass) self._config = config if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): @@ -38,6 +39,11 @@ def supports_auth(self): """Return if config supports auth.""" return self._auth is not None + @property + def should_report_state(self): + """Return if we should proactively report states.""" + return self._auth is not None + @property def endpoint(self): """Endpoint for report state.""" @@ -73,7 +79,7 @@ async def async_setup(hass, config): smart_home_config = AlexaConfig(hass, config) hass.http.register_view(SmartHomeView(smart_home_config)) - if smart_home_config.supports_auth: + if smart_home_config.should_report_state: await async_enable_proactive_mode(hass, smart_home_config) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 568502fb6bfe62..cdb3a88ed2247b 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -21,24 +21,23 @@ async def async_enable_proactive_mode(hass, smart_home_config): Proactive mode makes this component report state changes to Alexa. """ - if smart_home_config.async_get_access_token is None: - # no function to call to get token - return - if await smart_home_config.async_get_access_token() is None: # not ready yet return async def async_entity_state_listener(changed_entity, old_state, new_state): - if not smart_home_config.should_expose(changed_entity): - _LOGGER.debug("Not exposing %s because filtered by config", - changed_entity) + if not new_state: return if new_state.domain not in ENTITY_ADAPTERS: return + if not smart_home_config.should_expose(changed_entity): + _LOGGER.debug("Not exposing %s because filtered by config", + changed_entity) + return + alexa_changed_entity = \ ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config, new_state) @@ -49,7 +48,7 @@ async def async_entity_state_listener(changed_entity, old_state, alexa_changed_entity) return - hass.helpers.event.async_track_state_change( + return hass.helpers.event.async_track_state_change( MATCH_ALL, async_entity_state_listener ) @@ -94,7 +93,7 @@ async def async_send_changereport_message(hass, config, alexa_entity): allow_redirects=True) except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Timeout calling LWA to get auth token.") + _LOGGER.error("Timeout sending report to Alexa.") return None response_text = await response.text() diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f6d283ee1ebe70..e3c952898bd1b2 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,8 +2,11 @@ import asyncio from pathlib import Path from typing import Any, Dict +from datetime import timedelta +import logging import aiohttp +from hass_nabucasa import cloud_api from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback @@ -17,22 +20,41 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.aiohttp import MockRequest +from homeassistant.util.dt import utcnow from . import utils from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE, PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, - PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA, RequireRelink) from .prefs import CloudPreferences +_LOGGER = logging.getLogger(__name__) + + class AlexaConfig(alexa_config.AbstractConfig): """Alexa Configuration.""" - def __init__(self, config, prefs): + def __init__(self, hass, config, prefs, cloud): """Initialize the Alexa config.""" + super().__init__(hass) self._config = config self._prefs = prefs + self._cloud = cloud + self._token = None + self._token_valid = None + prefs.async_listen_updates(self.async_prefs_updated) + + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._prefs.alexa_report_state @property def endpoint(self): @@ -57,6 +79,34 @@ def should_expose(self, entity_id): return entity_config.get( PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + async def async_get_access_token(self): + """Get an access token.""" + if self._token_valid is not None and self._token_valid < utcnow(): + return self._token + + resp = await cloud_api.async_alexa_access_token(self._cloud) + body = await resp.json() + + if resp.status == 400: + if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): + raise RequireRelink + + return None + + self._token = body['access_token'] + self._token_valid = utcnow() + timedelta(seconds=body['expires_in']) + return self._token + + async def async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state == self.is_reporting_states: + return + + if self.should_report_state: + await self.async_enable_proactive_mode() + else: + await self.async_disable_proactive_mode() + class CloudClient(Interface): """Interface class for Home Assistant Cloud.""" @@ -70,9 +120,9 @@ def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, self._websession = websession self.google_user_config = google_config self.alexa_user_config = alexa_cfg - - self.alexa_config = AlexaConfig(alexa_cfg, prefs) + self._alexa_config = None self._google_config = None + self.cloud = None @property def base_path(self) -> Path: @@ -109,6 +159,15 @@ def remote_autostart(self) -> bool: """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def alexa_config(self) -> AlexaConfig: + """Return Alexa config.""" + if self._alexa_config is None: + self._alexa_config = AlexaConfig( + self._hass, self.alexa_user_config, self._prefs, self.cloud) + + return self._alexa_config + @property def google_config(self) -> ga_h.Config: """Return Google config.""" @@ -151,6 +210,13 @@ def should_2fa(entity): return self._google_config + async def async_initialize(self, cloud) -> None: + """Initialize the client.""" + self.cloud = cloud + + if self.alexa_config.should_report_state and self.cloud.is_logged_in: + await self.alexa_config.async_enable_proactive_mode() + async def cleanups(self) -> None: """Cleanup some stuff after logout.""" self._google_config = None diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 505232bfb85848..34324aca131f1a 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -10,12 +10,14 @@ PREF_CLOUD_USER = 'cloud_user' PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs' PREF_ALEXA_ENTITY_CONFIGS = 'alexa_entity_configs' +PREF_ALEXA_REPORT_STATE = 'alexa_report_state' PREF_OVERRIDE_NAME = 'override_name' PREF_DISABLE_2FA = 'disable_2fa' PREF_ALIASES = 'aliases' PREF_SHOULD_EXPOSE = 'should_expose' DEFAULT_SHOULD_EXPOSE = True DEFAULT_DISABLE_2FA = False +DEFAULT_ALEXA_REPORT_STATE = False CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -43,3 +45,7 @@ class InvalidTrustedNetworks(Exception): class InvalidTrustedProxies(Exception): """Raised when invalid trusted proxies config.""" + + +class RequireRelink(Exception): + """The skill needs to be relinked.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index eb3b056535144f..6eaa717f41c48e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -19,7 +19,7 @@ from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks, - InvalidTrustedProxies) + InvalidTrustedProxies, PREF_ALEXA_REPORT_STATE) _LOGGER = logging.getLogger(__name__) @@ -363,6 +363,7 @@ async def websocket_subscription(hass, connection, msg): vol.Required('type'): 'cloud/update_prefs', vol.Optional(PREF_ENABLE_GOOGLE): bool, vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), }) async def websocket_update_prefs(hass, connection, msg): @@ -424,7 +425,6 @@ def _account_data(cloud): 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, 'alexa_entities': client.alexa_user_config['filter'].config, - 'alexa_domains': list(alexa_entities.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, 'remote_connected': remote.is_connected, 'remote_certificate': certificate, diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 1a4511c8c88632..e848f54425b3d4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Cloud", "documentation": "https://www.home-assistant.io/components/cloud", "requirements": [ - "hass-nabucasa==0.14" + "hass-nabucasa==0.15" ], "dependencies": [ "http", diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 1e4ac754460527..a01a6dd4cb57e0 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,11 +1,15 @@ """Preference management for cloud.""" from ipaddress import ip_address +from homeassistant.core import callback +from homeassistant.util.logging import async_create_catching_coro + from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA, PREF_ALIASES, PREF_SHOULD_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE, InvalidTrustedNetworks, InvalidTrustedProxies) STORAGE_KEY = DOMAIN @@ -21,6 +25,7 @@ def __init__(self, hass): self._hass = hass self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._prefs = None + self._listeners = [] async def async_initialize(self): """Finish initializing the preferences.""" @@ -40,11 +45,17 @@ async def async_initialize(self): self._prefs = prefs + @callback + def async_listen_updates(self, listener): + """Listen for updates to the preferences.""" + self._listeners.append(listener) + async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, cloud_user=_UNDEF, google_entity_configs=_UNDEF, - alexa_entity_configs=_UNDEF): + alexa_entity_configs=_UNDEF, + alexa_report_state=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), @@ -55,18 +66,26 @@ async def async_update(self, *, google_enabled=_UNDEF, (PREF_CLOUD_USER, cloud_user), (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), + (PREF_ALEXA_REPORT_STATE, alexa_report_state), ): if value is not _UNDEF: self._prefs[key] = value if remote_enabled is True and self._has_local_trusted_network: + self._prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedNetworks if remote_enabled is True and self._has_local_trusted_proxies: + self._prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedProxies await self._store.async_save(self._prefs) + for listener in self._listeners: + self._hass.async_create_task( + async_create_catching_coro(listener(self)) + ) + async def async_update_google_entity_config( self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF, aliases=_UNDEF, should_expose=_UNDEF): @@ -134,6 +153,7 @@ def as_dict(self): PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, + PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUD_USER: self.cloud_user, } @@ -156,6 +176,12 @@ def alexa_enabled(self): """Return if Alexa is enabled.""" return self._prefs[PREF_ENABLE_ALEXA] + @property + def alexa_report_state(self): + """Return if Alexa report state is enabled.""" + return self._prefs.get(PREF_ALEXA_REPORT_STATE, + DEFAULT_ALEXA_REPORT_STATE) + @property def google_enabled(self): """Return if Google is enabled.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9e83ca6fd0e02e..044a5098303248 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ bcrypt==3.1.6 certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 -hass-nabucasa==0.14 +hass-nabucasa==0.15 home-assistant-frontend==20190614.0 importlib-metadata==0.15 jinja2>=2.10 diff --git a/requirements_all.txt b/requirements_all.txt index 4ba3b0ca939f10..097c68fadb9486 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -562,7 +562,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.14 +hass-nabucasa==0.15 # homeassistant.components.mqtt hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71d2da101ca31e..7fe8c1a8b0be4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -145,7 +145,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.14 +hass-nabucasa==0.15 # homeassistant.components.mqtt hbmqtt==0.9.4 diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index ab273d5e02410a..9ac6688ae249a3 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -38,7 +38,7 @@ async def async_accept_grant(self, code): pass -DEFAULT_CONFIG = MockConfig() +DEFAULT_CONFIG = MockConfig(None) def get_new_request(namespace, name, endpoint=None): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 3aa1c7df3667f7..26c9e4bb8b66df 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1012,7 +1012,7 @@ async def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - alexa_config = MockConfig() + alexa_config = MockConfig(hass) alexa_config.should_expose = entityfilter.generate_filter( include_domains=[], include_entities=[], @@ -1045,7 +1045,7 @@ async def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - alexa_config = MockConfig() + alexa_config = MockConfig(hass) alexa_config.should_expose = entityfilter.generate_filter( include_domains=['automation', 'group'], include_entities=['script.deny'], @@ -1072,7 +1072,7 @@ async def test_never_exposed_entities(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - alexa_config = MockConfig() + alexa_config = MockConfig(hass) alexa_config.should_expose = entityfilter.generate_filter( include_domains=['group'], include_entities=[], @@ -1155,7 +1155,7 @@ async def test_entity_config(hass): hass.states.async_set( 'light.test_1', 'on', {'friendly_name': "Test light 1"}) - alexa_config = MockConfig() + alexa_config = MockConfig(hass) alexa_config.entity_config = { 'light.test_1': { 'name': 'Config name', diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 163754dd3e1689..c9fd6360929263 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -3,6 +3,8 @@ from unittest.mock import patch +from homeassistant.components.cloud import prefs + from . import mock_cloud, mock_cloud_prefs @@ -18,3 +20,11 @@ def mock_cloud_fixture(hass): """Fixture for cloud component.""" mock_cloud(hass) return mock_cloud_prefs(hass) + + +@pytest.fixture +async def cloud_prefs(hass): + """Fixture for cloud preferences.""" + cloud_prefs = prefs.CloudPreferences(hass) + await cloud_prefs.async_initialize() + return cloud_prefs diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index ca82d1e0aba362..723e86f2f2def4 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -8,7 +8,7 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.components.cloud import ( - DOMAIN, ALEXA_SCHEMA, prefs, client) + DOMAIN, ALEXA_SCHEMA, client) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa @@ -254,18 +254,41 @@ async def test_google_config_should_2fa( assert not cloud_client.google_config.should_2fa(state) -async def test_alexa_config_expose_entity_prefs(hass): +async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs): """Test Alexa config should expose using prefs.""" - cloud_prefs = prefs.CloudPreferences(hass) - await cloud_prefs.async_initialize() entity_conf = { 'should_expose': False } await cloud_prefs.async_update(alexa_entity_configs={ 'light.kitchen': entity_conf }) - conf = client.AlexaConfig(ALEXA_SCHEMA({}), cloud_prefs) + conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) assert not conf.should_expose('light.kitchen') entity_conf['should_expose'] = True assert conf.should_expose('light.kitchen') + + +async def test_alexa_config_report_state(hass, cloud_prefs): + """Test Alexa config should expose using prefs.""" + conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + assert cloud_prefs.alexa_report_state is False + assert conf.should_report_state is False + assert conf.is_reporting_states is False + + with patch.object(conf, 'async_get_access_token', + return_value=mock_coro("hello")): + await cloud_prefs.async_update(alexa_report_state=True) + await hass.async_block_till_done() + + assert cloud_prefs.alexa_report_state is True + assert conf.should_report_state is True + assert conf.is_reporting_states is True + + await cloud_prefs.async_update(alexa_report_state=False) + await hass.async_block_till_done() + + assert cloud_prefs.alexa_report_state is False + assert conf.should_report_state is False + assert conf.is_reporting_states is False diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 0e4d46672ba0dd..60346dc6ea10a3 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -363,6 +363,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'google_entity_configs': {}, 'google_secure_devices_pin': None, 'alexa_entity_configs': {}, + 'alexa_report_state': False, 'remote_enabled': False, }, 'alexa_entities': { @@ -371,7 +372,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'exclude_domains': [], 'exclude_entities': [], }, - 'alexa_domains': ['switch'], 'google_entities': { 'include_domains': ['light'], 'include_entities': [], From 7564d1fb523a7c41f7349dcef341d6e8d5ba9320 Mon Sep 17 00:00:00 2001 From: kbickar Date: Mon, 17 Jun 2019 17:09:31 -0400 Subject: [PATCH 247/319] Added toggle service to covers (#23198) * Added toggle service to cover * Added toggle tilt service and tilt closed property * Added is_tilt_closed so tilt can be toggled * Added toggle services * Added toggle tilt service * Removed spaces * Added tests for tilt services * Updated tests * Added range conversion in comparison * Added tests to cover broken areas * Fixed open/close tilt values and added toggle function * Added default toggle behavior using tilt_position of 0, reverted other changes * blank space * Added constants and swapped assert comparisons * Fixed attribute name * Added mqtt responses in test * Added constants * Space * Fix tilt_optimistic flag being ignored if status topic set * Added more tests * Changed async toggle call * Updated group tilt test * Updated format of asserts * Updated states calls * Updated function variables * merge fixes * Added blank line * Changed calls to async * More async updates --- homeassistant/components/cover/__init__.py | 49 +++- homeassistant/components/cover/services.yaml | 22 +- homeassistant/components/homematic/cover.py | 1 + homeassistant/components/mqtt/cover.py | 20 +- homeassistant/const.py | 1 + tests/components/demo/test_cover.py | 139 +++++++++--- tests/components/group/test_cover.py | 221 ++++++++++++++----- tests/components/mqtt/test_cover.py | 210 ++++++++++++++++-- tests/components/template/test_cover.py | 43 ++++ 9 files changed, 592 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 4b05dedbf5e125..4e90a7bd186d6a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -15,9 +15,10 @@ from homeassistant.helpers import intent from homeassistant.const import ( SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, - SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, - SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, - STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) + SERVICE_STOP_COVER, SERVICE_TOGGLE, SERVICE_OPEN_COVER_TILT, + SERVICE_CLOSE_COVER_TILT, SERVICE_STOP_COVER_TILT, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_TOGGLE_COVER_TILT, + STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) _LOGGER = logging.getLogger(__name__) @@ -118,6 +119,11 @@ async def async_setup(hass, config): 'async_stop_cover' ) + component.async_register_entity_service( + SERVICE_TOGGLE, COVER_SERVICE_SCHEMA, + 'async_toggle' + ) + component.async_register_entity_service( SERVICE_OPEN_COVER_TILT, COVER_SERVICE_SCHEMA, 'async_open_cover_tilt' @@ -138,6 +144,11 @@ async def async_setup(hass, config): 'async_set_cover_tilt_position' ) + component.async_register_entity_service( + SERVICE_TOGGLE_COVER_TILT, COVER_SERVICE_SCHEMA, + 'async_toggle_tilt' + ) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER, "Opened {}")) @@ -259,6 +270,22 @@ def async_close_cover(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) + def toggle(self, **kwargs) -> None: + """Toggle the entity.""" + if self.is_closed: + self.open_cover(**kwargs) + else: + self.close_cover(**kwargs) + + def async_toggle(self, **kwargs): + """Toggle the entity. + + This method must be run in the event loop and returns a coroutine. + """ + if self.is_closed: + return self.async_open_cover(**kwargs) + return self.async_close_cover(**kwargs) + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" pass @@ -329,3 +356,19 @@ def async_stop_cover_tilt(self, **kwargs): """ return self.hass.async_add_job( ft.partial(self.stop_cover_tilt, **kwargs)) + + def toggle_tilt(self, **kwargs) -> None: + """Toggle the entity.""" + if self.current_cover_tilt_position == 0: + self.open_cover_tilt(**kwargs) + else: + self.close_cover_tilt(**kwargs) + + def async_toggle_tilt(self, **kwargs): + """Toggle the entity. + + This method must be run in the event loop and returns a coroutine. + """ + if self.current_cover_tilt_position == 0: + return self.async_open_cover_tilt(**kwargs) + return self.async_close_cover_tilt(**kwargs) diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 79f00180a8946d..64534e409742e8 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -14,6 +14,13 @@ close_cover: description: Name(s) of cover(s) to close. example: 'cover.living_room' +toggle: + description: Toggles a cover open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle. + example: 'cover.garage_door' + set_cover_position: description: Move to specific position all or specified cover. fields: @@ -36,21 +43,28 @@ open_cover_tilt: fields: entity_id: description: Name(s) of cover(s) tilt to open. - example: 'cover.living_room' + example: 'cover.living_room_blinds' close_cover_tilt: description: Close all or specified cover tilt. fields: entity_id: description: Name(s) of cover(s) to close tilt. - example: 'cover.living_room' + example: 'cover.living_room_blinds' + +toggle_cover_tilt: + description: Toggles a cover tilt open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle tilt. + example: 'cover.living_room_blinds' set_cover_tilt_position: description: Move to specific position all or specified cover tilt. fields: entity_id: description: Name(s) of cover(s) to set cover tilt position. - example: 'cover.living_room' + example: 'cover.living_room_blinds' tilt_position: description: Tilt position of the cover (0 to 100). example: 30 @@ -60,4 +74,4 @@ stop_cover_tilt: fields: entity_id: description: Name(s) of cover(s) to stop. - example: 'cover.living_room' + example: 'cover.living_room_blinds' diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index 387eb26f433d06..28e66f39a508a6 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -48,6 +48,7 @@ def is_closed(self): """Return if the cover is closed.""" if self.current_cover_position is not None: return self.current_cover_position == 0 + return None def open_cover(self, **kwargs): """Open the cover.""" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 17385e77ec3ac6..0c62f230032bd2 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -282,7 +282,6 @@ def position_message_received(msg): if self._config.get(CONF_TILT_STATUS_TOPIC) is None: self._tilt_optimistic = True else: - self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN topics['tilt_status_topic'] = { 'topic': self._config.get(CONF_TILT_STATUS_TOPIC), @@ -405,7 +404,8 @@ async def async_open_cover_tilt(self, **kwargs): self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._tilt_optimistic: - self._tilt_value = self._config[CONF_TILT_OPEN_POSITION] + self._tilt_value = self.find_percentage_in_range( + float(self._config[CONF_TILT_OPEN_POSITION])) self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs): @@ -416,7 +416,8 @@ async def async_close_cover_tilt(self, **kwargs): self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._tilt_optimistic: - self._tilt_value = self._config[CONF_TILT_CLOSED_POSITION] + self._tilt_value = self.find_percentage_in_range( + float(self._config[CONF_TILT_CLOSED_POSITION])) self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs): @@ -464,6 +465,19 @@ async def async_set_cover_position(self, **kwargs): self._position = percentage_position self.async_write_ha_state() + async def async_toggle_tilt(self, **kwargs): + """Toggle the entity.""" + if self.is_tilt_closed(): + await self.async_open_cover_tilt(**kwargs) + else: + await self.async_close_cover_tilt(**kwargs) + + def is_tilt_closed(self): + """Return if the cover is tilted closed.""" + return self._tilt_value == \ + self.find_percentage_in_range( + float(self._config[CONF_TILT_CLOSED_POSITION])) + def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): """Find the 0-100% value within the specified range.""" # the range of motion as defined by the min max values diff --git a/homeassistant/const.py b/homeassistant/const.py index 258c4d0e4e2edf..4b78ab9618ba77 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -411,6 +411,7 @@ SERVICE_SET_COVER_TILT_POSITION = 'set_cover_tilt_position' SERVICE_STOP_COVER = 'stop_cover' SERVICE_STOP_COVER_TILT = 'stop_cover_tilt' +SERVICE_TOGGLE_COVER_TILT = 'toggle_cover_tilt' SERVICE_SELECT_OPTION = 'select_option' diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 011928f851a129..1477afc44d2dff 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -4,9 +4,12 @@ import pytest from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN) + ATTR_POSITION, ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, + ATTR_TILT_POSITION, DOMAIN) from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_OPEN, STATE_OPENING, STATE_CLOSED, STATE_CLOSING, SERVICE_TOGGLE, + SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_TOGGLE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, SERVICE_STOP_COVER_TILT) @@ -29,60 +32,100 @@ async def setup_comp(hass): async def test_supported_features(hass, setup_comp): """Test cover supported features.""" state = hass.states.get('cover.garage_door') - assert 3 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 3 state = hass.states.get('cover.kitchen_window') - assert 11 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 11 state = hass.states.get('cover.hall_window') - assert 15 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 15 state = hass.states.get('cover.living_room_window') - assert 255 == state.attributes.get('supported_features') + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 255 async def test_close_cover(hass, setup_comp): """Test closing the cover.""" state = hass.states.get(ENTITY_COVER) - assert state.state == 'open' - assert 70 == state.attributes.get('current_position') + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) state = hass.states.get(ENTITY_COVER) - assert state.state == 'closing' + assert state.state == STATE_CLOSING for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert state.state == 'closed' - assert 0 == state.attributes.get('current_position') + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 async def test_open_cover(hass, setup_comp): """Test opening the cover.""" state = hass.states.get(ENTITY_COVER) - assert state.state == 'open' - assert 70 == state.attributes.get('current_position') + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) state = hass.states.get(ENTITY_COVER) - assert state.state == 'opening' + assert state.state == STATE_OPENING for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert state.state == 'open' - assert 100 == state.attributes.get('current_position') + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_toggle_cover(hass, setup_comp): + """Test toggling the cover.""" + # Start open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.state == STATE_OPEN + assert state.attributes['current_position'] == 100 + # Toggle closed + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + # Toggle open + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 async def test_set_cover_position(hass, setup_comp): """Test moving the cover to a specific position.""" state = hass.states.get(ENTITY_COVER) - assert 70 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 10}, blocking=True) @@ -92,13 +135,13 @@ async def test_set_cover_position(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 10 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 10 async def test_stop_cover(hass, setup_comp): """Test stopping the cover.""" state = hass.states.get(ENTITY_COVER) - assert 70 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -111,13 +154,13 @@ async def test_stop_cover(hass, setup_comp): async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 80 == state.attributes.get('current_position') + assert state.attributes[ATTR_CURRENT_POSITION] == 80 async def test_close_cover_tilt(hass, setup_comp): """Test closing the cover tilt.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -127,13 +170,13 @@ async def test_close_cover_tilt(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 0 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 async def test_open_cover_tilt(hass, setup_comp): """Test opening the cover tilt.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -143,29 +186,67 @@ async def test_open_cover_tilt(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 100 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + +async def test_toggle_cover_tilt(hass, setup_comp): + """Test toggling the cover tilt.""" + # Start open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + # Toggle closed + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + # Toggle Open + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_COVER) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 async def test_set_cover_tilt_position(hass, setup_comp): """Test moving the cover til to a specific position.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 90}, blocking=True) + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 90}, + blocking=True) for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 90 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 90 async def test_stop_cover_tilt(hass, setup_comp): """Test stopping the cover tilt.""" state = hass.states.get(ENTITY_COVER) - assert 50 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) @@ -178,4 +259,4 @@ async def test_stop_cover_tilt(hass, setup_comp): async_fire_time_changed(hass, future) await hass.async_block_till_done() state = hass.states.get(ENTITY_COVER) - assert 40 == state.attributes.get('current_tilt_position') + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 40 diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 04e8f9c964d1bd..8dd9f9bcbb5aed 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -11,6 +11,7 @@ ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, + SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, SERVICE_STOP_COVER_TILT, STATE_OPEN, STATE_CLOSED) @@ -52,11 +53,11 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - assert state.attributes.get(ATTR_FRIENDLY_NAME) == DEFAULT_NAME - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports open / close / stop hass.states.async_set( @@ -65,10 +66,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 11 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 11 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports set_cover_position hass.states.async_set( @@ -78,10 +79,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 15 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 15 + assert state.attributes[ATTR_CURRENT_POSITION] == 70 + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports open tilt / close tilt / stop tilt hass.states.async_set( @@ -90,10 +91,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 127 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 127 + assert state.attributes[ATTR_CURRENT_POSITION] == 70 + assert ATTR_CURRENT_TILT_POSITION not in state.attributes # Add Entity that supports set_tilt_position hass.states.async_set( @@ -103,10 +104,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 255 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 255 + assert state.attributes[ATTR_CURRENT_POSITION] == 70 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 # ### Test assumed state ### # ########################## @@ -119,10 +120,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is True - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 244 - assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert state.attributes[ATTR_ASSUMED_STATE] is True + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 hass.states.async_remove(DEMO_COVER) hass.states.async_remove(DEMO_COVER_POS) @@ -130,10 +131,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 240 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 240 + assert ATTR_CURRENT_POSITION not in state.attributes + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 # For tilts hass.states.async_set( @@ -143,10 +144,10 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_ASSUMED_STATE) is True - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 128 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100 + assert state.attributes[ATTR_ASSUMED_STATE] is True + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 + assert ATTR_CURRENT_POSITION not in state.attributes + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 hass.states.async_remove(DEMO_COVER_TILT) hass.states.async_set(DEMO_TILT, STATE_CLOSED) @@ -154,17 +155,17 @@ async def test_attributes(hass): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - assert state.attributes.get(ATTR_ASSUMED_STATE) is None - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_CURRENT_POSITION) is None - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes hass.states.async_set( DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.attributes.get(ATTR_ASSUMED_STATE) is True + assert state.attributes[ATTR_ASSUMED_STATE] is True async def test_open_covers(hass, setup_comp): @@ -179,13 +180,13 @@ async def test_open_covers(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 assert hass.states.get(DEMO_COVER).state == STATE_OPEN assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 100 + .attributes[ATTR_CURRENT_POSITION] == 100 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 100 + .attributes[ATTR_CURRENT_POSITION] == 100 async def test_close_covers(hass, setup_comp): @@ -200,13 +201,66 @@ async def test_close_covers(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 + assert state.attributes[ATTR_CURRENT_POSITION] == 0 assert hass.states.get(DEMO_COVER).state == STATE_CLOSED assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 0 + .attributes[ATTR_CURRENT_POSITION] == 0 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 0 + .attributes[ATTR_CURRENT_POSITION] == 0 + + +async def test_toggle_covers(hass, setup_comp): + """Test toggle cover function.""" + # Start covers in open state + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + + # Toggle will close covers + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + assert hass.states.get(DEMO_COVER).state == STATE_CLOSED + assert hass.states.get(DEMO_COVER_POS) \ + .attributes[ATTR_CURRENT_POSITION] == 0 + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_POSITION] == 0 + + # Toggle again will open covers + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + assert hass.states.get(DEMO_COVER).state == STATE_OPEN + assert hass.states.get(DEMO_COVER_POS) \ + .attributes[ATTR_CURRENT_POSITION] == 100 + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_POSITION] == 100 async def test_stop_covers(hass, setup_comp): @@ -227,13 +281,13 @@ async def test_stop_covers(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 assert hass.states.get(DEMO_COVER).state == STATE_OPEN assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 20 + .attributes[ATTR_CURRENT_POSITION] == 20 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 80 + .attributes[ATTR_CURRENT_POSITION] == 80 async def test_set_cover_position(hass, setup_comp): @@ -248,13 +302,13 @@ async def test_set_cover_position(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_POSITION) == 50 + assert state.attributes[ATTR_CURRENT_POSITION] == 50 assert hass.states.get(DEMO_COVER).state == STATE_CLOSED assert hass.states.get(DEMO_COVER_POS) \ - .attributes.get(ATTR_CURRENT_POSITION) == 50 + .attributes[ATTR_CURRENT_POSITION] == 50 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_POSITION) == 50 + .attributes[ATTR_CURRENT_POSITION] == 50 async def test_open_tilts(hass, setup_comp): @@ -269,10 +323,10 @@ async def test_open_tilts(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 100 + .attributes[ATTR_CURRENT_TILT_POSITION] == 100 async def test_close_tilts(hass, setup_comp): @@ -287,10 +341,61 @@ async def test_close_tilts(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + +async def test_toggle_tilts(hass, setup_comp): + """Test toggle tilt function.""" + # Start tilted open + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + # Toggle will tilt closed + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + assert hass.states.get(DEMO_COVER_TILT) \ + .attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Toggle again will tilt open + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True) + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 0 + .attributes[ATTR_CURRENT_TILT_POSITION] == 100 async def test_stop_tilts(hass, setup_comp): @@ -311,10 +416,10 @@ async def test_stop_tilts(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 60 + .attributes[ATTR_CURRENT_TILT_POSITION] == 60 async def test_set_tilt_positions(hass, setup_comp): @@ -329,7 +434,7 @@ async def test_set_tilt_positions(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 80 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 assert hass.states.get(DEMO_COVER_TILT) \ - .attributes.get(ATTR_CURRENT_TILT_POSITION) == 80 + .attributes[ATTR_CURRENT_TILT_POSITION] == 80 diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 91b0355ad22f2c..6ffc0df48096e7 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -11,7 +11,7 @@ SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, - STATE_UNKNOWN) + SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, STATE_UNKNOWN) from homeassistant.setup import async_setup_component from tests.common import ( @@ -174,6 +174,26 @@ async def test_optimistic_state_change(hass, mqtt_mock): cover.DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + mqtt_mock.async_publish.assert_called_once_with( + 'command-topic', 'CLOSE', 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get('cover.test') + assert STATE_CLOSED == state.state + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'command-topic', 'OPEN', 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get('cover.test') + assert STATE_OPEN == state.state + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + mqtt_mock.async_publish.assert_called_once_with( 'command-topic', 'CLOSE', 0, False) state = hass.states.get('cover.test') @@ -534,6 +554,36 @@ async def test_tilt_via_invocation_defaults(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with( 'tilt-command-topic', 0, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Close tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '0') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 100, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Open tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '100') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 100 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 0, 0, False) async def test_tilt_given_value(hass, mqtt_mock): @@ -550,25 +600,157 @@ async def test_tilt_given_value(hass, mqtt_mock): 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', 'tilt_status_topic': 'tilt-status-topic', - 'tilt_opened_value': 400, - 'tilt_closed_value': 125 + 'tilt_opened_value': 80, + 'tilt_closed_value': 25 } }) await hass.services.async_call( - cover.DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'}, - blocking=True) + cover.DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) mqtt_mock.async_publish.assert_called_once_with( - 'tilt-command-topic', 400, 0, False) + 'tilt-command-topic', 80, 0, False) mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'}, - blocking=True) + cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Close tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '25') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 25 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 80, 0, False) + mqtt_mock.async_publish.reset_mock() + + # Open tilt status would be received from device when non-optimistic + async_fire_mqtt_message(hass, 'tilt-status-topic', '80') + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 80 + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + + +async def test_tilt_given_value_optimistic(hass, mqtt_mock): + """Test tilting to a given value.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'tilt_opened_value': 80, + 'tilt_closed_value': 25, + 'tilt_optimistic': True + } + }) + + await hass.services.async_call( + cover.DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 80 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 80, 0, False) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 25 mqtt_mock.async_publish.assert_called_once_with( - 'tilt-command-topic', 125, 0, False) + 'tilt-command-topic', 25, 0, False) + + +async def test_tilt_given_value_altered_range(hass, mqtt_mock): + """Test tilting to a given value.""" + assert await async_setup_component(hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'tilt_opened_value': 25, + 'tilt_closed_value': 0, + 'tilt_min': 0, + 'tilt_max': 50, + 'tilt_optimistic': True + } + }) + + await hass.services.async_call( + cover.DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 0 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 0, 0, False) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: 'cover.test'}, blocking=True) + + current_cover_tilt_position = hass.states.get( + 'cover.test').attributes['current_tilt_position'] + assert current_cover_tilt_position == 50 + + mqtt_mock.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) async def test_tilt_via_topic(hass, mqtt_mock): @@ -584,9 +766,7 @@ async def test_tilt_via_topic(hass, mqtt_mock): 'payload_close': 'CLOSE', 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', - 'tilt_status_topic': 'tilt-status-topic', - 'tilt_opened_value': 400, - 'tilt_closed_value': 125 + 'tilt_status_topic': 'tilt-status-topic' } }) @@ -650,8 +830,6 @@ async def test_tilt_via_topic_altered_range(hass, mqtt_mock): 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', 'tilt_status_topic': 'tilt-status-topic', - 'tilt_opened_value': 400, - 'tilt_closed_value': 125, 'tilt_min': 0, 'tilt_max': 50 } @@ -730,9 +908,7 @@ async def test_tilt_position(hass, mqtt_mock): 'payload_close': 'CLOSE', 'payload_stop': 'STOP', 'tilt_command_topic': 'tilt-command-topic', - 'tilt_status_topic': 'tilt-status-topic', - 'tilt_opened_value': 400, - 'tilt_closed_value': 125 + 'tilt_status_topic': 'tilt-status-topic' } }) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 703ef787ec76be..83ba7bdf8162f7 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -7,6 +7,7 @@ ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, + SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN) @@ -464,6 +465,20 @@ async def test_set_position(hass, calls): state = hass.states.get('cover.test_template_cover') assert state.attributes.get('current_position') == 0.0 + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 100.0 + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True) @@ -626,6 +641,20 @@ async def test_set_position_optimistic(hass, calls): state = hass.states.get('cover.test_template_cover') assert state.state == STATE_OPEN + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + async def test_set_tilt_position_optimistic(hass, calls): """Test the optimistic tilt_position mode.""" @@ -675,6 +704,20 @@ async def test_set_tilt_position_optimistic(hass, calls): state = hass.states.get('cover.test_template_cover') assert state.attributes.get('current_tilt_position') == 100.0 + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 0.0 + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 100.0 + async def test_icon_template(hass, calls): """Test icon template.""" From cb5426c1fa223fb408e9d3a54ee473043b9c5d9f Mon Sep 17 00:00:00 2001 From: Tommaso Marchionni Date: Mon, 17 Jun 2019 23:44:47 +0200 Subject: [PATCH 248/319] Added invert_percent configuration for zwave rollershutter (#23101) * Added invert_percent configuration for zwave rollershutter * Added invert_percent configuration for zwave rollershutter * Fix typo in zwave default configuration --- homeassistant/components/zwave/__init__.py | 4 +++ homeassistant/components/zwave/cover.py | 21 ++++++++++----- tests/components/zwave/test_cover.py | 30 +++++++++++++++++++++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index fdc00903f09165..a5a460d129e29b 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -47,6 +47,7 @@ CONF_POLLING_INTENSITY = 'polling_intensity' CONF_IGNORED = 'ignored' CONF_INVERT_OPENCLOSE_BUTTONS = 'invert_openclose_buttons' +CONF_INVERT_PERCENT = 'invert_percent' CONF_REFRESH_VALUE = 'refresh_value' CONF_REFRESH_DELAY = 'delay' CONF_DEVICE_CONFIG = 'device_config' @@ -57,6 +58,7 @@ DEFAULT_CONF_IGNORED = False DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False +DEFAULT_CONF_INVERT_PERCENT = False DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 @@ -146,6 +148,8 @@ vol.Optional(CONF_IGNORED, default=DEFAULT_CONF_IGNORED): cv.boolean, vol.Optional(CONF_INVERT_OPENCLOSE_BUTTONS, default=DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS): cv.boolean, + vol.Optional(CONF_INVERT_PERCENT, + default=DEFAULT_CONF_INVERT_PERCENT): cv.boolean, vol.Optional(CONF_REFRESH_VALUE, default=DEFAULT_CONF_REFRESH_VALUE): cv.boolean, vol.Optional(CONF_REFRESH_DELAY, default=DEFAULT_CONF_REFRESH_DELAY): diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py index a3cd7269b99337..1ab643bde11add 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -6,7 +6,8 @@ from homeassistant.components.cover import CoverDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( - ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, workaround) + ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, CONF_INVERT_PERCENT, + workaround) from .const import ( COMMAND_CLASS_SWITCH_MULTILEVEL, COMMAND_CLASS_SWITCH_BINARY, COMMAND_CLASS_BARRIER_OPERATOR, DATA_NETWORK) @@ -35,10 +36,11 @@ def async_add_cover(cover): def get_device(hass, values, node_config, **kwargs): """Create Z-Wave entity device.""" invert_buttons = node_config.get(CONF_INVERT_OPENCLOSE_BUTTONS) + invert_percent = node_config.get(CONF_INVERT_PERCENT) if (values.primary.command_class == COMMAND_CLASS_SWITCH_MULTILEVEL and values.primary.index == 0): - return ZwaveRollershutter(hass, values, invert_buttons) + return ZwaveRollershutter(hass, values, invert_buttons, invert_percent) if values.primary.command_class == COMMAND_CLASS_SWITCH_BINARY: return ZwaveGarageDoorSwitch(values) if values.primary.command_class == \ @@ -50,7 +52,7 @@ def get_device(hass, values, node_config, **kwargs): class ZwaveRollershutter(ZWaveDeviceEntity, CoverDevice): """Representation of an Z-Wave cover.""" - def __init__(self, hass, values, invert_buttons): + def __init__(self, hass, values, invert_buttons, invert_percent): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._network = hass.data[DATA_NETWORK] @@ -58,6 +60,7 @@ def __init__(self, hass, values, invert_buttons): self._close_id = None self._current_position = None self._invert_buttons = invert_buttons + self._invert_percent = invert_percent self._workaround = workaround.get_device_mapping(values.primary) if self._workaround: @@ -92,12 +95,14 @@ def current_cover_position(self): """Return the current position of Zwave roller shutter.""" if self._workaround == workaround.WORKAROUND_NO_POSITION: return None + if self._current_position is not None: if self._current_position <= 5: - return 0 + return 100 if self._invert_percent else 0 if self._current_position >= 95: - return 100 - return self._current_position + return 0 if self._invert_percent else 100 + return 100 - self._current_position if self._invert_percent \ + else self._current_position def open_cover(self, **kwargs): """Move the roller shutter up.""" @@ -110,7 +115,9 @@ def close_cover(self, **kwargs): def set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" self.node.set_dimmer(self.values.primary.value_id, - kwargs.get(ATTR_POSITION)) + (100 - kwargs.get(ATTR_POSITION)) + if self._invert_percent + else kwargs.get(ATTR_POSITION)) def stop_cover(self, **kwargs): """Stop the roller shutter.""" diff --git a/tests/components/zwave/test_cover.py b/tests/components/zwave/test_cover.py index ce34111c6129fd..4d4d537e4b4071 100644 --- a/tests/components/zwave/test_cover.py +++ b/tests/components/zwave/test_cover.py @@ -3,7 +3,7 @@ from homeassistant.components.cover import SUPPORT_OPEN, SUPPORT_CLOSE from homeassistant.components.zwave import ( - const, cover, CONF_INVERT_OPENCLOSE_BUTTONS) + const, cover, CONF_INVERT_OPENCLOSE_BUTTONS, CONF_INVERT_PERCENT) from tests.mock.zwave import ( MockNode, MockValue, MockEntityValues, value_changed) @@ -141,6 +141,34 @@ def test_roller_commands(hass, mock_openzwave): assert value_id == open_value.value_id +def test_roller_invert_percent(hass, mock_openzwave): + """Test position changed.""" + mock_network = hass.data[const.DATA_NETWORK] = MagicMock() + node = MockNode() + value = MockValue(data=50, node=node, + command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL) + open_value = MockValue(data=False, node=node) + close_value = MockValue(data=False, node=node) + values = MockEntityValues(primary=value, open=open_value, + close=close_value, node=node) + device = cover.get_device( + hass=hass, + node=node, + values=values, + node_config={CONF_INVERT_PERCENT: True}) + + device.set_cover_position(position=25) + assert node.set_dimmer.called + value_id, brightness = node.set_dimmer.mock_calls[0][1] + assert value_id == value.value_id + assert brightness == 75 + + device.open_cover() + assert mock_network.manager.pressButton.called + value_id, = mock_network.manager.pressButton.mock_calls.pop(0)[1] + assert value_id == open_value.value_id + + def test_roller_reverse_open_close(hass, mock_openzwave): """Test position changed.""" mock_network = hass.data[const.DATA_NETWORK] = MagicMock() From f722a6c08d07e46dead286e897da4e984c1d7905 Mon Sep 17 00:00:00 2001 From: Kevin Cooper Date: Mon, 17 Jun 2019 22:49:10 +0100 Subject: [PATCH 249/319] Add code_arm_required to manual alarm with MQTT (#22641) * add code_arm_requited to manual-mqtt alarm * Add fix for alarm front end more-info-alarm_control_panel * Fix code mistake --- .../alarm_control_panel/__init__.py | 9 ++- .../manual_mqtt/alarm_control_panel.py | 24 ++++-- .../components/mqtt/alarm_control_panel.py | 6 ++ .../manual_mqtt/test_alarm_control_panel.py | 78 +++++++++++++++++++ 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 36a68eda174b32..47f92bfe641856 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -19,6 +19,7 @@ ATTR_CHANGED_BY = 'changed_by' FORMAT_TEXT = 'text' FORMAT_NUMBER = 'number' +ATTR_CODE_ARM_REQUIRED = 'code_arm_required' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -87,6 +88,11 @@ def changed_by(self): """Last change triggered by.""" return None + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return True + def alarm_disarm(self, code=None): """Send disarm command.""" raise NotImplementedError() @@ -159,6 +165,7 @@ def state_attributes(self): """Return the state attributes.""" state_attr = { ATTR_CODE_FORMAT: self.code_format, - ATTR_CHANGED_BY: self.changed_by + ATTR_CHANGED_BY: self.changed_by, + ATTR_CODE_ARM_REQUIRED: self.code_arm_required } return state_attr diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d952dd68ebb2f6..c051ce47173e5a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) CONF_CODE_TEMPLATE = 'code_template' +CONF_CODE_ARM_REQUIRED = 'code_arm_required' CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' @@ -108,6 +109,7 @@ def _state_schema(state): _state_schema(STATE_ALARM_TRIGGERED), vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, @@ -126,6 +128,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config.get(mqtt.CONF_STATE_TOPIC), config.get(mqtt.CONF_COMMAND_TOPIC), config.get(mqtt.CONF_QOS), + config.get(CONF_CODE_ARM_REQUIRED), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), config.get(CONF_PAYLOAD_ARM_AWAY), @@ -146,9 +149,9 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): """ def __init__(self, hass, name, code, code_template, disarm_after_trigger, - state_topic, command_topic, qos, payload_disarm, - payload_arm_home, payload_arm_away, payload_arm_night, - config): + state_topic, command_topic, qos, code_arm_required, + payload_disarm, payload_arm_home, payload_arm_away, + payload_arm_night, config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -175,6 +178,7 @@ def __init__(self, hass, name, code, code_template, disarm_after_trigger, self._state_topic = state_topic self._command_topic = command_topic self._qos = qos + self._code_arm_required = code_arm_required self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away @@ -237,6 +241,11 @@ def code_format(self): return alarm.FORMAT_NUMBER return alarm.FORMAT_TEXT + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return self._code_arm_required + def alarm_disarm(self, code=None): """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): @@ -248,21 +257,24 @@ def alarm_disarm(self, code=None): def alarm_arm_home(self, code=None): """Send arm home command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_HOME): return self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_AWAY): return self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return self._update_state(STATE_ALARM_ARMED_NIGHT) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index da3e2faf224229..9827c7c4df9d4e 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -192,6 +192,12 @@ def code_format(self): return alarm.FORMAT_NUMBER return alarm.FORMAT_TEXT + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + return code_required + async def async_alarm_disarm(self, code=None): """Send disarm command. diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index f5558331bce60c..a20041401410ea 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -77,6 +77,32 @@ def test_arm_home_no_pending(self): assert STATE_ALARM_ARMED_HOME == \ self.hass.states.get(entity_id).state + def test_arm_home_no_pending_when_code_not_req(self): + """Test arm home method.""" + assert setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + self.hass.states.get(entity_id).state + + common.alarm_arm_home(self.hass, 0) + self.hass.block_till_done() + + assert STATE_ALARM_ARMED_HOME == \ + self.hass.states.get(entity_id).state + def test_arm_home_with_pending(self): """Test arm home method.""" assert setup_component( @@ -164,6 +190,32 @@ def test_arm_away_no_pending(self): assert STATE_ALARM_ARMED_AWAY == \ self.hass.states.get(entity_id).state + def test_arm_away_no_pending_when_code_not_req(self): + """Test arm home method.""" + assert setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_arm_required': False, + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + self.hass.states.get(entity_id).state + + common.alarm_arm_away(self.hass, 0, entity_id) + self.hass.block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + self.hass.states.get(entity_id).state + def test_arm_home_with_template_code(self): """Attempt to arm with a template-based code.""" assert setup_component( @@ -279,6 +331,32 @@ def test_arm_night_no_pending(self): assert STATE_ALARM_ARMED_NIGHT == \ self.hass.states.get(entity_id).state + def test_arm_night_no_pending_when_code_not_req(self): + """Test arm night method.""" + assert setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_arm_required': False, + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + self.hass.states.get(entity_id).state + + common.alarm_arm_night(self.hass, 0, entity_id) + self.hass.block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + self.hass.states.get(entity_id).state + def test_arm_night_with_pending(self): """Test arm night method.""" assert setup_component( From 1460f7bd80933c45b87cc2e6c39a819d859b54b9 Mon Sep 17 00:00:00 2001 From: Kevin Cooper Date: Mon, 17 Jun 2019 22:59:20 +0100 Subject: [PATCH 250/319] Add code_arm_required to manual alarm (#22618) * Add code_arm_required to manual alarm * Add fix for alarm front end more-info-alarm_control_panel --- .../components/demo/alarm_control_panel.py | 2 +- .../components/manual/alarm_control_panel.py | 23 +++- .../manual/test_alarm_control_panel.py | 100 ++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 3cf5aaca57e797..a960848eee7c80 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -12,7 +12,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Demo alarm control panel platform.""" async_add_entities([ - ManualAlarm(hass, 'Alarm', '1234', None, False, { + ManualAlarm(hass, 'Alarm', '1234', None, True, False, { STATE_ALARM_ARMED_AWAY: { CONF_DELAY_TIME: datetime.timedelta(seconds=0), CONF_PENDING_TIME: datetime.timedelta(seconds=5), diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 14934db41c291f..da0f7035d5cdd2 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) CONF_CODE_TEMPLATE = 'code_template' +CONF_CODE_ARM_REQUIRED = 'code_arm_required' DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) @@ -76,6 +77,7 @@ def _state_schema(state): vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Exclusive(CONF_CODE, 'code validation'): cv.string, vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): @@ -106,6 +108,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config[CONF_NAME], config.get(CONF_CODE), config.get(CONF_CODE_TEMPLATE), + config.get(CONF_CODE_ARM_REQUIRED), config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), config )]) @@ -124,7 +127,7 @@ class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity): """ def __init__(self, hass, name, code, code_template, - disarm_after_trigger, config): + code_arm_required, disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -134,6 +137,7 @@ def __init__(self, hass, name, code, code_template, self._code.hass = hass else: self._code = code or None + self._code_arm_required = code_arm_required self._disarm_after_trigger = disarm_after_trigger self._previous_state = self._state self._state_ts = None @@ -205,6 +209,11 @@ def code_format(self): return alarm.FORMAT_NUMBER return alarm.FORMAT_TEXT + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return self._code_arm_required + def alarm_disarm(self, code=None): """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): @@ -216,28 +225,32 @@ def alarm_disarm(self, code=None): def alarm_arm_home(self, code=None): """Send arm home command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_HOME): return self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_AWAY): return self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command.""" - if not self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS): + if self._code_arm_required and not \ + self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS): return self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index f0f1072085363a..2cb7e3f14289b1 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -48,6 +48,31 @@ async def test_arm_home_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_home_no_pending_when_code_not_req(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, 0) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_HOME == \ + hass.states.get(entity_id).state + + async def test_arm_home_with_pending(hass): """Test arm home method.""" assert await async_setup_component( @@ -129,6 +154,31 @@ async def test_arm_away_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_away_no_pending_when_code_not_req(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, 0, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + async def test_arm_home_with_template_code(hass): """Attempt to arm with a template-based code.""" assert await async_setup_component( @@ -233,6 +283,31 @@ async def test_arm_night_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_night_no_pending_when_code_not_req(hass): + """Test arm night method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, 0) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + async def test_arm_night_with_pending(hass): """Test arm night method.""" assert await async_setup_component( @@ -1128,6 +1203,31 @@ async def test_arm_custom_bypass_no_pending(hass): hass.states.get(entity_id).state +async def test_arm_custom_bypass_no_pending_when_code_not_req(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'code_arm_required': False, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, 0) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ + hass.states.get(entity_id).state + + async def test_arm_custom_bypass_with_pending(hass): """Test arm custom bypass method.""" assert await async_setup_component( From 73008885c8fb287ff4ef011c99c409a1557af720 Mon Sep 17 00:00:00 2001 From: escoand Date: Tue, 18 Jun 2019 00:00:11 +0200 Subject: [PATCH 251/319] Add source selection to Samsung TV media player (#22612) * add source selection * return generic list * remove useless timeout * Fix test * Add test for select_source * Add negative source test * Change order * Arghhh * Add hass object * Simplify source list Co-Authored-By: escoand --- .../components/samsungtv/media_player.py | 25 ++++++++++++++++--- .../components/samsungtv/test_media_player.py | 20 ++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 05921d7e84b07f..6b2235fe7e6c77 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -10,8 +10,9 @@ MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.components.media_player.const import ( MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) + SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, STATE_ON) @@ -26,9 +27,13 @@ KEY_PRESS_TIMEOUT = 1.2 KNOWN_DEVICES_KEY = 'samsungtv_known_devices' +SOURCES = { + 'TV': 'KEY_DTV', + 'HDMI': 'KEY_HDMI' +} SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ - SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_SELECT_SOURCE | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -187,6 +192,11 @@ def is_volume_muted(self): """Boolean if volume is currently muted.""" return self._muted + @property + def source_list(self): + """List of available input sources.""" + return list(SOURCES) + @property def supported_features(self): """Flag media player features that are supported.""" @@ -262,6 +272,7 @@ async def async_play_media(self, media_type, media_id, **kwargs): for digit in media_id: await self.hass.async_add_job(self.send_key, 'KEY_' + digit) await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop) + await self.hass.async_add_job(self.send_key, 'KEY_ENTER') def turn_on(self): """Turn the media player on.""" @@ -269,3 +280,11 @@ def turn_on(self): self._wol.send_magic_packet(self._mac) else: self.send_key('KEY_POWERON') + + async def async_select_source(self, source): + """Select input source.""" + if source not in SOURCES: + _LOGGER.error('Unsupported source') + return + + await self.hass.async_add_job(self.send_key, SOURCES[source]) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index b175a7220364de..b42250e26136a6 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -318,7 +318,7 @@ async def sleep(duration, loop): device.send_key = mock.Mock() await device.async_play_media(MEDIA_TYPE_CHANNEL, "576") - exp = [call("KEY_5"), call("KEY_7"), call("KEY_6")] + exp = [call("KEY_5"), call("KEY_7"), call("KEY_6"), call("KEY_ENTER")] assert device.send_key.call_args_list == exp assert len(sleeps) == 3 @@ -347,3 +347,21 @@ async def test_play_media_channel_as_non_positive(hass, samsung_mock): device.send_key = mock.Mock() await device.async_play_media(MEDIA_TYPE_CHANNEL, "-4") assert device.send_key.call_count == 0 + + +async def test_select_source(hass, samsung_mock): + """Test for select_source.""" + device = SamsungTVDevice(**WORKING_CONFIG) + device.hass = hass + device.send_key = mock.Mock() + await device.async_select_source("HDMI") + exp = [call("KEY_HDMI")] + assert device.send_key.call_args_list == exp + + +async def test_select_source_invalid_source(hass, samsung_mock): + """Test for select_source with invalid source.""" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_select_source("INVALID") + assert device.send_key.call_count == 0 From f3e4e8dce8151ccf0d395ef8551f51001c6764a3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Jun 2019 20:23:12 -0700 Subject: [PATCH 252/319] Fix alarm control panel tests (#24586) --- tests/components/manual/test_alarm_control_panel.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 2cb7e3f14289b1..797b632ab15896 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -66,8 +66,7 @@ async def test_arm_home_no_pending_when_code_not_req(hass): assert STATE_ALARM_DISARMED == \ hass.states.get(entity_id).state - common.async_alarm_arm_home(hass, 0) - await hass.async_block_till_done() + await common.async_alarm_arm_home(hass, 0) assert STATE_ALARM_ARMED_HOME == \ hass.states.get(entity_id).state @@ -172,8 +171,7 @@ async def test_arm_away_no_pending_when_code_not_req(hass): assert STATE_ALARM_DISARMED == \ hass.states.get(entity_id).state - common.async_alarm_arm_away(hass, 0, entity_id) - await hass.async_block_till_done() + await common.async_alarm_arm_away(hass, 0, entity_id) assert STATE_ALARM_ARMED_AWAY == \ hass.states.get(entity_id).state @@ -301,8 +299,7 @@ async def test_arm_night_no_pending_when_code_not_req(hass): assert STATE_ALARM_DISARMED == \ hass.states.get(entity_id).state - common.async_alarm_arm_night(hass, 0) - await hass.async_block_till_done() + await common.async_alarm_arm_night(hass, 0) assert STATE_ALARM_ARMED_NIGHT == \ hass.states.get(entity_id).state @@ -1221,8 +1218,7 @@ async def test_arm_custom_bypass_no_pending_when_code_not_req(hass): assert STATE_ALARM_DISARMED == \ hass.states.get(entity_id).state - common.async_alarm_arm_custom_bypass(hass, 0) - await hass.async_block_till_done() + await common.async_alarm_arm_custom_bypass(hass, 0) assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ hass.states.get(entity_id).state From 266b3bc7144e1054c2f97d33e77915d073488954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Nenz=C3=A9n?= Date: Tue, 18 Jun 2019 05:23:59 +0200 Subject: [PATCH 253/319] Adds integration for Plaato Airlock (#23727) * Adds integration for Plaato Airlock * Updates codeowners and coveragerc * Fixes lint errors * Fixers lint check error * Removed sv translation file * Adds en translation file * Sets config flow to true in manifest * Moves config flow and domain to seperate files * Fixes lint errors * Runs hassfest to regenerate config_flows.py * Adds should poll property and fixes for loop * Only log a warning when webhook data was broken * Fixes static test failure * Moves state update from async_update to state prop * Unsubscribes the dispatch signal listener * Update sensor.py --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/plaato/.translations/en.json | 18 +++ homeassistant/components/plaato/__init__.py | 126 ++++++++++++++++ .../components/plaato/config_flow.py | 11 ++ homeassistant/components/plaato/const.py | 3 + homeassistant/components/plaato/manifest.json | 9 ++ homeassistant/components/plaato/sensor.py | 139 ++++++++++++++++++ homeassistant/components/plaato/strings.json | 18 +++ homeassistant/generated/config_flows.py | 1 + 10 files changed, 327 insertions(+) create mode 100644 homeassistant/components/plaato/.translations/en.json create mode 100644 homeassistant/components/plaato/__init__.py create mode 100644 homeassistant/components/plaato/config_flow.py create mode 100644 homeassistant/components/plaato/const.py create mode 100644 homeassistant/components/plaato/manifest.json create mode 100644 homeassistant/components/plaato/sensor.py create mode 100644 homeassistant/components/plaato/strings.json diff --git a/.coveragerc b/.coveragerc index 7e618b9d4b36e3..c8213378e91889 100644 --- a/.coveragerc +++ b/.coveragerc @@ -451,6 +451,7 @@ omit = homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py homeassistant/components/pjlink/media_player.py + homeassistant/components/plaato/* homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py homeassistant/components/plum_lightpad/* diff --git a/CODEOWNERS b/CODEOWNERS index a6b1b44e34cf51..fb6b204253b8e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -191,6 +191,7 @@ homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus homeassistant/components/pi_hole/* @fabaff +homeassistant/components/plaato/* @JohNan homeassistant/components/plant/* @ChristianKuehnel homeassistant/components/point/* @fredrike homeassistant/components/ps4/* @ktnrg45 diff --git a/homeassistant/components/plaato/.translations/en.json b/homeassistant/components/plaato/.translations/en.json new file mode 100644 index 00000000000000..6d3aa2c59c43eb --- /dev/null +++ b/homeassistant/components/plaato/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Plaato Airlock.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + }, + "step": { + "user": { + "description": "Are you sure you want to set up the Plaato Airlock?", + "title": "Set up the Plaato Webhook" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py new file mode 100644 index 00000000000000..9857ef47b1ce8c --- /dev/null +++ b/homeassistant/components/plaato/__init__.py @@ -0,0 +1,126 @@ +"""Support for Plaato Airlock.""" +import logging + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.const import ( + CONF_WEBHOOK_ID, HTTP_OK, + TEMP_CELSIUS, TEMP_FAHRENHEIT, VOLUME_GALLONS, VOLUME_LITERS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['webhook'] + +PLAATO_DEVICE_SENSORS = 'sensors' +PLAATO_DEVICE_ATTRS = 'attrs' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_TEMP_UNIT = 'temp_unit' +ATTR_VOLUME_UNIT = 'volume_unit' +ATTR_BPM = 'bpm' +ATTR_TEMP = 'temp' +ATTR_SG = 'sg' +ATTR_OG = 'og' +ATTR_BUBBLES = 'bubbles' +ATTR_ABV = 'abv' +ATTR_CO2_VOLUME = 'co2_volume' +ATTR_BATCH_VOLUME = 'batch_volume' + +SENSOR_UPDATE = '{}_sensor_update'.format(DOMAIN) +SENSOR_DATA_KEY = '{}.{}'.format(DOMAIN, SENSOR) + +WEBHOOK_SCHEMA = vol.Schema({ + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_DEVICE_ID): cv.positive_int, + vol.Required(ATTR_TEMP_UNIT): vol.Any(TEMP_CELSIUS, TEMP_FAHRENHEIT), + vol.Required(ATTR_VOLUME_UNIT): vol.Any(VOLUME_LITERS, VOLUME_GALLONS), + vol.Required(ATTR_BPM): cv.positive_int, + vol.Required(ATTR_TEMP): vol.Coerce(float), + vol.Required(ATTR_SG): vol.Coerce(float), + vol.Required(ATTR_OG): vol.Coerce(float), + vol.Required(ATTR_ABV): vol.Coerce(float), + vol.Required(ATTR_CO2_VOLUME): vol.Coerce(float), + vol.Required(ATTR_BATCH_VOLUME): vol.Coerce(float), + vol.Required(ATTR_BUBBLES): cv.positive_int, +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, hass_config): + """Set up the Plaato component.""" + return True + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + webhook_id = entry.data[CONF_WEBHOOK_ID] + hass.components.webhook.async_register( + DOMAIN, 'Plaato', webhook_id, handle_webhook) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, SENSOR) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + hass.data[SENSOR_DATA_KEY]() + + await hass.config_entries.async_forward_entry_unload(entry, SENSOR) + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook from Plaato.""" + try: + data = WEBHOOK_SCHEMA(await request.json()) + except vol.MultipleInvalid as error: + _LOGGER.warning("An error occurred when parsing webhook data <%s>", + error) + return + + device_id = _device_id(data) + + attrs = { + ATTR_DEVICE_NAME: data.get(ATTR_DEVICE_NAME), + ATTR_DEVICE_ID: data.get(ATTR_DEVICE_ID), + ATTR_TEMP_UNIT: data.get(ATTR_TEMP_UNIT), + ATTR_VOLUME_UNIT: data.get(ATTR_VOLUME_UNIT) + } + + sensors = { + ATTR_TEMP: data.get(ATTR_TEMP), + ATTR_BPM: data.get(ATTR_BPM), + ATTR_SG: data.get(ATTR_SG), + ATTR_OG: data.get(ATTR_OG), + ATTR_ABV: data.get(ATTR_ABV), + ATTR_CO2_VOLUME: data.get(ATTR_CO2_VOLUME), + ATTR_BATCH_VOLUME: data.get(ATTR_BATCH_VOLUME), + ATTR_BUBBLES: data.get(ATTR_BUBBLES) + } + + hass.data[DOMAIN][device_id] = { + PLAATO_DEVICE_ATTRS: attrs, + PLAATO_DEVICE_SENSORS: sensors + } + + async_dispatcher_send(hass, SENSOR_UPDATE, device_id) + + return web.Response( + text="Saving status for {}".format(device_id), status=HTTP_OK) + + +def _device_id(data): + """Return name of device sensor.""" + return "{}_{}".format(data.get(ATTR_DEVICE_NAME), data.get(ATTR_DEVICE_ID)) diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py new file mode 100644 index 00000000000000..c3f9279df05ffc --- /dev/null +++ b/homeassistant/components/plaato/config_flow.py @@ -0,0 +1,11 @@ +"""Config flow for GPSLogger.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + 'Webhook', + { + 'docs_url': 'https://www.home-assistant.io/components/plaato/' + } +) diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py new file mode 100644 index 00000000000000..f683ddb664ce8d --- /dev/null +++ b/homeassistant/components/plaato/const.py @@ -0,0 +1,3 @@ +"""Const for GPSLogger.""" + +DOMAIN = 'plaato' diff --git a/homeassistant/components/plaato/manifest.json b/homeassistant/components/plaato/manifest.json new file mode 100644 index 00000000000000..cd6111ba9da789 --- /dev/null +++ b/homeassistant/components/plaato/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "plaato", + "name": "Plaato Airlock", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/plaato", + "dependencies": ["webhook"], + "codeowners": ["@JohNan"], + "requirements": [] +} \ No newline at end of file diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py new file mode 100644 index 00000000000000..6352c83712181d --- /dev/null +++ b/homeassistant/components/plaato/sensor.py @@ -0,0 +1,139 @@ +"""Support for Plaato Airlock sensors.""" + +import logging + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import ( + ATTR_ABV, ATTR_BATCH_VOLUME, ATTR_BPM, ATTR_CO2_VOLUME, ATTR_TEMP, + ATTR_TEMP_UNIT, ATTR_VOLUME_UNIT, DOMAIN as PLAATO_DOMAIN, + PLAATO_DEVICE_ATTRS, PLAATO_DEVICE_SENSORS, SENSOR_DATA_KEY, SENSOR_UPDATE) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Plaato sensor.""" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Plaato from a config entry.""" + devices = {} + + def get_device(device_id): + """Get a device.""" + return hass.data[PLAATO_DOMAIN].get(device_id, False) + + def get_device_sensors(device_id): + """Get device sensors.""" + return hass.data[PLAATO_DOMAIN].get(device_id)\ + .get(PLAATO_DEVICE_SENSORS) + + async def _update_sensor(device_id): + """Update/Create the sensors.""" + if device_id not in devices and get_device(device_id): + entities = [] + sensors = get_device_sensors(device_id) + + for sensor_type in sensors: + entities.append(PlaatoSensor(device_id, sensor_type)) + + devices[device_id] = entities + + async_add_entities(entities, True) + else: + for entity in devices[device_id]: + entity.async_schedule_update_ha_state() + + hass.data[SENSOR_DATA_KEY] = async_dispatcher_connect( + hass, SENSOR_UPDATE, _update_sensor + ) + + return True + + +class PlaatoSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, device_id, sensor_type): + """Initialize the sensor.""" + self._device_id = device_id + self._type = sensor_type + self._state = 0 + self._name = "{} {}".format(device_id, sensor_type) + self._attributes = None + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(PLAATO_DOMAIN, self._name) + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "{}_{}".format(self._device_id, self._type) + + @property + def device_info(self): + """Get device info.""" + return { + 'identifiers': { + (PLAATO_DOMAIN, self._device_id) + }, + 'name': self._device_id, + 'manufacturer': 'Plaato', + 'model': 'Airlock' + } + + def get_sensors(self): + """Get device sensors.""" + return self.hass.data[PLAATO_DOMAIN].get(self._device_id)\ + .get(PLAATO_DEVICE_SENSORS, False) + + def get_sensors_unit_of_measurement(self, sensor_type): + """Get unit of measurement for sensor of type.""" + return self.hass.data[PLAATO_DOMAIN].get(self._device_id)\ + .get(PLAATO_DEVICE_ATTRS, []).get(sensor_type, '') + + @property + def state(self): + """Return the state of the sensor.""" + sensors = self.get_sensors() + if sensors is False: + _LOGGER.debug("Device with name %s has no sensors.", self.name) + return 0 + + if self._type == ATTR_ABV: + return round(sensors.get(self._type), 2) + if self._type == ATTR_TEMP: + return round(sensors.get(self._type), 1) + if self._type == ATTR_CO2_VOLUME: + return round(sensors.get(self._type), 2) + return sensors.get(self._type) + + @property + def device_state_attributes(self): + """Return the state attributes of the monitored installation.""" + if self._attributes is not None: + return self._attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self._type == ATTR_TEMP: + return self.get_sensors_unit_of_measurement(ATTR_TEMP_UNIT) + if self._type == ATTR_BATCH_VOLUME or self._type == ATTR_CO2_VOLUME: + return self.get_sensors_unit_of_measurement(ATTR_VOLUME_UNIT) + if self._type == ATTR_BPM: + return 'bpm' + if self._type == ATTR_ABV: + return '%' + + return '' + + @property + def should_poll(self): + """Return the polling state.""" + return False diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json new file mode 100644 index 00000000000000..ee99da0c8b1cd7 --- /dev/null +++ b/homeassistant/components/plaato/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Plaato Airlock", + "step": { + "user": { + "title": "Set up the Plaato Webhook", + "description": "Are you sure you want to set up the Plaato Airlock?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Plaato Airlock." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 296c620cd7de31..4c7d77e0dabbf5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -37,6 +37,7 @@ "nest", "openuv", "owntracks", + "plaato", "point", "ps4", "rainmachine", From 2e848c3f1f5ecb4ed1127c1553aa89e2bc8e2d42 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 18 Jun 2019 04:32:53 +0100 Subject: [PATCH 254/319] Fix honeywell issue #18932 (#24402) * change us-based default temps to Fahrenheit * update CODEOWNERS * update CODEOWNERS * tweak docstring * tweak docstring * Coerce Fahrenheit Temps to int --- CODEOWNERS | 1 + homeassistant/components/honeywell/__init__.py | 2 +- homeassistant/components/honeywell/climate.py | 12 ++++++------ homeassistant/components/honeywell/manifest.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fb6b204253b8e5..d8b6cc62b8a971 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -115,6 +115,7 @@ homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @cdce8p homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/honeywell/* @zxdavb homeassistant/components/html5/* @robbiet480 homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 59a90711e5752f..53259dcf275c65 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1 +1 @@ -"""Support for Honeywell Round Connected and Honeywell Evohome thermostats.""" +"""Support for Honeywell Total Connect Comfort climate systems.""" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 75bbb2ca5d8178..3ebb2a9bb85b87 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,4 +1,4 @@ -"""Support for Honeywell Round Connected and Honeywell Evohome thermostats.""" +"""Support for Honeywell Total Connect Comfort climate systems.""" import logging import datetime @@ -25,9 +25,9 @@ CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' -DEFAULT_AWAY_TEMPERATURE = 16 -DEFAULT_COOL_AWAY_TEMPERATURE = 30 -DEFAULT_HEAT_AWAY_TEMPERATURE = 16 +DEFAULT_AWAY_TEMPERATURE = 16 # in C, for eu regions, the others are F/us +DEFAULT_COOL_AWAY_TEMPERATURE = 88 +DEFAULT_HEAT_AWAY_TEMPERATURE = 61 DEFAULT_REGION = 'eu' REGIONS = ['eu', 'us'] @@ -37,9 +37,9 @@ vol.Optional(CONF_AWAY_TEMPERATURE, default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float), vol.Optional(CONF_COOL_AWAY_TEMPERATURE, - default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float), + default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(int), vol.Optional(CONF_HEAT_AWAY_TEMPERATURE, - default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float), + default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(int), vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), }) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index c3d76703e91dd9..ba75950452904d 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -7,5 +7,5 @@ "somecomfort==0.5.2" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@zxdavb"] } From 76549beb96b99365e0ec8a8581094d22534ec817 Mon Sep 17 00:00:00 2001 From: Ben Dews Date: Wed, 19 Jun 2019 01:32:35 +1000 Subject: [PATCH 255/319] Bump base Somfy MyLink library version (#24587) --- homeassistant/components/somfy_mylink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/somfy_mylink/manifest.json b/homeassistant/components/somfy_mylink/manifest.json index 5a3cec0def8bc2..d4e799c2cf1ab6 100644 --- a/homeassistant/components/somfy_mylink/manifest.json +++ b/homeassistant/components/somfy_mylink/manifest.json @@ -3,7 +3,7 @@ "name": "Somfy MyLink", "documentation": "https://www.home-assistant.io/components/somfy_mylink", "requirements": [ - "somfy-mylink-synergy==1.0.4" + "somfy-mylink-synergy==1.0.6" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 097c68fadb9486..6fe24a40c8213a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1692,7 +1692,7 @@ solax==0.0.3 somecomfort==0.5.2 # homeassistant.components.somfy_mylink -somfy-mylink-synergy==1.0.4 +somfy-mylink-synergy==1.0.6 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.1 From 227b8bdf8a9008ae53eed5c31d6843d4157463fd Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 18 Jun 2019 11:36:28 -0400 Subject: [PATCH 256/319] Better pairing for Xiaomi devices in ZHA (#24564) * better xiaomi pairing * cleanup. --- .../components/zha/core/channels/__init__.py | 39 +++++++++---------- .../components/zha/core/channels/security.py | 3 ++ 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 83ade5894652df..162ef5a59e4d05 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from ..helpers import ( - bind_configure_reporting, construct_unique_id, + configure_reporting, construct_unique_id, safe_read, get_attr_id_by_name, bind_cluster) from ..const import ( REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL, @@ -133,26 +133,25 @@ async def async_configure(self): """Set cluster binding and attribute reporting.""" manufacturer = None manufacturer_code = self._zha_device.manufacturer_code - if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: - manufacturer = manufacturer_code - if self.cluster.bind_only: + # Xiaomi devices don't need this and it disrupts pairing + if self._zha_device.manufacturer != 'LUMI': + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code await bind_cluster(self._unique_id, self.cluster) - else: - skip_bind = False # bind cluster only for the 1st configured attr - for report_config in self._report_config: - attr = report_config.get('attr') - min_report_interval, max_report_interval, change = \ - report_config.get('config') - await bind_configure_reporting( - self._unique_id, self.cluster, attr, - min_report=min_report_interval, - max_report=max_report_interval, - reportable_change=change, - skip_bind=skip_bind, - manufacturer=manufacturer - ) - skip_bind = True - await asyncio.sleep(uniform(0.1, 0.5)) + if not self.cluster.bind_only: + for report_config in self._report_config: + attr = report_config.get('attr') + min_report_interval, max_report_interval, change = \ + report_config.get('config') + await configure_reporting( + self._unique_id, self.cluster, attr, + min_report=min_report_interval, + max_report=max_report_interval, + reportable_change=change, + manufacturer=manufacturer + ) + await asyncio.sleep(uniform(0.1, 0.5)) + _LOGGER.debug( "%s: finished channel configuration", self._unique_id diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 03b50b7c7ba803..a69ab692da554d 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -35,6 +35,9 @@ def cluster_command(self, tsn, command_id, args): async def async_configure(self): """Configure IAS device.""" + # Xiaomi devices don't need this and it disrupts pairing + if self._zha_device.manufacturer == 'LUMI': + return from zigpy.exceptions import DeliveryError _LOGGER.debug("%s: started IASZoneChannel configuration", self._unique_id) From e669e1e2bf6c98e5e70c1147b20d905704f8cd52 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 18 Jun 2019 17:41:21 +0200 Subject: [PATCH 257/319] ESPHome config flow only connect when needed (#24593) * ESPHome config flow only connect when needed * Lint --- .../components/esphome/config_flow.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index b2a96ed53f3a77..2ce749d6ae9f6f 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -22,7 +22,6 @@ def __init__(self): self._host = None # type: Optional[str] self._port = None # type: Optional[int] self._password = None # type: Optional[str] - self._name = None # type: Optional[str] async def async_step_user(self, user_input: Optional[ConfigType] = None, error: Optional[str] = None): @@ -44,34 +43,41 @@ async def async_step_user(self, user_input: Optional[ConfigType] = None, errors=errors ) - async def _async_authenticate_or_add(self, user_input, - from_discovery=False): + @property + def _name(self): + return self.context.get('name') + + @_name.setter + def _name(self, value): + # pylint: disable=unsupported-assignment-operation + self.context['name'] = value + self.context['title_placeholders'] = { + 'name': self._name + } + + def _set_user_input(self, user_input): + if user_input is None: + return self._host = user_input['host'] self._port = user_input['port'] + + async def _async_authenticate_or_add(self, user_input): + self._set_user_input(user_input) error, device_info = await self.fetch_device_info() if error is not None: return await self.async_step_user(error=error) self._name = device_info.name - # pylint: disable=unsupported-assignment-operation - self.context['title_placeholders'] = { - 'name': self._name - } - self.context['name'] = self._name # Only show authentication step if device uses password if device_info.uses_password: return await self.async_step_authenticate() - if from_discovery: - # If from discovery, do not create entry immediately, - # First present user with message - return await self.async_step_discovery_confirm() return self._async_get_entry() async def async_step_discovery_confirm(self, user_input=None): """Handle user-confirmation of discovered node.""" if user_input is not None: - return self._async_get_entry() + return await self._async_authenticate_or_add(None) return self.async_show_form( step_id='discovery_confirm', description_placeholders={'name': self._name}, @@ -101,14 +107,16 @@ async def async_step_zeroconf(self, user_input: ConfigType): if already_configured: return self.async_abort(reason='already_configured') + self._host = address + self._port = user_input['port'] + self._name = node_name + + # Check if flow for this device already in progress for flow in self._async_in_progress(): - if flow['context']['name'] == node_name: + if flow['context'].get('name') == node_name: return self.async_abort(reason='already_configured') - return await self._async_authenticate_or_add(user_input={ - 'host': address, - 'port': user_input['port'], - }, from_discovery=True) + return await self.async_step_discovery_confirm() def _async_get_entry(self): return self.async_create_entry( From ee5540f3512542e53f8455e3a4f9f274994d71fc Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 18 Jun 2019 17:41:45 +0200 Subject: [PATCH 258/319] ESPHome load platforms lazily (#24594) --- homeassistant/components/esphome/__init__.py | 37 ++++---------- .../components/esphome/entry_data.py | 51 +++++++++++++++++-- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 395c145e5df241..338968a4fd7c34 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -39,18 +39,6 @@ STORAGE_KEY = 'esphome.{}' STORAGE_VERSION = 1 -# The HA component types this integration supports -HA_COMPONENTS = [ - 'binary_sensor', - 'camera', - 'climate', - 'cover', - 'fan', - 'light', - 'sensor', - 'switch', -] - # No config schema - only configuration entry CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -144,7 +132,8 @@ async def on_login() -> None: entry_data.async_update_device_state(hass) entity_infos, services = await cli.list_entities_services() - entry_data.async_update_static_infos(hass, entity_infos) + await entry_data.async_update_static_infos( + hass, entry, entity_infos) await _setup_services(hass, entry_data, services) await cli.subscribe_states(async_on_state) await cli.subscribe_service_calls(async_on_service_call) @@ -162,14 +151,8 @@ async def on_login() -> None: async def complete_setup() -> None: """Complete the config entry setup.""" - tasks = [] - for component in HA_COMPONENTS: - tasks.append(hass.config_entries.async_forward_entry_setup( - entry, component)) - await asyncio.wait(tasks) - infos, services = await entry_data.async_load_from_store() - entry_data.async_update_static_infos(hass, infos) + await entry_data.async_update_static_infos(hass, entry, infos) await _setup_services(hass, entry_data, services) # Create connection attempt outside of HA's tracked task in order @@ -308,7 +291,7 @@ async def _setup_services(hass: HomeAssistantType, async def _cleanup_instance(hass: HomeAssistantType, - entry: ConfigEntry) -> None: + entry: ConfigEntry) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" data = hass.data[DATA_KEY].pop(entry.entry_id) # type: RuntimeEntryData if data.reconnect_task is not None: @@ -318,19 +301,19 @@ async def _cleanup_instance(hass: HomeAssistantType, for cleanup_callback in data.cleanup_callbacks: cleanup_callback() await data.client.disconnect() + return data async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Unload an esphome config entry.""" - await _cleanup_instance(hass, entry) - + entry_data = await _cleanup_instance(hass, entry) tasks = [] - for component in HA_COMPONENTS: + for platform in entry_data.loaded_platforms: tasks.append(hass.config_entries.async_forward_entry_unload( - entry, component)) - await asyncio.wait(tasks) - + entry, platform)) + if tasks: + await asyncio.wait(tasks) return True diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 47cadc00653103..4e78718b760b78 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -1,11 +1,15 @@ """Runtime entry data for ESPHome stored in hass.data.""" import asyncio -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Set from aioesphomeapi import ( - COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService) + COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService, + BinarySensorInfo, + CameraInfo, ClimateInfo, CoverInfo, FanInfo, LightInfo, SensorInfo, + SwitchInfo, TextSensorInfo) import attr +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType @@ -17,6 +21,19 @@ DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update' DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state' +# Mapping from ESPHome info type to HA platform +INFO_TYPE_TO_PLATFORM = { + BinarySensorInfo: 'binary_sensor', + CameraInfo: 'camera', + ClimateInfo: 'climate', + CoverInfo: 'cover', + FanInfo: 'fan', + LightInfo: 'light', + SensorInfo: 'sensor', + SwitchInfo: 'switch', + TextSensorInfo: 'sensor', +} + @attr.s class RuntimeEntryData: @@ -33,6 +50,8 @@ class RuntimeEntryData: device_info = attr.ib(type=DeviceInfo, default=None) cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + loaded_platforms = attr.ib(type=Set[str], factory=set) + platform_load_lock = attr.ib(type=asyncio.Lock, factory=asyncio.Lock) def async_update_entity(self, hass: HomeAssistantType, component_key: str, key: int) -> None: @@ -48,9 +67,33 @@ def async_remove_entity(self, hass: HomeAssistantType, component_key: str, entry_id=self.entry_id, component_key=component_key, key=key) async_dispatcher_send(hass, signal) - def async_update_static_infos(self, hass: HomeAssistantType, - infos: List[EntityInfo]) -> None: + async def _ensure_platforms_loaded(self, hass: HomeAssistantType, + entry: ConfigEntry, + platforms: Set[str]): + async with self.platform_load_lock: + needed = platforms - self.loaded_platforms + tasks = [] + for platform in needed: + tasks.append(hass.config_entries.async_forward_entry_setup( + entry, platform)) + if tasks: + await asyncio.wait(tasks) + self.loaded_platforms |= needed + + async def async_update_static_infos( + self, hass: HomeAssistantType, entry: ConfigEntry, + infos: List[EntityInfo]) -> None: """Distribute an update of static infos to all platforms.""" + # First, load all platforms + needed_platforms = set() + for info in infos: + for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): + if isinstance(info, info_type): + needed_platforms.add(platform) + break + await self._ensure_platforms_loaded(hass, entry, needed_platforms) + + # Then send dispatcher event signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) async_dispatcher_send(hass, signal, infos) From 024ce0e8eb6608da7330aed8e022d3c5241148de Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 18 Jun 2019 17:43:11 +0200 Subject: [PATCH 259/319] Add ESPHome event generation and user-defined service array support (#24595) * Add ESPHome event generation and user-defined service array support * Comments * Lint --- homeassistant/components/esphome/__init__.py | 23 +++++++++++++++---- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 338968a4fd7c34..db5aeea2aa1fae 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -6,7 +6,7 @@ from aioesphomeapi import ( APIClient, APIConnectionError, DeviceInfo, EntityInfo, EntityState, - ServiceCall, UserService, UserServiceArgType) + HomeassistantServiceCall, UserService, UserServiceArgType) import voluptuous as vol from homeassistant import const @@ -86,7 +86,7 @@ def async_on_state(state: EntityState) -> None: entry_data.async_update_state(hass, state) @callback - def async_on_service_call(service: ServiceCall) -> None: + def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" domain, service_name = service.service.split('.', 1) service_data = service.data @@ -102,8 +102,17 @@ def async_on_service_call(service: ServiceCall) -> None: _LOGGER.error('Error rendering data template: %s', ex) return - hass.async_create_task(hass.services.async_call( - domain, service_name, service_data, blocking=True)) + if service.is_event: + # ESPHome uses servicecall packet for both events and service calls + # Ensure the user can only send events of form 'esphome.xyz' + if domain != 'esphome': + _LOGGER.error("Can only generate events under esphome " + "domain!") + return + hass.bus.async_fire(service.service, service_data) + else: + hass.async_create_task(hass.services.async_call( + domain, service_name, service_data, blocking=True)) async def send_home_assistant_state(entity_id: str, _, new_state: Optional[State]) -> None: @@ -222,7 +231,7 @@ async def _async_setup_device_registry(hass: HomeAssistantType, entry: ConfigEntry, device_info: DeviceInfo): """Set up device registry feature for a particular config entry.""" - sw_version = device_info.esphome_core_version + sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += ' ({})'.format(device_info.compilation_time) device_registry = await dr.async_get_registry(hass) @@ -249,6 +258,10 @@ async def _register_service(hass: HomeAssistantType, UserServiceArgType.INT: vol.Coerce(int), UserServiceArgType.FLOAT: vol.Coerce(float), UserServiceArgType.STRING: cv.string, + UserServiceArgType.BOOL_ARRAY: [cv.boolean], + UserServiceArgType.INT_ARRAY: [vol.Coerce(int)], + UserServiceArgType.FLOAT_ARRAY: [vol.Coerce(float)], + UserServiceArgType.STRING_ARRAY: [cv.string], }[arg.type_] async def execute_service(call): diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a986a8641897b6..43987cce2c9789 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/esphome", "requirements": [ - "aioesphomeapi==2.1.0" + "aioesphomeapi==2.2.0" ], "dependencies": [], "zeroconf": ["_esphomelib._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index 6fe24a40c8213a..a820ce2b61fc24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -129,7 +129,7 @@ aiobotocore==0.10.2 aiodns==2.0.0 # homeassistant.components.esphome -aioesphomeapi==2.1.0 +aioesphomeapi==2.2.0 # homeassistant.components.freebox aiofreepybox==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fe8c1a8b0be4e..651053a14dd214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -48,7 +48,7 @@ aioautomatic==0.6.5 aiobotocore==0.10.2 # homeassistant.components.esphome -aioesphomeapi==2.1.0 +aioesphomeapi==2.2.0 # homeassistant.components.emulated_hue # homeassistant.components.http From d22c3f13b2262a2a8fa4d4ef02ebfc709c3a5620 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 18 Jun 2019 18:04:36 +0200 Subject: [PATCH 260/319] Fix validator for lcn.send_keys service (#24580) * Fix validator for lcn.send_keys service * Removed lowercase table names from send_keys and lock_keys validators * Revert lowercase regex. * Fixed table name regex. --- homeassistant/components/lcn/services.py | 32 +++++++++++++----------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 78a887a80c101e..e89608a23b702e 100755 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -2,9 +2,9 @@ import pypck import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_ADDRESS, CONF_BRIGHTNESS, CONF_STATE, CONF_UNIT_OF_MEASUREMENT) -import homeassistant.helpers.config_validation as cv from .const import ( CONF_CONNECTIONS, CONF_KEYS, CONF_LED, CONF_OUTPUT, CONF_PCK, @@ -22,7 +22,7 @@ class LcnServiceCall(): schema = vol.Schema({ vol.Required(CONF_ADDRESS): is_address - }) + }) def __init__(self, hass): """Initialize service call.""" @@ -49,7 +49,7 @@ class OutputAbs(LcnServiceCall): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), vol.Optional(CONF_TRANSITION, default=0): vol.All(vol.Coerce(float), vol.Range(min=0., max=486.)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -69,7 +69,7 @@ class OutputRel(LcnServiceCall): vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)), vol.Required(CONF_BRIGHTNESS): vol.All(vol.Coerce(int), vol.Range(min=-100, max=100)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -87,7 +87,7 @@ class OutputToggle(LcnServiceCall): vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)), vol.Optional(CONF_TRANSITION, default=0): vol.All(vol.Coerce(float), vol.Range(min=0., max=486.)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -145,7 +145,7 @@ class VarAbs(LcnServiceCall): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='native'): vol.All(vol.Upper, vol.In(VAR_UNITS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -164,7 +164,7 @@ class VarReset(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_VARIABLE): vol.All(vol.Upper, vol.In(VARIABLES + SETPOINTS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -185,7 +185,7 @@ class VarRel(LcnServiceCall): vol.All(vol.Upper, vol.In(VAR_UNITS)), vol.Optional(CONF_RELVARREF, default='current'): vol.All(vol.Upper, vol.In(RELVARREF)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -206,7 +206,7 @@ class LockRegulator(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_SETPOINT): vol.All(vol.Upper, vol.In(SETPOINTS)), vol.Optional(CONF_STATE, default=False): bool, - }) + }) def __call__(self, call): """Execute service call.""" @@ -222,13 +222,14 @@ class SendKeys(LcnServiceCall): """Sends keys (which executes bound commands).""" schema = LcnServiceCall.schema.extend({ - vol.Required(CONF_KEYS): cv.matches_regex(r'^([a-dA-D][1-8])+$'), + vol.Required(CONF_KEYS): vol.All( + vol.Upper, cv.matches_regex(r'^([A-D][1-8])+$')), vol.Optional(CONF_STATE, default='hit'): vol.All(vol.Upper, vol.In(SENDKEYCOMMANDS)), vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), vol.Optional(CONF_TIME_UNIT, default='s'): vol.All(vol.Upper, vol.In(TIME_UNITS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -265,12 +266,13 @@ class LockKeys(LcnServiceCall): """Lock keys.""" schema = LcnServiceCall.schema.extend({ - vol.Optional(CONF_TABLE, default='a'): cv.matches_regex(r'^[a-dA-D]$'), + vol.Optional(CONF_TABLE, default='a'): vol.All( + vol.Upper, cv.matches_regex(r'^[A-D]$')), vol.Required(CONF_STATE): is_key_lock_states_string, vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), vol.Optional(CONF_TIME_UNIT, default='s'): vol.All(vol.Upper, vol.In(TIME_UNITS)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -301,7 +303,7 @@ class DynText(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_ROW): vol.All(int, vol.Range(min=1, max=4)), vol.Required(CONF_TEXT): vol.All(str, vol.Length(max=60)) - }) + }) def __call__(self, call): """Execute service call.""" @@ -317,7 +319,7 @@ class Pck(LcnServiceCall): schema = LcnServiceCall.schema.extend({ vol.Required(CONF_PCK): str - }) + }) def __call__(self, call): """Execute service call.""" From 37602647aa7c8fae51078ffd2df94789420c7d1f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 18 Jun 2019 11:54:38 -0700 Subject: [PATCH 261/319] Updated frontend to 20190618.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e2a213f3961cfb..d0a9b2ea2680db 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190614.0" + "home-assistant-frontend==20190618.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 044a5098303248..d08b3d513a44d0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.15 -home-assistant-frontend==20190614.0 +home-assistant-frontend==20190618.0 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index a820ce2b61fc24..750f9626acf228 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -592,7 +592,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190614.0 +home-assistant-frontend==20190618.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 651053a14dd214..6b1b90aa627e7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -157,7 +157,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190614.0 +home-assistant-frontend==20190618.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From ca70b96005371cbf819adad5a860650e4ac0a100 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 18 Jun 2019 11:54:56 -0700 Subject: [PATCH 262/319] Update translations --- .../components/adguard/.translations/lb.json | 2 ++ .../components/adguard/.translations/pl.json | 12 ++++++------ .../components/deconz/.translations/pl.json | 4 ++-- .../dialogflow/.translations/ca.json | 2 +- .../components/esphome/.translations/pl.json | 2 +- .../components/geofency/.translations/ca.json | 6 +++--- .../components/gpslogger/.translations/ca.json | 6 +++--- .../components/hue/.translations/pl.json | 2 +- .../components/ifttt/.translations/ca.json | 2 +- .../components/life360/.translations/pl.json | 10 +++++----- .../components/lifx/.translations/pl.json | 2 +- .../components/locative/.translations/ca.json | 6 +++--- .../components/mailgun/.translations/ca.json | 2 +- .../components/mqtt/.translations/pl.json | 2 +- .../components/plaato/.translations/ca.json | 18 ++++++++++++++++++ .../components/plaato/.translations/ru.json | 18 ++++++++++++++++++ .../plaato/.translations/zh-Hant.json | 18 ++++++++++++++++++ .../components/somfy/.translations/pl.json | 2 +- .../components/twilio/.translations/ca.json | 2 +- .../components/wemo/.translations/pl.json | 2 +- 20 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/plaato/.translations/ca.json create mode 100644 homeassistant/components/plaato/.translations/ru.json create mode 100644 homeassistant/components/plaato/.translations/zh-Hant.json diff --git a/homeassistant/components/adguard/.translations/lb.json b/homeassistant/components/adguard/.translations/lb.json index fd837994ebbd58..71a8488a93ac94 100644 --- a/homeassistant/components/adguard/.translations/lb.json +++ b/homeassistant/components/adguard/.translations/lb.json @@ -8,6 +8,7 @@ }, "step": { "hassio_confirm": { + "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mam AdGuard Home ze verbannen dee vum hass.io add-on {addon} bereet gestallt g\u00ebtt?", "title": "AdGuard Home via Hass.io add-on" }, "user": { @@ -19,6 +20,7 @@ "username": "Benotzernumm", "verify_ssl": "AdGuard Home benotzt een eegenen Zertifikat" }, + "description": "Konfigur\u00e9iert \u00e4r AdGuard Home Instanz fir d'Iwwerwaachung an d'Kontroll z'erlaben.", "title": "Verbannt \u00e4ren AdGuard Home" } }, diff --git a/homeassistant/components/adguard/.translations/pl.json b/homeassistant/components/adguard/.translations/pl.json index 44d7e6c93ed7af..8ba1c18f72262d 100644 --- a/homeassistant/components/adguard/.translations/pl.json +++ b/homeassistant/components/adguard/.translations/pl.json @@ -1,26 +1,26 @@ { "config": { "abort": { - "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja AdGuard Home." + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja AdGuard Home." }, "error": { - "connection_error": "Nieudane po\u0142\u0105czenie." + "connection_error": "Po\u0142\u0105czenie nieudane." }, "step": { "hassio_confirm": { - "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z AdGuard Home przez dodatek hass.io {addon}?", - "title": "AdGuard Home dzi\u0119ki dodatkowi Hass.io" + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z AdGuard Home przez dodatek Hass.io {addon}?", + "title": "AdGuard Home przez dodatek Hass.io" }, "user": { "data": { "host": "Host", "password": "Has\u0142o", "port": "Port", - "ssl": "AdGuard Home u\u017cywa certyfikatu SSL.", + "ssl": "AdGuard Home u\u017cywa certyfikatu SSL", "username": "Nazwa u\u017cytkownika", "verify_ssl": "AdGuard Home u\u017cywa odpowiedniego certyfikatu." }, - "description": "Skonfiguruj swoj\u0105 instancj\u0119 AdGuard Home aby umo\u017cliwi\u0107 monitorowanie i nadz\u00f3r sieci.", + "description": "Skonfiguruj swoj\u0105 instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i nadz\u00f3r sieci.", "title": "Po\u0142\u0105cz sw\u00f3j AdGuard Home" } }, diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index e9b5c21f31b806..a17835f79a3020 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -4,7 +4,7 @@ "already_configured": "Mostek jest ju\u017c skonfigurowany", "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", - "not_deconz_bridge": "Nie mostek deCONZ", + "not_deconz_bridge": "To nie jest mostek deCONZ", "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ", "updated_instance": "Zaktualizowano instancj\u0119 deCONZ o nowy adres hosta" }, @@ -17,7 +17,7 @@ "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w", "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ" }, - "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek hass.io {addon}?", + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek Hass.io {addon}?", "title": "Bramka deCONZ Zigbee przez dodatek Hass.io" }, "init": { diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json index 0967b1c158e7d4..f6dfc9399c2807 100644 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ b/homeassistant/components/dialogflow/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Dialogflow?", - "title": "Configuraci\u00f3 del Webhook Dialogflow" + "title": "Configuraci\u00f3 del Webhook de Dialogflow" } }, "title": "Dialogflow" diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index d2fceb93223f17..c8e6012ea94582 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -14,7 +14,7 @@ "data": { "password": "Has\u0142o" }, - "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {nazwa}.", + "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {name}.", "title": "Wprowad\u017a has\u0142o" }, "discovery_confirm": { diff --git a/homeassistant/components/geofency/.translations/ca.json b/homeassistant/components/geofency/.translations/ca.json index 125ca51399a2de..44377ce3021413 100644 --- a/homeassistant/components/geofency/.translations/ca.json +++ b/homeassistant/components/geofency/.translations/ca.json @@ -9,10 +9,10 @@ }, "step": { "user": { - "description": "Est\u00e0s segur que vols configurar el Webhook Geofency?", - "title": "Configuraci\u00f3 del Webhook Geofency" + "description": "Est\u00e0s segur que vols configurar el Webhook de Geofency?", + "title": "Configuraci\u00f3 del Webhook de Geofency" } }, - "title": "Webhook Geofency" + "title": "Webhook de Geofency" } } \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/ca.json b/homeassistant/components/gpslogger/.translations/ca.json index 2d3b08d236ee7c..296159f2e5ae0e 100644 --- a/homeassistant/components/gpslogger/.translations/ca.json +++ b/homeassistant/components/gpslogger/.translations/ca.json @@ -9,10 +9,10 @@ }, "step": { "user": { - "description": "Est\u00e0s segur que vols configurar el Webhook GPSLogger?", - "title": "Configuraci\u00f3 del Webhook GPSLogger" + "description": "Est\u00e0s segur que vols configurar el Webhook de GPSLogger?", + "title": "Configuraci\u00f3 del Webhook de GPSLogger" } }, - "title": "Webhook GPSLogger" + "title": "Webhook de GPSLogger" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json index 4c89dd151fbf96..9062e427a27c25 100644 --- a/homeassistant/components/hue/.translations/pl.json +++ b/homeassistant/components/hue/.translations/pl.json @@ -7,7 +7,7 @@ "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem", "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue", "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue", - "not_hue_bridge": "Nie mostek Hue", + "not_hue_bridge": "To nie jest mostek Hue", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, "error": { diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json index ff4cf67c23b26f..597328a2ee4003 100644 --- a/homeassistant/components/ifttt/.translations/ca.json +++ b/homeassistant/components/ifttt/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar IFTTT?", - "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook IFTTT" + "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook de IFTTT" } }, "title": "IFTTT" diff --git a/homeassistant/components/life360/.translations/pl.json b/homeassistant/components/life360/.translations/pl.json index b9136901e5634d..b1523da188ce88 100644 --- a/homeassistant/components/life360/.translations/pl.json +++ b/homeassistant/components/life360/.translations/pl.json @@ -2,15 +2,15 @@ "config": { "abort": { "invalid_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce", - "user_already_configured": "Konto ju\u017c zosta\u0142o skonfigurowane." + "user_already_configured": "Konto jest ju\u017c skonfigurowane." }, "create_entry": { - "default": "Aby ustawi\u0107 zaawansowane opcje, udaj si\u0119 do [Dokumentacja Life360] ( {docs_url} ). \n" + "default": "Aby skonfigurowa\u0107 zaawansowane ustawienia, zapoznaj si\u0119 z [dokumentacj\u0105 Life360]({docs_url})." }, "error": { "invalid_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce", - "invalid_username": "Nieprawid\u0142owa nazwa u\u017cytkownika ", - "user_already_configured": "Konto ju\u017c zosta\u0142o skonfigurowane." + "invalid_username": "Nieprawid\u0142owa nazwa u\u017cytkownika", + "user_already_configured": "Konto jest ju\u017c skonfigurowane." }, "step": { "user": { @@ -18,7 +18,7 @@ "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika" }, - "description": "Aby ustawi\u0107 zaawansowane opcje, udaj si\u0119 do [Dokumentacja Life360] ( {docs_url} ). \n Mo\u017cesz to zrobi\u0107 przed dodaniem kont.", + "description": "Aby skonfigurowa\u0107 zaawansowane ustawienia, zapoznaj si\u0119 z [dokumentacj\u0105 Life360]({docs_url}). Mo\u017cesz to zrobi\u0107 przed dodaniem kont.", "title": "Informacje o koncie Life360" } }, diff --git a/homeassistant/components/lifx/.translations/pl.json b/homeassistant/components/lifx/.translations/pl.json index f13c0b54bbdb84..d6a06ea9fac539 100644 --- a/homeassistant/components/lifx/.translations/pl.json +++ b/homeassistant/components/lifx/.translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 LIFX.", - "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja LIFX." + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja LIFX." }, "step": { "confirm": { diff --git a/homeassistant/components/locative/.translations/ca.json b/homeassistant/components/locative/.translations/ca.json index a08907a51ef922..ff3c150886decf 100644 --- a/homeassistant/components/locative/.translations/ca.json +++ b/homeassistant/components/locative/.translations/ca.json @@ -9,10 +9,10 @@ }, "step": { "user": { - "description": "Est\u00e0s segur que vols configurar el Webhook Locative?", - "title": "Configuraci\u00f3 del Webhook Locative" + "description": "Est\u00e0s segur que vols configurar el Webhook de Locative?", + "title": "Configuraci\u00f3 del Webhook de Locative" } }, - "title": "Webhook Locative" + "title": "Webhook de Locative" } } \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json index 8815360f7b4df6..f43467de7d9da7 100644 --- a/homeassistant/components/mailgun/.translations/ca.json +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Mailgun?", - "title": "Configuraci\u00f3 del Webhook Mailgun" + "title": "Configuraci\u00f3 del Webhook de Mailgun" } }, "title": "Mailgun" diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json index 33c33c5c09585b..24cdeb0f12e4d0 100644 --- a/homeassistant/components/mqtt/.translations/pl.json +++ b/homeassistant/components/mqtt/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja MQTT." + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja MQTT." }, "error": { "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z po\u015brednikiem." diff --git a/homeassistant/components/plaato/.translations/ca.json b/homeassistant/components/plaato/.translations/ca.json new file mode 100644 index 00000000000000..481450cbc5fce4 --- /dev/null +++ b/homeassistant/components/plaato/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Plaato Airlock.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de Plaato Airlock.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + }, + "step": { + "user": { + "description": "Est\u00e0s segur que vols configurar Plaato Airlock?", + "title": "Configuraci\u00f3 del Webhook de Plaato" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/ru.json b/homeassistant/components/plaato/.translations/ru.json new file mode 100644 index 00000000000000..59964fdedd63a4 --- /dev/null +++ b/homeassistant/components/plaato/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Plaato Airlock.", + "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f Plaato Airlock\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Plaato Airlock?", + "title": "Plaato Airlock" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/zh-Hant.json b/homeassistant/components/plaato/.translations/zh-Hant.json new file mode 100644 index 00000000000000..20cdb405f4ee10 --- /dev/null +++ b/homeassistant/components/plaato/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Plaato Airlock \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Plaato Airlock \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Plaato Airlock\uff1f", + "title": "\u8a2d\u5b9a Plaato Webhook" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/pl.json b/homeassistant/components/somfy/.translations/pl.json index ba62014ae44cef..cb19fcb793ae1b 100644 --- a/homeassistant/components/somfy/.translations/pl.json +++ b/homeassistant/components/somfy/.translations/pl.json @@ -3,7 +3,7 @@ "abort": { "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Somfy.", "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", - "missing_configuration": "Komponent Somfy nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105" + "missing_configuration": "Komponent Somfy nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105." }, "create_entry": { "default": "Pomy\u015blnie uwierzytelniono z Somfy" diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json index 796f71ee7e7d7f..324ab0dd69aa56 100644 --- a/homeassistant/components/twilio/.translations/ca.json +++ b/homeassistant/components/twilio/.translations/ca.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Est\u00e0s segur que vols configurar Twilio?", - "title": "Configuraci\u00f3 del Webhook Twilio" + "title": "Configuraci\u00f3 del Webhook de Twilio" } }, "title": "Twilio" diff --git a/homeassistant/components/wemo/.translations/pl.json b/homeassistant/components/wemo/.translations/pl.json index bde72d3eb64a59..a5315967ba4f98 100644 --- a/homeassistant/components/wemo/.translations/pl.json +++ b/homeassistant/components/wemo/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Nie znaleziono w Twojej sieci urz\u0105dze\u0144 Wemo.", + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Wemo.", "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Wemo." }, "step": { From f382be4c15ef32f557ba6d40bb01c084c5cbc692 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 18 Jun 2019 13:59:40 -0700 Subject: [PATCH 263/319] Cloud: Make sure on_connect forwards platform only once (#24582) * Make sure on_connect forwards platform only once * Make sure right handler --- homeassistant/components/cloud/__init__.py | 8 ++++++++ tests/components/cloud/test_init.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 01e2b48559b9e4..e874e4213bccc9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -191,8 +191,16 @@ async def _service_handler(service): hass.helpers.service.async_register_admin_service( DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler) + loaded_binary_sensor = False + async def _on_connect(): """Discover RemoteUI binary sensor.""" + nonlocal loaded_binary_sensor + + if loaded_binary_sensor: + return + + loaded_binary_sensor = True hass.async_create_task(hass.helpers.discovery.async_load_platform( 'binary_sensor', DOMAIN, {}, config)) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index ea611c29df1c0b..c938a404964d71 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -154,3 +154,25 @@ async def test_setup_setup_cloud_user(hass, hass_storage): assert cloud_user assert cloud_user.groups[0].id == GROUP_ID_ADMIN + + +async def test_on_connect(hass, mock_cloud_fixture): + """Test cloud on connect triggers.""" + cl = hass.data['cloud'] + + assert len(cl.iot._on_connect) == 4 + + assert len(hass.states.async_entity_ids('binary_sensor')) == 0 + + assert 'async_setup' in str(cl.iot._on_connect[-1]) + await cl.iot._on_connect[-1]() + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids('binary_sensor')) == 1 + + with patch('homeassistant.helpers.discovery.async_load_platform', + side_effect=mock_coro) as mock_load: + await cl.iot._on_connect[-1]() + await hass.async_block_till_done() + + assert len(mock_load.mock_calls) == 0 From a89c8eeabe6b9e847c33bce2ef3987b3caf387ad Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 18 Jun 2019 17:44:41 -0600 Subject: [PATCH 264/319] Add config entry for Met.no (#24608) * Add config entry for Met.no * Fixed tests --- .../components/met/.translations/en.json | 20 +++ homeassistant/components/met/__init__.py | 22 +++ homeassistant/components/met/config_flow.py | 63 +++++++++ homeassistant/components/met/const.py | 14 ++ homeassistant/components/met/manifest.json | 1 + homeassistant/components/met/strings.json | 20 +++ homeassistant/components/met/weather.py | 28 +++- homeassistant/generated/config_flows.py | 1 + tests/components/met/test_config_flow.py | 133 ++++++++++++++++++ 9 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/met/.translations/en.json create mode 100644 homeassistant/components/met/config_flow.py create mode 100644 homeassistant/components/met/const.py create mode 100644 homeassistant/components/met/strings.json create mode 100644 tests/components/met/test_config_flow.py diff --git a/homeassistant/components/met/.translations/en.json b/homeassistant/components/met/.translations/en.json new file mode 100644 index 00000000000000..21ae7cb78fa4f9 --- /dev/null +++ b/homeassistant/components/met/.translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "elevation": "Elevation", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "description": "Meteorologisk institutt", + "title": "Location" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 67bd64f3e1665a..aa284ad02e2308 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1 +1,23 @@ """The met component.""" +from homeassistant.core import Config, HomeAssistant +from .config_flow import MetFlowHandler # noqa +from .const import DOMAIN # noqa + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured Met.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Met as config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, 'weather')) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, 'weather') + return True diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py new file mode 100644 index 00000000000000..07123a918553ea --- /dev/null +++ b/homeassistant/components/met/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow to configure Met component.""" +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import ( + CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, HOME_LOCATION_NAME + + +@callback +def configured_instances(hass): + """Return a set of configured SimpliSafe instances.""" + return set( + entry.data[CONF_NAME] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class MetFlowHandler(data_entry_flow.FlowHandler): + """Config flow for Met component.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Init MetFlowHandler.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + if user_input[CONF_NAME] not in configured_instances(self.hass): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + self._errors[CONF_NAME] = 'name_exists' + + return await self._show_config_form( + name=HOME_LOCATION_NAME, + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + elevation=self.hass.config.elevation) + + async def _show_config_form(self, name=None, latitude=None, + longitude=None, elevation=None): + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=name): str, + vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, + vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, + vol.Required(CONF_ELEVATION, default=elevation): int + }), + errors=self._errors, + ) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py new file mode 100644 index 00000000000000..cf1f3aac53df11 --- /dev/null +++ b/homeassistant/components/met/const.py @@ -0,0 +1,14 @@ +"""Constants for Met component.""" +import logging + +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN + +DOMAIN = 'met' + +HOME_LOCATION_NAME = 'Home' + +ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".met_{}" +ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( + HOME_LOCATION_NAME) + +_LOGGER = logging.getLogger('.') diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index b2ef166be50194..426d0faf8608b7 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -1,6 +1,7 @@ { "domain": "met", "name": "Met", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/met", "requirements": [ "pyMetno==0.4.6" diff --git a/homeassistant/components/met/strings.json b/homeassistant/components/met/strings.json new file mode 100644 index 00000000000000..f5c49bac3c4420 --- /dev/null +++ b/homeassistant/components/met/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "Met.no", + "step": { + "user": { + "title": "Location", + "description": "Meteorologisk institutt", + "data": { + "name": "Name", + "latitude": "Latitude", + "longitude": "Longitude", + "elevation": "Elevation" + } + } + }, + "error": { + "name_exists": "Name already exists" + } + } +} diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index d9824e203c5462..20f408b91baaf1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -33,23 +33,43 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Met.no weather platform.""" - elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) + _LOGGER.warning("Loading Met.no via platform config is deprecated") + + name = config.get(CONF_NAME) latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config.get(CONF_NAME) + elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) if None in (latitude, longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return + station = await async_get_station( + hass, name, latitude, longitude, elevation) + async_add_entities([station]) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + name = config_entry.data.get(CONF_NAME) + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + elevation = config_entry.data[CONF_ELEVATION] + + station = await async_get_station( + hass, name, latitude, longitude, elevation) + async_add_entities([station]) + + +async def async_get_station(hass, name, latitude, longitude, elevation): + """Retrieve weather station, station name to be used as the entity name.""" coordinates = { 'lat': str(latitude), 'lon': str(longitude), 'msl': str(elevation), } - async_add_entities([MetWeather( - name, coordinates, async_get_clientsession(hass))]) + return MetWeather(name, coordinates, async_get_clientsession(hass)) class MetWeather(WeatherEntity): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4c7d77e0dabbf5..926023f4a75134 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -32,6 +32,7 @@ "logi_circle", "luftdaten", "mailgun", + "met", "mobile_app", "mqtt", "nest", diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py new file mode 100644 index 00000000000000..9e625eaa9f525b --- /dev/null +++ b/tests/components/met/test_config_flow.py @@ -0,0 +1,133 @@ +"""Tests for Met.no config flow.""" +from unittest.mock import Mock, patch + +from tests.common import MockConfigEntry, mock_coro + +from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.met import config_flow + + +async def test_show_config_form(): + """Test show configuration form.""" + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow._show_config_form() + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_show_config_form_default_values(): + """Test show configuration form.""" + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow._show_config_form( + name="test", latitude='0', longitude='0', elevation='0') + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_with_home_location(hass): + """Test config flow . + + Tests the flow when a default location is configured + then it should return a form with default values + """ + flow = config_flow.MetFlowHandler() + flow.hass = hass + + hass.config.location_name = 'Home' + hass.config.latitude = 1 + hass.config.longitude = 1 + hass.config.elevation = 1 + + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_show_form(): + """Test show form scenarios first time. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form: + await flow.async_step_user() + assert len(config_form.mock_calls) == 1 + + +async def test_flow_entry_created_from_user_input(): + """Test that create data from user input. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.MetFlowHandler() + flow.hass = hass + + test_data = { + 'name': 'home', + CONF_LONGITUDE: '0', + CONF_LATITUDE: '0', + CONF_ELEVATION: '0' + } + + # Test that entry created when user_input name not exists + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form,\ + patch.object(flow.hass.config_entries, 'async_entries', + return_value=mock_coro()) as config_entries: + + result = await flow.async_step_user(user_input=test_data) + + assert result['type'] == 'create_entry' + assert result['data'] == test_data + assert len(config_entries.mock_calls) == 1 + assert not config_form.mock_calls + + +async def test_flow_entry_config_entry_already_exists(): + """Test that create data from user input and config_entry already exists. + + Test when the form should show when user puts existing name + in the config gui. Then the form should show with error + """ + hass = Mock() + + flow = config_flow.MetFlowHandler() + flow.hass = hass + + first_entry = MockConfigEntry(domain='met') + first_entry.data['name'] = 'home' + first_entry.add_to_hass(hass) + + test_data = { + 'name': 'home', + CONF_LONGITUDE: '0', + CONF_LATITUDE: '0', + CONF_ELEVATION: '0' + } + + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form,\ + patch.object(flow.hass.config_entries, 'async_entries', + return_value=[first_entry]) as config_entries: + + await flow.async_step_user(user_input=test_data) + + assert len(config_form.mock_calls) == 1 + assert len(config_entries.mock_calls) == 1 + assert len(flow._errors) == 1 From 6d9f1b3fd3ab964ef6815117b1a23237290ead6c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 01:06:29 -0700 Subject: [PATCH 265/319] Notify Alexa when exposed entities change (#24609) --- homeassistant/components/alexa/entities.py | 14 ++ homeassistant/components/alexa/errors.py | 6 +- homeassistant/components/alexa/handlers.py | 12 +- homeassistant/components/alexa/messages.py | 2 +- homeassistant/components/alexa/smart_home.py | 35 ---- .../components/alexa/state_report.py | 92 +++++++++- homeassistant/components/cloud/__init__.py | 4 +- homeassistant/components/cloud/client.py | 165 +++++++++++++++++- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 22 +++ tests/components/alexa/test_state_report.py | 56 ++++++ tests/components/cloud/test_client.py | 95 +++++++++- 12 files changed, 436 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 30dfbbb88671a6..65deabadd17893 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -168,6 +168,20 @@ def serialize_properties(self): for prop in interface.serialize_properties(): yield prop + def serialize_discovery(self): + """Serialize the entity for discovery.""" + return { + 'displayCategories': self.display_categories(), + 'cookie': {}, + 'endpointId': self.alexa_id(), + 'friendlyName': self.friendly_name(), + 'description': self.description(), + 'manufacturerName': 'Home Assistant', + 'capabilities': [ + i.serialize_discovery() for i in self.interfaces() + ] + } + @callback def async_get_entities(hass, config) -> List[AlexaEntity]: diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 651ddc5b187d77..76ec92edf8dc86 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -12,8 +12,12 @@ class UnsupportedProperty(HomeAssistantError): """This entity does not support the requested Smart Home API property.""" +class NoTokenAvailable(HomeAssistantError): + """There is no access token available.""" + + class AlexaError(Exception): - """Base class for errors that can be serialized by the Alexa API. + """Base class for errors that can be serialized for the Alexa API. A handler can raise subclasses of this to return an error to the request. """ diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 98fb925946185a..89cf171c83c37d 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -54,17 +54,7 @@ async def async_api_discovery(hass, config, directive, context): Async friendly. """ discovery_endpoints = [ - { - 'displayCategories': alexa_entity.display_categories(), - 'cookie': {}, - 'endpointId': alexa_entity.alexa_id(), - 'friendlyName': alexa_entity.friendly_name(), - 'description': alexa_entity.description(), - 'manufacturerName': 'Home Assistant', - 'capabilities': [ - i.serialize_discovery() for i in alexa_entity.interfaces() - ] - } + alexa_entity.serialize_discovery() for alexa_entity in async_get_entities(hass, config) if config.should_expose(alexa_entity.entity_id) ] diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py index 3dd72c11294234..c1b0ac9c025b76 100644 --- a/homeassistant/components/alexa/messages.py +++ b/homeassistant/components/alexa/messages.py @@ -48,7 +48,7 @@ def load_entity(self, hass, config): self.entity_id = _endpoint_id.replace('#', '.') self.entity = hass.states.get(self.entity_id) - if not self.entity: + if not self.entity or not config.should_expose(self.entity_id): raise AlexaInvalidEndpointError(_endpoint_id) self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index f87e6bdee3592f..688828b20bd5ec 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -16,41 +16,6 @@ EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' -# def _capability(interface, -# version=3, -# supports_deactivation=None, -# retrievable=None, -# properties_supported=None, -# cap_type='AlexaInterface'): -# """Return a Smart Home API capability object. - -# https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object - -# There are some additional fields allowed but not implemented here since -# we've no use case for them yet: - -# - proactively_reported - -# `supports_deactivation` applies only to scenes. -# """ -# result = { -# 'type': cap_type, -# 'interface': interface, -# 'version': version, -# } - -# if supports_deactivation is not None: -# result['supportsDeactivation'] = supports_deactivation - -# if retrievable is not None: -# result['retrievable'] = retrievable - -# if properties_supported is not None: -# result['properties'] = {'supported': properties_supported} - -# return result - - async def async_handle_message( hass, config, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index cdb3a88ed2247b..4c11fb8c88c908 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -21,10 +21,6 @@ async def async_enable_proactive_mode(hass, smart_home_config): Proactive mode makes this component report state changes to Alexa. """ - if await smart_home_config.async_get_access_token() is None: - # not ready yet - return - async def async_entity_state_listener(changed_entity, old_state, new_state): if not new_state: @@ -54,11 +50,11 @@ async def async_entity_state_listener(changed_entity, old_state, async def async_send_changereport_message(hass, config, alexa_entity): - """Send a ChangeReport message for an Alexa entity.""" + """Send a ChangeReport message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events + """ token = await config.async_get_access_token() - if not token: - _LOGGER.error("Invalid access token.") - return headers = { "Authorization": "Bearer {}".format(token) @@ -83,9 +79,9 @@ async def async_send_changereport_message(hass, config, alexa_entity): message.set_endpoint_full(token, endpoint) message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() try: - session = hass.helpers.aiohttp_client.async_get_clientsession() with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post(config.endpoint, headers=headers, @@ -106,3 +102,81 @@ async def async_send_changereport_message(hass, config, alexa_entity): _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", response_json["payload"]["code"], response_json["payload"]["description"]) + + +async def async_send_add_or_update_message(hass, config, entity_ids): + """Send an AddOrUpdateReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report + """ + token = await config.async_get_access_token() + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split('.', 1)[0] + alexa_entity = ENTITY_ADAPTERS[domain]( + hass, config, hass.states.get(entity_id) + ) + endpoints.append(alexa_entity.serialize_discovery()) + + payload = { + 'endpoints': endpoints, + 'scope': { + 'type': 'BearerToken', + 'token': token, + } + } + + message = AlexaResponse( + name='AddOrUpdateReport', namespace='Alexa.Discovery', payload=payload) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post(config.endpoint, headers=headers, + json=message_serialized, allow_redirects=True) + + +async def async_send_delete_message(hass, config, entity_ids): + """Send an DeleteReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event + """ + token = await config.async_get_access_token() + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split('.', 1)[0] + alexa_entity = ENTITY_ADAPTERS[domain]( + hass, config, hass.states.get(entity_id) + ) + endpoints.append({ + 'endpointId': alexa_entity.alexa_id() + }) + + payload = { + 'endpoints': endpoints, + 'scope': { + 'type': 'BearerToken', + 'token': token, + } + } + + message = AlexaResponse(name='DeleteReport', namespace='Alexa.Discovery', + payload=payload) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post(config.endpoint, headers=headers, + json=message_serialized, allow_redirects=True) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e874e4213bccc9..bb539a270acd3a 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -21,7 +21,8 @@ CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL, CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL, - CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) + CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD, CONF_ALEXA_ACCESS_TOKEN_URL +) from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -72,6 +73,7 @@ vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(), vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, + vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): str, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index e3c952898bd1b2..92473974a9f5f4 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -6,19 +6,25 @@ import logging import aiohttp +import async_timeout from hass_nabucasa import cloud_api from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback from homeassistant.components.alexa import ( config as alexa_config, + errors as alexa_errors, smart_home as alexa_sh, + entities as alexa_entities, + state_report as alexa_state_report, ) from homeassistant.components.google_assistant import ( helpers as ga_h, smart_home as ga) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry from homeassistant.util.aiohttp import MockRequest from homeassistant.util.dt import utcnow @@ -31,6 +37,9 @@ _LOGGER = logging.getLogger(__name__) +# Time to wait when entity preferences have changed before syncing it to +# the cloud. +SYNC_DELAY = 1 class AlexaConfig(alexa_config.AbstractConfig): @@ -44,7 +53,20 @@ def __init__(self, hass, config, prefs, cloud): self._cloud = cloud self._token = None self._token_valid = None - prefs.async_listen_updates(self.async_prefs_updated) + self._cur_entity_prefs = prefs.alexa_entity_configs + self._alexa_sync_unsub = None + self._endpoint = None + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated + ) + + @property + def enabled(self): + """Return if Alexa is enabled.""" + return self._prefs.alexa_enabled @property def supports_auth(self): @@ -59,7 +81,10 @@ def should_report_state(self): @property def endpoint(self): """Endpoint for report state.""" - return None + if self._endpoint is None: + raise ValueError("No endpoint available. Fetch access token first") + + return self._endpoint @property def entity_config(self): @@ -91,21 +116,143 @@ async def async_get_access_token(self): if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): raise RequireRelink - return None + return alexa_errors.NoTokenAvailable self._token = body['access_token'] + self._endpoint = body['event_endpoint'] self._token_valid = utcnow() + timedelta(seconds=body['expires_in']) return self._token - async def async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs): """Handle updated preferences.""" - if self.should_report_state == self.is_reporting_states: + if self.should_report_state != self.is_reporting_states: + if self.should_report_state: + await self.async_enable_proactive_mode() + else: + await self.async_disable_proactive_mode() + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + if (self._cur_entity_prefs is prefs.alexa_entity_configs or + not self._config[CONF_FILTER].empty_filter): return - if self.should_report_state: - await self.async_enable_proactive_mode() - else: - await self.async_disable_proactive_mode() + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs) + + async def _sync_prefs(self, _now): + """Sync the updated preferences to Alexa.""" + self._alexa_sync_unsub = None + old_prefs = self._cur_entity_prefs + new_prefs = self._prefs.alexa_entity_configs + + seen = set() + to_update = [] + to_remove = [] + + for entity_id, info in old_prefs.items(): + seen.add(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) + + if entity_id in new_prefs: + new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE) + else: + new_expose = None + + if old_expose == new_expose: + continue + + if new_expose: + to_update.append(entity_id) + else: + to_remove.append(entity_id) + + # Now all the ones that are in new prefs but never were in old prefs + for entity_id, info in new_prefs.items(): + if entity_id in seen: + continue + + new_expose = info.get(PREF_SHOULD_EXPOSE) + + if new_expose is None: + continue + + # Only test if we should expose. It can never be a remove action, + # as it didn't exist in old prefs object. + if new_expose: + to_update.append(entity_id) + + # We only set the prefs when update is successful, that way we will + # retry when next change comes in. + if await self._sync_helper(to_update, to_remove): + self._cur_entity_prefs = new_prefs + + async def async_sync_entities(self): + """Sync all entities to Alexa.""" + to_update = [] + to_remove = [] + + for entity in alexa_entities.async_get_entities(self.hass, self): + if self.should_expose(entity.entity_id): + to_update.append(entity.entity_id) + else: + to_remove.append(entity.entity_id) + + return await self._sync_helper(to_update, to_remove) + + async def _sync_helper(self, to_update, to_remove) -> bool: + """Sync entities to Alexa. + + Return boolean if it was successful. + """ + if not to_update and not to_remove: + return True + + tasks = [] + + if to_update: + tasks.append(alexa_state_report.async_send_add_or_update_message( + self.hass, self, to_update + )) + + if to_remove: + tasks.append(alexa_state_report.async_send_delete_message( + self.hass, self, to_remove + )) + + try: + with async_timeout.timeout(10): + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + + return True + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout trying to sync entitites to Alexa") + return False + + except aiohttp.ClientError as err: + _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) + return False + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled: + return + + action = event.data['action'] + entity_id = event.data['entity_id'] + to_update = [] + to_remove = [] + + if action == 'create' and self.should_expose(entity_id): + to_update.append(entity_id) + elif action == 'remove' and self.should_expose(entity_id): + to_remove.append(entity_id) + + await self._sync_helper(to_update, to_remove) class CloudClient(Interface): diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 34324aca131f1a..fdb36723fdbb19 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -32,6 +32,7 @@ CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' CONF_REMOTE_API_URL = 'remote_api_url' CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server' +CONF_ALEXA_ACCESS_TOKEN_URL = 'alexa_access_token_url' MODE_DEV = "development" MODE_PROD = "production" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6eaa717f41c48e..d9c4ddcf1ce931 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -13,6 +13,7 @@ from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api +from homeassistant.components.websocket_api import const as ws_const from homeassistant.components.alexa import entities as alexa_entities from homeassistant.components.google_assistant import helpers as google_helpers @@ -92,6 +93,7 @@ async def async_setup(hass): hass.components.websocket_api.async_register_command(alexa_list) hass.components.websocket_api.async_register_command(alexa_update) + hass.components.websocket_api.async_register_command(alexa_sync) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) @@ -560,3 +562,23 @@ async def alexa_update(hass, connection, msg): connection.send_result( msg['id'], cloud.client.prefs.alexa_entity_configs.get(msg['entity_id'])) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command({ + 'type': 'cloud/alexa/sync', +}) +async def alexa_sync(hass, connection, msg): + """Sync with Alexa.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(10): + success = await cloud.client.alexa_config.async_sync_entities() + + if success: + connection.send_result(msg['id']) + else: + connection.send_error( + msg['id'], ws_const.ERR_UNKNOWN_ERROR, 'Unknown error') diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index c5f95a9621825c..f954aa825bc575 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -34,7 +34,63 @@ async def test_report_state(hass, aioclient_mock): call = aioclient_mock.mock_calls call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa" + assert call_json["event"]["header"]["name"] == "ChangeReport" assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \ == "NOT_DETECTED" assert call_json["event"]["endpoint"]["endpointId"] \ == "binary_sensor#test_contact" + + +async def test_send_add_or_update_message(hass, aioclient_mock): + """Test sending an AddOrUpdateReport message.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_send_add_or_update_message( + hass, DEFAULT_CONFIG, ['binary_sensor.test_contact']) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery" + assert call_json["event"]["header"]["name"] == "AddOrUpdateReport" + assert len(call_json["event"]["payload"]["endpoints"]) == 1 + assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \ + == "binary_sensor#test_contact" + + +async def test_send_delete_message(hass, aioclient_mock): + """Test sending an AddOrUpdateReport message.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_send_delete_message( + hass, DEFAULT_CONFIG, ['binary_sensor.test_contact']) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery" + assert call_json["event"]["header"]["name"] == "DeleteReport" + assert len(call_json["event"]["payload"]["endpoints"]) == 1 + assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \ + == "binary_sensor#test_contact" diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 723e86f2f2def4..7f4bf97086a179 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -1,4 +1,5 @@ """Test the cloud.iot module.""" +import contextlib from unittest.mock import patch, MagicMock from aiohttp import web @@ -11,8 +12,10 @@ DOMAIN, ALEXA_SCHEMA, client) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) +from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro +from tests.common import mock_coro, async_fire_time_changed from . import mock_cloud_prefs @@ -292,3 +295,93 @@ async def test_alexa_config_report_state(hass, cloud_prefs): assert cloud_prefs.alexa_report_state is False assert conf.should_report_state is False assert conf.is_reporting_states is False + + +@contextlib.contextmanager +def patch_sync_helper(): + """Patch sync helper. + + In Py3.7 this would have been an async context manager. + """ + to_update = [] + to_remove = [] + + with patch( + 'homeassistant.components.cloud.client.SYNC_DELAY', 0 + ), patch( + 'homeassistant.components.cloud.client.AlexaConfig._sync_helper', + side_effect=mock_coro + ) as mock_helper: + yield to_update, to_remove + + actual_to_update, actual_to_remove = mock_helper.mock_calls[0][1] + to_update.extend(actual_to_update) + to_remove.extend(actual_to_remove) + + +async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs): + """Test Alexa config responds to updating exposed entities.""" + client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update_alexa_entity_config( + entity_id='light.kitchen', should_expose=True + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert to_update == ['light.kitchen'] + assert to_remove == [] + + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update_alexa_entity_config( + entity_id='light.kitchen', should_expose=False + ) + await cloud_prefs.async_update_alexa_entity_config( + entity_id='binary_sensor.door', should_expose=True + ) + await cloud_prefs.async_update_alexa_entity_config( + entity_id='sensor.temp', should_expose=True + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert sorted(to_update) == ['binary_sensor.door', 'sensor.temp'] + assert to_remove == ['light.kitchen'] + + +async def test_alexa_entity_registry_sync(hass, cloud_prefs): + """Test Alexa config responds to entity registry.""" + client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'create', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == ['light.kitchen'] + assert to_remove == [] + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'remove', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == [] + assert to_remove == ['light.kitchen'] + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'update', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == [] + assert to_remove == [] From 08e295974267072ac7fd4e077948bb7c98126d6a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 19 Jun 2019 10:09:50 +0200 Subject: [PATCH 266/319] Update pysonos to 0.0.16 (#24607) --- homeassistant/components/sonos/manifest.json | 2 +- .../components/sonos/media_player.py | 39 ++++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 3c584f22070c4e..0aee135652dffe 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ - "pysonos==0.0.15" + "pysonos==0.0.16" ], "dependencies": [], "ssdp": { diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 056eb569538af2..6a4016c11f0965 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -34,8 +34,7 @@ _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 0 - +SCAN_INTERVAL = 10 DISCOVERY_INTERVAL = 60 # Quiet down pysonos logging to just actual problems. @@ -241,7 +240,7 @@ def __init__(self, player): """Initialize the Sonos entity.""" self._seen = None self._subscriptions = [] - self._receives_events = False + self._poll_timer = None self._volume_increment = 2 self._unique_id = player.uid self._player = player @@ -347,6 +346,10 @@ def check_unseen(self): if self._seen < time.monotonic() - 2*DISCOVERY_INTERVAL: self._available = False + if self._poll_timer: + self._poll_timer() + self._poll_timer = None + def _unsub(subscriptions): for subscription in subscriptions: subscription.unsubscribe() @@ -393,7 +396,8 @@ def _radio_artwork(self, url): def _subscribe_to_player_events(self): """Add event subscriptions.""" - self._receives_events = False + self._poll_timer = self.hass.helpers.event.track_time_interval( + self.update, datetime.timedelta(seconds=SCAN_INTERVAL)) # New player available, build the current group topology for entity in self.hass.data[DATA_SONOS].entities: @@ -412,16 +416,20 @@ def subscribe(service, action): subscribe(player.zoneGroupTopology, self.update_groups) subscribe(player.contentDirectory, self.update_content) - def update(self): + @property + def should_poll(self): + """Return that we should not be polled (we handle that internally).""" + return False + + def update(self, now=None): """Retrieve latest state.""" - if self._available and not self._receives_events: - try: - self.update_groups() - self.update_volume() - if self.is_coordinator: - self.update_media() - except SoCoException: - pass + try: + self.update_groups() + self.update_volume() + if self.is_coordinator: + self.update_media() + except SoCoException: + pass def update_media(self, event=None): """Update information about currently playing media.""" @@ -653,7 +661,10 @@ async def _async_handle_group_event(event): self.hass.data[DATA_SONOS].topology_condition.notify_all() if event: - self._receives_events = True + # Cancel poll timer since we do receive events + if self._poll_timer: + self._poll_timer() + self._poll_timer = None if not hasattr(event, 'zone_player_uui_ds_in_group'): return diff --git a/requirements_all.txt b/requirements_all.txt index 750f9626acf228..47340818cc3749 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1351,7 +1351,7 @@ pysmarty==0.8 pysnmp==4.4.9 # homeassistant.components.sonos -pysonos==0.0.15 +pysonos==0.0.16 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b1b90aa627e7d..5fbd5512454efc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -286,7 +286,7 @@ pysmartapp==0.3.2 pysmartthings==0.6.8 # homeassistant.components.sonos -pysonos==0.0.15 +pysonos==0.0.16 # homeassistant.components.spc pyspcwebgw==0.4.0 From 9413b5a41581cc1dde356013390c4aecebef7c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 19 Jun 2019 10:10:38 +0200 Subject: [PATCH 267/319] check for None state in broadlink (#24589) --- homeassistant/components/broadlink/switch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 96a45322114152..5c67e9dbc2867a 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -322,6 +322,8 @@ def __init__(self, device): def get_outlet_status(self, slot): """Get status of outlet from cached status list.""" + if self._states is None: + return None return self._states['s{}'.format(slot)] @Throttle(TIME_BETWEEN_UPDATES) From 03bb3d9ddc9d79eef81cdeab5153c13ace1bd971 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Wed, 19 Jun 2019 21:59:29 +1000 Subject: [PATCH 268/319] Queensland bushfire alert feed platform (#24473) * initial version of qfes bushfire geolocation platform * removed all occurrences of legally protected names; using new georss library * regenerated codeowners * fixed pylint * added one more valid category * moved library import to top and ran isort --- CODEOWNERS | 1 + .../components/qld_bushfire/__init__.py | 1 + .../components/qld_bushfire/geo_location.py | 228 ++++++++++++++++++ .../components/qld_bushfire/manifest.json | 12 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/qld_bushfire/__init__.py | 1 + .../qld_bushfire/test_geo_location.py | 189 +++++++++++++++ 9 files changed, 439 insertions(+) create mode 100644 homeassistant/components/qld_bushfire/__init__.py create mode 100644 homeassistant/components/qld_bushfire/geo_location.py create mode 100644 homeassistant/components/qld_bushfire/manifest.json create mode 100644 tests/components/qld_bushfire/__init__.py create mode 100644 tests/components/qld_bushfire/test_geo_location.py diff --git a/CODEOWNERS b/CODEOWNERS index d8b6cc62b8a971..6555e58c88a011 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -199,6 +199,7 @@ homeassistant/components/ps4/* @ktnrg45 homeassistant/components/ptvsd/* @swamp-ig homeassistant/components/push/* @dgomes homeassistant/components/pvoutput/* @fabaff +homeassistant/components/qld_bushfire/* @exxamalte homeassistant/components/qnap/* @colinodell homeassistant/components/quantum_gateway/* @cisasteelersfan homeassistant/components/qwikswitch/* @kellerza diff --git a/homeassistant/components/qld_bushfire/__init__.py b/homeassistant/components/qld_bushfire/__init__.py new file mode 100644 index 00000000000000..893ae2ce7999d4 --- /dev/null +++ b/homeassistant/components/qld_bushfire/__init__.py @@ -0,0 +1 @@ +"""The qld_bushfire component.""" diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py new file mode 100644 index 00000000000000..eff0f11019af2b --- /dev/null +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -0,0 +1,228 @@ +"""Support for Queensland Bushfire Alert Feeds.""" +from datetime import timedelta +import logging +from typing import Optional + +from georss_qld_bushfire_alert_client import QldBushfireAlertFeedManager +import voluptuous as vol + +from homeassistant.components.geo_location import ( + PLATFORM_SCHEMA, GeolocationEvent) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, + CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +ATTR_CATEGORY = 'category' +ATTR_EXTERNAL_ID = 'external_id' +ATTR_PUBLICATION_DATE = 'publication_date' +ATTR_STATUS = 'status' +ATTR_UPDATED_DATE = 'updated_date' + +CONF_CATEGORIES = 'categories' + +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'km' + +SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = 'qld_bushfire_delete_{}' +SIGNAL_UPDATE_ENTITY = 'qld_bushfire_update_{}' + +SOURCE = 'qld_bushfire' + +VALID_CATEGORIES = [ + 'Emergency Warning', + 'Watch and Act', + 'Advice', + 'Notification', + 'Information', +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + vol.Optional(CONF_CATEGORIES, default=[]): + vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Queensland Bushfire Alert Feed platform.""" + scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) + radius_in_km = config[CONF_RADIUS] + categories = config[CONF_CATEGORIES] + # Initialize the entity manager. + feed = QldBushfireFeedEntityManager( + hass, add_entities, scan_interval, coordinates, radius_in_km, + categories) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + + +class QldBushfireFeedEntityManager: + """Feed Entity Manager for Qld Bushfire Alert GeoRSS feed.""" + + def __init__(self, hass, add_entities, scan_interval, coordinates, + radius_in_km, categories): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._feed_manager = QldBushfireAlertFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, filter_radius=radius_in_km, + filter_categories=categories) + self._add_entities = add_entities + self._scan_interval = scan_interval + + def startup(self): + """Start up this manager.""" + self._feed_manager.update() + self._init_regular_updates() + + def _init_regular_updates(self): + """Schedule regular updates at the specified interval.""" + track_time_interval( + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = QldBushfireLocationEvent(self, external_id) + # Add new entities to HA. + self._add_entities([new_entity], True) + + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + + +class QldBushfireLocationEvent(GeolocationEvent): + """This represents an external event with Qld Bushfire feed data.""" + + def __init__(self, feed_manager, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._name = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._category = None + self._publication_date = None + self._updated_date = None + self._status = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback) + self._remove_signal_update = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback) + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self._remove_signal_update() + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for Qld Bushfire Alert feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._name = feed_entry.title + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._category = feed_entry.category + self._publication_date = feed_entry.published + self._updated_date = feed_entry.updated + self._status = feed_entry.status + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._name + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_CATEGORY, self._category), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_PUBLICATION_DATE, self._publication_date), + (ATTR_UPDATED_DATE, self._updated_date), + (ATTR_STATUS, self._status) + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json new file mode 100644 index 00000000000000..47a4a4b5f85ce1 --- /dev/null +++ b/homeassistant/components/qld_bushfire/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "qld_bushfire", + "name": "Queensland Bushfire Alert", + "documentation": "https://www.home-assistant.io/components/qld_bushfire", + "requirements": [ + "georss_qld_bushfire_alert_client==0.3" + ], + "dependencies": [], + "codeowners": [ + "@exxamalte" + ] +} \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 47340818cc3749..c2d28d4f397301 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,6 +510,9 @@ georss_generic_client==0.2 # homeassistant.components.ign_sismologia georss_ign_sismologia_client==0.2 +# homeassistant.components.qld_bushfire +georss_qld_bushfire_alert_client==0.3 + # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fbd5512454efc..6b003dea37e205 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -135,6 +135,9 @@ georss_generic_client==0.2 # homeassistant.components.ign_sismologia georss_ign_sismologia_client==0.2 +# homeassistant.components.qld_bushfire +georss_qld_bushfire_alert_client==0.3 + # homeassistant.components.google google-api-python-client==1.6.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4b3e2de3e42cf9..e178c29b51c013 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -70,6 +70,7 @@ 'geopy', 'georss_generic_client', 'georss_ign_sismologia_client', + 'georss_qld_bushfire_alert_client', 'google-api-python-client', 'gTTS-token', 'ha-ffmpeg', diff --git a/tests/components/qld_bushfire/__init__.py b/tests/components/qld_bushfire/__init__.py new file mode 100644 index 00000000000000..83b096c758f34c --- /dev/null +++ b/tests/components/qld_bushfire/__init__.py @@ -0,0 +1 @@ +"""Tests for the qld_bushfire component.""" diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py new file mode 100644 index 00000000000000..43f2ac22ef6eee --- /dev/null +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -0,0 +1,189 @@ +"""The tests for the Queensland Bushfire Alert Feed platform.""" +import datetime +from unittest.mock import patch, MagicMock, call + +from homeassistant.components import geo_location +from homeassistant.components.geo_location import ATTR_SOURCE +from homeassistant.components.qld_bushfire.geo_location import ( + ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, + ATTR_STATUS, ATTR_PUBLICATION_DATE, ATTR_UPDATED_DATE) +from homeassistant.const import EVENT_HOMEASSISTANT_START, \ + CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ + ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed +import homeassistant.util.dt as dt_util + +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'qld_bushfire', + CONF_RADIUS: 200 + } + ] +} + +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'qld_bushfire', + CONF_RADIUS: 200, + CONF_LATITUDE: 40.4, + CONF_LONGITUDE: -3.7 + } + ] +} + + +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates, category=None, attribution=None, + published=None, updated=None, status=None): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + feed_entry.category = category + feed_entry.attribution = attribution + feed_entry.published = published + feed_entry.updated = updated + feed_entry.status = status + return feed_entry + + +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (38.0, -3.0), + category='Category 1', + attribution='Attribution 1', + published=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + updated=datetime.datetime(2018, 9, 22, 8, 10, + tzinfo=datetime.timezone.utc), + status='Status 1') + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (38.1, -3.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (38.2, -3.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (38.3, -3.3)) + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('georss_qld_bushfire_alert_client.' + 'QldBushfireAlertFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, + mock_entry_2, + mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", + ATTR_LATITUDE: 38.0, + ATTR_LONGITUDE: -3.0, + ATTR_FRIENDLY_NAME: "Title 1", + ATTR_CATEGORY: "Category 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_PUBLICATION_DATE: + datetime.datetime( + 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + ATTR_UPDATED_DATE: + datetime.datetime( + 2018, 9, 22, 8, 10, tzinfo=datetime.timezone.utc), + ATTR_STATUS: 'Status 1', + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'qld_bushfire'} + assert float(state.state) == 15.5 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", + ATTR_LATITUDE: 38.1, + ATTR_LONGITUDE: -3.1, + ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'qld_bushfire'} + assert float(state.state) == 20.5 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", + ATTR_LATITUDE: 38.2, + ATTR_LONGITUDE: -3.2, + ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'qld_bushfire'} + assert float(state.state) == 25.5 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (38.1, -3.1), category="Category 1") + + with patch('georss_qld_bushfire_alert_client.' + 'QldBushfireAlertFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (40.4, -3.7), filter_categories=[], filter_radius=200.0) From d63c44f7786f589c8e6bcc334041fedea93f6125 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 19 Jun 2019 22:25:15 +0800 Subject: [PATCH 269/319] Fixed issue #24335 (#24612) --- homeassistant/components/sun/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index edb2549164bb80..eecd2a1df55aac 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -170,6 +170,7 @@ def update_events(self, utc_point_in_time): utc_point_in_time, 'dusk', PHASE_ASTRONOMICAL_TWILIGHT) self.next_midnight = self._check_event( utc_point_in_time, 'solar_midnight', None) + self.location.solar_depression = 'civil' # if the event was solar midday or midnight, phase will now # be None. Solar noon doesn't always happen when the sun is From c1d441b0acb2078aa829d53bcc2315f37f4ed64f Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 19 Jun 2019 17:03:18 +0100 Subject: [PATCH 270/319] Add incomfort sensor and binary_sensor (#23812) * Initial commit - add sensors to incomfort * improve temp heuristics * remove self._hass * device_state_attributes shoudln't be None * bump client * refactor to reduce duplication of attributes * refactor binary_sensor to simplify * refactor binary_sensor to simplify 2 * delint * fix rebase regression * small refactor * delint * remove DEVICE_CLASS for CV pressure * tidy up exception handling * delint * fix exception handling * use differnt icon for boiler temp --- .../components/incomfort/__init__.py | 20 ++-- .../components/incomfort/binary_sensor.py | 52 +++++++++ homeassistant/components/incomfort/sensor.py | 110 ++++++++++++++++++ .../components/incomfort/water_heater.py | 23 +++- homeassistant/const.py | 1 + 5 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/incomfort/binary_sensor.py create mode 100644 homeassistant/components/incomfort/sensor.py diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 8aaa8e7e19db32..024875e38c1302 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -1,6 +1,7 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" import logging +from aiohttp import ClientResponseError import voluptuous as vol from incomfortclient import Gateway as InComfortGateway @@ -30,21 +31,20 @@ async def async_setup(hass, hass_config): credentials = dict(hass_config[DOMAIN]) hostname = credentials.pop(CONF_HOST) - try: - client = incomfort_data['client'] = InComfortGateway( - hostname, **credentials, session=async_get_clientsession(hass) - ) + client = incomfort_data['client'] = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) + ) + try: heater = incomfort_data['heater'] = list(await client.heaters)[0] - await heater.update() - - except AssertionError: # assert response.status == HTTP_OK + except ClientResponseError as err: _LOGGER.warning( - "Setup failed, check your configuration.", - exc_info=True) + "Setup failed, check your configuration, message is: %s", err) return False - for platform in ['water_heater', 'climate']: + await heater.update() + + for platform in ['water_heater', 'binary_sensor', 'sensor', 'climate']: hass.async_create_task(async_load_platform( hass, platform, DOMAIN, {}, hass_config)) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py new file mode 100644 index 00000000000000..87ca5d5385ffdf --- /dev/null +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -0,0 +1,52 @@ +"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up an InComfort/InTouch binary_sensor device.""" + async_add_entities([ + IncomfortFailed(hass.data[DOMAIN]['client'], + hass.data[DOMAIN]['heater']) + ]) + + +class IncomfortFailed(BinarySensorDevice): + """Representation of an InComfort Failed sensor.""" + + def __init__(self, client, boiler): + """Initialize the binary sensor.""" + self._client = client + self._boiler = boiler + + async def async_added_to_hass(self): + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + + @callback + def _refresh(self): + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def name(self): + """Return the name of the sensor.""" + return 'Fault state' + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._boiler.status['is_failed'] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {'fault_code': self._boiler.status['fault_code']} + + @property + def should_poll(self) -> bool: + """Return False as this device should never be polled.""" + return False diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py new file mode 100644 index 00000000000000..1d4ddff37b9d44 --- /dev/null +++ b/homeassistant/components/incomfort/sensor.py @@ -0,0 +1,110 @@ +"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" +from homeassistant.const import ( + PRESSURE_BAR, TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import DOMAIN + +INTOUCH_HEATER_TEMP = 'CV Temp' +INTOUCH_PRESSURE = 'CV Pressure' +INTOUCH_TAP_TEMP = 'Tap Temp' + +INTOUCH_MAP_ATTRS = { + INTOUCH_HEATER_TEMP: ['heater_temp', 'is_pumping'], + INTOUCH_TAP_TEMP: ['tap_temp', 'is_tapping'], +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up an InComfort/InTouch sensor device.""" + client = hass.data[DOMAIN]['client'] + heater = hass.data[DOMAIN]['heater'] + + async_add_entities([ + IncomfortPressure(client, heater, INTOUCH_PRESSURE), + IncomfortTemperature(client, heater, INTOUCH_HEATER_TEMP), + IncomfortTemperature(client, heater, INTOUCH_TAP_TEMP) + ]) + + +class IncomfortSensor(Entity): + """Representation of an InComfort/InTouch sensor device.""" + + def __init__(self, client, boiler): + """Initialize the sensor.""" + self._client = client + self._boiler = boiler + + self._name = None + self._device_class = None + self._unit_of_measurement = None + + async def async_added_to_hass(self): + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + + @callback + def _refresh(self): + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the sensor.""" + return self._unit_of_measurement + + @property + def should_poll(self) -> bool: + """Return False as this device should never be polled.""" + return False + + +class IncomfortPressure(IncomfortSensor): + """Representation of an InTouch CV Pressure sensor.""" + + def __init__(self, client, boiler, name): + """Initialize the sensor.""" + super().__init__(client, boiler) + + self._name = name + self._unit_of_measurement = PRESSURE_BAR + + @property + def state(self): + """Return the state/value of the sensor.""" + return self._boiler.status['pressure'] + + +class IncomfortTemperature(IncomfortSensor): + """Representation of an InTouch Temperature sensor.""" + + def __init__(self, client, boiler, name): + """Initialize the signal strength sensor.""" + super().__init__(client, boiler) + + self._name = name + self._device_class = DEVICE_CLASS_TEMPERATURE + self._unit_of_measurement = TEMP_CELSIUS + + @property + def state(self): + """Return the state of the sensor.""" + return self._boiler.status[INTOUCH_MAP_ATTRS[self._name][0]] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + key = INTOUCH_MAP_ATTRS[self._name][1] + return {key: self._boiler.status[key]} diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 9223902f5a3a87..535d55df193222 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -2,8 +2,10 @@ import asyncio import logging +from aiohttp import ClientResponseError from homeassistant.components.water_heater import WaterHeaterDevice from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.dispatcher import async_dispatcher_send from . import DOMAIN @@ -16,8 +18,8 @@ HEATER_NAME = 'Boiler' HEATER_ATTRS = [ - 'display_code', 'display_text', 'fault_code', 'is_burning', 'is_failed', - 'is_pumping', 'is_tapping', 'heater_temp', 'tap_temp', 'pressure'] + 'display_code', 'display_text', 'is_burning', + 'rf_message_rssi', 'nodenr', 'rfstatus_cntr'] async def async_setup_platform(hass, hass_config, async_add_entities, @@ -43,6 +45,11 @@ def name(self): """Return the name of the water_heater device.""" return HEATER_NAME + @property + def icon(self): + """Return the icon of the water_heater device.""" + return "mdi:oil-temperature" + @property def device_state_attributes(self): """Return the device state attributes.""" @@ -55,7 +62,9 @@ def current_temperature(self): """Return the current temperature.""" if self._heater.is_tapping: return self._heater.tap_temp - return self._heater.heater_temp + if self._heater.is_pumping: + return self._heater.heater_temp + return max(self._heater.heater_temp, self._heater.tap_temp) @property def min_temp(self): @@ -81,7 +90,7 @@ def supported_features(self): def current_operation(self): """Return the current operation mode.""" if self._heater.is_failed: - return "Failed ({})".format(self._heater.fault_code) + return "Fault code: {}".format(self._heater.fault_code) return self._heater.display_text @@ -90,5 +99,7 @@ async def async_update(self): try: await self._heater.update() - except (AssertionError, asyncio.TimeoutError) as err: - _LOGGER.warning("Update failed, message: %s", err) + except (ClientResponseError, asyncio.TimeoutError) as err: + _LOGGER.warning("Update failed, message is: %s", err) + + async_dispatcher_send(self.hass, DOMAIN) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4b78ab9618ba77..972e9e25f72a99 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -346,6 +346,7 @@ # Pressure units PRESSURE_PA = 'Pa' # type: str PRESSURE_HPA = 'hPa' # type: str +PRESSURE_BAR = 'bar' # type: str PRESSURE_MBAR = 'mbar' # type: str PRESSURE_INHG = 'inHg' # type: str PRESSURE_PSI = 'psi' # type: str From 21c96fa76c06adb51f0df8e825aa6a936b755d45 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 19 Jun 2019 18:23:05 +0200 Subject: [PATCH 271/319] Add support for opencv wheels (#24620) --- .gitignore | 1 + homeassistant/components/opencv/manifest.json | 3 ++- requirements_all.txt | 3 +++ script/gen_requirements_all.py | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7a0cb29bc2b26c..397a584c28eab9 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ virtualization/vagrant/config # Visual Studio Code .vscode +.devcontainer # Built docs docs/build diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index dfc493f1c96f49..9892e51ba0fb7e 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -3,7 +3,8 @@ "name": "Opencv", "documentation": "https://www.home-assistant.io/components/opencv", "requirements": [ - "numpy==1.16.3" + "numpy==1.16.3", + "opencv-python-headless==4.1.0.25" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index c2d28d4f397301..efe78e0b93f9f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -838,6 +838,9 @@ onkyo-eiscp==1.2.4 # homeassistant.components.onvif onvif-zeep-async==0.2.0 +# homeassistant.components.opencv +# opencv-python-headless==4.1.0.25 + # homeassistant.components.openevse openevsewifi==0.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e178c29b51c013..f1f3655cef3b3c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -24,7 +24,7 @@ 'face_recognition', 'fritzconnection', 'i2csense', - 'opencv-python', + 'opencv-python-headless', 'py_noaa', 'VL53L1X2', 'pybluez', From f5da0e341cb559680955cb3b6b2c3e6be13d6ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Wed, 19 Jun 2019 18:23:29 +0200 Subject: [PATCH 272/319] tellstick: Add socat to package dependencies (#24531) Otherwise the tellstick component will fail when used with a remote host running tellcore-net. Fixes #24113 --- virtualization/Docker/scripts/tellstick | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtualization/Docker/scripts/tellstick b/virtualization/Docker/scripts/tellstick index c9658d14029f23..d35e1cac2dbd92 100755 --- a/virtualization/Docker/scripts/tellstick +++ b/virtualization/Docker/scripts/tellstick @@ -6,7 +6,7 @@ set -e PACKAGES=( # homeassistant.components.tellstick - libtelldus-core2 + libtelldus-core2 socat ) # Add Tellstick repository From dbc4f285f150b97b4d9a4ae305f4f0f4791f51b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Gonz=C3=A1lez=20Calleja?= Date: Wed, 19 Jun 2019 22:54:24 +0200 Subject: [PATCH 273/319] Tolerance configuration for dlib_face_identify (#24497) * Adding tolerance configuration support for dlib_face_identify component * Fix tolerance parameter * Fix static-check * Fix flake8 check * Fix circleci tests * Changes for improve maintainability * Change type of confidence option --- .../dlib_face_identify/image_processing.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index 569b1ecece2bcf..1dd7b6e46265da 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -7,7 +7,7 @@ from homeassistant.core import split_entity_id from homeassistant.components.image_processing import ( ImageProcessingFaceEntity, PLATFORM_SCHEMA, CONF_SOURCE, CONF_ENTITY_ID, - CONF_NAME) + CONF_NAME, CONF_CONFIDENCE) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -17,6 +17,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FACES): {cv.string: cv.isfile}, + vol.Optional(CONF_CONFIDENCE, default=0.6): vol.Coerce(float), }) @@ -25,7 +26,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for camera in config[CONF_SOURCE]: entities.append(DlibFaceIdentifyEntity( - camera[CONF_ENTITY_ID], config[CONF_FACES], camera.get(CONF_NAME) + camera[CONF_ENTITY_ID], config[CONF_FACES], camera.get(CONF_NAME), + config[CONF_CONFIDENCE] )) add_entities(entities) @@ -34,7 +36,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" - def __init__(self, camera_entity, faces, name=None): + def __init__(self, camera_entity, faces, name, tolerance): """Initialize Dlib face identify entry.""" # pylint: disable=import-error import face_recognition @@ -57,6 +59,8 @@ def __init__(self, camera_entity, faces, name=None): except IndexError as err: _LOGGER.error("Failed to parse %s. Error: %s", face_file, err) + self._tolerance = tolerance + @property def camera_entity(self): """Return camera entity id from process pictures.""" @@ -82,7 +86,10 @@ def process_image(self, image): found = [] for unknown_face in unknowns: for name, face in self._faces.items(): - result = face_recognition.compare_faces([face], unknown_face) + result = face_recognition.compare_faces( + [face], + unknown_face, + tolerance=self._tolerance) if result[0]: found.append({ ATTR_NAME: name From 4e066f468169f0613382a1f140f8026e24690ce0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 13:58:00 -0700 Subject: [PATCH 274/319] Fix sending update when not logged in (#24624) * Fix sending update when not logged in * Fix test --- homeassistant/components/cloud/client.py | 4 ++-- tests/components/cloud/test_client.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 92473974a9f5f4..f8cfc255aa457f 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -116,7 +116,7 @@ async def async_get_access_token(self): if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): raise RequireRelink - return alexa_errors.NoTokenAvailable + raise alexa_errors.NoTokenAvailable self._token = body['access_token'] self._endpoint = body['event_endpoint'] @@ -239,7 +239,7 @@ async def _sync_helper(self, to_update, to_remove) -> bool: async def _handle_entity_registry_updated(self, event): """Handle when entity registry updated.""" - if not self.enabled: + if not self.enabled or not self._cloud.is_logged_in: return action = event.data['action'] diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 7f4bf97086a179..7d1afda7e6a686 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -352,9 +352,9 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs): assert to_remove == ['light.kitchen'] -async def test_alexa_entity_registry_sync(hass, cloud_prefs): +async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" - client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, hass.data['cloud']) with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { From 55997c74b05c078d23412505c77d6a43015cb5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Nenz=C3=A9n?= Date: Wed, 19 Jun 2019 23:26:09 +0200 Subject: [PATCH 275/319] Uses signal dispatcher to invoke state update (#24627) --- homeassistant/components/plaato/sensor.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 6352c83712181d..4362accee240a3 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -3,6 +3,7 @@ import logging from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from . import ( @@ -45,7 +46,8 @@ async def _update_sensor(device_id): async_add_entities(entities, True) else: for entity in devices[device_id]: - entity.async_schedule_update_ha_state() + async_dispatcher_send(hass, "{}_{}".format(PLAATO_DOMAIN, + entity.unique_id)) hass.data[SENSOR_DATA_KEY] = async_dispatcher_connect( hass, SENSOR_UPDATE, _update_sensor @@ -137,3 +139,9 @@ def unit_of_measurement(self): def should_poll(self): """Return the polling state.""" return False + + async def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + "{}_{}".format(PLAATO_DOMAIN, self.unique_id), + self.async_schedule_update_ha_state) From 6ea92f86a59c4aebf33c3264e69afcefd8ff3d6d Mon Sep 17 00:00:00 2001 From: Oncleben31 Date: Wed, 19 Jun 2019 23:39:13 +0200 Subject: [PATCH 276/319] Add weather alert sensor to meteo france component (#23128) * Quick & Dirty weather alert integration * Add attributes in weather alert sensor. * MeteoFranceUpdate returns dept to init the alert watcher * add rain forecast to weather attribute * Add checks when no weather alert data are available * Improve date and state when online source is unreachable * update to take into account new API of vigilancemeteo 3.0.0 * Clean local patchs and put requirements in manfiest.json * Use only one proxy for weather alerts to avoid too much HTTP requests * linting and comments style corrections * Add error catching and debug logging * Correction following PR checklist * Add code owners * Update requirements_all.txt * Comment style * Update CODEOWNERS after rebaseline with dev branch * update requirements_all.txt --- CODEOWNERS | 2 +- .../components/meteo_france/__init__.py | 24 ++++++++ .../components/meteo_france/manifest.json | 8 ++- .../components/meteo_france/sensor.py | 60 +++++++++++++++++-- .../components/meteo_france/weather.py | 8 +++ requirements_all.txt | 3 + 6 files changed, 97 insertions(+), 8 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6555e58c88a011..3945feb927c842 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -159,7 +159,7 @@ homeassistant/components/mcp23017/* @jardiamj homeassistant/components/mediaroom/* @dgomes homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen -homeassistant/components/meteo_france/* @victorcerutti +homeassistant/components/meteo_france/* @victorcerutti @oncleben31 homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/mill/* @danielhiversen diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index df0292ec407db5..3e9f0cf75f3c1c 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -31,6 +31,7 @@ 'next_rain': ['Next rain', 'min'], 'temperature': ['Temperature', TEMP_CELSIUS], 'uv': ['UV', None], + 'weather_alert': ['Weather Alert', None], } CONDITION_CLASSES = { @@ -77,6 +78,28 @@ def setup(hass, config): """Set up the Meteo-France component.""" hass.data[DATA_METEO_FRANCE] = {} + # Check if at least weather alert have to be monitored for one location. + need_weather_alert_watcher = False + for location in config[DOMAIN]: + if CONF_MONITORED_CONDITIONS in location \ + and 'weather_alert' in location[CONF_MONITORED_CONDITIONS]: + need_weather_alert_watcher = True + + # If weather alert monitoring is expected initiate a client to be used by + # all weather_alert entities. + if need_weather_alert_watcher: + from vigilancemeteo import VigilanceMeteoFranceProxy, \ + VigilanceMeteoError + + weather_alert_client = VigilanceMeteoFranceProxy() + try: + weather_alert_client.update_data() + except VigilanceMeteoError as exp: + _LOGGER.error(exp) + else: + weather_alert_client = None + hass.data[DATA_METEO_FRANCE]['weather_alert_client'] = weather_alert_client + for location in config[DOMAIN]: city = location[CONF_CITY] @@ -98,6 +121,7 @@ def setup(hass, config): if CONF_MONITORED_CONDITIONS in location: monitored_conditions = location[CONF_MONITORED_CONDITIONS] + _LOGGER.debug("meteo_france sensor platfrom loaded for %s", city) load_platform( hass, 'sensor', DOMAIN, { CONF_CITY: city, diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 301d9538c20142..b485458be409e2 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,10 +3,12 @@ "name": "Meteo france", "documentation": "https://www.home-assistant.io/components/meteo_france", "requirements": [ - "meteofrance==0.3.7" + "meteofrance==0.3.7", + "vigilancemeteo==3.0.0" ], "dependencies": [], "codeowners": [ - "@victorcerutti" + "@victorcerutti", + "@oncleben31" ] -} +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 122b91cae44d63..d30b58bdd5a283 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -9,6 +9,7 @@ _LOGGER = logging.getLogger(__name__) STATE_ATTR_FORECAST = '1h rain forecast' +STATE_ATTR_BULLETIN_TIME = 'Bulletin date' def setup_platform(hass, config, add_entities, discovery_info=None): @@ -19,18 +20,44 @@ def setup_platform(hass, config, add_entities, discovery_info=None): city = discovery_info[CONF_CITY] monitored_conditions = discovery_info[CONF_MONITORED_CONDITIONS] client = hass.data[DATA_METEO_FRANCE][city] - - add_entities([MeteoFranceSensor(variable, client) + weather_alert_client = hass.data[DATA_METEO_FRANCE]['weather_alert_client'] + + from vigilancemeteo import DepartmentWeatherAlert + + alert_watcher = None + if 'weather_alert' in monitored_conditions: + datas = hass.data[DATA_METEO_FRANCE][city].get_data() + # Check if a department code is available for this city. + if "dept" in datas: + try: + # If yes create the watcher DepartmentWeatherAlert object. + alert_watcher = DepartmentWeatherAlert(datas["dept"], + weather_alert_client) + except ValueError as exp: + _LOGGER.error(exp) + alert_watcher = None + else: + _LOGGER.info("weather alert watcher added for %s" + "in department %s", + city, datas["dept"]) + else: + _LOGGER.warning("No dept key found for '%s'. So weather alert " + "information won't be available", city) + # Exit and don't create the sensor if no department code available. + return + + add_entities([MeteoFranceSensor(variable, client, alert_watcher) for variable in monitored_conditions], True) class MeteoFranceSensor(Entity): """Representation of a Meteo-France sensor.""" - def __init__(self, condition, client): + def __init__(self, condition, client, alert_watcher): """Initialize the Meteo-France sensor.""" self._condition = condition self._client = client + self._alert_watcher = alert_watcher self._state = None self._data = {} @@ -48,12 +75,25 @@ def state(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" + # Attributes for next_rain sensor. if self._condition == 'next_rain' and 'rain_forecast' in self._data: return { **{STATE_ATTR_FORECAST: self._data['rain_forecast']}, ** self._data['next_rain_intervals'], **{ATTR_ATTRIBUTION: ATTRIBUTION} } + + # Attributes for weather_alert sensor. + if self._condition == 'weather_alert' \ + and self._alert_watcher is not None: + return { + **{STATE_ATTR_BULLETIN_TIME: + self._alert_watcher.bulletin_date}, + ** self._alert_watcher.alerts_list, + ATTR_ATTRIBUTION: ATTRIBUTION + } + + # Attributes for all other sensors. return {ATTR_ATTRIBUTION: ATTRIBUTION} @property @@ -66,7 +106,19 @@ def update(self): try: self._client.update() self._data = self._client.get_data() - self._state = self._data[self._condition] + + if self._condition == 'weather_alert': + if self._alert_watcher is not None: + self._alert_watcher.update_department_status() + self._state = self._alert_watcher.department_color + _LOGGER.debug("weather alert watcher for %s updated. Proxy" + " have the status: %s", self._data['name'], + self._alert_watcher.proxy.status) + else: + _LOGGER.warning("No weather alert data for location %s", + self._data['name']) + else: + self._state = self._data[self._condition] except KeyError: _LOGGER.error("No condition %s for location %s", self._condition, self._data['name']) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index b2b94c7622e464..05b760e49f14a1 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -102,3 +102,11 @@ def format_condition(condition): if condition in value: return key return condition + + @property + def device_state_attributes(self): + """Return the state attributes.""" + data = dict() + if self._data and "next_rain" in self._data: + data["next_rain"] = self._data["next_rain"] + return data diff --git a/requirements_all.txt b/requirements_all.txt index efe78e0b93f9f5..c8d6911e7129f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1820,6 +1820,9 @@ uvcclient==0.11.0 # homeassistant.components.venstar venstarcolortouch==0.7 +# homeassistant.components.meteo_france +vigilancemeteo==3.0.0 + # homeassistant.components.volkszaehler volkszaehler==0.1.2 From f2962a0d1659e8bda36dc0484fdfd302cc5f1746 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 14:41:27 -0700 Subject: [PATCH 277/319] Set up Met during onboarding (#24622) * Set up Met during onboarding * Lint * Add pyMetNo to test reqs --- homeassistant/components/met/config_flow.py | 11 +- homeassistant/components/met/const.py | 2 + homeassistant/components/met/weather.py | 152 +++++++++++++------ homeassistant/components/onboarding/views.py | 4 + homeassistant/config.py | 4 - requirements_test_all.txt | 4 + script/gen_requirements_all.py | 1 + tests/components/met/__init__.py | 1 + tests/components/met/conftest.py | 24 +++ tests/components/met/test_config_flow.py | 16 ++ tests/components/met/test_weather.py | 52 +++++++ tests/components/onboarding/test_views.py | 25 +++ 12 files changed, 242 insertions(+), 54 deletions(-) create mode 100644 tests/components/met/__init__.py create mode 100644 tests/components/met/conftest.py create mode 100644 tests/components/met/test_weather.py diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 07123a918553ea..2480b5f29b810d 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -7,7 +7,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, HOME_LOCATION_NAME +from .const import DOMAIN, HOME_LOCATION_NAME, CONF_TRACK_HOME @callback @@ -61,3 +61,12 @@ async def _show_config_form(self, name=None, latitude=None, }), errors=self._errors, ) + + async def async_step_onboarding(self, data=None): + """Handle a flow initialized by onboarding.""" + return self.async_create_entry( + title=HOME_LOCATION_NAME, + data={ + CONF_TRACK_HOME: True + } + ) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index cf1f3aac53df11..5d61ecadfa366b 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -7,6 +7,8 @@ HOME_LOCATION_NAME = 'Home' +CONF_TRACK_HOME = 'track_home' + ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".met_{}" ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( HOME_LOCATION_NAME) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 20f408b91baaf1..c9d0912e623002 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,17 +2,21 @@ import logging from random import randrange +import metno import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity from homeassistant.const import ( - CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) + CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, + EVENT_CORE_CONFIG_UPDATE) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import ( - async_call_later, async_track_utc_time_change) +from homeassistant.helpers.event import async_call_later import homeassistant.util.dt as dt_util +from .const import CONF_TRACK_HOME + _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Weather forecast from met.no, delivered by the Norwegian " \ @@ -27,6 +31,7 @@ 'Latitude and longitude must exist together'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, + vol.Optional(CONF_ELEVATION): int, }) @@ -35,60 +40,82 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the Met.no weather platform.""" _LOGGER.warning("Loading Met.no via platform config is deprecated") - name = config.get(CONF_NAME) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) + # Add defaults. + config = { + CONF_ELEVATION: hass.config.elevation, + **config, + } - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return + if config.get(CONF_LATITUDE) is None: + config[CONF_TRACK_HOME] = True - station = await async_get_station( - hass, name, latitude, longitude, elevation) - async_add_entities([station]) + async_add_entities([MetWeather(config)]) async def async_setup_entry(hass, config_entry, async_add_entities): """Add a weather entity from a config_entry.""" - name = config_entry.data.get(CONF_NAME) - latitude = config_entry.data[CONF_LATITUDE] - longitude = config_entry.data[CONF_LONGITUDE] - elevation = config_entry.data[CONF_ELEVATION] - - station = await async_get_station( - hass, name, latitude, longitude, elevation) - async_add_entities([station]) - - -async def async_get_station(hass, name, latitude, longitude, elevation): - """Retrieve weather station, station name to be used as the entity name.""" - coordinates = { - 'lat': str(latitude), - 'lon': str(longitude), - 'msl': str(elevation), - } - - return MetWeather(name, coordinates, async_get_clientsession(hass)) + async_add_entities([MetWeather(config_entry.data)]) class MetWeather(WeatherEntity): """Implementation of a Met.no weather condition.""" - def __init__(self, name, coordinates, clientsession): + def __init__(self, config): """Initialise the platform with a data instance and site.""" - import metno - self._name = name - self._weather_data = metno.MetWeatherData( - coordinates, clientsession, URL) + self._config = config + self._unsub_track_home = None + self._unsub_fetch_data = None + self._weather_data = None self._current_weather_data = {} self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" + self._init_data() + await self._fetch_data() + if self._config.get(CONF_TRACK_HOME): + self._unsub_track_home = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, self._core_config_updated) + + @callback + def _init_data(self): + """Initialize a data object.""" + conf = self._config + + if self.track_home: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + elevation = self.hass.config.elevation + else: + latitude = conf[CONF_LATITUDE] + longitude = conf[CONF_LONGITUDE] + elevation = conf[CONF_ELEVATION] + + coordinates = { + 'lat': str(latitude), + 'lon': str(longitude), + 'msl': str(elevation), + } + self._weather_data = metno.MetWeatherData( + coordinates, async_get_clientsession(self.hass), URL) + + async def _core_config_updated(self, _event): + """Handle core config updated.""" + self._init_data() + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None await self._fetch_data() - async_track_utc_time_change( - self.hass, self._update, minute=31, second=0) + + async def will_remove_from_hass(self): + """Handle entity will be removed from hass.""" + if self._unsub_track_home: + self._unsub_track_home() + self._unsub_track_home = None + + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None async def _fetch_data(self, *_): """Get the latest data from met.no.""" @@ -96,28 +123,55 @@ async def _fetch_data(self, *_): # Retry in 15 to 20 minutes. minutes = 15 + randrange(6) _LOGGER.error("Retrying in %i minutes", minutes) - async_call_later(self.hass, minutes*60, self._fetch_data) + self._unsub_fetch_data = async_call_later( + self.hass, minutes*60, self._fetch_data) return - async_call_later(self.hass, 60*60, self._fetch_data) - await self._update() + # Wait between 55-65 minutes. If people update HA on the hour, this + # will make sure it will spread it out. - @property - def should_poll(self): - """No polling needed.""" - return False + self._unsub_fetch_data = async_call_later( + self.hass, randrange(55, 65)*60, self._fetch_data) + self._update() - async def _update(self, *_): + def _update(self, *_): """Get the latest data from Met.no.""" self._current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE self._forecast_data = self._weather_data.get_forecast(time_zone) - self.async_schedule_update_ha_state() + self.async_write_ha_state() + + @property + def track_home(self): + """Return if we are tracking home.""" + return self._config.get(CONF_TRACK_HOME, False) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return unique ID.""" + if self.track_home: + return 'home' + + return '{}-{}'.format( + self._config[CONF_LATITUDE], self._config[CONF_LONGITUDE]) @property def name(self): """Return the name of the sensor.""" - return self._name + name = self._config.get(CONF_NAME) + + if name is not None: + return CONF_NAME + + if self.track_home: + return self.hass.config.location_name + + return DEFAULT_NAME @property def condition(self): diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index c8060891fd4f1e..90217016d60a0e 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -148,6 +148,10 @@ async def post(self, request): await self._async_mark_done(hass) + await hass.config_entries.flow.async_init('met', context={ + 'source': 'onboarding' + }) + return self.json({}) diff --git a/homeassistant/config.py b/homeassistant/config.py index 7d36fb6f7989b4..ae5d2ce24fd6cc 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -60,10 +60,6 @@ # http: # base_url: example.duckdns.org:8123 -# Weather prediction -weather: - - platform: met - # Text to speech tts: - platform: google_translate diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b003dea37e205..5544793cc7ff72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -238,6 +238,10 @@ py-canary==0.5.0 # homeassistant.components.tplink pyHS100==0.3.5 +# homeassistant.components.met +# homeassistant.components.norway_air +pyMetno==0.4.6 + # homeassistant.components.blackbird pyblackbird==0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f1f3655cef3b3c..a8df6f6323210f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -90,6 +90,7 @@ 'libpurecool', 'libsoundtouch', 'luftdaten', + 'pyMetno', 'mbddns', 'mficlient', 'netdisco', diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py new file mode 100644 index 00000000000000..658ed2901ec7ea --- /dev/null +++ b/tests/components/met/__init__.py @@ -0,0 +1 @@ +"""Tests for Met.no.""" diff --git a/tests/components/met/conftest.py b/tests/components/met/conftest.py new file mode 100644 index 00000000000000..47df348102e91d --- /dev/null +++ b/tests/components/met/conftest.py @@ -0,0 +1,24 @@ +"""Fixtures for Met weather testing.""" +from unittest.mock import patch + +import pytest + +from tests.common import mock_coro + + +@pytest.fixture +def mock_weather(): + """Mock weather data.""" + with patch('metno.MetWeatherData') as mock_data: + mock_data = mock_data.return_value + mock_data.fetching_data.side_effect = lambda: mock_coro(True) + mock_data.get_current_weather.return_value = { + 'condition': 'cloudy', + 'temperature': 15, + 'pressure': 100, + 'humidity': 50, + 'wind_speed': 10, + 'wind_bearing': 'NE', + } + mock_data.get_forecast.return_value = {} + yield mock_data diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 9e625eaa9f525b..b74cc6e9efeaf8 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -131,3 +131,19 @@ async def test_flow_entry_config_entry_already_exists(): assert len(config_form.mock_calls) == 1 assert len(config_entries.mock_calls) == 1 assert len(flow._errors) == 1 + + +async def test_onboarding_step(hass, mock_weather): + """Test initializing via onboarding step.""" + hass = Mock() + + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow.async_step_onboarding({}) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Home' + assert result['data'] == { + 'track_home': True, + } diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py new file mode 100644 index 00000000000000..14f7fa2bfbe828 --- /dev/null +++ b/tests/components/met/test_weather.py @@ -0,0 +1,52 @@ +"""Test Met weather entity.""" + + +async def test_tracking_home(hass, mock_weather): + """Test we track home.""" + await hass.config_entries.flow.async_init('met', context={ + 'source': 'onboarding' + }) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 + assert len(mock_weather.mock_calls) == 3 + + # Test we track config + await hass.config.async_update( + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + assert len(mock_weather.mock_calls) == 6 + + entry = hass.config_entries.async_entries()[0] + await hass.config_entries.async_remove(entry.entry_id) + assert len(hass.states.async_entity_ids('weather')) == 0 + + +async def test_not_tracking_home(hass, mock_weather): + """Test when we not track home.""" + await hass.config_entries.flow.async_init('met', context={ + 'source': 'user' + }, data={ + 'name': 'Somewhere', + 'latitude': 10, + 'longitude': 20, + 'elevation': 0, + }) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 + assert len(mock_weather.mock_calls) == 3 + + # Test we do not track config + await hass.config.async_update( + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + assert len(mock_weather.mock_calls) == 3 + + entry = hass.config_entries.async_entries()[0] + await hass.config_entries.async_remove(entry.entry_id) + assert len(hass.states.async_entity_ids('weather')) == 0 diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 4e253741286eb0..3f26f5f42e6481 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -9,10 +9,17 @@ from homeassistant.components.onboarding import const, views from tests.common import CLIENT_ID, register_auth_provider +from tests.components.met.conftest import mock_weather # noqa from . import mock_storage +@pytest.fixture(autouse=True) +def always_mock_weather(mock_weather): # noqa + """Mock the Met weather provider.""" + pass + + @pytest.fixture(autouse=True) def auth_active(hass): """Ensure auth is always active.""" @@ -224,3 +231,21 @@ async def test_onboarding_integration_requires_auth(hass, hass_storage, }) assert resp.status == 401 + + +async def test_onboarding_core_sets_up_met(hass, hass_storage, hass_client): + """Test finishing the core step.""" + mock_storage(hass_storage, { + 'done': [const.STEP_USER] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await hass_client() + + resp = await client.post('/api/onboarding/core_config') + + assert resp.status == 200 + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 From 14752baf272dc6620637c2d91a8f778339e60e31 Mon Sep 17 00:00:00 2001 From: majuss Date: Thu, 20 Jun 2019 00:41:43 +0200 Subject: [PATCH 278/319] Added ELV PCA 301 smart emeter switch (#23300) * added pca * removed req from py * try to fix codeowners * redo req * ran codeowners * processed comments * fix style * fix style * fix style * Set availalbe to False when communication fails. --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/elv/manifest.json | 8 ++ homeassistant/components/elv/switch.py | 103 +++++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 116 insertions(+) create mode 100644 homeassistant/components/elv/manifest.json create mode 100644 homeassistant/components/elv/switch.py diff --git a/.coveragerc b/.coveragerc index c8213378e91889..8bf5509c126c80 100644 --- a/.coveragerc +++ b/.coveragerc @@ -157,6 +157,7 @@ omit = homeassistant/components/eight_sleep/* homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/* + homeassistant/components/elv/switch.py homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py homeassistant/components/emoncms_history/* diff --git a/CODEOWNERS b/CODEOWNERS index 3945feb927c842..60703b8cf42557 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,6 +72,7 @@ homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/edp_redy/* @abmantis homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 +homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json new file mode 100644 index 00000000000000..4c9ed56352ecd8 --- /dev/null +++ b/homeassistant/components/elv/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "elv", + "name": "ELV PCA", + "documentation": "https://www.home-assistant.io/components/pca", + "dependencies": [], + "codeowners": ["@majuss"], + "requirements": ["pypca==0.0.4"] + } \ No newline at end of file diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py new file mode 100644 index 00000000000000..bd97d10cecf4dc --- /dev/null +++ b/homeassistant/components/elv/switch.py @@ -0,0 +1,103 @@ +"""Support for PCA 301 smart switch.""" +import logging + +import voluptuous as vol + +from homeassistant.components.switch import ( + SwitchDevice, PLATFORM_SCHEMA, ATTR_CURRENT_POWER_W) +from homeassistant.const import ( + CONF_NAME, CONF_DEVICE, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_ENERGY_KWH = 'total_energy_kwh' + +DEFAULT_NAME = 'PCA 301' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_DEVICE): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the PCA switch platform.""" + import pypca + from serial import SerialException + + name = config[CONF_NAME] + usb_device = config[CONF_DEVICE] + + try: + pca = pypca.PCA(usb_device) + pca.open() + entities = [SmartPlugSwitch(pca, device, name) + for device in pca.get_devices()] + add_entities(entities, True) + + except SerialException as exc: + _LOGGER.warning("Unable to open serial port: %s", exc) + return + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, pca.close) + + pca.start_scan() + + +class SmartPlugSwitch(SwitchDevice): + """Representation of a PCA Smart Plug switch.""" + + def __init__(self, pca, device_id, name): + """Initialize the switch.""" + self._device_id = device_id + self._name = name + self._state = None + self._available = True + self._emeter_params = {} + self._pca = pca + + @property + def name(self): + """Return the name of the Smart Plug, if any.""" + return self._name + + @property + def available(self) -> bool: + """Return if switch is available.""" + return self._available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._pca.turn_on(self._device_id) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._pca.turn_off(self._device_id) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._emeter_params + + def update(self): + """Update the PCA switch's state.""" + try: + self._emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format( + self._pca.get_current_power(self._device_id)) + self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = "{:.2f}".format( + self._pca.get_total_consumption(self._device_id)) + + self._available = True + self._state = self._pca.get_state(self._device_id) + + except (OSError) as ex: + if self._available: + _LOGGER.warning( + "Could not read state for %s: %s", self.name, ex) + self._available = False diff --git a/requirements_all.txt b/requirements_all.txt index c8d6911e7129f2..bf4c98191b7e50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1290,6 +1290,9 @@ pyowlet==1.0.2 # homeassistant.components.openweathermap pyowm==2.10.0 +# homeassistant.components.elv +pypca==0.0.4 + # homeassistant.components.lcn pypck==0.6.1 From 114af8e24b436056341eaa93f25a57da3f0c3752 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 15:58:39 -0700 Subject: [PATCH 279/319] Add missing init file --- homeassistant/components/elv/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 homeassistant/components/elv/__init__.py diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py new file mode 100644 index 00000000000000..13ade253ff659a --- /dev/null +++ b/homeassistant/components/elv/__init__.py @@ -0,0 +1 @@ +"""The Elv integration.""" From 96af0cffc8ff6d6c57c0f20007adb77406ae38fb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 16:21:54 -0700 Subject: [PATCH 280/319] Updated frontend to 20190619.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d0a9b2ea2680db..7d84e0a492ee6a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190618.0" + "home-assistant-frontend==20190619.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d08b3d513a44d0..506e9788e02c25 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.15 -home-assistant-frontend==20190618.0 +home-assistant-frontend==20190619.0 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index bf4c98191b7e50..1b305a8223603f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -595,7 +595,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190618.0 +home-assistant-frontend==20190619.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5544793cc7ff72..264e43dd93eee8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,7 +160,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190618.0 +home-assistant-frontend==20190619.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From c5d443a710be538c71780756249c308b62571358 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 16:22:13 -0700 Subject: [PATCH 281/319] Update translations --- .../components/adguard/.translations/de.json | 28 +++++++++++++++++++ .../components/adguard/.translations/vi.json | 14 ++++++++++ .../components/axis/.translations/de.json | 3 +- .../components/ipma/.translations/ru.json | 2 +- .../components/life360/.translations/de.json | 26 +++++++++++++++++ .../components/met/.translations/ca.json | 20 +++++++++++++ .../components/met/.translations/de.json | 20 +++++++++++++ .../components/met/.translations/ko.json | 20 +++++++++++++ .../components/met/.translations/ru.json | 20 +++++++++++++ .../components/met/.translations/vi.json | 14 ++++++++++ .../components/plaato/.translations/de.json | 18 ++++++++++++ .../components/plaato/.translations/ko.json | 18 ++++++++++++ .../components/smhi/.translations/ru.json | 2 +- .../components/somfy/.translations/de.json | 13 +++++++++ 14 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/adguard/.translations/de.json create mode 100644 homeassistant/components/adguard/.translations/vi.json create mode 100644 homeassistant/components/life360/.translations/de.json create mode 100644 homeassistant/components/met/.translations/ca.json create mode 100644 homeassistant/components/met/.translations/de.json create mode 100644 homeassistant/components/met/.translations/ko.json create mode 100644 homeassistant/components/met/.translations/ru.json create mode 100644 homeassistant/components/met/.translations/vi.json create mode 100644 homeassistant/components/plaato/.translations/de.json create mode 100644 homeassistant/components/plaato/.translations/ko.json create mode 100644 homeassistant/components/somfy/.translations/de.json diff --git a/homeassistant/components/adguard/.translations/de.json b/homeassistant/components/adguard/.translations/de.json new file mode 100644 index 00000000000000..c72293c6afb18b --- /dev/null +++ b/homeassistant/components/adguard/.translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von AdGuard Home zul\u00e4ssig." + }, + "error": { + "connection_error": "Fehler beim Herstellen einer Verbindung." + }, + "step": { + "hassio_confirm": { + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit AdGuard Home als Hass.io-Add-On hergestellt wird: {addon}?", + "title": "AdGuard Home \u00fcber das Hass.io Add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "ssl": "AdGuard Home verwendet ein SSL-Zertifikat", + "username": "Benutzername", + "verify_ssl": "AdGuard Home verwendet ein richtiges Zertifikat" + }, + "description": "Richte deine AdGuard Home-Instanz ein um sie zu \u00dcberwachen und zu Steuern.", + "title": "Verkn\u00fcpfe AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/vi.json b/homeassistant/components/adguard/.translations/vi.json new file mode 100644 index 00000000000000..1b76fef567192d --- /dev/null +++ b/homeassistant/components/adguard/.translations/vi.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0110\u1ecba ch\u1ec9", + "password": "M\u1eadt kh\u1ea9u", + "port": "C\u1ed5ng", + "username": "T\u00ean \u0111\u0103ng nh\u1eadp" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/de.json b/homeassistant/components/axis/.translations/de.json index 123b0621424ac3..05c1853d769222 100644 --- a/homeassistant/components/axis/.translations/de.json +++ b/homeassistant/components/axis/.translations/de.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "bad_config_file": "Fehlerhafte Daten aus der Konfigurationsdatei", - "link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt" + "link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt", + "not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t" }, "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", diff --git a/homeassistant/components/ipma/.translations/ru.json b/homeassistant/components/ipma/.translations/ru.json index f49852d5c0c0bb..a260efa5bd9dc2 100644 --- a/homeassistant/components/ipma/.translations/ru.json +++ b/homeassistant/components/ipma/.translations/ru.json @@ -11,7 +11,7 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, "description": "\u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u044c\u0441\u043a\u0438\u0439 \u0438\u043d\u0441\u0442\u0438\u0442\u0443\u0442 \u043c\u043e\u0440\u044f \u0438 \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u044b", - "title": "\u041c\u0435\u0441\u0442\u043e\u043d\u0430\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435" + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" } }, "title": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0430\u044f \u0441\u043b\u0443\u0436\u0431\u0430 \u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u0438\u0438 (IPMA)" diff --git a/homeassistant/components/life360/.translations/de.json b/homeassistant/components/life360/.translations/de.json new file mode 100644 index 00000000000000..9833a0c9959a7f --- /dev/null +++ b/homeassistant/components/life360/.translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "user_already_configured": "Konto wurde bereits konfiguriert" + }, + "create_entry": { + "default": "M\u00f6gliche erweiterte Einstellungen finden sich unter [Life360-Dokumentation]({docs_url})." + }, + "error": { + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen", + "invalid_username": "Ung\u00fcltiger Benutzername", + "user_already_configured": "Konto wurde bereits konfiguriert" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Erweiterte Optionen sind in der [Life360-Dokumentation]({docs_url}) zu finden.\nDies sollte vor dem Hinzuf\u00fcgen von Kontoinformationen getan werden.", + "title": "Life360-Kontoinformationen" + } + }, + "title": "Life360" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/ca.json b/homeassistant/components/met/.translations/ca.json new file mode 100644 index 00000000000000..5335bfd48ea923 --- /dev/null +++ b/homeassistant/components/met/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "El nom ja existeix" + }, + "step": { + "user": { + "data": { + "elevation": "Altitud", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom" + }, + "description": "Meteorologisk institutt", + "title": "Ubicaci\u00f3" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/de.json b/homeassistant/components/met/.translations/de.json new file mode 100644 index 00000000000000..b70d3f12a838ce --- /dev/null +++ b/homeassistant/components/met/.translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "Name existiert bereits" + }, + "step": { + "user": { + "data": { + "elevation": "H\u00f6he", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "name": "Name" + }, + "description": "Meteorologisches Institut", + "title": "Standort" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/ko.json b/homeassistant/components/met/.translations/ko.json new file mode 100644 index 00000000000000..3cb6fd6694348d --- /dev/null +++ b/homeassistant/components/met/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "elevation": "\uace0\ub3c4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\uc774\ub984" + }, + "description": "\ub178\ub974\uc6e8\uc774 \uae30\uc0c1 \uc5f0\uad6c\uc18c", + "title": "\uc704\uce58" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/ru.json b/homeassistant/components/met/.translations/ru.json new file mode 100644 index 00000000000000..d298b1e3b07a5c --- /dev/null +++ b/homeassistant/components/met/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442" + }, + "step": { + "user": { + "data": { + "elevation": "\u0412\u044b\u0441\u043e\u0442\u0430", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0438\u0439 \u0438\u043d\u0441\u0442\u0438\u0442\u0443\u0442", + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/vi.json b/homeassistant/components/met/.translations/vi.json new file mode 100644 index 00000000000000..e2bfbeb8a41242 --- /dev/null +++ b/homeassistant/components/met/.translations/vi.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "V\u0129 \u0111\u1ed9", + "longitude": "Kinh \u0111\u1ed9", + "name": "T\u00ean" + }, + "title": "V\u1ecb tr\u00ed" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/de.json b/homeassistant/components/plaato/.translations/de.json new file mode 100644 index 00000000000000..92dafa1c3232ba --- /dev/null +++ b/homeassistant/components/plaato/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Deine Home-Assistant-Instanz muss aus dem Internet erreichbar sein, um Nachrichten von Plaato Airlock zu erhalten.", + "one_instance_allowed": "Nur eine einzige Instanz ist notwendig." + }, + "create_entry": { + "default": "Um Ereignisse an Home Assistant zu senden, muss das Webhook Feature in Plaato Airlock konfiguriert werden.\n\n F\u00fcge die folgenden Informationen ein: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n \n Weitere Informationen finden sich in der [Dokumentation]({docs_url})." + }, + "step": { + "user": { + "description": "Soll Plaato Airlock wirklich eingerichtet werden?", + "title": "Plaato Webhook einrichten" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/ko.json b/homeassistant/components/plaato/.translations/ko.json new file mode 100644 index 00000000000000..34432f6b108f0b --- /dev/null +++ b/homeassistant/components/plaato/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Plaato Airlock \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Plaato Airlock \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4.\n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Plaato Airlock \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Plaato Webhook \uc124\uc815" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/ru.json b/homeassistant/components/smhi/.translations/ru.json index 3496d19f5f4c55..88ea988ff1bb9a 100644 --- a/homeassistant/components/smhi/.translations/ru.json +++ b/homeassistant/components/smhi/.translations/ru.json @@ -11,7 +11,7 @@ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "title": "\u041c\u0435\u0441\u0442\u043e\u043d\u0430\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435 \u0432 \u0428\u0432\u0435\u0446\u0438\u0438" + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432 \u0428\u0432\u0435\u0446\u0438\u0438" } }, "title": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0430\u044f \u0441\u043b\u0443\u0436\u0431\u0430 \u0428\u0432\u0435\u0446\u0438\u0438 (SMHI)" diff --git a/homeassistant/components/somfy/.translations/de.json b/homeassistant/components/somfy/.translations/de.json new file mode 100644 index 00000000000000..1dd1b7b4448223 --- /dev/null +++ b/homeassistant/components/somfy/.translations/de.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "Es kann nur ein Somfy-Account konfiguriert werden.", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "missing_configuration": "Die Somfy-Komponente ist nicht konfiguriert. Folge bitte der Dokumentation." + }, + "create_entry": { + "default": "Erfolgreich mit Somfy authentifiziert." + }, + "title": "Somfy" + } +} \ No newline at end of file From 8623294fcdcd1dec9b75a5756bd75a4dfd53ece0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 16:37:53 -0700 Subject: [PATCH 282/319] Bumped version to 0.95.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 972e9e25f72a99..3466918dbd3086 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 95 -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 8f928982e0f4848c478758313a59844fdd35bd05 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Jun 2019 23:52:45 -0700 Subject: [PATCH 283/319] Updated frontend to 20190620.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7d84e0a492ee6a..355a26931fe457 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190619.0" + "home-assistant-frontend==20190620.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 506e9788e02c25..31a2e79e06d666 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.15 -home-assistant-frontend==20190619.0 +home-assistant-frontend==20190620.0 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1b305a8223603f..052babd06704d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -595,7 +595,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190619.0 +home-assistant-frontend==20190620.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 264e43dd93eee8..ff8b87f594e08f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,7 +160,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190619.0 +home-assistant-frontend==20190620.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From b899dd59c5f6f1cf1027a48d40a16dadaf461ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Fri, 21 Jun 2019 06:13:47 -0300 Subject: [PATCH 284/319] Vlc telnet (#24290) * Vlc telnet first commit First functional version, remains to add more functionality. * New functions added and bugfixes * Compliance with dev checklist * Compliance with dev checklist * Compliance with pydocstyle * Removed unused import * Fixed wrong reference for exception * Module renamed * Fixed module rename in other * Fixed wrong reference for exception Module renamed Fixed module rename in other * Update homeassistant/components/vlc_telnet/media_player.py Accepted suggestion by @OttoWinter Co-Authored-By: Otto Winter * Update homeassistant/components/vlc_telnet/media_player.py Accepted suggestion by @OttoWinter Co-Authored-By: Otto Winter * Update homeassistant/components/vlc_telnet/media_player.py Accepted suggestion by @OttoWinter Co-Authored-By: Otto Winter * Update homeassistant/components/vlc_telnet/media_player.py Accepted suggestion by @OttoWinter Co-Authored-By: Otto Winter * Suggestions by @OttoWinter +Manage error when the VLC dissapears to show status unavailable. * Removed error log, instead set unavailable state * Changes suggested by @pvizeli -Import location -Use of constants * Implemented available method * Improved available method --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/vlc_telnet/__init__.py | 1 + .../components/vlc_telnet/manifest.json | 10 + .../components/vlc_telnet/media_player.py | 233 ++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 249 insertions(+) create mode 100644 homeassistant/components/vlc_telnet/__init__.py create mode 100644 homeassistant/components/vlc_telnet/manifest.json create mode 100644 homeassistant/components/vlc_telnet/media_player.py diff --git a/.coveragerc b/.coveragerc index 8bf5509c126c80..397db5394d6bbf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -665,6 +665,7 @@ omit = homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vizio/media_player.py homeassistant/components/vlc/media_player.py + homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* diff --git a/CODEOWNERS b/CODEOWNERS index 60703b8cf42557..86e731264ec1e8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -275,6 +275,7 @@ homeassistant/components/utility_meter/* @dgomes homeassistant/components/velux/* @Julius2342 homeassistant/components/version/* @fabaff homeassistant/components/vizio/* @raman325 +homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff diff --git a/homeassistant/components/vlc_telnet/__init__.py b/homeassistant/components/vlc_telnet/__init__.py new file mode 100644 index 00000000000000..91a3eb35444b94 --- /dev/null +++ b/homeassistant/components/vlc_telnet/__init__.py @@ -0,0 +1 @@ +"""The vlc component.""" diff --git a/homeassistant/components/vlc_telnet/manifest.json b/homeassistant/components/vlc_telnet/manifest.json new file mode 100644 index 00000000000000..1e0f1c71df5061 --- /dev/null +++ b/homeassistant/components/vlc_telnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "vlc_telnet", + "name": "VLC telnet", + "documentation": "https://www.home-assistant.io/components/vlc-telnet", + "requirements": [ + "python-telnet-vlc==1.0.4" + ], + "dependencies": [], + "codeowners": ["@rodripf"] +} diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py new file mode 100644 index 00000000000000..096afcc1044fca --- /dev/null +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -0,0 +1,233 @@ +"""Provide functionality to interact with the vlc telnet interface.""" +import logging +import voluptuous as vol + +from python_telnet_vlc import VLCTelnet, ConnectionError as ConnErr + +from homeassistant.components.media_player import ( + MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_NEXT_TRACK, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET) +from homeassistant.const import ( + CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE, + CONF_HOST, CONF_PORT, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'vlc_telnet' + +DEFAULT_NAME = 'VLC-TELNET' +DEFAULT_PORT = 4212 + +SUPPORT_VLC = SUPPORT_PAUSE | SUPPORT_SEEK | SUPPORT_VOLUME_SET \ + | SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK \ + | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP \ + | SUPPORT_CLEAR_PLAYLIST | SUPPORT_PLAY \ + | SUPPORT_SHUFFLE_SET +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the vlc platform.""" + add_entities([VlcDevice(config.get(CONF_NAME), + config.get(CONF_HOST), + config.get(CONF_PORT), + config.get(CONF_PASSWORD))], True) + + +class VlcDevice(MediaPlayerDevice): + """Representation of a vlc player.""" + + def __init__(self, name, host, port, passwd): + """Initialize the vlc device.""" + self._instance = None + self._name = name + self._volume = None + self._muted = None + self._state = STATE_UNAVAILABLE + self._media_position_updated_at = None + self._media_position = None + self._media_duration = None + self._host = host + self._port = port + self._password = passwd + self._vlc = None + self._available = False + self._volume_bkp = 0 + self._media_artist = "" + self._media_title = "" + + def update(self): + """Get the latest details from the device.""" + if self._vlc is None: + try: + self._vlc = VLCTelnet(self._host, self._password, self._port) + self._state = STATE_IDLE + self._available = True + except (ConnErr, EOFError): + self._available = False + self._vlc = None + else: + try: + status = self._vlc.status() + if status: + if 'volume' in status: + self._volume = int(status['volume']) / 500.0 + else: + self._volume = None + if 'state' in status: + state = status["state"] + if state == "playing": + self._state = STATE_PLAYING + elif state == "paused": + self._state = STATE_PAUSED + else: + self._state = STATE_IDLE + else: + self._state = STATE_IDLE + + self._media_duration = self._vlc.get_length() + self._media_position = self._vlc.get_time() + + info = self._vlc.info() + if info: + self._media_artist = info[0].get('artist') + self._media_title = info[0].get('title') + + except (ConnErr, EOFError): + self._available = False + self._vlc = None + + return True + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_VLC + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + return self._media_position_updated_at + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + def media_seek(self, position): + """Seek the media to a specific location.""" + track_length = self._vlc.get_length() / 1000 + self._vlc.seek(position / track_length) + + def mute_volume(self, mute): + """Mute the volume.""" + if mute: + self._volume_bkp = self._volume + self._volume = 0 + self._vlc.set_volume("0") + else: + self._vlc.set_volume(str(self._volume_bkp)) + self._volume = self._volume_bkp + + self._muted = mute + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._vlc.set_volume(str(volume * 500)) + self._volume = volume + + def media_play(self): + """Send play command.""" + self._vlc.play() + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self._vlc.pause() + self._state = STATE_PAUSED + + def media_stop(self): + """Send stop command.""" + self._vlc.stop() + self._state = STATE_IDLE + + def play_media(self, media_type, media_id, **kwargs): + """Play media from a URL or file.""" + if media_type != MEDIA_TYPE_MUSIC: + _LOGGER.error( + "Invalid media type %s. Only %s is supported", + media_type, MEDIA_TYPE_MUSIC) + return + self._vlc.add(media_id) + self._state = STATE_PLAYING + + def media_previous_track(self): + """Send previous track command.""" + self._vlc.prev() + + def media_next_track(self): + """Send next track command.""" + self._vlc.next() + + def clear_playlist(self): + """Clear players playlist.""" + self._vlc.clear() + + def set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + self._vlc.random(shuffle) diff --git a/requirements_all.txt b/requirements_all.txt index 052babd06704d5..3dfc9029e600d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1473,6 +1473,9 @@ python-tado==0.2.9 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 +# homeassistant.components.vlc_telnet +python-telnet-vlc==1.0.4 + # homeassistant.components.twitch python-twitch-client==0.6.0 From d527e2c926d9eed9677c69878460cde6c98a840f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Jun 2019 03:22:33 +0200 Subject: [PATCH 285/319] Fix device tracker see for entity registry entities (#24633) * Add a test for see service gaurd * Guard from seeing devices part of entity registry * Await registry task early * Lint * Correct comment * Clean up wait for registry * Fix spelling Co-Authored-By: Paulus Schoutsen * Fix spelling Co-Authored-By: Paulus Schoutsen --- .../components/device_tracker/legacy.py | 10 ++++++ tests/components/device_tracker/test_init.py | 32 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 1fdd807772801a..1a2e7c854e50e1 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,6 +14,7 @@ from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import GPSType, HomeAssistantType from homeassistant import util @@ -115,6 +116,7 @@ async def async_see( This method is a coroutine. """ + registry = await async_get_registry(self.hass) if mac is None and dev_id is None: raise HomeAssistantError('Neither mac or device id passed in') if mac is not None: @@ -134,6 +136,14 @@ async def async_see( await device.async_update_ha_state() return + # Guard from calling see on entity registry entities. + entity_id = ENTITY_ID_FORMAT.format(dev_id) + if registry.async_is_registered(entity_id): + LOGGER.error( + "The see service is not supported for this entity %s", + entity_id) + return + # If no device can be found, create it dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) device = Device( diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 9a59855e8c14a5..cd518770c5b216 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,7 +3,7 @@ import json import logging import os -from unittest.mock import call +from unittest.mock import Mock, call from asynctest import patch import pytest @@ -12,9 +12,9 @@ import homeassistant.components.device_tracker as device_tracker from homeassistant.components.device_tracker import const, legacy from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, - ATTR_ICON, CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, - ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_GPS_ACCURACY) + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_GPS_ACCURACY, + ATTR_HIDDEN, ATTR_ICON, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_PLATFORM, + STATE_HOME, STATE_NOT_HOME) from homeassistant.core import State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery @@ -23,8 +23,8 @@ import homeassistant.util.dt as dt_util from tests.common import ( - assert_setup_component, async_fire_time_changed, mock_restore_cache, - patch_yaml_files) + assert_setup_component, async_fire_time_changed, mock_registry, + mock_restore_cache, patch_yaml_files) from tests.components.device_tracker import common TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}} @@ -321,6 +321,26 @@ async def test_see_service(mock_see, hass): assert mock_see.call_args == call(**params) +async def test_see_service_guard_config_entry(hass, mock_device_tracker_conf): + """Test the guard if the device is registered in the entity registry.""" + mock_entry = Mock() + dev_id = 'test' + entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + mock_registry(hass, {entity_id: mock_entry}) + devices = mock_device_tracker_conf + assert await async_setup_component( + hass, device_tracker.DOMAIN, TEST_PLATFORM) + params = { + 'dev_id': dev_id, + 'gps': [.3, .8], + } + + common.async_see(hass, **params) + await hass.async_block_till_done() + + assert not devices + + async def test_new_device_event_fired(hass, mock_device_tracker_conf): """Test that the device tracker will fire an event.""" with assert_setup_component(1, device_tracker.DOMAIN): From d5edbb424a60f59f06ade6b9204ab91bb0cbf016 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 19 Jun 2019 22:32:31 -0400 Subject: [PATCH 286/319] Bump ZHA dependencies. (#24637) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4e327381902b05..9734b10fab22c9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,9 +4,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ - "bellows-homeassistant==0.8.0", + "bellows-homeassistant==0.8.1", "zha-quirks==0.0.14", - "zigpy-deconz==0.1.4", + "zigpy-deconz==0.1.6", "zigpy-homeassistant==0.5.0", "zigpy-xbee-homeassistant==0.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3dfc9029e600d3..25d4963e5ee3f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ batinfo==0.4.2 beautifulsoup4==4.7.1 # homeassistant.components.zha -bellows-homeassistant==0.8.0 +bellows-homeassistant==0.8.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.5.3 @@ -1926,7 +1926,7 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.1.4 +zigpy-deconz==0.1.6 # homeassistant.components.zha zigpy-homeassistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff8b87f594e08f..f0338a877f7bf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -79,7 +79,7 @@ av==6.1.2 axis==25 # homeassistant.components.zha -bellows-homeassistant==0.8.0 +bellows-homeassistant==0.8.1 # homeassistant.components.caldav caldav==0.6.1 From 79b10612aaae79ead11abfd07471e4e8785fbef9 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 20 Jun 2019 22:24:45 +0200 Subject: [PATCH 287/319] Update LIFX brightness during long transitions (#24653) --- homeassistant/components/lifx/light.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 5f462941062480..42d9ecd8c9f545 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -484,7 +484,8 @@ def supported_features(self): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return convert_16_to_8(self.bulb.color[2]) + fade = self.bulb.power_level / 65535 + return convert_16_to_8(int(fade * self.bulb.color[2])) @property def color_temp(self): From da12ceae5b367655504ed40ad25c183c976ec6bd Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Thu, 20 Jun 2019 16:24:03 -0400 Subject: [PATCH 288/319] Upgrade blinkpy==0.14.1 for startup bugfix (#24656) --- homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index abce8a4a0d1c63..98c609731c6d31 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -3,7 +3,7 @@ "name": "Blink", "documentation": "https://www.home-assistant.io/components/blink", "requirements": [ - "blinkpy==0.14.0" + "blinkpy==0.14.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 25d4963e5ee3f2..d713b1e7664868 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ bimmer_connected==0.5.3 bizkaibus==0.1.1 # homeassistant.components.blink -blinkpy==0.14.0 +blinkpy==0.14.1 # homeassistant.components.blinksticklight blinkstick==1.1.8 From d4cab60343557a57fc506feac0ddbd40e2af4bee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Jun 2019 13:22:12 -0700 Subject: [PATCH 289/319] Warn when user tries run custom config flow (#24657) --- homeassistant/config_entries.py | 8 ++++++++ homeassistant/loader.py | 5 +++++ tests/test_config_entries.py | 16 +++++++++++++++- tests/test_loader.py | 10 ++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a018713dee7be4..bfd8c0f2df7661 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -553,6 +553,14 @@ async def _async_create_flow(self, handler_key, *, context, data): _LOGGER.error('Cannot find integration %s', handler_key) raise data_entry_flow.UnknownHandler + # Our config flow list is based on built-in integrations. If overriden, + # we should not load it's config flow. + if not integration.is_built_in: + _LOGGER.error( + 'Config flow is not supported for custom integration %s', + handler_key) + raise data_entry_flow.UnknownHandler + # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs( self.hass, self._hass_config, integration) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index fb2c1bae894106..70fbc3710279ba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -123,6 +123,11 @@ def __init__(self, hass: 'HomeAssistant', pkg_path: str, self.requirements = manifest['requirements'] # type: List[str] _LOGGER.info("Loaded %s from %s", self.domain, pkg_path) + @property + def is_built_in(self) -> bool: + """Test if package is a built-in integration.""" + return self.pkg_path.startswith(PACKAGE_BUILTIN) + def get_component(self) -> ModuleType: """Return the component.""" cache = self.hass.data.setdefault(DATA_COMPONENTS, {}) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 752cb5eb277c5a..9de92f88557bc4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5,7 +5,7 @@ import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow, loader from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component @@ -934,3 +934,17 @@ async def test_entry_reload_error(hass, manager, state): assert len(async_setup_entry.mock_calls) == 0 assert entry.state == state + + +async def test_init_custom_integration(hass): + """Test initializing flow for custom integration.""" + integration = loader.Integration(hass, 'custom_components.hue', None, { + 'name': 'Hue', + 'dependencies': [], + 'requirements': [], + 'domain': 'hue', + }) + with pytest.raises(data_entry_flow.UnknownHandler): + with patch('homeassistant.loader.async_get_integration', + return_value=mock_coro(integration)): + await hass.config_entries.flow.async_init('bla') diff --git a/tests/test_loader.py b/tests/test_loader.py index 8af000c5d05502..cd0cb69270232f 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -152,6 +152,16 @@ def test_integration_properties(hass): assert integration.domain == 'hue' assert integration.dependencies == ['test-dep'] assert integration.requirements == ['test-req==1.0.0'] + assert integration.is_built_in is True + + integration = loader.Integration( + hass, 'custom_components.hue', None, { + 'name': 'Philips Hue', + 'domain': 'hue', + 'dependencies': ['test-dep'], + 'requirements': ['test-req==1.0.0'], + }) + assert integration.is_built_in is False async def test_integrations_only_once(hass): From a868685ac900e5cf53e64f7f752e53db53b406cc Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 20 Jun 2019 15:25:32 -0500 Subject: [PATCH 290/319] Bump pysmartthings (#24659) --- homeassistant/components/smartthings/manifest.json | 2 +- homeassistant/components/smartthings/smartapp.py | 12 ++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 75b113354ff74e..621da91f4f8c5f 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/components/smartthings", "requirements": [ "pysmartapp==0.3.2", - "pysmartthings==0.6.8" + "pysmartthings==0.6.9" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 9aa44d26f2dd7e..68999914d71cce 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -282,9 +282,9 @@ async def create_subscription(target: str): await api.create_subscription(sub) _LOGGER.debug("Created subscription for '%s' under app '%s'", target, installed_app_id) - except Exception: # pylint:disable=broad-except - _LOGGER.exception("Failed to create subscription for '%s' under " - "app '%s'", target, installed_app_id) + except Exception as error: # pylint:disable=broad-except + _LOGGER.error("Failed to create subscription for '%s' under app " + "'%s': %s", target, installed_app_id, error) async def delete_subscription(sub: SubscriptionEntity): try: @@ -293,9 +293,9 @@ async def delete_subscription(sub: SubscriptionEntity): _LOGGER.debug("Removed subscription for '%s' under app '%s' " "because it was no longer needed", sub.capability, installed_app_id) - except Exception: # pylint:disable=broad-except - _LOGGER.exception("Failed to remove subscription for '%s' under " - "app '%s'", sub.capability, installed_app_id) + except Exception as error: # pylint:disable=broad-except + _LOGGER.error("Failed to remove subscription for '%s' under app " + "'%s': %s", sub.capability, installed_app_id, error) # Build set of capabilities and prune unsupported ones capabilities = set() diff --git a/requirements_all.txt b/requirements_all.txt index d713b1e7664868..1986d325ae8ad7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1351,7 +1351,7 @@ pysma==0.3.1 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.6.8 +pysmartthings==0.6.9 # homeassistant.components.smarty pysmarty==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0338a877f7bf5..96d13c97e9b953 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -290,7 +290,7 @@ pyqwikswitch==0.93 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.6.8 +pysmartthings==0.6.9 # homeassistant.components.sonos pysonos==0.0.16 From 198432f2220af9dfb61ceaa3421c0aff27b0af0e Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 21 Jun 2019 17:47:56 +0200 Subject: [PATCH 291/319] Prefere binary with wheels (#24669) --- homeassistant/util/package.py | 2 +- tests/util/test_package.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 6f6d03d67b6491..bc2245fd208dad 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -66,7 +66,7 @@ def install_package(package: str, upgrade: bool = True, if constraints is not None: args += ['--constraint', constraints] if find_links is not None: - args += ['--find-links', find_links] + args += ['--find-links', find_links, '--prefer-binary'] if target: assert not is_virtual_env() # This only works if not running in venv diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 3751c0569074b2..623d79ddfe0872 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -178,7 +178,7 @@ def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv): mock_popen.call_args == call([ mock_sys.executable, '-m', 'pip', 'install', '--quiet', - TEST_NEW_REQ, '--find-links', link + TEST_NEW_REQ, '--find-links', link, '--prefer-binary' ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) ) assert mock_popen.return_value.communicate.call_count == 1 From 1761a7133812ca7fa5285f3e2f880a03c31d1ced Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Jun 2019 09:27:58 -0700 Subject: [PATCH 292/319] Bumped version to 0.95.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3466918dbd3086..2e58571add3c60 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 95 -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 7f169e97ca213ddd79b76526b9cece7661897d63 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 21 Jun 2019 20:08:19 +0200 Subject: [PATCH 293/319] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 8f250f16ce3456..d6395dad5aca3f 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -8,7 +8,7 @@ trigger: pr: none variables: - name: versionBuilder - value: '3.2' + value: '4.2' - group: docker - group: github - group: twine From 9b096322e1124404e18157a6f68b084dc124d07a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 08:26:50 -0700 Subject: [PATCH 294/319] Updated frontend to 20190624.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 355a26931fe457..c0d9c95849bf4f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190620.0" + "home-assistant-frontend==20190624.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 31a2e79e06d666..b73b4ba784bedb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.15 -home-assistant-frontend==20190620.0 +home-assistant-frontend==20190624.0 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1986d325ae8ad7..cb267d08ff7e8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -595,7 +595,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190620.0 +home-assistant-frontend==20190624.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96d13c97e9b953..feb08c3d226280 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,7 +160,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190620.0 +home-assistant-frontend==20190624.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 23722dc291028681bc8d9f17386af0fd543fdc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Fri, 21 Jun 2019 22:16:28 +0200 Subject: [PATCH 295/319] Allow extra js modules to be included in frontend (#24675) * Add extra_module_url and extra_module_url_es5 to frontend options * Address review comments --- homeassistant/components/frontend/__init__.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a18ed6eb3d1d2e..b295c94ec31701 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,6 +24,8 @@ CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' +CONF_EXTRA_MODULE_URL = 'extra_module_url' +CONF_EXTRA_JS_URL_ES5 = 'extra_js_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' EVENT_PANELS_UPDATED = 'panels_updated' @@ -55,6 +57,8 @@ DATA_JS_VERSION = 'frontend_js_version' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_EXTRA_HTML_URL_ES5 = 'frontend_extra_html_url_es5' +DATA_EXTRA_MODULE_URL = 'frontend_extra_module_url' +DATA_EXTRA_JS_URL_ES5 = 'frontend_extra_js_url_es5' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' DEFAULT_THEME = 'default' @@ -71,6 +75,10 @@ }), vol.Optional(CONF_EXTRA_HTML_URL): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXTRA_MODULE_URL): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXTRA_JS_URL_ES5): + vol.All(cv.ensure_list, [cv.string]), # We no longer use these options. vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all, vol.Optional(CONF_JS_VERSION): cv.match_all, @@ -184,6 +192,15 @@ def add_extra_html_url(hass, url, es5=False): url_set.add(url) +def add_extra_js_url(hass, url, es5=False): + """Register extra js or module url to load.""" + key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL + url_set = hass.data.get(key) + if url_set is None: + url_set = hass.data[key] = set() + url_set.add(url) + + def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -249,6 +266,18 @@ async def async_setup(hass, config): for url in conf.get(CONF_EXTRA_HTML_URL, []): add_extra_html_url(hass, url, False) + if DATA_EXTRA_MODULE_URL not in hass.data: + hass.data[DATA_EXTRA_MODULE_URL] = set() + + for url in conf.get(CONF_EXTRA_MODULE_URL, []): + add_extra_js_url(hass, url) + + if DATA_EXTRA_JS_URL_ES5 not in hass.data: + hass.data[DATA_EXTRA_JS_URL_ES5] = set() + + for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): + add_extra_js_url(hass, url, True) + _async_setup_themes(hass, conf.get(CONF_THEMES)) return True @@ -396,6 +425,8 @@ async def get(self, request: web.Request): text=template.render( theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[DATA_EXTRA_HTML_URL], + extra_modules=hass.data[DATA_EXTRA_MODULE_URL], + extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5], ), content_type='text/html' ) From fb0cb43261b0c0908389bf8014b5a082ed089bf7 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 22 Jun 2019 13:39:33 +0200 Subject: [PATCH 296/319] Fix time expression parsing (#24696) --- homeassistant/util/dt.py | 4 ++-- tests/util/test_dt.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index b3f7cdd434c14c..b0c803990649b6 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -221,7 +221,7 @@ def parse_time_expression(parameter: Any, min_value: int, max_value: int) \ if parameter is None or parameter == MATCH_ALL: res = [x for x in range(min_value, max_value + 1)] elif isinstance(parameter, str) and parameter.startswith('/'): - parameter = float(parameter[1:]) + parameter = int(parameter[1:]) res = [x for x in range(min_value, max_value + 1) if x % parameter == 0] elif not hasattr(parameter, '__iter__'): @@ -302,7 +302,7 @@ def _lower_bound(arr: List[int], cmp: int) -> Optional[int]: next_hour = _lower_bound(hours, result.hour) if next_hour != result.hour: # We're in the next hour. Seconds+minutes needs to be reset. - result.replace(second=seconds[0], minute=minutes[0]) + result = result.replace(second=seconds[0], minute=minutes[0]) if next_hour is None: # No minute to match in this day. Roll-over to next day. diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 61f10ab1bf6f17..19d96227a44664 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -213,7 +213,7 @@ def find(dt, hour, minute, second): assert datetime(2018, 10, 7, 10, 30, 0) == \ find(datetime(2018, 10, 7, 10, 30, 0), '*', '/30', 0) - assert datetime(2018, 10, 7, 12, 30, 30) == \ + assert datetime(2018, 10, 7, 12, 0, 30) == \ find(datetime(2018, 10, 7, 10, 30, 0), '/3', '/30', [30, 45]) assert datetime(2018, 10, 8, 5, 0, 0) == \ From 9c85ba5b669aafefe2a6ecf639c49b634e0b2bd1 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 22 Jun 2019 15:05:36 -0400 Subject: [PATCH 297/319] ZHA fix device type mappings (#24699) --- homeassistant/components/zha/core/registries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index a7b89362de9369..8a6832caed6de1 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -131,8 +131,6 @@ def get_deconz_radio(): zha.DeviceType.DIMMABLE_LIGHT: LIGHT, zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, zha.DeviceType.ON_OFF_LIGHT_SWITCH: SWITCH, - zha.DeviceType.DIMMER_SWITCH: LIGHT, - zha.DeviceType.COLOR_DIMMER_SWITCH: LIGHT, zha.DeviceType.ON_OFF_BALLAST: SWITCH, zha.DeviceType.DIMMABLE_BALLAST: LIGHT, zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH, @@ -202,6 +200,8 @@ def get_deconz_radio(): REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_CONTROLLER) REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.REMOTE_CONTROL) REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.SCENE_SELECTOR) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.DIMMER_SWITCH) + REMOTE_DEVICE_TYPES[zhap].append(zha.DeviceType.COLOR_DIMMER_SWITCH) zllp = zll.PROFILE_ID REMOTE_DEVICE_TYPES[zllp].append(zll.DeviceType.COLOR_CONTROLLER) From 4a8149627e7218f6b2fc5d500bc7022832f2bd88 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Sun, 23 Jun 2019 07:50:04 +0200 Subject: [PATCH 298/319] Bump version pyatmo to 2.0.1 (#24703) --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index dd72dab576337a..d057dcd6e80689 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/components/netatmo", "requirements": [ - "pyatmo==2.0.0" + "pyatmo==2.0.1" ], "dependencies": [ "webhook" diff --git a/requirements_all.txt b/requirements_all.txt index cb267d08ff7e8f..4d18f6c730c3f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1018,7 +1018,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==2.0.0 +pyatmo==2.0.1 # homeassistant.components.apple_tv pyatv==0.3.12 From 14b62120fdc12b2ae8acdce97e0618819ec7703b Mon Sep 17 00:00:00 2001 From: Oleg Kurapov Date: Sun, 23 Jun 2019 21:11:25 +0200 Subject: [PATCH 299/319] Extend websocket method usage to port 8002 in Samsung TV media player (#24716) --- homeassistant/components/samsungtv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 6b2235fe7e6c77..6f928e830dc3d6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -120,7 +120,7 @@ def __init__(self, host, port, name, timeout, mac, uuid): 'timeout': timeout, } - if self._config['port'] == 8001: + if self._config['port'] in (8001, 8002): self._config['method'] = 'websocket' else: self._config['method'] = 'legacy' From 48e97426582fd10796262e1be3d509d0551162e7 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 23 Jun 2019 13:43:19 -0400 Subject: [PATCH 300/319] Update ZHA dependencies (#24718) * update deps and remove legacy constants bridge * run deps script and fix test import --- homeassistant/components/zha/const.py | 4 ---- homeassistant/components/zha/device_entity.py | 2 +- homeassistant/components/zha/light.py | 2 +- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- tests/components/zha/test_config_flow.py | 2 +- 7 files changed, 8 insertions(+), 12 deletions(-) delete mode 100644 homeassistant/components/zha/const.py diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py deleted file mode 100644 index 1ccc3e0ea25357..00000000000000 --- a/homeassistant/components/zha/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Backwards compatible constants bridge.""" -# pylint: disable=W0614,W0401 -from .core.const import * # noqa: F401,F403 -from .core.registries import * # noqa: F401,F403 diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index b3cb19f2c5ac73..c61c0347704b53 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -7,7 +7,7 @@ from homeassistant.core import callback from homeassistant.util import slugify from .entity import ZhaEntity -from .const import POWER_CONFIGURATION_CHANNEL, SIGNAL_STATE_ATTR +from .core.const import POWER_CONFIGURATION_CHANNEL, SIGNAL_STATE_ATTR _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 64c515b06b0919..9e0f2739290acc 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -9,7 +9,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util -from .const import ( +from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, COLOR_CHANNEL, ON_OFF_CHANNEL, LEVEL_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9734b10fab22c9..e8f417b8eb085d 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,9 +5,9 @@ "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ "bellows-homeassistant==0.8.1", - "zha-quirks==0.0.14", + "zha-quirks==0.0.15", "zigpy-deconz==0.1.6", - "zigpy-homeassistant==0.5.0", + "zigpy-homeassistant==0.6.1", "zigpy-xbee-homeassistant==0.3.0" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 4d18f6c730c3f2..10b756da8cc0e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1917,7 +1917,7 @@ zengge==0.2 zeroconf==0.23.0 # homeassistant.components.zha -zha-quirks==0.0.14 +zha-quirks==0.0.15 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -1929,7 +1929,7 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.1.6 # homeassistant.components.zha -zigpy-homeassistant==0.5.0 +zigpy-homeassistant==0.6.1 # homeassistant.components.zha zigpy-xbee-homeassistant==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index feb08c3d226280..03ff023866d161 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -371,4 +371,4 @@ wakeonlan==1.1.6 zeroconf==0.23.0 # homeassistant.components.zha -zigpy-homeassistant==0.5.0 +zigpy-homeassistant==0.6.1 diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e46f1849fa128b..a05de08f804140 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for ZHA config flow.""" from asynctest import patch from homeassistant.components.zha import config_flow -from homeassistant.components.zha.const import DOMAIN +from homeassistant.components.zha.core.const import DOMAIN from tests.common import MockConfigEntry From 2c5080e382cf2cddc7e341d64624913be3708710 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 24 Jun 2019 10:05:34 -0500 Subject: [PATCH 301/319] Add show_as_state options to Life360 (#24725) --- homeassistant/components/life360/__init__.py | 7 +++++- homeassistant/components/life360/const.py | 4 ++++ .../components/life360/device_tracker.py | 22 ++++++++++++++++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index a42dcf9b72c10b..b59ace1d1ffa2e 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -16,7 +16,8 @@ from .const import ( CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS, - CONF_WARNING_THRESHOLD, DOMAIN) + CONF_SHOW_AS_STATE, CONF_WARNING_THRESHOLD, DOMAIN, SHOW_DRIVING, + SHOW_MOVING) from .helpers import get_api _LOGGER = logging.getLogger(__name__) @@ -25,6 +26,8 @@ CONF_ACCOUNTS = 'accounts' +SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] + def _excl_incl_list_to_filter_dict(value): return { @@ -108,6 +111,8 @@ def _thresholds(config): vol.All(vol.Any(None, cv.string), _prefix), vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_SHOW_AS_STATE, default=[]): vol.All( + cv.ensure_list, [vol.In(SHOW_AS_STATE_OPTS)]), vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD, }), _thresholds diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py index 4c4016c6b4039c..602c5ee48468c5 100644 --- a/homeassistant/components/life360/const.py +++ b/homeassistant/components/life360/const.py @@ -8,4 +8,8 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_MAX_UPDATE_WAIT = 'max_update_wait' CONF_MEMBERS = 'members' +CONF_SHOW_AS_STATE = 'show_as_state' CONF_WARNING_THRESHOLD = 'warning_threshold' + +SHOW_DRIVING = 'driving' +SHOW_MOVING = 'moving' diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 00201f1aa0d7c6..cf69d8b656a243 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -8,18 +8,21 @@ from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL from homeassistant.components.device_tracker.const import ( ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT) +from homeassistant.components.zone import async_active_zone from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_ENTITY_ID, CONF_PREFIX, LENGTH_FEET, LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_time_interval +from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.distance import convert import homeassistant.util.dt as dt_util from .const import ( CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS, - CONF_WARNING_THRESHOLD, DOMAIN) + CONF_SHOW_AS_STATE, CONF_WARNING_THRESHOLD, DOMAIN, SHOW_DRIVING, + SHOW_MOVING) _LOGGER = logging.getLogger(__name__) @@ -107,6 +110,7 @@ def __init__(self, hass, config, see, apis): self._circles_filter = config.get(CONF_CIRCLES) self._members_filter = config.get(CONF_MEMBERS) self._driving_speed = config.get(CONF_DRIVING_SPEED) + self._show_as_state = config[CONF_SHOW_AS_STATE] self._apis = apis self._errs = {} self._error_threshold = config[CONF_ERROR_THRESHOLD] @@ -266,8 +270,20 @@ def _update_member(self, member, dev_id): ATTR_WIFI_ON: _bool_attr_from_int(loc.get('wifiState')), } - self._see(dev_id=dev_id, gps=(lat, lon), gps_accuracy=gps_accuracy, - battery=battery, attributes=attrs, + # If user wants driving or moving to be shown as state, and current + # location is not in a HA zone, then set location name accordingly. + loc_name = None + active_zone = run_callback_threadsafe( + self._hass.loop, async_active_zone, self._hass, lat, lon, + gps_accuracy).result() + if not active_zone: + if SHOW_DRIVING in self._show_as_state and driving is True: + loc_name = SHOW_DRIVING + elif SHOW_MOVING in self._show_as_state and moving is True: + loc_name = SHOW_MOVING + + self._see(dev_id=dev_id, location_name=loc_name, gps=(lat, lon), + gps_accuracy=gps_accuracy, battery=battery, attributes=attrs, picture=member.get('avatar')) def _update_members(self, members, members_updated): From 75ec8558226cb86a79e73e71989f09cbe2435805 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 08:33:21 -0700 Subject: [PATCH 302/319] Bumped version to 0.95.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2e58571add3c60..a9a3e9daa4c5d9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 95 -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 34231383ec5a12734d57f430873ddce4b3f8365f Mon Sep 17 00:00:00 2001 From: Evan Bruhn Date: Tue, 25 Jun 2019 02:36:39 +1000 Subject: [PATCH 303/319] Save cached logi_circle tokens in config folder (#24726) Instead of the working directory, which it's doing currently. Matches pattern observed on Abode, Ring, Skybell integrations. --- homeassistant/components/logi_circle/__init__.py | 2 +- homeassistant/components/logi_circle/config_flow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 4e5ad0c5aebb75..2f34366aafa630 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -105,7 +105,7 @@ async def async_setup_entry(hass, entry): client_secret=entry.data[CONF_CLIENT_SECRET], api_key=entry.data[CONF_API_KEY], redirect_uri=entry.data[CONF_REDIRECT_URI], - cache_file=DEFAULT_CACHEDB + cache_file=hass.config.path(DEFAULT_CACHEDB) ) if not logi_circle.authorized: diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index 728ca27ba51511..7f1f085bbac246 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -157,7 +157,7 @@ async def _async_create_session(self, code): client_secret=client_secret, api_key=api_key, redirect_uri=redirect_uri, - cache_file=DEFAULT_CACHEDB) + cache_file=self.hass.config.path(DEFAULT_CACHEDB)) try: with async_timeout.timeout(_TIMEOUT): From 82cad58b8dd06683d5be54fe30c60905983ff69c Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 24 Jun 2019 16:57:07 -0400 Subject: [PATCH 304/319] Update ZHA dependencies. (#24736) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e8f417b8eb085d..15fcf38100fb11 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ - "bellows-homeassistant==0.8.1", + "bellows-homeassistant==0.8.2", "zha-quirks==0.0.15", "zigpy-deconz==0.1.6", "zigpy-homeassistant==0.6.1", diff --git a/requirements_all.txt b/requirements_all.txt index 10b756da8cc0e1..f16b59d2afe7d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ batinfo==0.4.2 beautifulsoup4==4.7.1 # homeassistant.components.zha -bellows-homeassistant==0.8.1 +bellows-homeassistant==0.8.2 # homeassistant.components.bmw_connected_drive bimmer_connected==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03ff023866d161..c59e04ef8e6e8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -79,7 +79,7 @@ av==6.1.2 axis==25 # homeassistant.components.zha -bellows-homeassistant==0.8.1 +bellows-homeassistant==0.8.2 # homeassistant.components.caldav caldav==0.6.1 From ec777a802c769232e184f0bfca7a5207cdf82001 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 14:46:32 -0700 Subject: [PATCH 305/319] AdGuard to update entry (#24737) --- .../components/adguard/config_flow.py | 27 +++++- homeassistant/components/adguard/strings.json | 5 +- tests/components/adguard/test_config_flow.py | 87 +++++++++++++++++-- 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index 7e144a76e222e0..9ef789f83a8f4b 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -104,12 +104,33 @@ async def async_step_hassio(self, user_input=None): This flow is triggered by the discovery component. """ - if self._async_current_entries(): + entries = self._async_current_entries() + + if not entries: + self._hassio_discovery = user_input + return await self.async_step_hassio_confirm() + + cur_entry = entries[0] + + if (cur_entry.data[CONF_HOST] == user_input[CONF_HOST] and + cur_entry.data[CONF_PORT] == user_input[CONF_PORT]): return self.async_abort(reason='single_instance_allowed') - self._hassio_discovery = user_input + is_loaded = cur_entry.state == config_entries.ENTRY_STATE_LOADED + + if is_loaded: + await self.hass.config_entries.async_unload(cur_entry.entry_id) + + self.hass.config_entries.async_update_entry(cur_entry, data={ + **cur_entry.data, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }) + + if is_loaded: + await self.hass.config_entries.async_setup(cur_entry.entry_id) - return await self.async_step_hassio_confirm() + return self.async_abort(reason='existing_instance_updated') async def async_step_hassio_confirm(self, user_input=None): """Confirm Hass.io discovery.""" diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index c88f7085e341c0..b3966bca8206a3 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -23,7 +23,8 @@ "connection_error": "Failed to connect." }, "abort": { - "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed.", + "existing_instance_updated": "Updated existing configuration." } } -} \ No newline at end of file +} diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 451fd1436d411d..41af02345a9ed5 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -1,14 +1,16 @@ """Tests for the AdGuard Home config flow.""" +from unittest.mock import patch + import aiohttp -from homeassistant import data_entry_flow +from homeassistant import data_entry_flow, config_entries from homeassistant.components.adguard import config_flow from homeassistant.components.adguard.const import DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_coro FIXTURE_USER_INPUT = { CONF_HOST: '127.0.0.1', @@ -94,17 +96,90 @@ async def test_integration_already_exists(hass): async def test_hassio_single_instance(hass): """Test we only allow a single config flow.""" - MockConfigEntry(domain='adguard', data={'host': '1.2.3.4'}).add_to_hass( - hass - ) + MockConfigEntry(domain='adguard', data={ + 'host': 'mock-adguard', + 'port': '3000' + }).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - 'adguard', context={'source': 'hassio'} + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': '3000', + }, + context={'source': 'hassio'} ) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'single_instance_allowed' +async def test_hassio_update_instance_not_running(hass): + """Test we only allow a single config flow.""" + entry = MockConfigEntry(domain='adguard', data={ + 'host': 'mock-adguard', + 'port': '3000' + }) + entry.add_to_hass(hass) + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard-updated', + 'port': '3000', + }, + context={'source': 'hassio'} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'existing_instance_updated' + + +async def test_hassio_update_instance_running(hass): + """Test we only allow a single config flow.""" + entry = MockConfigEntry(domain='adguard', data={ + 'host': 'mock-adguard', + 'port': '3000', + 'verify_ssl': False, + 'username': None, + 'password': None, + 'ssl': False, + }) + entry.add_to_hass(hass) + + with patch.object( + hass.config_entries, 'async_forward_entry_setup', + side_effect=lambda *_: mock_coro(True) + ) as mock_load: + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(mock_load.mock_calls) == 2 + + with patch.object( + hass.config_entries, 'async_forward_entry_unload', + side_effect=lambda *_: mock_coro(True) + ) as mock_unload, patch.object( + hass.config_entries, 'async_forward_entry_setup', + side_effect=lambda *_: mock_coro(True) + ) as mock_load: + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard-updated', + 'port': '3000', + }, + context={'source': 'hassio'} + ) + assert len(mock_unload.mock_calls) == 2 + assert len(mock_load.mock_calls) == 2 + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'existing_instance_updated' + assert entry.data['host'] == 'mock-adguard-updated' + + async def test_hassio_confirm(hass, aioclient_mock): """Test we can finish a config flow.""" aioclient_mock.get( From f71d4312e2bb73f7d9ac6ac58c087ea4ebc639c8 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 24 Jun 2019 23:59:15 +0200 Subject: [PATCH 306/319] Update pysonos to 0.0.17 (#24740) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 0aee135652dffe..98f5784a028a2b 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/sonos", "requirements": [ - "pysonos==0.0.16" + "pysonos==0.0.17" ], "dependencies": [], "ssdp": { diff --git a/requirements_all.txt b/requirements_all.txt index f16b59d2afe7d7..f35cfa955365af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1360,7 +1360,7 @@ pysmarty==0.8 pysnmp==4.4.9 # homeassistant.components.sonos -pysonos==0.0.16 +pysonos==0.0.17 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c59e04ef8e6e8d..376c0d03ae5ec1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -293,7 +293,7 @@ pysmartapp==0.3.2 pysmartthings==0.6.9 # homeassistant.components.sonos -pysonos==0.0.16 +pysonos==0.0.17 # homeassistant.components.spc pyspcwebgw==0.4.0 From d699a550c858b4b4b6244dcaa78f11f3d5a7c28d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 15:01:17 -0700 Subject: [PATCH 307/319] Bumped version to 0.95.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a9a3e9daa4c5d9..84444e9d5801ae 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 95 -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 0f5c9b4af323cff8444b8528a8a24ab812662c72 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 22:07:39 -0700 Subject: [PATCH 308/319] Updated frontend to 20190624.1 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c0d9c95849bf4f..2dae7aaa1ec148 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190624.0" + "home-assistant-frontend==20190624.1" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b73b4ba784bedb..c704336ddaf451 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.15 -home-assistant-frontend==20190624.0 +home-assistant-frontend==20190624.1 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index f35cfa955365af..8f511a2a64c64e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -595,7 +595,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190624.0 +home-assistant-frontend==20190624.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 376c0d03ae5ec1..36bb3c0ae8499d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,7 +160,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190624.0 +home-assistant-frontend==20190624.1 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From 327fe63047ce7fb7b25f3393cab1b27f40b0cfd0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Jun 2019 02:17:21 -0700 Subject: [PATCH 309/319] Clean up Google Config (#24663) * Clean up Google Config * Lint * pylint * pylint2 --- .../components/cloud/alexa_config.py | 244 +++++++++++++++ homeassistant/components/cloud/client.py | 293 +----------------- .../components/cloud/google_config.py | 52 ++++ .../components/google_assistant/helpers.py | 32 +- .../components/google_assistant/http.py | 61 ++-- tests/components/cloud/__init__.py | 20 +- tests/components/cloud/conftest.py | 2 +- tests/components/cloud/test_client.py | 80 +++-- tests/components/cloud/test_http_api.py | 14 +- tests/components/google_assistant/__init__.py | 29 +- .../google_assistant/test_smart_home.py | 11 +- .../components/google_assistant/test_trait.py | 9 +- 12 files changed, 460 insertions(+), 387 deletions(-) create mode 100644 homeassistant/components/cloud/alexa_config.py create mode 100644 homeassistant/components/cloud/google_config.py diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py new file mode 100644 index 00000000000000..746f01dd04b013 --- /dev/null +++ b/homeassistant/components/cloud/alexa_config.py @@ -0,0 +1,244 @@ +"""Alexa configuration for Home Assistant Cloud.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +from hass_nabucasa import cloud_api + +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers import entity_registry +from homeassistant.helpers.event import async_call_later +from homeassistant.util.dt import utcnow +from homeassistant.components.alexa import ( + config as alexa_config, + errors as alexa_errors, + entities as alexa_entities, + state_report as alexa_state_report, +) + + +from .const import ( + CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, + RequireRelink +) + +_LOGGER = logging.getLogger(__name__) + +# Time to wait when entity preferences have changed before syncing it to +# the cloud. +SYNC_DELAY = 1 + + +class AlexaConfig(alexa_config.AbstractConfig): + """Alexa Configuration.""" + + def __init__(self, hass, config, prefs, cloud): + """Initialize the Alexa config.""" + super().__init__(hass) + self._config = config + self._prefs = prefs + self._cloud = cloud + self._token = None + self._token_valid = None + self._cur_entity_prefs = prefs.alexa_entity_configs + self._alexa_sync_unsub = None + self._endpoint = None + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated + ) + + @property + def enabled(self): + """Return if Alexa is enabled.""" + return self._prefs.alexa_enabled + + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._prefs.alexa_report_state + + @property + def endpoint(self): + """Endpoint for report state.""" + if self._endpoint is None: + raise ValueError("No endpoint available. Fetch access token first") + + return self._endpoint + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG, {}) + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config[CONF_FILTER].empty_filter: + return self._config[CONF_FILTER](entity_id) + + entity_configs = self._prefs.alexa_entity_configs + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + async def async_get_access_token(self): + """Get an access token.""" + if self._token_valid is not None and self._token_valid < utcnow(): + return self._token + + resp = await cloud_api.async_alexa_access_token(self._cloud) + body = await resp.json() + + if resp.status == 400: + if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): + raise RequireRelink + + raise alexa_errors.NoTokenAvailable + + self._token = body['access_token'] + self._endpoint = body['event_endpoint'] + self._token_valid = utcnow() + timedelta(seconds=body['expires_in']) + return self._token + + async def _async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state != self.is_reporting_states: + if self.should_report_state: + await self.async_enable_proactive_mode() + else: + await self.async_disable_proactive_mode() + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + if (self._cur_entity_prefs is prefs.alexa_entity_configs or + not self._config[CONF_FILTER].empty_filter): + return + + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs) + + async def _sync_prefs(self, _now): + """Sync the updated preferences to Alexa.""" + self._alexa_sync_unsub = None + old_prefs = self._cur_entity_prefs + new_prefs = self._prefs.alexa_entity_configs + + seen = set() + to_update = [] + to_remove = [] + + for entity_id, info in old_prefs.items(): + seen.add(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) + + if entity_id in new_prefs: + new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE) + else: + new_expose = None + + if old_expose == new_expose: + continue + + if new_expose: + to_update.append(entity_id) + else: + to_remove.append(entity_id) + + # Now all the ones that are in new prefs but never were in old prefs + for entity_id, info in new_prefs.items(): + if entity_id in seen: + continue + + new_expose = info.get(PREF_SHOULD_EXPOSE) + + if new_expose is None: + continue + + # Only test if we should expose. It can never be a remove action, + # as it didn't exist in old prefs object. + if new_expose: + to_update.append(entity_id) + + # We only set the prefs when update is successful, that way we will + # retry when next change comes in. + if await self._sync_helper(to_update, to_remove): + self._cur_entity_prefs = new_prefs + + async def async_sync_entities(self): + """Sync all entities to Alexa.""" + to_update = [] + to_remove = [] + + for entity in alexa_entities.async_get_entities(self.hass, self): + if self.should_expose(entity.entity_id): + to_update.append(entity.entity_id) + else: + to_remove.append(entity.entity_id) + + return await self._sync_helper(to_update, to_remove) + + async def _sync_helper(self, to_update, to_remove) -> bool: + """Sync entities to Alexa. + + Return boolean if it was successful. + """ + if not to_update and not to_remove: + return True + + tasks = [] + + if to_update: + tasks.append(alexa_state_report.async_send_add_or_update_message( + self.hass, self, to_update + )) + + if to_remove: + tasks.append(alexa_state_report.async_send_delete_message( + self.hass, self, to_remove + )) + + try: + with async_timeout.timeout(10): + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + + return True + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout trying to sync entitites to Alexa") + return False + + except aiohttp.ClientError as err: + _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) + return False + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled or not self._cloud.is_logged_in: + return + + action = event.data['action'] + entity_id = event.data['entity_id'] + to_update = [] + to_remove = [] + + if action == 'create' and self.should_expose(entity_id): + to_update.append(entity_id) + elif action == 'remove' and self.should_expose(entity_id): + to_remove.append(entity_id) + + await self._sync_helper(to_update, to_remove) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f8cfc255aa457f..16a05b0d127222 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,257 +2,24 @@ import asyncio from pathlib import Path from typing import Any, Dict -from datetime import timedelta import logging import aiohttp -import async_timeout -from hass_nabucasa import cloud_api from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback -from homeassistant.components.alexa import ( - config as alexa_config, - errors as alexa_errors, - smart_home as alexa_sh, - entities as alexa_entities, - state_report as alexa_state_report, -) -from homeassistant.components.google_assistant import ( - helpers as ga_h, smart_home as ga) -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.helpers.event import async_call_later +from homeassistant.components.google_assistant import smart_home as ga from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers import entity_registry from homeassistant.util.aiohttp import MockRequest -from homeassistant.util.dt import utcnow +from homeassistant.components.alexa import smart_home as alexa_sh -from . import utils -from .const import ( - CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE, - PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, - PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA, RequireRelink) +from . import utils, alexa_config, google_config +from .const import DISPATCHER_REMOTE_UPDATE from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) -# Time to wait when entity preferences have changed before syncing it to -# the cloud. -SYNC_DELAY = 1 - - -class AlexaConfig(alexa_config.AbstractConfig): - """Alexa Configuration.""" - - def __init__(self, hass, config, prefs, cloud): - """Initialize the Alexa config.""" - super().__init__(hass) - self._config = config - self._prefs = prefs - self._cloud = cloud - self._token = None - self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs - self._alexa_sync_unsub = None - self._endpoint = None - - prefs.async_listen_updates(self._async_prefs_updated) - hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated - ) - - @property - def enabled(self): - """Return if Alexa is enabled.""" - return self._prefs.alexa_enabled - - @property - def supports_auth(self): - """Return if config supports auth.""" - return True - - @property - def should_report_state(self): - """Return if states should be proactively reported.""" - return self._prefs.alexa_report_state - - @property - def endpoint(self): - """Endpoint for report state.""" - if self._endpoint is None: - raise ValueError("No endpoint available. Fetch access token first") - - return self._endpoint - - @property - def entity_config(self): - """Return entity config.""" - return self._config.get(CONF_ENTITY_CONFIG, {}) - - def should_expose(self, entity_id): - """If an entity should be exposed.""" - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - - entity_configs = self._prefs.alexa_entity_configs - entity_config = entity_configs.get(entity_id, {}) - return entity_config.get( - PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) - - async def async_get_access_token(self): - """Get an access token.""" - if self._token_valid is not None and self._token_valid < utcnow(): - return self._token - - resp = await cloud_api.async_alexa_access_token(self._cloud) - body = await resp.json() - - if resp.status == 400: - if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): - raise RequireRelink - - raise alexa_errors.NoTokenAvailable - - self._token = body['access_token'] - self._endpoint = body['event_endpoint'] - self._token_valid = utcnow() + timedelta(seconds=body['expires_in']) - return self._token - - async def _async_prefs_updated(self, prefs): - """Handle updated preferences.""" - if self.should_report_state != self.is_reporting_states: - if self.should_report_state: - await self.async_enable_proactive_mode() - else: - await self.async_disable_proactive_mode() - - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - if (self._cur_entity_prefs is prefs.alexa_entity_configs or - not self._config[CONF_FILTER].empty_filter): - return - - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs) - - async def _sync_prefs(self, _now): - """Sync the updated preferences to Alexa.""" - self._alexa_sync_unsub = None - old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs - - seen = set() - to_update = [] - to_remove = [] - - for entity_id, info in old_prefs.items(): - seen.add(entity_id) - old_expose = info.get(PREF_SHOULD_EXPOSE) - - if entity_id in new_prefs: - new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE) - else: - new_expose = None - - if old_expose == new_expose: - continue - - if new_expose: - to_update.append(entity_id) - else: - to_remove.append(entity_id) - - # Now all the ones that are in new prefs but never were in old prefs - for entity_id, info in new_prefs.items(): - if entity_id in seen: - continue - - new_expose = info.get(PREF_SHOULD_EXPOSE) - - if new_expose is None: - continue - - # Only test if we should expose. It can never be a remove action, - # as it didn't exist in old prefs object. - if new_expose: - to_update.append(entity_id) - - # We only set the prefs when update is successful, that way we will - # retry when next change comes in. - if await self._sync_helper(to_update, to_remove): - self._cur_entity_prefs = new_prefs - - async def async_sync_entities(self): - """Sync all entities to Alexa.""" - to_update = [] - to_remove = [] - - for entity in alexa_entities.async_get_entities(self.hass, self): - if self.should_expose(entity.entity_id): - to_update.append(entity.entity_id) - else: - to_remove.append(entity.entity_id) - - return await self._sync_helper(to_update, to_remove) - - async def _sync_helper(self, to_update, to_remove) -> bool: - """Sync entities to Alexa. - - Return boolean if it was successful. - """ - if not to_update and not to_remove: - return True - - tasks = [] - - if to_update: - tasks.append(alexa_state_report.async_send_add_or_update_message( - self.hass, self, to_update - )) - - if to_remove: - tasks.append(alexa_state_report.async_send_delete_message( - self.hass, self, to_remove - )) - - try: - with async_timeout.timeout(10): - await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) - - return True - - except asyncio.TimeoutError: - _LOGGER.warning("Timeout trying to sync entitites to Alexa") - return False - - except aiohttp.ClientError as err: - _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) - return False - - async def _handle_entity_registry_updated(self, event): - """Handle when entity registry updated.""" - if not self.enabled or not self._cloud.is_logged_in: - return - - action = event.data['action'] - entity_id = event.data['entity_id'] - to_update = [] - to_remove = [] - - if action == 'create' and self.should_expose(entity_id): - to_update.append(entity_id) - elif action == 'remove' and self.should_expose(entity_id): - to_remove.append(entity_id) - - await self._sync_helper(to_update, to_remove) class CloudClient(Interface): @@ -260,13 +27,14 @@ class CloudClient(Interface): def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, websession: aiohttp.ClientSession, - alexa_cfg: Dict[str, Any], google_config: Dict[str, Any]): + alexa_user_config: Dict[str, Any], + google_user_config: Dict[str, Any]): """Initialize client interface to Cloud.""" self._hass = hass self._prefs = prefs self._websession = websession - self.google_user_config = google_config - self.alexa_user_config = alexa_cfg + self.google_user_config = google_user_config + self.alexa_user_config = alexa_user_config self._alexa_config = None self._google_config = None self.cloud = None @@ -307,53 +75,22 @@ def remote_autostart(self) -> bool: return self._prefs.remote_enabled @property - def alexa_config(self) -> AlexaConfig: + def alexa_config(self) -> alexa_config.AlexaConfig: """Return Alexa config.""" if self._alexa_config is None: - self._alexa_config = AlexaConfig( + assert self.cloud is not None + self._alexa_config = alexa_config.AlexaConfig( self._hass, self.alexa_user_config, self._prefs, self.cloud) return self._alexa_config @property - def google_config(self) -> ga_h.Config: + def google_config(self) -> google_config.CloudGoogleConfig: """Return Google config.""" if not self._google_config: - google_conf = self.google_user_config - - def should_expose(entity): - """If an entity should be exposed.""" - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - - if not google_conf['filter'].empty_filter: - return google_conf['filter'](entity.entity_id) - - entity_configs = self.prefs.google_entity_configs - entity_config = entity_configs.get(entity.entity_id, {}) - return entity_config.get( - PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) - - def should_2fa(entity): - """If an entity should be checked for 2FA.""" - entity_configs = self.prefs.google_entity_configs - entity_config = entity_configs.get(entity.entity_id, {}) - return not entity_config.get( - PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - - username = self._hass.data[DOMAIN].claims["cognito:username"] - - self._google_config = ga_h.Config( - should_expose=should_expose, - should_2fa=should_2fa, - secure_devices_pin=self._prefs.google_secure_devices_pin, - entity_config=google_conf.get(CONF_ENTITY_CONFIG), - agent_user_id=username, - ) - - # Set it to the latest. - self._google_config.secure_devices_pin = \ - self._prefs.google_secure_devices_pin + assert self.cloud is not None + self._google_config = google_config.CloudGoogleConfig( + self.google_user_config, self._prefs, self.cloud) return self._google_config diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py new file mode 100644 index 00000000000000..b047d25ee4976d --- /dev/null +++ b/homeassistant/components/cloud/google_config.py @@ -0,0 +1,52 @@ +"""Google config for Cloud.""" +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.components.google_assistant.helpers import AbstractConfig + +from .const import ( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, CONF_ENTITY_CONFIG, + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + + +class CloudGoogleConfig(AbstractConfig): + """HA Cloud Configuration for Google Assistant.""" + + def __init__(self, config, prefs, cloud): + """Initialize the Alexa config.""" + self._config = config + self._prefs = prefs + self._cloud = cloud + + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return self._cloud.claims["cognito:username"] + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._prefs.google_secure_devices_pin + + def should_expose(self, state): + """If an entity should be exposed.""" + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config['filter'].empty_filter: + return self._config['filter'](state.entity_id) + + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(state.entity_id, {}) + return entity_config.get( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(state.entity_id, {}) + return not entity_config.get( + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 770a502ad5dbdb..87c4fb78f3a533 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -17,24 +17,32 @@ from .error import SmartHomeError -class Config: +class AbstractConfig: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, - entity_config=None, secure_devices_pin=None, - agent_user_id=None, should_2fa=None): - """Initialize the configuration.""" - self.should_expose = should_expose - self.entity_config = entity_config or {} - self.secure_devices_pin = secure_devices_pin - self._should_2fa = should_2fa + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return None - # Agent User Id to use for query responses - self.agent_user_id = agent_user_id + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + raise NotImplementedError def should_2fa(self, state): """If an entity should have 2FA checked.""" - return self._should_2fa is None or self._should_2fa(state) + # pylint: disable=no-self-use + return True class RequestData: diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index d385d742c7d180..95528eea3cae2e 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -17,33 +17,50 @@ CONF_SECURE_DEVICES_PIN, ) from .smart_home import async_handle_message -from .helpers import Config +from .helpers import AbstractConfig _LOGGER = logging.getLogger(__name__) -@callback -def async_register_http(hass, cfg): - """Register HTTP views for Google Assistant.""" - expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) - exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) - entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} - secure_devices_pin = cfg.get(CONF_SECURE_DEVICES_PIN) - - def is_exposed(entity) -> bool: - """Determine if an entity should be exposed to Google Assistant.""" - if entity.attributes.get('view') is not None: +class GoogleConfig(AbstractConfig): + """Config for manual setup of Google.""" + + def __init__(self, config): + """Initialize the config.""" + self._config = config + + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return None + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG, {}) + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._config.get(CONF_SECURE_DEVICES_PIN) + + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) + exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) + + if state.attributes.get('view') is not None: # Ignore entities that are views return False - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False explicit_expose = \ - entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) + self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default and entity.domain in exposed_domains + expose_by_default and state.domain in exposed_domains # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being @@ -53,13 +70,15 @@ def is_exposed(entity) -> bool: return is_default_exposed or explicit_expose - config = Config( - should_expose=is_exposed, - entity_config=entity_config, - secure_devices_pin=secure_devices_pin - ) + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + return True - hass.http.register_view(GoogleAssistantView(config)) + +@callback +def async_register_http(hass, cfg): + """Register HTTP views for Google Assistant.""" + hass.http.register_view(GoogleAssistantView(GoogleConfig(cfg))) class GoogleAssistantView(HomeAssistantView): diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 08ab5324b970e1..3f2b8f034cd07b 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1,24 +1,22 @@ """Tests for the cloud component.""" from unittest.mock import patch + from homeassistant.setup import async_setup_component from homeassistant.components import cloud from homeassistant.components.cloud import const -from jose import jwt - from tests.common import mock_coro -def mock_cloud(hass, config={}): +async def mock_cloud(hass, config=None): """Mock cloud.""" - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - assert hass.loop.run_until_complete(async_setup_component( - hass, cloud.DOMAIN, { - 'cloud': config - })) - - hass.data[cloud.DOMAIN]._decode_claims = \ - lambda token: jwt.get_unverified_claims(token) + assert await async_setup_component( + hass, cloud.DOMAIN, { + 'cloud': config or {} + }) + cloud_inst = hass.data['cloud'] + with patch('hass_nabucasa.Cloud.run_executor', return_value=mock_coro()): + await cloud_inst.start() def mock_cloud_prefs(hass, prefs={}): diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index c9fd6360929263..87ef6809fddb15 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -18,7 +18,7 @@ def mock_user_data(): @pytest.fixture def mock_cloud_fixture(hass): """Fixture for cloud component.""" - mock_cloud(hass) + hass.loop.run_until_complete(mock_cloud(hass)) return mock_cloud_prefs(hass) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 7d1afda7e6a686..fa42bda32db3d6 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -9,7 +9,7 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.components.cloud import ( - DOMAIN, ALEXA_SCHEMA, client) + DOMAIN, ALEXA_SCHEMA, alexa_config) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from homeassistant.util.dt import utcnow @@ -17,11 +17,11 @@ from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro, async_fire_time_changed -from . import mock_cloud_prefs +from . import mock_cloud_prefs, mock_cloud @pytest.fixture -def mock_cloud(): +def mock_cloud_inst(): """Mock cloud class.""" return MagicMock(subscription_expired=False) @@ -29,10 +29,7 @@ def mock_cloud(): @pytest.fixture async def mock_cloud_setup(hass): """Set up the cloud.""" - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - assert await async_setup_component(hass, 'cloud', { - 'cloud': {} - }) + await mock_cloud(hass) @pytest.fixture @@ -52,24 +49,20 @@ async def test_handler_alexa(hass): hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - setup = await async_setup_component(hass, 'cloud', { - 'cloud': { - 'alexa': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'description': 'Config description', - 'display_categories': 'LIGHT' - } - } + await mock_cloud(hass, { + 'alexa': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'description': 'Config description', + 'display_categories': 'LIGHT' } } - }) - assert setup + } + }) mock_cloud_prefs(hass) cloud = hass.data['cloud'] @@ -110,24 +103,20 @@ async def test_handler_google_actions(hass): hass.states.async_set( 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) - with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): - setup = await async_setup_component(hass, 'cloud', { - 'cloud': { - 'google_actions': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'aliases': 'Config alias', - 'room': 'living room' - } - } + await mock_cloud(hass, { + 'google_actions': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'aliases': 'Config alias', + 'room': 'living room' } } - }) - assert setup + } + }) mock_cloud_prefs(hass) cloud = hass.data['cloud'] @@ -265,7 +254,7 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs): await cloud_prefs.async_update(alexa_entity_configs={ 'light.kitchen': entity_conf }) - conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) assert not conf.should_expose('light.kitchen') entity_conf['should_expose'] = True @@ -274,7 +263,7 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs): async def test_alexa_config_report_state(hass, cloud_prefs): """Test Alexa config should expose using prefs.""" - conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) assert cloud_prefs.alexa_report_state is False assert conf.should_report_state is False @@ -307,9 +296,9 @@ def patch_sync_helper(): to_remove = [] with patch( - 'homeassistant.components.cloud.client.SYNC_DELAY', 0 + 'homeassistant.components.cloud.alexa_config.SYNC_DELAY', 0 ), patch( - 'homeassistant.components.cloud.client.AlexaConfig._sync_helper', + 'homeassistant.components.cloud.alexa_config.AlexaConfig._sync_helper', side_effect=mock_coro ) as mock_helper: yield to_update, to_remove @@ -321,7 +310,7 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs): """Test Alexa config responds to updating exposed entities.""" - client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update_alexa_entity_config( @@ -354,7 +343,8 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs): async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" - client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, hass.data['cloud']) + alexa_config.AlexaConfig( + hass, ALEXA_SCHEMA({}), cloud_prefs, hass.data['cloud']) with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 60346dc6ea10a3..55cd9e9e2e5806 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,10 +14,11 @@ PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, DOMAIN) from homeassistant.components.google_assistant.helpers import ( - GoogleEntity, Config) + GoogleEntity) from homeassistant.components.alexa.entities import LightCapabilities from tests.common import mock_coro +from tests.components.google_assistant import MockConfig from . import mock_cloud, mock_cloud_prefs @@ -45,7 +46,7 @@ def mock_cloud_login(hass, setup_api): @pytest.fixture(autouse=True) def setup_api(hass, aioclient_mock): """Initialize HTTP API.""" - mock_cloud(hass, { + hass.loop.run_until_complete(mock_cloud(hass, { 'mode': 'development', 'cognito_client_id': 'cognito_client_id', 'user_pool_id': 'user_pool_id', @@ -63,7 +64,7 @@ def setup_api(hass, aioclient_mock): 'include_entities': ['light.kitchen', 'switch.ac'] } } - }) + })) return mock_cloud_prefs(hass) @@ -709,9 +710,10 @@ async def test_list_google_entities( hass, hass_ws_client, setup_api, mock_cloud_login): """Test that we can list Google entities.""" client = await hass_ws_client(hass) - entity = GoogleEntity(hass, Config(lambda *_: False), State( - 'light.kitchen', 'on' - )) + entity = GoogleEntity( + hass, MockConfig(should_expose=lambda *_: False), State( + 'light.kitchen', 'on' + )) with patch('homeassistant.components.google_assistant.helpers' '.async_get_entities', return_value=[entity]): await client.send_json({ diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index f3732c12213716..c7930f3c62f50f 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -1,6 +1,33 @@ +"""Tests for the Google Assistant integration.""" +from homeassistant.components.google_assistant import helpers -"""Tests for the Google Assistant integration.""" +class MockConfig(helpers.AbstractConfig): + """Fake config that always exposes everything.""" + + def __init__(self, *, secure_devices_pin=None, should_expose=None, + entity_config=None): + """Initialize config.""" + self._should_expose = should_expose + self._secure_devices_pin = secure_devices_pin + self._entity_config = entity_config or {} + + @property + def secure_devices_pin(self): + """Return secure devices pin.""" + return self._secure_devices_pin + + @property + def entity_config(self): + """Return secure devices pin.""" + return self._entity_config + + def should_expose(self, state): + """Expose it all.""" + return self._should_expose is None or self._should_expose(state) + + +BASIC_CONFIG = MockConfig() DEMO_DEVICES = [{ 'id': diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index a65387d48a2026..cfe7b9466119a0 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -11,7 +11,7 @@ ATTR_MIN_TEMP, ATTR_MAX_TEMP, STATE_HEAT, SUPPORT_OPERATION_MODE ) from homeassistant.components.google_assistant import ( - const, trait, helpers, smart_home as sh, + const, trait, smart_home as sh, EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED) from homeassistant.components.demo.binary_sensor import DemoBinarySensor from homeassistant.components.demo.cover import DemoCover @@ -23,9 +23,8 @@ from tests.common import (mock_device_registry, mock_registry, mock_area_registry, mock_coro) -BASIC_CONFIG = helpers.Config( - should_expose=lambda state: True, -) +from . import BASIC_CONFIG, MockConfig + REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -57,7 +56,7 @@ async def test_sync_message(hass): # Excluded via config hass.states.async_set('light.not_expose', 'on') - config = helpers.Config( + config = MockConfig( should_expose=lambda state: state.entity_id != 'light.not_expose', entity_config={ 'light.demo_light': { @@ -145,7 +144,7 @@ async def test_sync_in_area(hass, registries): light.entity_id = entity.entity_id await light.async_update_ha_state() - config = helpers.Config( + config = MockConfig( should_expose=lambda _: True, entity_config={} ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 6b1b6a7c9f401b..d2d216a9fc5850 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -29,10 +29,8 @@ from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE from homeassistant.util import color from tests.common import async_mock_service, mock_coro +from . import BASIC_CONFIG, MockConfig -BASIC_CONFIG = helpers.Config( - should_expose=lambda state: True, -) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -42,8 +40,7 @@ REQ_ID, ) -PIN_CONFIG = helpers.Config( - should_expose=lambda state: True, +PIN_CONFIG = MockConfig( secure_devices_pin='1234' ) @@ -927,7 +924,7 @@ async def test_lock_unlock_unlock(hass): # Test with 2FA override with patch('homeassistant.components.google_assistant.helpers' - '.Config.should_2fa', return_value=False): + '.AbstractConfig.should_2fa', return_value=False): await trt.execute( trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {}) assert len(calls) == 2 From 8830054fad14e32b918bdc84392c6be3b3c43656 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 25 Jun 2019 05:00:28 +0200 Subject: [PATCH 310/319] Fix locative device update (#24744) * Add a test for two devices * Fix locative updating all devices * Add a guard clause that checks if correct device is passed. --- .../components/locative/device_tracker.py | 2 + tests/components/locative/test_init.py | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 6f86519c47c2b3..38efab7e8c015b 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -85,6 +85,8 @@ async def async_will_remove_from_hass(self): @callback def _async_receive_data(self, device, location, location_name): """Update device data.""" + if device != self._name: + return self._location_name = location_name self._location = location self.async_write_ha_state() diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 81248764971713..ba96789007b731 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -242,6 +242,43 @@ async def test_exit_first(hass, locative_client, webhook_id): assert state.state == 'not_home' +async def test_two_devices(hass, locative_client, webhook_id): + """Test updating two different devices.""" + url = '/api/webhook/{}'.format(webhook_id) + + data_device_1 = { + 'latitude': 40.7855, + 'longitude': -111.7367, + 'device': 'device_1', + 'id': 'Home', + 'trigger': 'exit' + } + + # Exit Home + req = await locative_client.post(url, data=data_device_1) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data_device_1['device'])) + assert state.state == 'not_home' + + # Enter Home + data_device_2 = dict(data_device_1) + data_device_2['device'] = 'device_2' + data_device_2['trigger'] = 'enter' + req = await locative_client.post(url, data=data_device_2) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data_device_2['device'])) + assert state.state == 'home' + state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data_device_1['device'])) + assert state.state == 'not_home' + + @pytest.mark.xfail( reason='The device_tracker component does not support unloading yet.' ) From 510d6d78745760810ece48ca2ad9f0e6f6d2299a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 22:04:31 -0700 Subject: [PATCH 311/319] Improve Alexa error handling (#24745) --- homeassistant/components/alexa/config.py | 8 +-- .../components/alexa/state_report.py | 3 ++ .../components/cloud/alexa_config.py | 17 ++++++- homeassistant/components/cloud/client.py | 13 ++++- homeassistant/components/cloud/http_api.py | 33 +++++++++++- .../components/websocket_api/connection.py | 4 ++ .../components/websocket_api/const.py | 1 + tests/components/cloud/test_http_api.py | 51 +++++++++++++++++++ 8 files changed, 121 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index 36f15735b8b8bd..a22ebbcd30d431 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -42,11 +42,11 @@ async def async_enable_proactive_mode(self): self._unsub_proactive_report = self.hass.async_create_task( async_enable_proactive_mode(self.hass, self) ) - resp = await self._unsub_proactive_report - - # Failed to start reporting. - if resp is None: + try: + await self._unsub_proactive_report + except Exception: # pylint: disable=broad-except self._unsub_proactive_report = None + raise async def async_disable_proactive_mode(self): """Disable proactive mode.""" diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 4c11fb8c88c908..022b38be59d2b8 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -21,6 +21,9 @@ async def async_enable_proactive_mode(hass, smart_home_config): Proactive mode makes this component report state changes to Alexa. """ + # Validate we can get access token. + await smart_home_config.async_get_access_token() + async def async_entity_state_listener(changed_entity, old_state, new_state): if not new_state: diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 746f01dd04b013..aae48df9884cfe 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -103,6 +103,15 @@ async def async_get_access_token(self): if resp.status == 400: if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): + if self.should_report_state: + await self._prefs.async_update(alexa_report_state=False) + self.hass.components.persistent_notification.async_create( + "There was an error reporting state to Alexa ({}). " + "Please re-link your Alexa skill via the Alexa app to " + "continue using it.".format(body['reason']), + "Alexa state reporting disabled", + "cloud_alexa_report", + ) raise RequireRelink raise alexa_errors.NoTokenAvailable @@ -200,6 +209,9 @@ async def _sync_helper(self, to_update, to_remove) -> bool: if not to_update and not to_remove: return True + # Make sure it's valid. + await self.async_get_access_token() + tasks = [] if to_update: @@ -241,4 +253,7 @@ async def _handle_entity_registry_updated(self, event): elif action == 'remove' and self.should_expose(entity_id): to_remove.append(entity_id) - await self._sync_helper(to_update, to_remove) + try: + await self._sync_helper(to_update, to_remove) + except alexa_errors.NoTokenAvailable: + pass diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 16a05b0d127222..d22e5bf37ba1cb 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -12,7 +12,10 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.aiohttp import MockRequest -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import ( + smart_home as alexa_sh, + errors as alexa_errors, +) from . import utils, alexa_config, google_config from .const import DISPATCHER_REMOTE_UPDATE @@ -98,8 +101,14 @@ async def async_initialize(self, cloud) -> None: """Initialize the client.""" self.cloud = cloud - if self.alexa_config.should_report_state and self.cloud.is_logged_in: + if (not self.alexa_config.should_report_state or + not self.cloud.is_logged_in): + return + + try: await self.alexa_config.async_enable_proactive_mode() + except alexa_errors.NoTokenAvailable: + pass async def cleanups(self) -> None: """Cleanup some stuff after logout.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index d9c4ddcf1ce931..0cd08dd3d5f91f 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,7 +14,10 @@ RequestDataValidator) from homeassistant.components import websocket_api from homeassistant.components.websocket_api import const as ws_const -from homeassistant.components.alexa import entities as alexa_entities +from homeassistant.components.alexa import ( + entities as alexa_entities, + errors as alexa_errors, +) from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( @@ -375,6 +378,24 @@ async def websocket_update_prefs(hass, connection, msg): changes = dict(msg) changes.pop('id') changes.pop('type') + + # If we turn alexa linking on, validate that we can fetch access token + if changes.get(PREF_ALEXA_REPORT_STATE): + try: + with async_timeout.timeout(10): + await cloud.client.alexa_config.async_get_access_token() + except asyncio.TimeoutError: + connection.send_error(msg['id'], 'alexa_timeout', + 'Timeout validating Alexa access token.') + return + except alexa_errors.NoTokenAvailable: + connection.send_error( + msg['id'], 'alexa_relink', + 'Please go to the Alexa app and re-link the Home Assistant ' + 'skill and then try to enable state reporting.' + ) + return + await cloud.client.prefs.async_update(**changes) connection.send_message(websocket_api.result_message(msg['id'])) @@ -575,7 +596,15 @@ async def alexa_sync(hass, connection, msg): cloud = hass.data[DOMAIN] with async_timeout.timeout(10): - success = await cloud.client.alexa_config.async_sync_entities() + try: + success = await cloud.client.alexa_config.async_sync_entities() + except alexa_errors.NoTokenAvailable: + connection.send_error( + msg['id'], 'alexa_relink', + 'Please go to the Alexa app and re-link the Home Assistant ' + 'skill.' + ) + return if success: connection.send_result(msg['id']) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 1aa1efc0ecad1f..b8cce030109cf3 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -1,4 +1,5 @@ """Connection session.""" +import asyncio import voluptuous as vol from homeassistant.core import callback, Context @@ -101,6 +102,9 @@ def async_handle_exception(self, msg, err): elif isinstance(err, vol.Invalid): code = const.ERR_INVALID_FORMAT err_message = vol.humanize.humanize_error(msg, err) + elif isinstance(err, asyncio.TimeoutError): + code = const.ERR_TIMEOUT + err_message = 'Timeout' else: code = const.ERR_UNKNOWN_ERROR err_message = 'Unknown error' diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 9c776e3b949a25..2f79ced7d999bf 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -16,6 +16,7 @@ ERR_UNKNOWN_COMMAND = 'unknown_command' ERR_UNKNOWN_ERROR = 'unknown_error' ERR_UNAUTHORIZED = 'unauthorized' +ERR_TIMEOUT = 'timeout' TYPE_RESULT = 'result' diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 55cd9e9e2e5806..bc60568f0d4bc5 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -16,6 +16,7 @@ from homeassistant.components.google_assistant.helpers import ( GoogleEntity) from homeassistant.components.alexa.entities import LightCapabilities +from homeassistant.components.alexa import errors as alexa_errors from tests.common import mock_coro from tests.components.google_assistant import MockConfig @@ -847,3 +848,53 @@ async def test_update_alexa_entity( assert prefs.alexa_entity_configs['light.kitchen'] == { 'should_expose': False, } + + +async def test_sync_alexa_entities_timeout( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that timeout syncing Alexa entities.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.alexa_config.AlexaConfig' + '.async_sync_entities', side_effect=asyncio.TimeoutError): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/sync', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'timeout' + + +async def test_sync_alexa_entities_no_token( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test sync Alexa entities when we have no token.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.alexa_config.AlexaConfig' + '.async_sync_entities', + side_effect=alexa_errors.NoTokenAvailable): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/sync', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'alexa_relink' + + +async def test_enable_alexa_state_report_fail( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test enable Alexa entities state reporting when no token available.""" + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.alexa_config.AlexaConfig' + '.async_sync_entities', + side_effect=alexa_errors.NoTokenAvailable): + await client.send_json({ + 'id': 5, + 'type': 'cloud/alexa/sync', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 'alexa_relink' From 87712b9fa5ded8bf12b4d8f09330643069f92f0c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Jun 2019 22:23:41 -0700 Subject: [PATCH 312/319] Bumped version to 0.95.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 84444e9d5801ae..a3bfbf3b2c76f9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 95 -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 5d2f97de747caa6ca3a33221536c8a8fd0fc67a8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Jun 2019 09:15:54 -0700 Subject: [PATCH 313/319] Updated frontend to 20190626.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2dae7aaa1ec148..d4bd24f8ab7316 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190624.1" + "home-assistant-frontend==20190626.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c704336ddaf451..1f36e9f8fdd655 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ certifi>=2018.04.16 cryptography==2.6.1 distro==1.4.0 hass-nabucasa==0.15 -home-assistant-frontend==20190624.1 +home-assistant-frontend==20190626.0 importlib-metadata==0.15 jinja2>=2.10 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8f511a2a64c64e..37b53f6365d4e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -595,7 +595,7 @@ hole==0.3.0 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190624.1 +home-assistant-frontend==20190626.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36bb3c0ae8499d..413d239690a095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,7 +160,7 @@ hdate==0.8.7 holidays==0.9.10 # homeassistant.components.frontend -home-assistant-frontend==20190624.1 +home-assistant-frontend==20190626.0 # homeassistant.components.homekit_controller homekit[IP]==0.14.0 From b47b555c4f66bc0435c7e0800c8ed374fdfa5774 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Mon, 24 Jun 2019 07:43:49 +0200 Subject: [PATCH 314/319] Bump pyatmo to v2.1.0 (#24724) --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index d057dcd6e80689..a8a8c28f2376f0 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/components/netatmo", "requirements": [ - "pyatmo==2.0.1" + "pyatmo==2.1.0" ], "dependencies": [ "webhook" diff --git a/requirements_all.txt b/requirements_all.txt index 37b53f6365d4e9..a0445ff1b5bbe2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1018,7 +1018,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==2.0.1 +pyatmo==2.1.0 # homeassistant.components.apple_tv pyatv==0.3.12 From ca4c6ffe8d0898bafca8ff549470830c2e27a1ff Mon Sep 17 00:00:00 2001 From: cgtobi Date: Tue, 25 Jun 2019 17:57:43 +0200 Subject: [PATCH 315/319] Handle timeouts gracefully (#24752) --- homeassistant/components/netatmo/climate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index a49c83d2dd97d6..ec8d8275b1b9ca 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,6 +2,7 @@ import logging from datetime import timedelta +import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -371,6 +372,9 @@ def update(self): except TypeError: _LOGGER.error("Error when getting homestatus.") return + except requests.exceptions.Timeout: + _LOGGER.warning("Timed out when connecting to Netatmo server.") + return _LOGGER.debug("Following is the debugging output for homestatus:") _LOGGER.debug(self.homestatus.rawData) for room in self.homestatus.rooms: From 92053342359584a662cba024ad0a988c3530c43e Mon Sep 17 00:00:00 2001 From: John Dyer Date: Tue, 25 Jun 2019 18:25:53 -0400 Subject: [PATCH 316/319] Update Waze route dependency to 0.10 (#24754) * Update manifest.json Update waze calculator to 0.10, this was supposed to have been done in #22428 but was missed. See discussion [here](https://community.home-assistant.io/t/waze-travel-time-update/50955/201) * Update requirements_all.txt --- homeassistant/components/waze_travel_time/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 64b384356ce7ca..09ae4f812d7ade 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -3,7 +3,7 @@ "name": "Waze travel time", "documentation": "https://www.home-assistant.io/components/waze_travel_time", "requirements": [ - "WazeRouteCalculator==0.9" + "WazeRouteCalculator==0.10" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index a0445ff1b5bbe2..fd6af461a0c26a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -93,7 +93,7 @@ TwitterAPI==2.5.9 # VL53L1X2==0.1.5 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.9 +WazeRouteCalculator==0.10 # homeassistant.components.yessssms YesssSMS==0.2.3 From 760b62e06816d38ae8c15112ad6a6262b506ec84 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Jun 2019 09:54:40 -0700 Subject: [PATCH 317/319] Ignore duplicate tradfri discovery (#24759) * Ignore duplicate tradfri discovery * Update name --- .../components/tradfri/config_flow.py | 13 +++++++++++-- homeassistant/components/tradfri/strings.json | 3 ++- tests/components/tradfri/test_config_flow.py | 19 ++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index bfabf4fd12a933..7cdf4b9de6c5a3 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -78,13 +78,22 @@ async def async_step_auth(self, user_input=None): async def async_step_zeroconf(self, user_input): """Handle zeroconf discovery.""" + host = user_input['host'] + + # pylint: disable=unsupported-assignment-operation + self.context['host'] = host + + if any(host == flow['context']['host'] + for flow in self._async_in_progress()): + return self.async_abort(reason='already_in_progress') + for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == user_input['host']: + if entry.data[CONF_HOST] == host: return self.async_abort( reason='already_configured' ) - self._host = user_input['host'] + self._host = host return await self.async_step_auth() async_step_homekit = async_step_zeroconf diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 38c58486a6a738..868fbbed550c65 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -17,7 +17,8 @@ "timeout": "Timeout validating the code." }, "abort": { - "already_configured": "Bridge is already configured" + "already_configured": "Bridge is already configured.", + "already_in_progress": "Bridge configuration is already in progress." } } } diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 8fcc72dd4a585c..490f8484bbf679 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -258,7 +258,7 @@ async def test_discovery_duplicate_aborted(hass): async def test_import_duplicate_aborted(hass): - """Test a duplicate discovery host is ignored.""" + """Test a duplicate import host is ignored.""" MockConfigEntry( domain='tradfri', data={'host': 'some-host'} @@ -271,3 +271,20 @@ async def test_import_duplicate_aborted(hass): assert flow['type'] == data_entry_flow.RESULT_TYPE_ABORT assert flow['reason'] == 'already_configured' + + +async def test_duplicate_discovery(hass, mock_auth, mock_entry_setup): + """Test a duplicate discovery in progress is ignored.""" + result = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'zeroconf'}, data={ + 'host': '123.123.123.123' + }) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result2 = await hass.config_entries.flow.async_init( + 'tradfri', context={'source': 'zeroconf'}, data={ + 'host': '123.123.123.123' + }) + + assert result2['type'] == data_entry_flow.RESULT_TYPE_ABORT From 5fe8a43e36a83c40e42e28a9a2528f6bd40870dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 25 Jun 2019 22:09:04 +0200 Subject: [PATCH 318/319] Return correct name for met.no (#24763) --- homeassistant/components/met/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index c9d0912e623002..e97918ceba1bc4 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -166,7 +166,7 @@ def name(self): name = self._config.get(CONF_NAME) if name is not None: - return CONF_NAME + return name if self.track_home: return self.hass.config.location_name From 5f37852695aaedd48d927a6e05b9f15081b2b2ad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Jun 2019 09:17:45 -0700 Subject: [PATCH 319/319] Bumped version to 0.95.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a3bfbf3b2c76f9..6cf77275f6e111 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 95 -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)