diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index 739ce6be6a3..ef69860c23e 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -2,9 +2,13 @@ from abc import ABC, abstractmethod from homeassistant.core import callback +from homeassistant.helpers.storage import Store +from .const import DOMAIN from .state_report import async_enable_proactive_mode +STORE_AUTHORIZED = "authorized" + class AbstractConfig(ABC): """Hold the configuration for Alexa.""" @@ -14,6 +18,12 @@ class AbstractConfig(ABC): def __init__(self, hass): """Initialize abstract config.""" self.hass = hass + self._store = None + + async def async_initialize(self): + """Perform async initialization of config.""" + self._store = AlexaConfigStore(self.hass) + await self._store.async_load() @property def supports_auth(self): @@ -86,3 +96,48 @@ class AbstractConfig(ABC): async def async_accept_grant(self, code): """Accept a grant.""" raise NotImplementedError + + @property + def authorized(self): + """Return authorization status.""" + return self._store.authorized + + def set_authorized(self, authorized): + """Set authorization status. + + - Set when an incoming message is received from Alexa. + - Unset if state reporting fails + """ + self._store.set_authorized(authorized) + + +class AlexaConfigStore: + """A configuration store for Alexa.""" + + _STORAGE_VERSION = 1 + _STORAGE_KEY = DOMAIN + + def __init__(self, hass): + """Initialize a configuration store.""" + self._data = None + self._hass = hass + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + + @property + def authorized(self): + """Return authorization status.""" + return self._data[STORE_AUTHORIZED] + + @callback + def set_authorized(self, authorized): + """Set authorization status.""" + if authorized != self._data[STORE_AUTHORIZED]: + self._data[STORE_AUTHORIZED] = authorized + self._store.async_delay_save(lambda: self._data, 1.0) + + async def async_load(self): + """Load saved configuration from disk.""" + if data := await self._store.async_load(): + self._data = data + else: + self._data = {STORE_AUTHORIZED: False} diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index a6adc488f75..f4c50a24267 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -18,6 +18,10 @@ class NoTokenAvailable(HomeAssistantError): """There is no access token available.""" +class RequireRelink(Exception): + """The skill needs to be relinked.""" + + class AlexaError(Exception): """Base class for errors that can be serialized for the Alexa API. diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 0f166ab3a27..da436f7d15f 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -31,6 +31,8 @@ async def async_handle_message(hass, config, request, context=None, enabled=True "Alexa API not enabled in Home Assistant configuration" ) + config.set_authorized(True) + if directive.has_endpoint: directive.load_entity(hass, config) diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 9651b8cad15..7a6f89414e7 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -97,6 +97,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> None: by the cloud component which will call async_handle_message directly. """ smart_home_config = AlexaConfig(hass, config) + await smart_home_config.async_initialize() hass.http.register_view(SmartHomeView(smart_home_config)) if smart_home_config.should_report_state: diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 115dd308596..fff82c6b286 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -17,6 +17,7 @@ import homeassistant.util.dt as dt_util from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id +from .errors import NoTokenAvailable, RequireRelink from .messages import AlexaResponse _LOGGER = logging.getLogger(__name__) @@ -113,7 +114,10 @@ async def async_send_changereport_message( 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() + try: + token = await config.async_get_access_token() + except (RequireRelink, NoTokenAvailable): + config.set_authorized(False) headers = {"Authorization": f"Bearer {token}"} @@ -155,14 +159,18 @@ async def async_send_changereport_message( response_json = json.loads(response_text) - if ( - response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION" - and not invalidate_access_token - ): - config.async_invalidate_access_token() - return await async_send_changereport_message( - hass, config, alexa_entity, alexa_properties, invalidate_access_token=False - ) + if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": + if invalidate_access_token: + # Invalidate the access token and try again + config.async_invalidate_access_token() + return await async_send_changereport_message( + hass, + config, + alexa_entity, + alexa_properties, + invalidate_access_token=False, + ) + config.set_authorized(False) _LOGGER.error( "Error when sending ChangeReport to Alexa: %s: %s", diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index eeddc76c7a3..b7289341a0a 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -24,7 +24,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, RequireRelink +from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -103,6 +103,7 @@ class AlexaConfig(alexa_config.AbstractConfig): async def async_initialize(self): """Initialize the Alexa config.""" + await super().async_initialize() async def hass_started(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: @@ -167,7 +168,7 @@ class AlexaConfig(alexa_config.AbstractConfig): "Alexa state reporting disabled", "cloud_alexa_report", ) - raise RequireRelink + raise alexa_errors.RequireRelink raise alexa_errors.NoTokenAvailable diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 919a32097b8..90b7cbf7bc9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -61,7 +61,3 @@ MODE_DEV = "development" MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" - - -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 5f0a2ddcbca..50766cd2c6c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -35,7 +35,6 @@ from .const import ( PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, - RequireRelink, ) _LOGGER = logging.getLogger(__name__) @@ -366,15 +365,18 @@ async def websocket_update_prefs(hass, connection, msg): msg["id"], "alexa_timeout", "Timeout validating Alexa access token." ) return - except (alexa_errors.NoTokenAvailable, RequireRelink): + except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink): 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.", ) + alexa_config.set_authorized(False) return + alexa_config.set_authorized(True) + await cloud.client.prefs.async_update(**changes) connection.send_message(websocket_api.result_message(msg["id"])) @@ -422,7 +424,8 @@ async def _account_data(cloud): client = cloud.client remote = cloud.remote - gconf = await client.get_google_config() + alexa_config = await client.get_alexa_config() + google_config = await client.get_google_config() # Load remote certificate if remote.certificate: @@ -435,8 +438,9 @@ async def _account_data(cloud): "email": claims["email"], "cloud": cloud.iot.state, "prefs": client.prefs.as_dict(), - "google_registered": gconf.has_registered_user_agent, + "google_registered": google_config.has_registered_user_agent, "google_entities": client.google_user_config["filter"].config, + "alexa_registered": alexa_config.authorized, "alexa_entities": client.alexa_user_config["filter"].config, "remote_domain": remote.instance_domain, "remote_connected": remote.is_connected, diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 7bd9d2ace69..1d8289b5ec0 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1,5 +1,6 @@ """Tests for the Alexa integration.""" import re +from unittest.mock import Mock from uuid import uuid4 from homeassistant.components.alexa import config, smart_home @@ -23,6 +24,11 @@ class MockConfig(config.AbstractConfig): "camera.test": {"display_categories": "CAMERA"}, } + def __init__(self, hass): + """Mock Alexa config.""" + super().__init__(hass) + self._store = Mock(spec_set=config.AlexaConfigStore) + @property def supports_auth(self): """Return if config supports auth.""" @@ -47,6 +53,10 @@ class MockConfig(config.AbstractConfig): """If an entity should be exposed.""" return True + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + async def async_get_access_token(self): """Get an access token.""" return "thisisnotanacesstoken" diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 6bdc2fe67c1..85d2fd3129c 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3975,3 +3975,14 @@ async def test_button(hass, domain): await assert_scene_controller_works( f"{domain}#ring_doorbell", f"{domain}.press", False, hass ) + + +async def test_api_message_sets_authorized(hass): + """Test an incoming API messages sets the authorized flag.""" + msg = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") + async_mock_service(hass, "switch", "turn_on") + + config = get_default_config() + config._store.set_authorized.assert_not_called() + await smart_home.async_handle_message(hass, config, msg) + config._store.set_authorized.assert_called_once_with(True) diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index d985ef0153f..923d91ed4dd 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -41,6 +41,64 @@ async def test_report_state(hass, aioclient_mock): assert call_json["event"]["endpoint"]["endpointId"] == "binary_sensor#test_contact" +async def test_report_state_retry(hass, aioclient_mock): + """Test proactive state retries once.""" + aioclient_mock.post( + TEST_URL, + text='{"payload":{"code":"INVALID_ACCESS_TOKEN_EXCEPTION","description":""}}', + status=403, + ) + + 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, get_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) == 2 + + +async def test_report_state_unsets_authorized_on_error(hass, aioclient_mock): + """Test proactive state unsets authorized on error.""" + aioclient_mock.post( + TEST_URL, + text='{"payload":{"code":"INVALID_ACCESS_TOKEN_EXCEPTION","description":""}}', + status=403, + ) + + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + config = get_default_config() + await state_report.async_enable_proactive_mode(hass, config) + + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + config._store.set_authorized.assert_not_called() + + # To trigger event listener + await hass.async_block_till_done() + config._store.set_authorized.assert_called_once_with(False) + + async def test_report_state_instance(hass, aioclient_mock): """Test proactive state reports with instance.""" aioclient_mock.post(TEST_URL, text="", status=202) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index bfc141dcf9b..1f6510d92c9 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -12,7 +12,7 @@ import pytest from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities -from homeassistant.components.cloud.const import DOMAIN, RequireRelink +from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.core import State from homeassistant.util.location import LocationInfo @@ -414,6 +414,7 @@ async def test_websocket_status( "exclude_entity_globs": [], "exclude_entities": [], }, + "alexa_registered": False, "google_entities": { "include_domains": ["light"], "include_entity_globs": [], @@ -509,6 +510,28 @@ async def test_websocket_update_preferences( assert setup_api.tts_default_voice == ("en-GB", "male") +async def test_websocket_update_preferences_alexa_report_state( + hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login +): + """Test updating alexa_report_state sets alexa authorized.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.cloud.alexa_config.AlexaConfig" + ".async_get_access_token", + ), patch( + "homeassistant.components.cloud.alexa_config.AlexaConfig.set_authorized" + ) as set_authorized_mock: + set_authorized_mock.assert_not_called() + await client.send_json( + {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} + ) + response = await client.receive_json() + set_authorized_mock.assert_called_once_with(True) + + assert response["success"] + + async def test_websocket_update_preferences_require_relink( hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login ): @@ -518,12 +541,16 @@ async def test_websocket_update_preferences_require_relink( with patch( "homeassistant.components.cloud.alexa_config.AlexaConfig" ".async_get_access_token", - side_effect=RequireRelink, - ): + side_effect=alexa_errors.RequireRelink, + ), patch( + "homeassistant.components.cloud.alexa_config.AlexaConfig.set_authorized" + ) as set_authorized_mock: + set_authorized_mock.assert_not_called() await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] assert response["error"]["code"] == "alexa_relink" @@ -539,11 +566,15 @@ async def test_websocket_update_preferences_no_token( "homeassistant.components.cloud.alexa_config.AlexaConfig" ".async_get_access_token", side_effect=alexa_errors.NoTokenAvailable, - ): + ), patch( + "homeassistant.components.cloud.alexa_config.AlexaConfig.set_authorized" + ) as set_authorized_mock: + set_authorized_mock.assert_not_called() await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] assert response["error"]["code"] == "alexa_relink"