initial commit

This commit is contained in:
Torsten Brendgen
2026-05-24 17:12:52 +02:00
parent 5cbd39e6ed
commit 75e55b660a
14 changed files with 5424 additions and 0 deletions

174
config.py Normal file
View File

@@ -0,0 +1,174 @@
"""Config helpers for Alexa."""
from abc import ABC, abstractmethod
import asyncio
import logging
from typing import Any
from yarl import URL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.storage import Store
from .const import DOMAIN
from .entities import TRANSLATION_TABLE
from .state_report import async_enable_proactive_mode
STORE_AUTHORIZED = "authorized"
_LOGGER = logging.getLogger(__name__)
class AbstractConfig(ABC):
"""Hold the configuration for Alexa."""
_store: AlexaConfigStore
_unsub_proactive_report: CALLBACK_TYPE | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize abstract config."""
self.hass = hass
self._enable_proactive_mode_lock = asyncio.Lock()
self._on_deinitialize: list[CALLBACK_TYPE] = []
async def async_initialize(self) -> None:
"""Perform async initialization of config."""
self._store = AlexaConfigStore(self.hass)
await self._store.async_load()
@callback
def async_deinitialize(self) -> None:
"""Remove listeners."""
_LOGGER.debug("async_deinitialize")
while self._on_deinitialize:
self._on_deinitialize.pop()()
@property
def supports_auth(self) -> bool:
"""Return if config supports auth."""
return False
@property
def should_report_state(self) -> bool:
"""Return if states should be proactively reported."""
return False
@property
@abstractmethod
def endpoint(self) -> str | URL | None:
"""Endpoint for report state."""
@property
@abstractmethod
def locale(self) -> str | None:
"""Return config locale."""
@property
def entity_config(self) -> dict[str, Any]:
"""Return entity config."""
return {}
@property
def is_reporting_states(self) -> bool:
"""Return if proactive mode is enabled."""
return self._unsub_proactive_report is not None
@callback
@abstractmethod
def user_identifier(self) -> str:
"""Return an identifier for the user that represents this config."""
async def async_enable_proactive_mode(self) -> None:
"""Enable proactive mode."""
_LOGGER.debug("Enable proactive mode")
async with self._enable_proactive_mode_lock:
if self._unsub_proactive_report is not None:
return
self._unsub_proactive_report = await async_enable_proactive_mode(
self.hass, self
)
async def async_disable_proactive_mode(self) -> None:
"""Disable proactive mode."""
_LOGGER.debug("Disable proactive mode")
if unsub_func := self._unsub_proactive_report:
unsub_func()
self._unsub_proactive_report = None
@callback
def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
return False
def generate_alexa_id(self, entity_id: str) -> str:
"""Return the alexa ID for an entity ID."""
return entity_id.replace(".", "#").translate(TRANSLATION_TABLE)
@callback
def async_invalidate_access_token(self) -> None:
"""Invalidate access token."""
raise NotImplementedError
async def async_get_access_token(self) -> str | None:
"""Get an access token."""
raise NotImplementedError
async def async_accept_grant(self, code: str) -> str | None:
"""Accept a grant."""
raise NotImplementedError
@property
def authorized(self) -> bool:
"""Return authorization status."""
return self._store.authorized
async def set_authorized(self, authorized: bool) -> None:
"""Set authorization status.
- Set when an incoming message is received from Alexa.
- Unset if state reporting fails
"""
self._store.set_authorized(authorized)
if self.should_report_state != self.is_reporting_states:
if self.should_report_state:
try:
await self.async_enable_proactive_mode()
except Exception:
# We failed to enable proactive mode, unset authorized flag
self._store.set_authorized(False)
raise
else:
await self.async_disable_proactive_mode()
class AlexaConfigStore:
"""A configuration store for Alexa."""
_STORAGE_VERSION = 1
_STORAGE_KEY = DOMAIN
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a configuration store."""
self._data: dict[str, Any] | None = None
self._hass = hass
self._store: Store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY)
@property
def authorized(self) -> bool:
"""Return authorization status."""
assert self._data is not None
return bool(self._data[STORE_AUTHORIZED])
@callback
def set_authorized(self, authorized: bool) -> None:
"""Set authorization status."""
if self._data is not None and 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) -> None:
"""Load saved configuration from disk."""
if data := await self._store.async_load():
self._data = data
else:
self._data = {STORE_AUTHORIZED: False}

222
const.py Normal file
View File

@@ -0,0 +1,222 @@
"""Constants for the Alexa integration."""
from collections import OrderedDict
from homeassistant.components import climate
from homeassistant.const import UnitOfTemperature
DOMAIN = "alexa"
EVENT_ALEXA_SMART_HOME = "alexa_smart_home"
# Flash briefing constants
CONF_UID = "uid"
CONF_TITLE = "title"
CONF_AUDIO = "audio"
CONF_TEXT = "text"
CONF_DISPLAY_URL = "display_url"
CONF_FILTER = "filter"
CONF_ENTITY_CONFIG = "entity_config"
CONF_ENDPOINT = "endpoint"
CONF_LOCALE = "locale"
ATTR_UID = "uid"
ATTR_UPDATE_DATE = "updateDate"
ATTR_TITLE_TEXT = "titleText"
ATTR_STREAM_URL = "streamUrl"
ATTR_MAIN_TEXT = "mainText"
ATTR_REDIRECTION_URL = "redirectionURL"
SYN_RESOLUTION_MATCH = "ER_SUCCESS_MATCH"
# Alexa requires timestamps to be formatted according to ISO 8601, YYYY-MM-DDThh:mm:ssZ
# https://developer.amazon.com/es-ES/docs/alexa/device-apis/alexa-scenecontroller.html#activate-response-event
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
API_DIRECTIVE = "directive"
API_ENDPOINT = "endpoint"
API_EVENT = "event"
API_CONTEXT = "context"
API_HEADER = "header"
API_PAYLOAD = "payload"
API_SCOPE = "scope"
API_CHANGE = "change"
API_PASSWORD = "password"
CONF_DISPLAY_CATEGORIES = "display_categories"
CONF_SUPPORTED_LOCALES = (
"de-DE",
"en-AU",
"en-CA",
"en-GB",
"en-IN",
"en-US",
"es-ES",
"es-MX",
"es-US",
"fr-CA",
"fr-FR",
"hi-IN",
"it-IT",
"ja-JP",
"nl-NL",
"pt-BR",
)
API_TEMP_UNITS = {
UnitOfTemperature.FAHRENHEIT: "FAHRENHEIT",
UnitOfTemperature.CELSIUS: "CELSIUS",
}
# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a
# reverse mapping of this dict and we want to map the first occurrence of OFF
# back to HA state.
API_THERMOSTAT_MODES: OrderedDict[str, str] = OrderedDict(
[
(climate.HVACMode.HEAT, "HEAT"),
(climate.HVACMode.COOL, "COOL"),
(climate.HVACMode.HEAT_COOL, "AUTO"),
(climate.HVACMode.AUTO, "AUTO"),
(climate.HVACMode.OFF, "OFF"),
(climate.HVACMode.FAN_ONLY, "CUSTOM"),
(climate.HVACMode.DRY, "CUSTOM"),
]
)
API_THERMOSTAT_MODES_CUSTOM = {
climate.HVACMode.DRY: "DEHUMIDIFY",
climate.HVACMode.FAN_ONLY: "FAN",
}
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
# AlexaModeController does not like a single mode for the fan preset or humidifier mode,
# we add PRESET_MODE_NA if a fan / humidifier / remote has only one preset_mode
PRESET_MODE_NA = "-"
STORAGE_ACCESS_TOKEN = "access_token"
STORAGE_REFRESH_TOKEN = "refresh_token"
class Cause:
"""Possible causes for property changes.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object
"""
# Indicates that the event was caused by a customer interaction with an
# application. For example, a customer switches on a light, or locks a door
# using the Alexa app or an app provided by a device vendor.
APP_INTERACTION = "APP_INTERACTION"
# Indicates that the event was caused by a physical interaction with an
# endpoint. For example manually switching on a light or manually locking a
# door lock
PHYSICAL_INTERACTION = "PHYSICAL_INTERACTION"
# Indicates that the event was caused by the periodic poll of an appliance,
# which found a change in value. For example, you might poll a temperature
# sensor every hour, and send the updated temperature to Alexa.
PERIODIC_POLL = "PERIODIC_POLL"
# Indicates that the event was caused by the application of a device rule.
# For example, a customer configures a rule to switch on a light if a
# motion sensor detects motion. In this case, Alexa receives an event from
# the motion sensor, and another event from the light to indicate that its
# state change was caused by the rule.
RULE_TRIGGER = "RULE_TRIGGER"
# Indicates that the event was caused by a voice interaction with Alexa.
# For example a user speaking to their Echo device.
VOICE_INTERACTION = "VOICE_INTERACTION"
class Inputs:
"""Valid names for the InputController.
https://developer.amazon.com/docs/device-apis/alexa-property-schemas.html#input
"""
VALID_SOURCE_NAME_MAP = {
"antenna": "TUNER",
"antennatv": "TUNER",
"aux": "AUX 1",
"aux1": "AUX 1",
"aux2": "AUX 2",
"aux3": "AUX 3",
"aux4": "AUX 4",
"aux5": "AUX 5",
"aux6": "AUX 6",
"aux7": "AUX 7",
"bluray": "BLURAY",
"blurayplayer": "BLURAY",
"cable": "CABLE",
"cd": "CD",
"coax": "COAX 1",
"coax1": "COAX 1",
"coax2": "COAX 2",
"composite": "COMPOSITE 1",
"composite1": "COMPOSITE 1",
"dvd": "DVD",
"game": "GAME",
"gameconsole": "GAME",
"hdradio": "HD RADIO",
"hdmi": "HDMI 1",
"hdmi1": "HDMI 1",
"hdmi2": "HDMI 2",
"hdmi3": "HDMI 3",
"hdmi4": "HDMI 4",
"hdmi5": "HDMI 5",
"hdmi6": "HDMI 6",
"hdmi7": "HDMI 7",
"hdmi8": "HDMI 8",
"hdmi9": "HDMI 9",
"hdmi10": "HDMI 10",
"hdmiarc": "HDMI ARC",
"input": "INPUT 1",
"input1": "INPUT 1",
"input2": "INPUT 2",
"input3": "INPUT 3",
"input4": "INPUT 4",
"input5": "INPUT 5",
"input6": "INPUT 6",
"input7": "INPUT 7",
"input8": "INPUT 8",
"input9": "INPUT 9",
"input10": "INPUT 10",
"ipod": "IPOD",
"line": "LINE 1",
"line1": "LINE 1",
"line2": "LINE 2",
"line3": "LINE 3",
"line4": "LINE 4",
"line5": "LINE 5",
"line6": "LINE 6",
"line7": "LINE 7",
"mediaplayer": "MEDIA PLAYER",
"optical": "OPTICAL 1",
"optical1": "OPTICAL 1",
"optical2": "OPTICAL 2",
"phono": "PHONO",
"playstation": "PLAYSTATION",
"playstation3": "PLAYSTATION 3",
"playstation4": "PLAYSTATION 4",
"rokumediaplayer": "MEDIA PLAYER",
"satellite": "SATELLITE",
"satellitetv": "SATELLITE",
"smartcast": "SMARTCAST",
"tuner": "TUNER",
"tv": "TV",
"usbdac": "USB DAC",
"video": "VIDEO 1",
"video1": "VIDEO 1",
"video2": "VIDEO 2",
"video3": "VIDEO 3",
"xbox": "XBOX",
}
VALID_SOUND_MODE_MAP = {
"movie": "MOVIE",
"music": "MUSIC",
"night": "NIGHT",
"sport": "SPORT",
"tv": "TV",
}

32
diagnostics.py Normal file
View File

@@ -0,0 +1,32 @@
"""Diagnostics helpers for Alexa."""
from collections.abc import Mapping
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import callback
STORAGE_ACCESS_TOKEN = "access_token"
STORAGE_REFRESH_TOKEN = "refresh_token"
TO_REDACT_LWA = {
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
STORAGE_ACCESS_TOKEN,
STORAGE_REFRESH_TOKEN,
}
TO_REDACT_AUTH = {"correlationToken", "token"}
@callback
def async_redact_lwa_params(lwa_params: dict[str, str]) -> dict[str, str]:
"""Redact lwa_params."""
return async_redact_data(lwa_params, TO_REDACT_LWA)
@callback
def async_redact_auth_data(mapping: Mapping[Any, Any]) -> dict[str, str]:
"""React auth data."""
return async_redact_data(mapping, TO_REDACT_AUTH)

1081
entities.py Normal file

File diff suppressed because it is too large Load Diff

159
errors.py Normal file
View File

@@ -0,0 +1,159 @@
"""Alexa related errors."""
from typing import Any, Literal
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import API_TEMP_UNITS
class UnsupportedProperty(HomeAssistantError):
"""Does not support the requested Smart Home API property."""
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.
A handler can raise subclasses of this to return an error to the request.
"""
namespace: str | None = None
error_type: str | None = None
def __init__(
self, error_message: str, payload: dict[str, Any] | None = None
) -> None:
"""Initialize an alexa error."""
Exception.__init__(self)
self.error_message = error_message
self.payload = None
class AlexaInvalidEndpointError(AlexaError):
"""The endpoint in the request does not exist."""
namespace = "Alexa"
error_type = "NO_SUCH_ENDPOINT"
def __init__(self, endpoint_id: str) -> None:
"""Initialize invalid endpoint error."""
msg = f"The endpoint {endpoint_id} does not exist"
AlexaError.__init__(self, msg)
self.endpoint_id = endpoint_id
class AlexaInvalidValueError(AlexaError):
"""Class to represent InvalidValue errors."""
namespace = "Alexa"
error_type = "INVALID_VALUE"
class AlexaInteralError(AlexaError):
"""Class to represent internal errors."""
namespace = "Alexa"
error_type = "INTERNAL_ERROR"
class AlexaNotSupportedInCurrentMode(AlexaError):
"""The device is not in the correct mode to support this command."""
namespace = "Alexa"
error_type = "NOT_SUPPORTED_IN_CURRENT_MODE"
def __init__(
self,
endpoint_id: str,
current_mode: Literal["COLOR", "ASLEEP", "NOT_PROVISIONED", "OTHER"],
) -> None:
"""Initialize invalid endpoint error."""
msg = f"Not supported while in {current_mode} mode"
AlexaError.__init__(self, msg, {"currentDeviceMode": current_mode})
self.endpoint_id = endpoint_id
class AlexaUnsupportedThermostatModeError(AlexaError):
"""Class to represent UnsupportedThermostatMode errors."""
namespace = "Alexa.ThermostatController"
error_type = "UNSUPPORTED_THERMOSTAT_MODE"
class AlexaUnsupportedThermostatTargetStateError(AlexaError):
"""Class to represent unsupported climate target state error."""
namespace = "Alexa.ThermostatController"
error_type = "INVALID_TARGET_STATE"
class AlexaTempRangeError(AlexaError):
"""Class to represent TempRange errors."""
namespace = "Alexa"
error_type = "TEMPERATURE_VALUE_OUT_OF_RANGE"
def __init__(
self, hass: HomeAssistant, temp: float, min_temp: float, max_temp: float
) -> None:
"""Initialize TempRange error."""
unit = hass.config.units.temperature_unit
temp_range = {
"minimumValue": {"value": min_temp, "scale": API_TEMP_UNITS[unit]},
"maximumValue": {"value": max_temp, "scale": API_TEMP_UNITS[unit]},
}
payload = {"validRange": temp_range}
msg = f"The requested temperature {temp} is out of range"
AlexaError.__init__(self, msg, payload)
class AlexaBridgeUnreachableError(AlexaError):
"""Class to represent BridgeUnreachable errors."""
namespace = "Alexa"
error_type = "BRIDGE_UNREACHABLE"
class AlexaSecurityPanelUnauthorizedError(AlexaError):
"""Class to represent SecurityPanelController Unauthorized errors."""
namespace = "Alexa.SecurityPanelController"
error_type = "UNAUTHORIZED"
class AlexaSecurityPanelAuthorizationRequired(AlexaError):
"""Class to represent SecurityPanelController AuthorizationRequired errors."""
namespace = "Alexa.SecurityPanelController"
error_type = "AUTHORIZATION_REQUIRED"
class AlexaAlreadyInOperationError(AlexaError):
"""Class to represent AlreadyInOperation errors."""
namespace = "Alexa"
error_type = "ALREADY_IN_OPERATION"
class AlexaInvalidDirectiveError(AlexaError):
"""Class to represent InvalidDirective errors."""
namespace = "Alexa"
error_type = "INVALID_DIRECTIVE"
class AlexaVideoActionNotPermittedForContentError(AlexaError):
"""Class to represent action not permitted for content errors."""
namespace = "Alexa.Video"
error_type = "ACTION_NOT_PERMITTED_FOR_CONTENT"

125
flash_briefings.py Normal file
View File

@@ -0,0 +1,125 @@
"""Support for Alexa skill service end point."""
import hmac
from http import HTTPStatus
import logging
import uuid
from aiohttp.web_response import StreamResponse
from homeassistant.components import http
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import template
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import (
API_PASSWORD,
ATTR_MAIN_TEXT,
ATTR_REDIRECTION_URL,
ATTR_STREAM_URL,
ATTR_TITLE_TEXT,
ATTR_UID,
ATTR_UPDATE_DATE,
CONF_AUDIO,
CONF_DISPLAY_URL,
CONF_TEXT,
CONF_TITLE,
CONF_UID,
DATE_FORMAT,
)
_LOGGER = logging.getLogger(__name__)
FLASH_BRIEFINGS_API_ENDPOINT = "/api/alexa/flash_briefings/{briefing_id}"
@callback
def async_setup(hass: HomeAssistant, flash_briefing_config: ConfigType) -> None:
"""Activate Alexa component."""
hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefing_config))
class AlexaFlashBriefingView(http.HomeAssistantView):
"""Handle Alexa Flash Briefing skill requests."""
url = FLASH_BRIEFINGS_API_ENDPOINT
requires_auth = False
name = "api:alexa:flash_briefings"
def __init__(self, hass: HomeAssistant, flash_briefings: ConfigType) -> None:
"""Initialize Alexa view."""
super().__init__()
self.flash_briefings = flash_briefings
@callback
def get(
self, request: http.HomeAssistantRequest, briefing_id: str
) -> StreamResponse | tuple[bytes, HTTPStatus]:
"""Handle Alexa Flash Briefing request."""
_LOGGER.debug("Received Alexa flash briefing request for: %s", briefing_id)
if request.query.get(API_PASSWORD) is None:
err = "No password provided for Alexa flash briefing: %s"
_LOGGER.error(err, briefing_id)
return b"", HTTPStatus.UNAUTHORIZED
if not hmac.compare_digest(
request.query[API_PASSWORD].encode("utf-8"),
self.flash_briefings[CONF_PASSWORD].encode("utf-8"),
):
err = "Wrong password for Alexa flash briefing: %s"
_LOGGER.error(err, briefing_id)
return b"", HTTPStatus.UNAUTHORIZED
if not isinstance(self.flash_briefings.get(briefing_id), list):
err = "No configured Alexa flash briefing was found for: %s"
_LOGGER.error(err, briefing_id)
return b"", HTTPStatus.NOT_FOUND
briefing = []
for item in self.flash_briefings.get(briefing_id, []):
output = {}
if item.get(CONF_TITLE) is not None:
if isinstance(item.get(CONF_TITLE), template.Template):
output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render(
parse_result=False
)
else:
output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE)
if item.get(CONF_TEXT) is not None:
if isinstance(item.get(CONF_TEXT), template.Template):
output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render(
parse_result=False
)
else:
output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT)
if (uid := item.get(CONF_UID)) is None:
uid = str(uuid.uuid4())
output[ATTR_UID] = uid
if item.get(CONF_AUDIO) is not None:
if isinstance(item.get(CONF_AUDIO), template.Template):
output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render(
parse_result=False
)
else:
output[ATTR_STREAM_URL] = item.get(CONF_AUDIO)
if item.get(CONF_DISPLAY_URL) is not None:
if isinstance(item.get(CONF_DISPLAY_URL), template.Template):
output[ATTR_REDIRECTION_URL] = item[CONF_DISPLAY_URL].async_render(
parse_result=False
)
else:
output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL)
output[ATTR_UPDATE_DATE] = dt_util.utcnow().strftime(DATE_FORMAT)
briefing.append(output)
return self.json(briefing)

1965
handlers.py Normal file

File diff suppressed because it is too large Load Diff

316
intent.py Normal file
View File

@@ -0,0 +1,316 @@
"""Support for Alexa skill service end point."""
from collections.abc import Callable, Coroutine
import enum
import logging
from typing import Any
from aiohttp.web import Response
from homeassistant.components import http
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.util.decorator import Registry
from .const import DOMAIN, SYN_RESOLUTION_MATCH
_LOGGER = logging.getLogger(__name__)
HANDLERS: Registry[
str, Callable[[HomeAssistant, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
] = Registry()
INTENTS_API_ENDPOINT = "/api/alexa"
class SpeechType(enum.StrEnum):
"""The Alexa speech types."""
plaintext = "PlainText"
ssml = "SSML"
SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml}
class CardType(enum.StrEnum):
"""The Alexa card types."""
simple = "Simple"
link_account = "LinkAccount"
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Activate Alexa component."""
hass.http.register_view(AlexaIntentsView)
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Do intents setup.
Right now this module does not expose any, but the intent component breaks
without it.
"""
class UnknownRequest(HomeAssistantError):
"""When an unknown Alexa request is passed in."""
class AlexaIntentsView(http.HomeAssistantView):
"""Handle Alexa requests."""
url = INTENTS_API_ENDPOINT
name = "api:alexa"
async def post(self, request: http.HomeAssistantRequest) -> Response | bytes:
"""Handle Alexa."""
hass = request.app[http.KEY_HASS]
message: dict[str, Any] = await request.json()
_LOGGER.debug("Received Alexa request: %s", message)
try:
response: dict[str, Any] = await async_handle_message(hass, message)
return b"" if response is None else self.json(response)
except UnknownRequest as err:
_LOGGER.warning(str(err))
return self.json(intent_error_response(hass, message, str(err)))
except intent.UnknownIntent as err:
_LOGGER.warning(str(err))
return self.json(
intent_error_response(
hass,
message,
"This intent is not yet configured within Home Assistant.",
)
)
except intent.InvalidSlotInfo as err:
_LOGGER.error("Received invalid slot data from Alexa: %s", err)
return self.json(
intent_error_response(
hass, message, "Invalid slot information received for this intent."
)
)
except intent.IntentError:
_LOGGER.exception("Error handling intent")
return self.json(
intent_error_response(hass, message, "Error handling intent.")
)
def intent_error_response(
hass: HomeAssistant, message: dict[str, Any], error: str
) -> dict[str, Any]:
"""Return an Alexa response that will speak the error message."""
alexa_intent_info = message["request"].get("intent")
alexa_response = AlexaIntentResponse(hass, alexa_intent_info)
alexa_response.add_speech(SpeechType.plaintext, error)
return alexa_response.as_dict()
async def async_handle_message(
hass: HomeAssistant, message: dict[str, Any]
) -> dict[str, Any]:
"""Handle an Alexa intent.
Raises:
- UnknownRequest
- intent.UnknownIntent
- intent.InvalidSlotInfo
- intent.IntentError
"""
req = message["request"]
req_type = req["type"]
if not (handler := HANDLERS.get(req_type)):
raise UnknownRequest(f"Received unknown request {req_type}")
return await handler(hass, message)
@HANDLERS.register("SessionEndedRequest")
@HANDLERS.register("IntentRequest")
@HANDLERS.register("LaunchRequest")
async def async_handle_intent(
hass: HomeAssistant, message: dict[str, Any]
) -> dict[str, Any]:
"""Handle an intent request.
Raises:
- intent.UnknownIntent
- intent.InvalidSlotInfo
- intent.IntentError
"""
req = message["request"]
alexa_intent_info = req.get("intent")
alexa_response = AlexaIntentResponse(hass, alexa_intent_info)
if req["type"] == "LaunchRequest":
intent_name = (
message.get("session", {}).get("application", {}).get("applicationId")
)
elif req["type"] == "SessionEndedRequest":
app_id = message.get("session", {}).get("application", {}).get("applicationId")
intent_name = f"{app_id}.{req['type']}"
alexa_response.variables["reason"] = req["reason"]
alexa_response.variables["error"] = req.get("error")
else:
intent_name = alexa_intent_info["name"]
intent_response = await intent.async_handle(
hass,
DOMAIN,
intent_name,
{key: {"value": value} for key, value in alexa_response.variables.items()},
)
for intent_speech, alexa_speech in SPEECH_MAPPINGS.items():
if intent_speech in intent_response.speech:
alexa_response.add_speech(
alexa_speech, intent_response.speech[intent_speech]["speech"]
)
if intent_speech in intent_response.reprompt:
alexa_response.add_reprompt(
alexa_speech, intent_response.reprompt[intent_speech]["reprompt"]
)
if "simple" in intent_response.card:
alexa_response.add_card(
CardType.simple,
intent_response.card["simple"]["title"],
intent_response.card["simple"]["content"],
)
return alexa_response.as_dict()
def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
"""Check slot request for synonym resolutions."""
# Default to the spoken slot value if more than one or none are found. Always
# passes the id and name of the nearest possible slot resolution. For
# reference to the request object structure, see the Alexa docs:
# https://tinyurl.com/ybvm7jhs
resolved_data: dict[str, Any] = {}
resolved_data["value"] = request["value"]
resolved_data["id"] = ""
if (
"resolutions" in request
and "resolutionsPerAuthority" in request["resolutions"]
and len(request["resolutions"]["resolutionsPerAuthority"]) >= 1
):
# Extract all of the possible values from each authority with a
# successful match
possible_values = []
for entry in request["resolutions"]["resolutionsPerAuthority"]:
if entry["status"]["code"] != SYN_RESOLUTION_MATCH:
continue
possible_values.extend([item["value"] for item in entry["values"]])
# Always set id if available, otherwise an empty string is used as id
if len(possible_values) >= 1:
# Set ID if available
if "id" in possible_values[0]:
resolved_data["id"] = possible_values[0]["id"]
# If there is only one match use the resolved value, otherwise the
# resolution cannot be determined, so use the spoken slot
# value and empty string as id
if len(possible_values) == 1:
resolved_data["value"] = possible_values[0]["name"]
else:
_LOGGER.debug(
"Found multiple synonym resolutions for slot value: {%s: %s}",
key,
resolved_data["value"],
)
return resolved_data
class AlexaIntentResponse:
"""Help generating the response for Alexa."""
def __init__(self, hass: HomeAssistant, intent_info: dict[str, Any] | None) -> None:
"""Initialize the response."""
self.hass = hass
self.speech: dict[str, Any] | None = None
self.card: dict[str, Any] | None = None
self.reprompt: dict[str, Any] | None = None
self.session_attributes: dict[str, Any] = {}
self.should_end_session = True
self.variables: dict[str, Any] = {}
# Intent is None if request was a LaunchRequest or SessionEndedRequest
if intent_info is not None:
for key, value in intent_info.get("slots", {}).items():
# Only include slots with values
if "value" not in value:
continue
_key = key.replace(".", "_")
_slot_data = resolve_slot_data(key, value)
self.variables[_key] = _slot_data["value"]
self.variables[_key + "_Id"] = _slot_data["id"]
def add_card(self, card_type: CardType, title: str, content: str) -> None:
"""Add a card to the response."""
assert self.card is None
card = {"type": card_type.value}
if card_type == CardType.link_account:
self.card = card
return
card["title"] = title
card["content"] = content
self.card = card
def add_speech(self, speech_type: SpeechType, text: str) -> None:
"""Add speech to the response."""
assert self.speech is None
key = "ssml" if speech_type == SpeechType.ssml else "text"
self.speech = {"type": speech_type.value, key: text}
def add_reprompt(self, speech_type: SpeechType, text: str) -> None:
"""Add reprompt if user does not answer."""
assert self.reprompt is None
key = "ssml" if speech_type == SpeechType.ssml else "text"
self.should_end_session = False
self.reprompt = {"type": speech_type.value, key: text}
def as_dict(self) -> dict[str, Any]:
"""Return response in an Alexa valid dict."""
response: dict[str, Any] = {"shouldEndSession": self.should_end_session}
if self.card is not None:
response["card"] = self.card
if self.speech is not None:
response["outputSpeech"] = self.speech
if self.reprompt is not None:
response["reprompt"] = {"outputSpeech": self.reprompt}
return {
"version": "1.0",
"sessionAttributes": self.session_attributes,
"response": response,
}

46
logbook.py Normal file
View File

@@ -0,0 +1,46 @@
"""Describe logbook events."""
from collections.abc import Callable
from typing import Any
from homeassistant.components.logbook import (
LOGBOOK_ENTRY_ENTITY_ID,
LOGBOOK_ENTRY_MESSAGE,
LOGBOOK_ENTRY_NAME,
)
from homeassistant.core import Event, HomeAssistant, callback
from .const import DOMAIN, EVENT_ALEXA_SMART_HOME
@callback
def async_describe_events(
hass: HomeAssistant,
async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None],
) -> None:
"""Describe logbook events."""
@callback
def async_describe_logbook_event(event: Event) -> dict[str, Any]:
"""Describe a logbook event."""
data = event.data
if entity_id := data["request"].get("entity_id"):
state = hass.states.get(entity_id)
name = state.name if state else entity_id
message = (
"sent command"
f" {data['request']['namespace']}/{data['request']['name']} for {name}"
)
else:
message = (
f"sent command {data['request']['namespace']}/{data['request']['name']}"
)
return {
LOGBOOK_ENTRY_NAME: "Amazon Alexa",
LOGBOOK_ENTRY_MESSAGE: message,
LOGBOOK_ENTRY_ENTITY_ID: entity_id,
}
async_describe_event(DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event)

10
manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"domain": "alexa",
"name": "Amazon Alexa",
"after_dependencies": ["camera"],
"codeowners": ["@home-assistant/cloud", "@ochlocracy", "@jbouwh"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/alexa",
"integration_type": "system",
"iot_class": "cloud_push"
}

439
resources.py Normal file
View File

@@ -0,0 +1,439 @@
"""Alexa Resources and Assets."""
from typing import Any
class AlexaGlobalCatalog:
"""The Global Alexa catalog.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#global-alexa-catalog
You can use the global Alexa catalog for pre-defined names of devices, settings,
values, and units.
This catalog is localized into all the languages that Alexa supports.
You can reference the following catalog of pre-defined friendly names.
Each item in the following list is an asset identifier followed by its
supported friendly names. The first friendly name for each identifier is
the one displayed in the Alexa mobile app.
"""
# Air Purifier, Air Cleaner,Clean Air Machine
DEVICE_NAME_AIR_PURIFIER = "Alexa.DeviceName.AirPurifier"
# Fan, Blower
DEVICE_NAME_FAN = "Alexa.DeviceName.Fan"
# Router, Internet Router, Network Router, Wifi Router, Net Router
DEVICE_NAME_ROUTER = "Alexa.DeviceName.Router"
# Shade, Blind, Curtain, Roller, Shutter, Drape, Awning,
# Window shade, Interior blind
DEVICE_NAME_SHADE = "Alexa.DeviceName.Shade"
# Shower
DEVICE_NAME_SHOWER = "Alexa.DeviceName.Shower"
# Space Heater, Portable Heater
DEVICE_NAME_SPACE_HEATER = "Alexa.DeviceName.SpaceHeater"
# Washer, Washing Machine
DEVICE_NAME_WASHER = "Alexa.DeviceName.Washer"
# 2.4G Guest Wi-Fi, 2.4G Guest Network, Guest Network 2.4G, 2G Guest Wifi
SETTING_2G_GUEST_WIFI = "Alexa.Setting.2GGuestWiFi"
# 5G Guest Wi-Fi, 5G Guest Network, Guest Network 5G, 5G Guest Wifi
SETTING_5G_GUEST_WIFI = "Alexa.Setting.5GGuestWiFi"
# Auto, Automatic, Automatic Mode, Auto Mode
SETTING_AUTO = "Alexa.Setting.Auto"
# Direction
SETTING_DIRECTION = "Alexa.Setting.Direction"
# Dry Cycle, Dry Preset, Dry Setting, Dryer Cycle, Dryer Preset, Dryer Setting
SETTING_DRY_CYCLE = "Alexa.Setting.DryCycle"
# Fan Speed, Airflow speed, Wind Speed, Air speed, Air velocity
SETTING_FAN_SPEED = "Alexa.Setting.FanSpeed"
# Guest Wi-fi, Guest Network, Guest Net
SETTING_GUEST_WIFI = "Alexa.Setting.GuestWiFi"
# Heat
SETTING_HEAT = "Alexa.Setting.Heat"
# Mode
SETTING_MODE = "Alexa.Setting.Mode"
# Night, Night Mode
SETTING_NIGHT = "Alexa.Setting.Night"
# Opening, Height, Lift, Width
SETTING_OPENING = "Alexa.Setting.Opening"
# Oscillate, Swivel, Oscillation, Spin, Back and forth
SETTING_OSCILLATE = "Alexa.Setting.Oscillate"
# Preset, Setting
SETTING_PRESET = "Alexa.Setting.Preset"
# Quiet, Quiet Mode, Noiseless, Silent
SETTING_QUIET = "Alexa.Setting.Quiet"
# Temperature, Temp
SETTING_TEMPERATURE = "Alexa.Setting.Temperature"
# Wash Cycle, Wash Preset, Wash setting
SETTING_WASH_CYCLE = "Alexa.Setting.WashCycle"
# Water Temperature, Water Temp, Water Heat
SETTING_WATER_TEMPERATURE = "Alexa.Setting.WaterTemperature"
# Handheld Shower, Shower Wand, Hand Shower
SHOWER_HAND_HELD = "Alexa.Shower.HandHeld"
# Rain Head, Overhead shower, Rain Shower, Rain Spout, Rain Faucet
SHOWER_RAIN_HEAD = "Alexa.Shower.RainHead"
# Degrees, Degree
UNIT_ANGLE_DEGREES = "Alexa.Unit.Angle.Degrees"
# Radians, Radian
UNIT_ANGLE_RADIANS = "Alexa.Unit.Angle.Radians"
# Feet, Foot
UNIT_DISTANCE_FEET = "Alexa.Unit.Distance.Feet"
# Inches, Inch
UNIT_DISTANCE_INCHES = "Alexa.Unit.Distance.Inches"
# Kilometers
UNIT_DISTANCE_KILOMETERS = "Alexa.Unit.Distance.Kilometers"
# Meters, Meter, m
UNIT_DISTANCE_METERS = "Alexa.Unit.Distance.Meters"
# Miles, Mile
UNIT_DISTANCE_MILES = "Alexa.Unit.Distance.Miles"
# Yards, Yard
UNIT_DISTANCE_YARDS = "Alexa.Unit.Distance.Yards"
# Grams, Gram, g
UNIT_MASS_GRAMS = "Alexa.Unit.Mass.Grams"
# Kilograms, Kilogram, kg
UNIT_MASS_KILOGRAMS = "Alexa.Unit.Mass.Kilograms"
# Percent
UNIT_PERCENT = "Alexa.Unit.Percent"
# Celsius, Degrees Celsius, Degrees, C, Centigrade, Degrees Centigrade
UNIT_TEMPERATURE_CELSIUS = "Alexa.Unit.Temperature.Celsius"
# Degrees, Degree
UNIT_TEMPERATURE_DEGREES = "Alexa.Unit.Temperature.Degrees"
# Fahrenheit, Degrees Fahrenheit, Degrees F, Degrees, F
UNIT_TEMPERATURE_FAHRENHEIT = "Alexa.Unit.Temperature.Fahrenheit"
# Kelvin, Degrees Kelvin, Degrees K, Degrees, K
UNIT_TEMPERATURE_KELVIN = "Alexa.Unit.Temperature.Kelvin"
# Cubic Feet, Cubic Foot
UNIT_VOLUME_CUBIC_FEET = "Alexa.Unit.Volume.CubicFeet"
# Cubic Meters, Cubic Meter, Meters Cubed
UNIT_VOLUME_CUBIC_METERS = "Alexa.Unit.Volume.CubicMeters"
# Gallons, Gallon
UNIT_VOLUME_GALLONS = "Alexa.Unit.Volume.Gallons"
# Liters, Liter, L
UNIT_VOLUME_LITERS = "Alexa.Unit.Volume.Liters"
# Pints, Pint
UNIT_VOLUME_PINTS = "Alexa.Unit.Volume.Pints"
# Quarts, Quart
UNIT_VOLUME_QUARTS = "Alexa.Unit.Volume.Quarts"
# Ounces, Ounce, oz
UNIT_WEIGHT_OUNCES = "Alexa.Unit.Weight.Ounces"
# Pounds, Pound, lbs
UNIT_WEIGHT_POUNDS = "Alexa.Unit.Weight.Pounds"
# Close
VALUE_CLOSE = "Alexa.Value.Close"
# Delicates, Delicate
VALUE_DELICATE = "Alexa.Value.Delicate"
# High
VALUE_HIGH = "Alexa.Value.High"
# Low
VALUE_LOW = "Alexa.Value.Low"
# Maximum, Max
VALUE_MAXIMUM = "Alexa.Value.Maximum"
# Medium, Mid
VALUE_MEDIUM = "Alexa.Value.Medium"
# Minimum, Min
VALUE_MINIMUM = "Alexa.Value.Minimum"
# Open
VALUE_OPEN = "Alexa.Value.Open"
# Quick Wash, Fast Wash, Wash Quickly, Speed Wash
VALUE_QUICK_WASH = "Alexa.Value.QuickWash"
class AlexaCapabilityResource:
"""Base class for Alexa capabilityResources, modeResources, and presetResources.
Resources objects labels must be unique across all modeResources and
presetResources within the same device. To provide support for all
supported locales, include one label from the AlexaGlobalCatalog in the
labels array.
You cannot use any words from the following list as friendly names:
https://developer.amazon.com/docs/alexa/device-apis/resources-and-assets.html#names-you-cannot-use
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources
"""
def __init__(self, labels: list[str]) -> None:
"""Initialize an Alexa resource."""
self._resource_labels = []
for label in labels:
self._resource_labels.append(label)
def serialize_capability_resources(self) -> dict[str, list[dict[str, Any]]]:
"""Return capabilityResources object serialized for an API response."""
return self.serialize_labels(self._resource_labels)
def serialize_configuration(self) -> dict[str, Any]:
"""Return serialized configuration for an API response.
Return ModeResources, PresetResources friendlyNames serialized.
"""
raise NotImplementedError
def serialize_labels(self, resources: list[str]) -> dict[str, list[dict[str, Any]]]:
"""Return serialized labels for an API response.
Returns resource label objects for friendlyNames serialized.
"""
labels: list[dict[str, Any]] = []
label_dict: dict[str, Any]
for label in resources:
if label in AlexaGlobalCatalog.__dict__.values():
label_dict = {"@type": "asset", "value": {"assetId": label}}
else:
label_dict = {
"@type": "text",
"value": {"text": label, "locale": "en-US"},
}
labels.append(label_dict)
return {"friendlyNames": labels}
class AlexaModeResource(AlexaCapabilityResource):
"""Implements Alexa ModeResources.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources
"""
def __init__(self, labels: list[str], ordered: bool = False) -> None:
"""Initialize an Alexa modeResource."""
super().__init__(labels)
self._supported_modes: list[dict[str, Any]] = []
self._mode_ordered: bool = ordered
def add_mode(self, value: str, labels: list[str]) -> None:
"""Add mode to the supportedModes object."""
self._supported_modes.append({"value": value, "labels": labels})
def serialize_configuration(self) -> dict[str, Any]:
"""Return serialized configuration for an API response.
Returns configuration for ModeResources friendlyNames serialized.
"""
mode_resources: list[dict[str, Any]] = []
for mode in self._supported_modes:
result = {
"value": mode["value"],
"modeResources": self.serialize_labels(mode["labels"]),
}
mode_resources.append(result)
return {"ordered": self._mode_ordered, "supportedModes": mode_resources}
class AlexaPresetResource(AlexaCapabilityResource):
"""Implements Alexa PresetResources.
Use presetResources with RangeController to provide a set of
friendlyNames for each RangeController preset.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#presetresources
"""
def __init__(
self,
labels: list[str],
min_value: float,
max_value: float,
precision: float,
unit: str | None = None,
) -> None:
"""Initialize an Alexa presetResource."""
super().__init__(labels)
self._presets: list[dict[str, Any]] = []
self._minimum_value = min_value
self._maximum_value = max_value
self._precision = precision
self._unit_of_measure = None
if unit in AlexaGlobalCatalog.__dict__.values():
self._unit_of_measure = unit
def add_preset(self, value: float, labels: list[str]) -> None:
"""Add preset to configuration presets array."""
self._presets.append({"value": value, "labels": labels})
def serialize_configuration(self) -> dict[str, Any]:
"""Return serialized configuration for an API response.
Returns configuration for PresetResources friendlyNames serialized.
"""
configuration: dict[str, Any] = {
"supportedRange": {
"minimumValue": self._minimum_value,
"maximumValue": self._maximum_value,
"precision": self._precision,
}
}
if self._unit_of_measure:
configuration["unitOfMeasure"] = self._unit_of_measure
if self._presets:
preset_resources = [
{
"rangeValue": preset["value"],
"presetResources": self.serialize_labels(preset["labels"]),
}
for preset in self._presets
]
configuration["presets"] = preset_resources
return configuration
class AlexaSemantics:
"""Class for Alexa Semantics Object.
You can optionally enable additional utterances by using semantics. When
you use semantics, you manually map the phrases "open", "close", "raise",
and "lower" to directives.
Semantics is supported for the following interfaces only: ModeController,
RangeController, and ToggleController.
Semantics stateMappings are only supported for one interface of the same
type on the same device. If a device has multiple RangeControllers only
one interface may use stateMappings otherwise discovery will fail.
You can support semantics actionMappings on different controllers for the
same device, however each controller must support different phrases.
For example, you can support "raise" on a RangeController, and "open"
on a ModeController, but you can't support "open" on both RangeController
and ModeController. Semantics stateMappings are only supported for one
interface on the same device.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#semantics-object
"""
MAPPINGS_ACTION = "actionMappings"
MAPPINGS_STATE = "stateMappings"
ACTIONS_TO_DIRECTIVE = "ActionsToDirective"
STATES_TO_VALUE = "StatesToValue"
STATES_TO_RANGE = "StatesToRange"
ACTION_CLOSE = "Alexa.Actions.Close"
ACTION_LOWER = "Alexa.Actions.Lower"
ACTION_OPEN = "Alexa.Actions.Open"
ACTION_RAISE = "Alexa.Actions.Raise"
STATES_OPEN = "Alexa.States.Open"
STATES_CLOSED = "Alexa.States.Closed"
DIRECTIVE_RANGE_SET_VALUE = "SetRangeValue"
DIRECTIVE_RANGE_ADJUST_VALUE = "AdjustRangeValue"
DIRECTIVE_TOGGLE_TURN_ON = "TurnOn"
DIRECTIVE_TOGGLE_TURN_OFF = "TurnOff"
DIRECTIVE_MODE_SET_MODE = "SetMode"
DIRECTIVE_MODE_ADJUST_MODE = "AdjustMode"
def __init__(self) -> None:
"""Initialize an Alexa modeResource."""
self._action_mappings: list[dict[str, Any]] = []
self._state_mappings: list[dict[str, Any]] = []
def _add_action_mapping(self, semantics: dict[str, Any]) -> None:
"""Add action mapping between actions and interface directives."""
self._action_mappings.append(semantics)
def _add_state_mapping(self, semantics: dict[str, Any]) -> None:
"""Add state mapping between states and interface directives."""
self._state_mappings.append(semantics)
def add_states_to_value(self, states: list[str], value: Any) -> None:
"""Add StatesToValue stateMappings."""
self._add_state_mapping(
{"@type": self.STATES_TO_VALUE, "states": states, "value": value}
)
def add_states_to_range(
self, states: list[str], min_value: float, max_value: float
) -> None:
"""Add StatesToRange stateMappings."""
self._add_state_mapping(
{
"@type": self.STATES_TO_RANGE,
"states": states,
"range": {"minimumValue": min_value, "maximumValue": max_value},
}
)
def add_action_to_directive(
self, actions: list[str], directive: str, payload: dict[str, Any]
) -> None:
"""Add ActionsToDirective actionMappings."""
self._add_action_mapping(
{
"@type": self.ACTIONS_TO_DIRECTIVE,
"actions": actions,
"directive": {"name": directive, "payload": payload},
}
)
def serialize_semantics(self) -> dict[str, Any]:
"""Return semantics object serialized for an API response."""
semantics: dict[str, Any] = {}
if self._action_mappings:
semantics[self.MAPPINGS_ACTION] = self._action_mappings
if self._state_mappings:
semantics[self.MAPPINGS_STATE] = self._state_mappings
return semantics

0
services.yaml Normal file
View File

249
smart_home.py Normal file
View File

@@ -0,0 +1,249 @@
"""Support for alexa Smart Home Skill API."""
import logging
from typing import Any
from aiohttp import web
from yarl import URL
from homeassistant import core
from homeassistant.auth.models import User
from homeassistant.components.http import (
KEY_HASS,
HomeAssistantRequest,
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 .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 .diagnostics import async_redact_auth_data
from .errors import AlexaBridgeUnreachableError, AlexaError
from .handlers import HANDLERS
from .state_report import AlexaDirective
_LOGGER = logging.getLogger(__name__)
SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home"
class AlexaConfig(AbstractConfig):
"""Alexa config."""
_auth: Auth | None
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""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) -> bool:
"""Return if config supports auth."""
return self._auth is not None
@property
def should_report_state(self) -> bool:
"""Return if we should proactively report states."""
return self._auth is not None and self.authorized
@property
def endpoint(self) -> str | URL | None:
"""Endpoint for report state."""
return self._config.get(CONF_ENDPOINT)
@property
def entity_config(self) -> dict[str, Any]:
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG) or {}
@property
def locale(self) -> str | None:
"""Return config locale."""
return self._config.get(CONF_LOCALE)
@core.callback
def user_identifier(self) -> str:
"""Return an identifier for the user that represents this config."""
return ""
@core.callback
def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:
return bool(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) -> None:
"""Invalidate access token."""
assert self._auth is not None
self._auth.async_invalidate_access_token()
async def async_get_access_token(self) -> str | None:
"""Get an access token."""
assert self._auth is not None
return await self._auth.async_get_access_token()
async def async_accept_grant(self, code: str) -> str | None:
"""Accept a grant."""
assert self._auth is not None
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: AlexaConfig) -> None:
"""Initialize."""
self.smart_home_config = smart_home_config
async def post(self, request: HomeAssistantRequest) -> web.Response | bytes:
"""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[KEY_HASS]
user: User = request["hass_user"]
message: dict[str, Any] = await request.json()
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Received Alexa Smart Home request: %s",
async_redact_auth_data(message),
)
response = await async_handle_message(
hass, self.smart_home_config, message, context=core.Context(user_id=user.id)
)
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Sending Alexa Smart Home response: %s",
async_redact_auth_data(response),
)
return b"" if response is None else self.json(response)
async def async_handle_message(
hass: HomeAssistant,
config: AbstractConfig,
request: dict[str, Any],
context: Context | None = None,
enabled: bool = True,
) -> dict[str, Any]:
"""Handle incoming API messages.
If enabled is False, the response to all messages will be a
BRIDGE_UNREACHABLE error. This can be used if the API has been disabled in
configuration.
"""
assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3"
if context is None:
context = Context()
directive = AlexaDirective(request)
try:
if not enabled:
raise AlexaBridgeUnreachableError( # noqa: TRY301
"Alexa API not enabled in Home Assistant configuration"
)
await config.set_authorized(True)
if directive.has_endpoint:
directive.load_entity(hass, config)
funct_ref = HANDLERS.get((directive.namespace, directive.name))
if funct_ref:
response = await funct_ref(hass, config, directive, context)
if directive.has_endpoint:
response.merge_context_properties(directive.endpoint)
else:
_LOGGER.warning(
"Unsupported API request %s/%s", directive.namespace, directive.name
)
response = directive.error()
except AlexaError as err:
response = directive.error(
error_type=str(err.error_type),
error_message=err.error_message,
payload=err.payload,
)
except Exception:
_LOGGER.exception(
"Uncaught exception processing Alexa %s/%s request (%s)",
directive.namespace,
directive.name,
directive.entity_id or "-",
)
response = directive.error(error_message="Unknown error")
request_info: dict[str, Any] = {
"namespace": directive.namespace,
"name": directive.name,
}
if directive.has_endpoint:
assert directive.entity_id is not None
request_info["entity_id"] = directive.entity_id
hass.bus.async_fire(
EVENT_ALEXA_SMART_HOME,
{
"request": request_info,
"response": {"namespace": response.namespace, "name": response.name},
},
context=context,
)
return response.serialize()

606
state_report.py Normal file
View File

@@ -0,0 +1,606 @@
"""Alexa state report code."""
from asyncio import timeout
from collections.abc import Mapping
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.significant_change import create_checker
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import (
API_CHANGE,
API_CONTEXT,
API_DIRECTIVE,
API_ENDPOINT,
API_EVENT,
API_HEADER,
API_PAYLOAD,
API_SCOPE,
DATE_FORMAT,
DOMAIN,
Cause,
)
from .diagnostics import async_redact_auth_data
from .entities import ENTITY_ADAPTERS, AlexaEntity
from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
if TYPE_CHECKING:
from .config import AbstractConfig
_LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
"""Check if doorbell event timestamp is valid."""
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
try:
timestamp = datetime.fromisoformat(event_state)
except ValueError:
_LOGGER.debug(
"Unable to parse ISO timestamp from state for %s. Got %s",
entity_id,
event_state,
)
return False
else:
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
return True
return False
class AlexaDirective:
"""An incoming Alexa directive."""
entity: State
entity_id: str | None
endpoint: AlexaEntity
instance: str | None
def __init__(self, request: dict[str, Any]) -> None:
"""Initialize a directive."""
self._directive: dict[str, Any] = request[API_DIRECTIVE]
self.namespace: str = self._directive[API_HEADER]["namespace"]
self.name: str = self._directive[API_HEADER]["name"]
self.payload: dict[str, Any] = self._directive[API_PAYLOAD]
self.has_endpoint: bool = API_ENDPOINT in self._directive
self.instance = None
self.entity_id = None
def load_entity(self, hass: HomeAssistant, config: AbstractConfig) -> None:
"""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: str = self._directive[API_ENDPOINT]["endpointId"]
self.entity_id = _endpoint_id.replace("#", ".")
entity: State | None = hass.states.get(self.entity_id)
if not entity or not config.should_expose(self.entity_id):
raise AlexaInvalidEndpointError(_endpoint_id)
self.entity = entity
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: str = "Response",
namespace: str = "Alexa",
payload: dict[str, Any] | None = None,
) -> AlexaResponse:
"""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: str = "Alexa",
error_type: str = "INTERNAL_ERROR",
error_message: str = "",
payload: dict[str, Any] | None = None,
) -> AlexaResponse:
"""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: str, namespace: str, payload: dict[str, Any] | None = None
) -> None:
"""Initialize the response."""
payload = payload or {}
self._response: dict[str, Any] = {
API_EVENT: {
API_HEADER: {
"namespace": namespace,
"name": name,
"messageId": str(uuid4()),
"payloadVersion": "3",
},
API_PAYLOAD: payload,
}
}
@property
def name(self) -> str:
"""Return the name of this response."""
name: str = self._response[API_EVENT][API_HEADER]["name"]
return name
@property
def namespace(self) -> str:
"""Return the namespace of this response."""
namespace: str = self._response[API_EVENT][API_HEADER]["namespace"]
return namespace
def set_correlation_token(self, token: str) -> None:
"""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: str | None, endpoint_id: str | None
) -> 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
def set_endpoint(self, endpoint: dict[str, Any]) -> None:
"""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) -> list[dict[str, Any]]:
context: dict[str, Any] = self._response.setdefault(API_CONTEXT, {})
properties: list[dict[str, Any]] = context.setdefault("properties", [])
return properties
def add_context_property(self, prop: dict[str, Any]) -> None:
"""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: AlexaEntity) -> None:
"""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) -> dict[str, Any]:
"""Return response as a JSON-able data structure."""
return self._response
async def async_enable_proactive_mode(
hass: HomeAssistant, smart_home_config: AbstractConfig
) -> CALLBACK_TYPE | None:
"""Enable the proactive mode.
Proactive mode makes this component report state changes to Alexa.
"""
# Validate we can get access token.
await smart_home_config.async_get_access_token()
@callback
def extra_significant_check(
hass: HomeAssistant,
old_state: str,
old_attrs: Mapping[Any, Any],
old_extra_arg: Any,
new_state: str,
new_attrs: Mapping[Any, Any],
new_extra_arg: Any,
) -> bool:
"""Check if the serialized data has changed."""
return old_extra_arg is not None and old_extra_arg != new_extra_arg
checker = await create_checker(hass, DOMAIN, extra_significant_check)
@callback
def _async_entity_state_filter(data: EventStateChangedData) -> bool:
if not hass.is_running:
return False
if not (new_state := data["new_state"]):
return False
if new_state.domain not in ENTITY_ADAPTERS:
return False
changed_entity = data["entity_id"]
if not smart_home_config.should_expose(changed_entity):
_LOGGER.debug("Not exposing %s because filtered by config", changed_entity)
return False
return True
async def _async_entity_state_listener(
event_: Event[EventStateChangedData],
) -> None:
data = event_.data
new_state = data["new_state"]
if TYPE_CHECKING:
assert new_state is not None
alexa_changed_entity: AlexaEntity = ENTITY_ADAPTERS[new_state.domain](
hass, smart_home_config, new_state
)
# Determine how entity should be reported on
should_report = False
should_doorbell = False
for interface in alexa_changed_entity.interfaces():
if not should_report and interface.properties_proactively_reported():
should_report = True
if interface.name() == "Alexa.DoorbellEventSource":
should_doorbell = True
break
if not should_report and not should_doorbell:
return
if should_doorbell:
old_state = data["old_state"]
if (
new_state.domain == event.DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
) or (
new_state.state == STATE_ON
and (
old_state is None
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
)
):
await async_send_doorbell_event_message(
hass, smart_home_config, alexa_changed_entity
)
return
alexa_properties = list(alexa_changed_entity.serialize_properties())
if not checker.async_is_significant_change(
new_state, extra_arg=alexa_properties
):
return
await async_send_changereport_message(
hass, smart_home_config, alexa_changed_entity, alexa_properties
)
return hass.bus.async_listen(
EVENT_STATE_CHANGED,
_async_entity_state_listener,
event_filter=_async_entity_state_filter,
)
async def async_send_changereport_message(
hass: HomeAssistant,
config: AbstractConfig,
alexa_entity: AlexaEntity,
alexa_properties: list[dict[str, Any]],
*,
invalidate_access_token: bool = True,
) -> None:
"""Send a ChangeReport message for an Alexa entity.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events
"""
try:
token = await config.async_get_access_token()
except RequireRelink, NoTokenAvailable:
await config.set_authorized(False)
_LOGGER.error(
"Error when sending ChangeReport to Alexa, could not get access token"
)
return
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoint = alexa_entity.alexa_id()
payload: dict[str, Any] = {
API_CHANGE: {
"cause": {"type": Cause.APP_INTERACTION},
"properties": alexa_properties,
}
}
message = AlexaResponse(name="ChangeReport", namespace="Alexa", payload=payload)
message.set_endpoint_full(token, endpoint)
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
try:
async with timeout(DEFAULT_TIMEOUT):
response = await session.post(
config.endpoint,
headers=headers,
json=message_serialized,
allow_redirects=True,
)
except TimeoutError, aiohttp.ClientError:
_LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
return
response_text = await response.text()
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Sent: %s", json.dumps(async_redact_auth_data(message_serialized))
)
_LOGGER.debug("Received (%s): %s", response.status, response_text)
if response.status == HTTPStatus.ACCEPTED:
return
response_json = json_loads_object(response_text)
response_payload = cast(JsonObjectType, response_json["payload"])
if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
if invalidate_access_token:
# Invalidate the access token and try again
config.async_invalidate_access_token()
await async_send_changereport_message(
hass,
config,
alexa_entity,
alexa_properties,
invalidate_access_token=False,
)
return
await config.set_authorized(False)
_LOGGER.error(
"Error when sending ChangeReport for %s to Alexa: %s: %s",
alexa_entity.entity_id,
response_payload["code"],
response_payload["description"],
)
async def async_send_add_or_update_message(
hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
) -> aiohttp.ClientResponse:
"""Send an AddOrUpdateReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report
"""
token = await config.async_get_access_token()
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoints: list[dict[str, Any]] = []
for entity_id in entity_ids:
if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS:
continue
if (state := hass.states.get(entity_id)) is None:
continue
alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state)
endpoints.append(alexa_entity.serialize_discovery())
payload: dict[str, Any] = {
"endpoints": endpoints,
"scope": {"type": "BearerToken", "token": token},
}
message = AlexaResponse(
name="AddOrUpdateReport", namespace="Alexa.Discovery", payload=payload
)
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
return await session.post(
config.endpoint, headers=headers, json=message_serialized, allow_redirects=True
)
async def async_send_delete_message(
hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
) -> aiohttp.ClientResponse:
"""Send an DeleteReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event
"""
token = await config.async_get_access_token()
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoints: list[dict[str, Any]] = []
for entity_id in entity_ids:
domain = entity_id.split(".", 1)[0]
if domain not in ENTITY_ADAPTERS:
continue
endpoints.append({"endpointId": config.generate_alexa_id(entity_id)})
payload: dict[str, Any] = {
"endpoints": endpoints,
"scope": {"type": "BearerToken", "token": token},
}
message = AlexaResponse(
name="DeleteReport", namespace="Alexa.Discovery", payload=payload
)
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
return await session.post(
config.endpoint, headers=headers, json=message_serialized, allow_redirects=True
)
async def async_send_doorbell_event_message(
hass: HomeAssistant, config: AbstractConfig, alexa_entity: AlexaEntity
) -> None:
"""Send a DoorbellPress event message for an Alexa entity.
https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-doorbelleventsource.html
"""
token = await config.async_get_access_token()
headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
endpoint = alexa_entity.alexa_id()
message = AlexaResponse(
name="DoorbellPress",
namespace="Alexa.DoorbellEventSource",
payload={
"cause": {"type": Cause.PHYSICAL_INTERACTION},
"timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
},
)
message.set_endpoint_full(token, endpoint)
message_serialized = message.serialize()
session = async_get_clientsession(hass)
assert config.endpoint is not None
try:
async with timeout(DEFAULT_TIMEOUT):
response = await session.post(
config.endpoint,
headers=headers,
json=message_serialized,
allow_redirects=True,
)
except TimeoutError, aiohttp.ClientError:
_LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
return
response_text = await response.text()
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Sent: %s", json.dumps(async_redact_auth_data(message_serialized))
)
_LOGGER.debug("Received (%s): %s", response.status, response_text)
if response.status == HTTPStatus.ACCEPTED:
return
response_json = json_loads_object(response_text)
response_payload = cast(JsonObjectType, response_json["payload"])
_LOGGER.error(
"Error when sending DoorbellPress event for %s to Alexa: %s: %s",
alexa_entity.entity_id,
response_payload["code"],
response_payload["description"],
)