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.typing import ConfigType
|
||||
|
||||
from . import flash_briefings, intent, smart_home_http
|
||||
from . import flash_briefings, intent, smart_home
|
||||
from .const import (
|
||||
CONF_AUDIO,
|
||||
CONF_DISPLAY_CATEGORIES,
|
||||
|
@ -100,6 +100,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||
if CONF_SMART_HOME in config:
|
||||
smart_home_config: dict[str, Any] | None = config[CONF_SMART_HOME]
|
||||
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
|
||||
|
|
|
@ -75,8 +75,7 @@ from .errors import (
|
|||
AlexaUnsupportedThermostatModeError,
|
||||
AlexaVideoActionNotPermittedForContentError,
|
||||
)
|
||||
from .messages import AlexaDirective, AlexaResponse
|
||||
from .state_report import async_enable_proactive_mode
|
||||
from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
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."""
|
||||
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 .handlers import HANDLERS
|
||||
from .messages import AlexaDirective
|
||||
from .state_report import AlexaDirective
|
||||
|
||||
SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home"
|
||||
|
||||
_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):
|
||||
"""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"
|
||||
|
||||
if context is None:
|
||||
context = ha.Context()
|
||||
context = Context()
|
||||
|
||||
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 logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
@ -19,10 +20,21 @@ from homeassistant.helpers.significant_change import create_checker
|
|||
import homeassistant.util.dt as dt_util
|
||||
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 .errors import NoTokenAvailable, RequireRelink
|
||||
from .messages import AlexaResponse
|
||||
from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AbstractConfig
|
||||
|
@ -31,6 +43,184 @@ _LOGGER = logging.getLogger(__name__)
|
|||
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):
|
||||
"""Enable the proactive mode.
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ from uuid import uuid4
|
|||
|
||||
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.core import Context, callback
|
||||
from homeassistant.helpers import entityfilter
|
||||
|
@ -16,7 +16,7 @@ TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token"
|
|||
TEST_LOCALE = "en-US"
|
||||
|
||||
|
||||
class MockConfig(smart_home_http.AlexaConfig):
|
||||
class MockConfig(smart_home.AlexaConfig):
|
||||
"""Mock Alexa config."""
|
||||
|
||||
entity_config = {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
"""Test for smart home alexa support."""
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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
|
||||
from homeassistant.components.cover import CoverDeviceClass
|
||||
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.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
@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."""
|
||||
request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy")
|
||||
directive_header = request["directive"]["header"]
|
||||
directive = messages.AlexaDirective(request)
|
||||
directive = state_report.AlexaDirective(request)
|
||||
|
||||
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")
|
||||
directive_header = request["directive"]["header"]
|
||||
directive_header.pop("correlationToken")
|
||||
directive = messages.AlexaDirective(request)
|
||||
directive = state_report.AlexaDirective(request)
|
||||
|
||||
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()
|
||||
await smart_home.async_handle_message(hass, config, msg)
|
||||
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."""
|
||||
from http import HTTPStatus
|
||||
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.core import HomeAssistant
|
||||
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")
|
||||
response = await http_client.post(
|
||||
smart_home_http.SMART_HOME_HTTP_ENDPOINT,
|
||||
smart_home.SMART_HOME_HTTP_ENDPOINT,
|
||||
data=json.dumps(request),
|
||||
headers={"content-type": CONTENT_TYPE_JSON},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
{"alexa": {"smart_home": None}},
|
||||
{
|
||||
"alexa": {
|
||||
"smart_home": {
|
||||
"client_id": "someclientid",
|
||||
"client_secret": "verysecret",
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_http_api(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""With `smart_home:` HTTP API is exposed."""
|
||||
config = {"alexa": {"smart_home": None}}
|
||||
|
||||
response = await do_http_discovery(config, hass, hass_client)
|
||||
response_data = await response.json()
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue