Refactor alexa modules to avoid circular deps (#97618)
* Refactor alexa modules to avoid circula deps * Add test http api auth and AlexaConfig * Update test * Improve test
This commit is contained in:
parent
6c8971f18a
commit
2e8e5aabae
9 changed files with 388 additions and 354 deletions
|
@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv, entityfilter
|
from homeassistant.helpers import config_validation as cv, entityfilter
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import flash_briefings, intent, smart_home_http
|
from . import flash_briefings, intent, smart_home
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_AUDIO,
|
CONF_AUDIO,
|
||||||
CONF_DISPLAY_CATEGORIES,
|
CONF_DISPLAY_CATEGORIES,
|
||||||
|
@ -100,6 +100,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
if CONF_SMART_HOME in config:
|
if CONF_SMART_HOME in config:
|
||||||
smart_home_config: dict[str, Any] | None = config[CONF_SMART_HOME]
|
smart_home_config: dict[str, Any] | None = config[CONF_SMART_HOME]
|
||||||
smart_home_config = smart_home_config or SMART_HOME_SCHEMA({})
|
smart_home_config = smart_home_config or SMART_HOME_SCHEMA({})
|
||||||
await smart_home_http.async_setup(hass, smart_home_config)
|
await smart_home.async_setup(hass, smart_home_config)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -75,8 +75,7 @@ from .errors import (
|
||||||
AlexaUnsupportedThermostatModeError,
|
AlexaUnsupportedThermostatModeError,
|
||||||
AlexaVideoActionNotPermittedForContentError,
|
AlexaVideoActionNotPermittedForContentError,
|
||||||
)
|
)
|
||||||
from .messages import AlexaDirective, AlexaResponse
|
from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode
|
||||||
from .state_report import async_enable_proactive_mode
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive"
|
DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive"
|
||||||
|
|
|
@ -1,195 +0,0 @@
|
||||||
"""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 = self.instance = 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
|
|
||||||
- instance (when header includes instance property)
|
|
||||||
|
|
||||||
Behavior when self.has_endpoint is False is undefined.
|
|
||||||
|
|
||||||
Will raise AlexaInvalidEndpointError if the endpoint in the request is
|
|
||||||
malformed or nonexistent.
|
|
||||||
"""
|
|
||||||
_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 or not config.should_expose(self.entity_id):
|
|
||||||
raise AlexaInvalidEndpointError(_endpoint_id)
|
|
||||||
|
|
||||||
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity)
|
|
||||||
if "instance" in self._directive[API_HEADER]:
|
|
||||||
self.instance = self._directive[API_HEADER]["instance"]
|
|
||||||
|
|
||||||
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 thermostat 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
|
|
|
@ -1,16 +1,153 @@
|
||||||
"""Support for alexa Smart Home Skill API."""
|
"""Support for alexa Smart Home Skill API."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import homeassistant.core as ha
|
from homeassistant import core
|
||||||
|
from homeassistant.components.http.view import HomeAssistantView
|
||||||
|
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||||
|
from homeassistant.core import Context, HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import API_DIRECTIVE, API_HEADER, EVENT_ALEXA_SMART_HOME
|
from .auth import Auth
|
||||||
|
from .config import AbstractConfig
|
||||||
|
from .const import (
|
||||||
|
API_DIRECTIVE,
|
||||||
|
API_HEADER,
|
||||||
|
CONF_ENDPOINT,
|
||||||
|
CONF_ENTITY_CONFIG,
|
||||||
|
CONF_FILTER,
|
||||||
|
CONF_LOCALE,
|
||||||
|
EVENT_ALEXA_SMART_HOME,
|
||||||
|
)
|
||||||
from .errors import AlexaBridgeUnreachableError, AlexaError
|
from .errors import AlexaBridgeUnreachableError, AlexaError
|
||||||
from .handlers import HANDLERS
|
from .handlers import HANDLERS
|
||||||
from .messages import AlexaDirective
|
from .state_report import AlexaDirective
|
||||||
|
|
||||||
|
SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home"
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AlexaConfig(AbstractConfig):
|
||||||
|
"""Alexa config."""
|
||||||
|
|
||||||
|
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):
|
||||||
|
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 should_report_state(self):
|
||||||
|
"""Return if we should proactively report states."""
|
||||||
|
return self._auth is not None and self.authorized
|
||||||
|
|
||||||
|
@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) or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def locale(self):
|
||||||
|
"""Return config locale."""
|
||||||
|
return self._config.get(CONF_LOCALE)
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def user_identifier(self):
|
||||||
|
"""Return an identifier for the user that represents this config."""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def should_expose(self, entity_id):
|
||||||
|
"""If an entity should be exposed."""
|
||||||
|
if not self._config[CONF_FILTER].empty_filter:
|
||||||
|
return self._config[CONF_FILTER](entity_id)
|
||||||
|
|
||||||
|
entity_registry = er.async_get(self.hass)
|
||||||
|
if registry_entry := entity_registry.async_get(entity_id):
|
||||||
|
auxiliary_entity = (
|
||||||
|
registry_entry.entity_category is not None
|
||||||
|
or registry_entry.hidden_by is not None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
auxiliary_entity = False
|
||||||
|
return not auxiliary_entity
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def async_invalidate_access_token(self):
|
||||||
|
"""Invalidate access token."""
|
||||||
|
self._auth.async_invalidate_access_token()
|
||||||
|
|
||||||
|
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: HomeAssistant, config: ConfigType) -> None:
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
await smart_home_config.async_enable_proactive_mode()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
async def async_handle_message(hass, config, request, context=None, enabled=True):
|
async def async_handle_message(hass, config, request, context=None, enabled=True):
|
||||||
"""Handle incoming API messages.
|
"""Handle incoming API messages.
|
||||||
|
|
||||||
|
@ -21,7 +158,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True
|
||||||
assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3"
|
assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3"
|
||||||
|
|
||||||
if context is None:
|
if context is None:
|
||||||
context = ha.Context()
|
context = Context()
|
||||||
|
|
||||||
directive = AlexaDirective(request)
|
directive = AlexaDirective(request)
|
||||||
|
|
||||||
|
|
|
@ -1,137 +0,0 @@
|
||||||
"""Alexa HTTP interface."""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from homeassistant import core
|
|
||||||
from homeassistant.components.http.view import HomeAssistantView
|
|
||||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .auth import Auth
|
|
||||||
from .config import AbstractConfig
|
|
||||||
from .const import CONF_ENDPOINT, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_LOCALE
|
|
||||||
from .smart_home import async_handle_message
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home"
|
|
||||||
|
|
||||||
|
|
||||||
class AlexaConfig(AbstractConfig):
|
|
||||||
"""Alexa config."""
|
|
||||||
|
|
||||||
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):
|
|
||||||
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 should_report_state(self):
|
|
||||||
"""Return if we should proactively report states."""
|
|
||||||
return self._auth is not None and self.authorized
|
|
||||||
|
|
||||||
@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) or {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def locale(self):
|
|
||||||
"""Return config locale."""
|
|
||||||
return self._config.get(CONF_LOCALE)
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def user_identifier(self):
|
|
||||||
"""Return an identifier for the user that represents this config."""
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def should_expose(self, entity_id):
|
|
||||||
"""If an entity should be exposed."""
|
|
||||||
if not self._config[CONF_FILTER].empty_filter:
|
|
||||||
return self._config[CONF_FILTER](entity_id)
|
|
||||||
|
|
||||||
entity_registry = er.async_get(self.hass)
|
|
||||||
if registry_entry := entity_registry.async_get(entity_id):
|
|
||||||
auxiliary_entity = (
|
|
||||||
registry_entry.entity_category is not None
|
|
||||||
or registry_entry.hidden_by is not None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
auxiliary_entity = False
|
|
||||||
return not auxiliary_entity
|
|
||||||
|
|
||||||
@core.callback
|
|
||||||
def async_invalidate_access_token(self):
|
|
||||||
"""Invalidate access token."""
|
|
||||||
self._auth.async_invalidate_access_token()
|
|
||||||
|
|
||||||
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: HomeAssistant, config: ConfigType) -> None:
|
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
await smart_home_config.async_enable_proactive_mode()
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
|
@ -6,6 +6,7 @@ from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import async_timeout
|
import async_timeout
|
||||||
|
@ -19,10 +20,21 @@ from homeassistant.helpers.significant_change import create_checker
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
|
|
||||||
from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause
|
from .const import (
|
||||||
|
API_CHANGE,
|
||||||
|
API_CONTEXT,
|
||||||
|
API_DIRECTIVE,
|
||||||
|
API_ENDPOINT,
|
||||||
|
API_EVENT,
|
||||||
|
API_HEADER,
|
||||||
|
API_PAYLOAD,
|
||||||
|
API_SCOPE,
|
||||||
|
DATE_FORMAT,
|
||||||
|
DOMAIN,
|
||||||
|
Cause,
|
||||||
|
)
|
||||||
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id
|
||||||
from .errors import NoTokenAvailable, RequireRelink
|
from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
|
||||||
from .messages import AlexaResponse
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .config import AbstractConfig
|
from .config import AbstractConfig
|
||||||
|
@ -31,6 +43,184 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
DEFAULT_TIMEOUT = 10
|
DEFAULT_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
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 = self.instance = 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
|
||||||
|
- instance (when header includes instance property)
|
||||||
|
|
||||||
|
Behavior when self.has_endpoint is False is undefined.
|
||||||
|
|
||||||
|
Will raise AlexaInvalidEndpointError if the endpoint in the request is
|
||||||
|
malformed or nonexistent.
|
||||||
|
"""
|
||||||
|
_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 or not config.should_expose(self.entity_id):
|
||||||
|
raise AlexaInvalidEndpointError(_endpoint_id)
|
||||||
|
|
||||||
|
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity)
|
||||||
|
if "instance" in self._directive[API_HEADER]:
|
||||||
|
self.instance = self._directive[API_HEADER]["instance"]
|
||||||
|
|
||||||
|
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 thermostat 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
|
||||||
|
|
||||||
|
|
||||||
async def async_enable_proactive_mode(hass, smart_home_config):
|
async def async_enable_proactive_mode(hass, smart_home_config):
|
||||||
"""Enable the proactive mode.
|
"""Enable the proactive mode.
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.alexa import config, smart_home, smart_home_http
|
from homeassistant.components.alexa import config, smart_home
|
||||||
from homeassistant.components.alexa.const import CONF_ENDPOINT, CONF_FILTER, CONF_LOCALE
|
from homeassistant.components.alexa.const import CONF_ENDPOINT, CONF_FILTER, CONF_LOCALE
|
||||||
from homeassistant.core import Context, callback
|
from homeassistant.core import Context, callback
|
||||||
from homeassistant.helpers import entityfilter
|
from homeassistant.helpers import entityfilter
|
||||||
|
@ -16,7 +16,7 @@ TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token"
|
||||||
TEST_LOCALE = "en-US"
|
TEST_LOCALE = "en-US"
|
||||||
|
|
||||||
|
|
||||||
class MockConfig(smart_home_http.AlexaConfig):
|
class MockConfig(smart_home.AlexaConfig):
|
||||||
"""Mock Alexa config."""
|
"""Mock Alexa config."""
|
||||||
|
|
||||||
entity_config = {
|
entity_config = {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
"""Test for smart home alexa support."""
|
"""Test for smart home alexa support."""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.alexa import messages, smart_home
|
from homeassistant.components.alexa import smart_home, state_report
|
||||||
import homeassistant.components.camera as camera
|
import homeassistant.components.camera as camera
|
||||||
from homeassistant.components.cover import CoverDeviceClass
|
from homeassistant.components.cover import CoverDeviceClass
|
||||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
@ -29,6 +29,7 @@ from .test_common import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import async_capture_events, async_mock_service
|
from tests.common import async_capture_events, async_mock_service
|
||||||
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -58,7 +59,7 @@ def test_create_api_message_defaults(hass: HomeAssistant) -> None:
|
||||||
"""Create an API message response of a request with defaults."""
|
"""Create an API message response of a request with defaults."""
|
||||||
request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy")
|
request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy")
|
||||||
directive_header = request["directive"]["header"]
|
directive_header = request["directive"]["header"]
|
||||||
directive = messages.AlexaDirective(request)
|
directive = state_report.AlexaDirective(request)
|
||||||
|
|
||||||
msg = directive.response(payload={"test": 3})._response
|
msg = directive.response(payload={"test": 3})._response
|
||||||
|
|
||||||
|
@ -84,7 +85,7 @@ def test_create_api_message_special() -> None:
|
||||||
request = get_new_request("Alexa.PowerController", "TurnOn")
|
request = get_new_request("Alexa.PowerController", "TurnOn")
|
||||||
directive_header = request["directive"]["header"]
|
directive_header = request["directive"]["header"]
|
||||||
directive_header.pop("correlationToken")
|
directive_header.pop("correlationToken")
|
||||||
directive = messages.AlexaDirective(request)
|
directive = state_report.AlexaDirective(request)
|
||||||
|
|
||||||
msg = directive.response("testName", "testNameSpace")._response
|
msg = directive.response("testName", "testNameSpace")._response
|
||||||
|
|
||||||
|
@ -4372,3 +4373,27 @@ async def test_api_message_sets_authorized(hass: HomeAssistant) -> None:
|
||||||
config._store.set_authorized.assert_not_called()
|
config._store.set_authorized.assert_not_called()
|
||||||
await smart_home.async_handle_message(hass, config, msg)
|
await smart_home.async_handle_message(hass, config, msg)
|
||||||
config._store.set_authorized.assert_called_once_with(True)
|
config._store.set_authorized.assert_called_once_with(True)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_alexa_config(
|
||||||
|
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||||
|
) -> None:
|
||||||
|
"""Test all methods of the AlexaConfig class."""
|
||||||
|
config = {
|
||||||
|
"filter": entityfilter.FILTER_SCHEMA({"include_domains": ["sensor"]}),
|
||||||
|
}
|
||||||
|
test_config = smart_home.AlexaConfig(hass, config)
|
||||||
|
await test_config.async_initialize()
|
||||||
|
assert not test_config.supports_auth
|
||||||
|
assert not test_config.should_report_state
|
||||||
|
assert test_config.endpoint is None
|
||||||
|
assert test_config.entity_config == {}
|
||||||
|
assert test_config.user_identifier() == ""
|
||||||
|
assert test_config.locale is None
|
||||||
|
assert test_config.should_expose("sensor.test")
|
||||||
|
assert not test_config.should_expose("switch.test")
|
||||||
|
with patch.object(test_config, "_auth", AsyncMock()):
|
||||||
|
test_config.async_invalidate_access_token()
|
||||||
|
assert len(test_config._auth.async_invalidate_access_token.mock_calls)
|
||||||
|
await test_config.async_accept_grant("grant_code")
|
||||||
|
test_config._auth.async_do_auth.assert_called_once_with("grant_code")
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
"""Test Smart Home HTTP endpoints."""
|
"""Test Smart Home HTTP endpoints."""
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.alexa import DOMAIN, smart_home_http
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.alexa import DOMAIN, smart_home
|
||||||
from homeassistant.const import CONTENT_TYPE_JSON
|
from homeassistant.const import CONTENT_TYPE_JSON
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
@ -19,19 +22,31 @@ async def do_http_discovery(config, hass, hass_client):
|
||||||
|
|
||||||
request = get_new_request("Alexa.Discovery", "Discover")
|
request = get_new_request("Alexa.Discovery", "Discover")
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
smart_home_http.SMART_HOME_HTTP_ENDPOINT,
|
smart_home.SMART_HOME_HTTP_ENDPOINT,
|
||||||
data=json.dumps(request),
|
data=json.dumps(request),
|
||||||
headers={"content-type": CONTENT_TYPE_JSON},
|
headers={"content-type": CONTENT_TYPE_JSON},
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"config",
|
||||||
|
[
|
||||||
|
{"alexa": {"smart_home": None}},
|
||||||
|
{
|
||||||
|
"alexa": {
|
||||||
|
"smart_home": {
|
||||||
|
"client_id": "someclientid",
|
||||||
|
"client_secret": "verysecret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
async def test_http_api(
|
async def test_http_api(
|
||||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
hass: HomeAssistant, hass_client: ClientSessionGenerator, config: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""With `smart_home:` HTTP API is exposed."""
|
"""With `smart_home:` HTTP API is exposed."""
|
||||||
config = {"alexa": {"smart_home": None}}
|
|
||||||
|
|
||||||
response = await do_http_discovery(config, hass, hass_client)
|
response = await do_http_discovery(config, hass, hass_client)
|
||||||
response_data = await response.json()
|
response_data = await response.json()
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue