mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
Keep track of Alexa authorization status (#63979)
This commit is contained in:
parent
49a32c398c
commit
be628a7c4d
@ -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}
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user