diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index b5060709ce3..36f15735b8b 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 @@ class AbstractConfig: """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 5a1a899ea69..98fb9259461 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 d0c4429e6b2..e9437a411d6 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 @@ class AlexaConfig(AbstractConfig): """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 568502fb6bf..cdb3a88ed22 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_enable_proactive_mode(hass, smart_home_config): 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 f6d283ee1eb..e3c952898bd 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.const import CLOUD_NEVER_EXPOSED_ENTITIES 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 @@ class AlexaConfig(alexa_config.AbstractConfig): 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 @@ class CloudClient(Interface): 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 @@ class CloudClient(Interface): """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 @@ class CloudClient(Interface): 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 505232bfb85..34324aca131 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -10,12 +10,14 @@ PREF_CLOUDHOOKS = 'cloudhooks' 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 eb3b0565351..6eaa717f41c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -19,7 +19,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers 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 1a4511c8c88..e848f54425b 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 1e4ac754460..a01a6dd4cb5 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: (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 @@ class CloudPreferences: 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 @@ class CloudPreferences: """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 9e83ca6fd0e..044a5098303 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 4ba3b0ca939..097c68fadb9 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 71d2da101ca..7fe8c1a8b0b 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 ab273d5e024..9ac6688ae24 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -38,7 +38,7 @@ class MockConfig(config.AbstractConfig): 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 3aa1c7df366..26c9e4bb8b6 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 163754dd3e1..c9fd6360929 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -3,6 +3,8 @@ import pytest 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 ca82d1e0aba..723e86f2f2d 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -8,7 +8,7 @@ import pytest 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 0e4d46672ba..60346dc6ea1 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': [],