initial commit
This commit is contained in:
174
config.py
Normal file
174
config.py
Normal 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
222
const.py
Normal 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
32
diagnostics.py
Normal 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
1081
entities.py
Normal file
File diff suppressed because it is too large
Load Diff
159
errors.py
Normal file
159
errors.py
Normal 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
125
flash_briefings.py
Normal 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
1965
handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
316
intent.py
Normal file
316
intent.py
Normal 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
46
logbook.py
Normal 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
10
manifest.json
Normal 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
439
resources.py
Normal 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
0
services.yaml
Normal file
249
smart_home.py
Normal file
249
smart_home.py
Normal 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
606
state_report.py
Normal 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"],
|
||||
)
|
||||
Reference in New Issue
Block a user