From 75e55b660a7f959c19261069fa1c25a439e55d56 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sun, 24 May 2026 17:12:52 +0200 Subject: [PATCH] initial commit --- config.py | 174 ++++ const.py | 222 +++++ diagnostics.py | 32 + entities.py | 1081 ++++++++++++++++++++++++ errors.py | 159 ++++ flash_briefings.py | 125 +++ handlers.py | 1965 ++++++++++++++++++++++++++++++++++++++++++++ intent.py | 316 +++++++ logbook.py | 46 ++ manifest.json | 10 + resources.py | 439 ++++++++++ services.yaml | 0 smart_home.py | 249 ++++++ state_report.py | 606 ++++++++++++++ 14 files changed, 5424 insertions(+) create mode 100644 config.py create mode 100644 const.py create mode 100644 diagnostics.py create mode 100644 entities.py create mode 100644 errors.py create mode 100644 flash_briefings.py create mode 100644 handlers.py create mode 100644 intent.py create mode 100644 logbook.py create mode 100644 manifest.json create mode 100644 resources.py create mode 100644 services.yaml create mode 100644 smart_home.py create mode 100644 state_report.py diff --git a/config.py b/config.py new file mode 100644 index 0000000..87b97f7 --- /dev/null +++ b/config.py @@ -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} diff --git a/const.py b/const.py new file mode 100644 index 0000000..27e9bbd --- /dev/null +++ b/const.py @@ -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", + } diff --git a/diagnostics.py b/diagnostics.py new file mode 100644 index 0000000..04c003e --- /dev/null +++ b/diagnostics.py @@ -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) diff --git a/entities.py b/entities.py new file mode 100644 index 0000000..4c1ff97 --- /dev/null +++ b/entities.py @@ -0,0 +1,1081 @@ +"""Alexa entity adapters.""" + +from collections.abc import Generator, Iterable +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.components import ( + alarm_control_panel, + alert, + automation, + binary_sensor, + button, + camera, + climate, + cover, + event, + fan, + group, + humidifier, + image_processing, + input_boolean, + input_button, + input_number, + light, + lock, + media_player, + number, + remote, + scene, + script, + sensor, + switch, + timer, + vacuum, + valve, + water_heater, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CONF_DESCRIPTION, + CONF_NAME, + UnitOfTemperature, + __version__, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import network +from homeassistant.helpers.entity import entity_sources +from homeassistant.util.decorator import Registry + +from .capabilities import ( + Alexa, + AlexaBrightnessController, + AlexaCameraStreamController, + AlexaCapability, + AlexaChannelController, + AlexaColorController, + AlexaColorTemperatureController, + AlexaContactSensor, + AlexaDoorbellEventSource, + AlexaEndpointHealth, + AlexaEqualizerController, + AlexaEventDetectionSensor, + AlexaInputController, + AlexaLockController, + AlexaModeController, + AlexaMotionSensor, + AlexaPlaybackController, + AlexaPlaybackStateReporter, + AlexaPowerController, + AlexaRangeController, + AlexaSceneController, + AlexaSecurityPanelController, + AlexaSeekController, + AlexaSpeaker, + AlexaStepSpeaker, + AlexaTemperatureSensor, + AlexaThermostatController, + AlexaTimeHoldController, + AlexaToggleController, +) +from .const import CONF_DISPLAY_CATEGORIES + +if TYPE_CHECKING: + from .config import AbstractConfig + +_LOGGER = logging.getLogger(__name__) + +ENTITY_ADAPTERS: Registry[str, type[AlexaEntity]] = Registry() + +TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None) + + +class DisplayCategory: + """Possible display categories for Discovery response. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories + """ + + # Describes a combination of devices set to a specific state, when the + # state change must occur in a specific order. For example, a "watch + # Netflix" scene might require the: 1. TV to be powered on & 2. Input set + # to HDMI1. Applies to Scenes + ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" + + # Indicates a device that cools the air in interior spaces. + AIR_CONDITIONER = "AIR_CONDITIONER" + + # Indicates a device that emits pleasant odors and masks unpleasant + # odors in interior spaces. + AIR_FRESHENER = "AIR_FRESHENER" + + # Indicates a device that improves the quality of air in interior spaces. + AIR_PURIFIER = "AIR_PURIFIER" + + # Indicates a smart device in an automobile, such as a dash camera. + AUTO_ACCESSORY = "AUTO_ACCESSORY" + + # Indicates a security device with video or photo functionality. + CAMERA = "CAMERA" + + # Indicates a religious holiday decoration that often contains lights. + CHRISTMAS_TREE = "CHRISTMAS_TREE" + + # Indicates a device that makes coffee. + COFFEE_MAKER = "COFFEE_MAKER" + + # Indicates a non-mobile computer, such as a desktop computer. + COMPUTER = "COMPUTER" + + # Indicates an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates a doorbell. + DOORBELL = "DOORBELL" + + # Indicates a window covering on the outside of a structure. + EXTERIOR_BLIND = "EXTERIOR_BLIND" + + # Indicates a fan. + FAN = "FAN" + + # Indicates a game console, such as Microsoft Xbox or Nintendo Switch + GAME_CONSOLE = "GAME_CONSOLE" + + # Indicates a garage door. + # Garage doors must implement the ModeController interface to + # open and close the door. + GARAGE_DOOR = "GARAGE_DOOR" + + # Indicates a wearable device that transmits audio directly into the ear. + HEADPHONES = "HEADPHONES" + + # Indicates a smart-home hub. + HUB = "HUB" + + # Indicates a window covering on the inside of a structure. + INTERIOR_BLIND = "INTERIOR_BLIND" + + # Indicates a laptop or other mobile computer. + LAPTOP = "LAPTOP" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates a microwave oven. + MICROWAVE = "MICROWAVE" + + # Indicates a mobile phone. + MOBILE_PHONE = "MOBILE_PHONE" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # Indicates a network-connected music system. + MUSIC_SYSTEM = "MUSIC_SYSTEM" + + # Indicates a network router. + NETWORK_HARDWARE = "NETWORK_HARDWARE" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Indicates an oven cooking appliance. + OVEN = "OVEN" + + # Indicates a non-mobile phone, such as landline or an IP phone. + PHONE = "PHONE" + + # Indicates a device that prints. + PRINTER = "PRINTER" + + # Indicates a decive that support stateless events, + # such as remote switches and smart buttons. + REMOTE = "REMOTE" + + # Indicates a network router. + ROUTER = "ROUTER" + + # Describes a combination of devices set to a specific state, when the + # order of the state change is not important. For example a bedtime scene + # might include turning off lights and lowering the thermostat, but the + # order is unimportant. Applies to Scenes + SCENE_TRIGGER = "SCENE_TRIGGER" + + # Indicates a projector screen. + SCREEN = "SCREEN" + + # Indicates a security panel. + SECURITY_PANEL = "SECURITY_PANEL" + + # Indicates a security system. + SECURITY_SYSTEM = "SECURITY_SYSTEM" + + # Indicates an electric cooking device that sits on a countertop, + # cooks at low temperatures, and is often shaped like a cooking pot. + SLOW_COOKER = "SLOW_COOKER" + + # Indicates an endpoint that locks. + SMARTLOCK = "SMARTLOCK" + + # Indicates modules that are plugged into an existing electrical outlet. + # Can control a variety of devices. + SMARTPLUG = "SMARTPLUG" + + # Indicates the endpoint is a speaker or speaker system. + SPEAKER = "SPEAKER" + + # Indicates a streaming device such as Apple TV, Chromecast, or Roku. + STREAMING_DEVICE = "STREAMING_DEVICE" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates a tablet computer. + TABLET = "TABLET" + + # Indicates endpoints that report the temperature only. + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" + + # Indicates endpoints that control temperature, stand-alone air + # conditioners, or heaters with direct temperature control. + THERMOSTAT = "THERMOSTAT" + + # Indicates the endpoint is a television. + TV = "TV" + + # Indicates a vacuum cleaner. + VACUUM_CLEANER = "VACUUM_CLEANER" + + # Indicates a water heater. + WATER_HEATER = "WATER_HEATER" + + # Indicates a network-connected wearable device, such as an Apple Watch, + # Fitbit, or Samsung Gear. + WEARABLE = "WEARABLE" + + +class AlexaEntity: + """An adaptation of an entity, expressed in Alexa's terms. + + The API handlers should manipulate entities only through this interface. + """ + + def __init__( + self, hass: HomeAssistant, config: AbstractConfig, entity: State + ) -> None: + """Initialize Alexa Entity.""" + self.hass = hass + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + @property + def entity_id(self) -> str: + """Return the Entity ID.""" + return self.entity.entity_id + + def friendly_name(self) -> str: + """Return the Alexa API friendly name.""" + friendly_name: str = self.entity_conf.get( + CONF_NAME, self.entity.name + ).translate(TRANSLATION_TABLE) + return friendly_name + + def description(self) -> str: + """Return the Alexa API description.""" + description = self.entity_conf.get(CONF_DESCRIPTION) or self.entity_id + return f"{description} via Home Assistant".translate(TRANSLATION_TABLE) + + def alexa_id(self) -> str: + """Return the Alexa API entity id.""" + return self.config.generate_alexa_id(self.entity.entity_id) + + def display_categories(self) -> list[str] | None: + """Return a list of display categories.""" + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + return [entity_conf[CONF_DISPLAY_CATEGORIES]] + return self.default_display_categories() + + def default_display_categories(self) -> list[str] | None: + """Return a list of default display categories. + + This can be overridden by the user in the Home Assistant configuration. + + See also DisplayCategory. + """ + raise NotImplementedError + + def interfaces(self) -> Iterable[AlexaCapability]: + """Return a list of supported interfaces. + + Used for discovery. The list should contain AlexaInterface instances. + If the list is empty, this entity will not be discovered. + """ + raise NotImplementedError + + def serialize_properties(self) -> Generator[dict[str, Any]]: + """Yield each supported property in API format.""" + for interface in self.interfaces(): + if not interface.properties_proactively_reported(): + continue + + yield from interface.serialize_properties() + + def serialize_discovery(self) -> dict[str, Any]: + """Serialize the entity for discovery.""" + result: dict[str, Any] = { + "displayCategories": self.display_categories(), + "cookie": {}, + "endpointId": self.alexa_id(), + "friendlyName": self.friendly_name(), + "description": self.description(), + "manufacturerName": "Home Assistant", + "additionalAttributes": { + "manufacturer": "Home Assistant", + "model": self.entity.domain, + "softwareVersion": __version__, + "customIdentifier": f"{self.config.user_identifier()}-{self.entity_id}", + }, + } + + locale = self.config.locale + capabilities = [] + + for i in self.interfaces(): + if locale not in i.supported_locales: + continue + + try: + capabilities.append(i.serialize_discovery()) + except Exception: + _LOGGER.exception( + "Error serializing %s discovery for %s", i.name(), self.entity + ) + + result["capabilities"] = capabilities + + return result + + +@callback +def async_get_entities( + hass: HomeAssistant, config: AbstractConfig +) -> list[AlexaEntity]: + """Return all entities that are supported by Alexa.""" + entities: list[AlexaEntity] = [] + for state in hass.states.async_all(): + if state.domain not in ENTITY_ADAPTERS: + continue + + try: + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + interfaces = list(alexa_entity.interfaces()) + except Exception: + _LOGGER.exception("Unable to serialize %s for discovery", state.entity_id) + else: + if not interfaces: + continue + entities.append(alexa_entity) + + return entities + + +@ENTITY_ADAPTERS.register(alert.DOMAIN) +@ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) +class GenericCapabilities(AlexaEntity): + """A generic, on/off device. + + The choice of last resort. + """ + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + if self.entity.domain == automation.DOMAIN: + return [DisplayCategory.ACTIVITY_TRIGGER] + + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class SwitchCapabilities(AlexaEntity): + """Class to represent Switch capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + if self.entity.domain == input_boolean.DOMAIN: + return [DisplayCategory.OTHER] + + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == switch.SwitchDeviceClass.OUTLET: + return [DisplayCategory.SMARTPLUG] + + return [DisplayCategory.SWITCH] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + yield AlexaContactSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(button.DOMAIN) +@ENTITY_ADAPTERS.register(input_button.DOMAIN) +class ButtonCapabilities(AlexaEntity): + """Class to represent Button capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaSceneController(self.entity, supports_deactivation=False) + yield AlexaEventDetectionSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(climate.DOMAIN) +@ENTITY_ADAPTERS.register(water_heater.DOMAIN) +class ClimateCapabilities(AlexaEntity): + """Class to represent Climate capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + if self.entity.domain == water_heater.DOMAIN: + return [DisplayCategory.WATER_HEATER] + return [DisplayCategory.THERMOSTAT] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + # If we support two modes, one being off, we allow turning on too. + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + ) + or ( + self.entity.domain == climate.DOMAIN + and ( + supported_features + & ( + climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + ) + ) + ) + or ( + self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + ) + ): + yield AlexaPowerController(self.entity) + + if self.entity.domain == climate.DOMAIN or ( + self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + ): + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + if ( + self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) + ): + yield AlexaModeController( + self.entity, + instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}", + ) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(cover.DOMAIN) +class CoverCapabilities(AlexaEntity): + """Class to represent Cover capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class in (cover.CoverDeviceClass.GARAGE, cover.CoverDeviceClass.GATE): + return [DisplayCategory.GARAGE_DOOR] + if device_class == cover.CoverDeviceClass.DOOR: + return [DisplayCategory.DOOR] + if device_class in ( + cover.CoverDeviceClass.BLIND, + cover.CoverDeviceClass.SHADE, + cover.CoverDeviceClass.CURTAIN, + ): + return [DisplayCategory.INTERIOR_BLIND] + if device_class in ( + cover.CoverDeviceClass.WINDOW, + cover.CoverDeviceClass.AWNING, + cover.CoverDeviceClass.SHUTTER, + ): + return [DisplayCategory.EXTERIOR_BLIND] + + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class not in ( + cover.CoverDeviceClass.GARAGE, + cover.CoverDeviceClass.GATE, + ): + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.CoverEntityFeature.SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) + elif supported & ( + cover.CoverEntityFeature.CLOSE | cover.CoverEntityFeature.OPEN + ): + yield AlexaModeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) + if supported & cover.CoverEntityFeature.SET_TILT_POSITION: + yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt") + if supported & ( + cover.CoverEntityFeature.STOP | cover.CoverEntityFeature.STOP_TILT + ): + yield AlexaPlaybackController(self.entity, instance=f"{cover.DOMAIN}.stop") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(event.DOMAIN) +class EventCapabilities(AlexaEntity): + """Class to represent doorbel event capabilities.""" + + def default_display_categories(self) -> list[str] | None: + """Return the display categories for this entity.""" + attrs = self.entity.attributes + device_class: event.EventDeviceClass | None = attrs.get(ATTR_DEVICE_CLASS) + if device_class == event.EventDeviceClass.DOORBELL: + return [DisplayCategory.DOORBELL] + return None + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + if self.default_display_categories() is not None: + yield AlexaDoorbellEventSource(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class LightCapabilities(AlexaEntity): + """Class to represent Light capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.LIGHT] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + color_modes = self.entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) + if light.brightness_supported(color_modes): + yield AlexaBrightnessController(self.entity) + if light.color_supported(color_modes): + yield AlexaColorController(self.entity) + if light.color_temp_supported(color_modes): + yield AlexaColorTemperatureController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class FanCapabilities(AlexaEntity): + """Class to represent Fan capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.FAN] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + force_range_controller = True + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.FanEntityFeature.OSCILLATE: + yield AlexaToggleController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" + ) + force_range_controller = False + if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get( + fan.ATTR_PRESET_MODES + ): + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" + ) + force_range_controller = False + if supported & fan.FanEntityFeature.DIRECTION: + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" + ) + force_range_controller = False + + # AlexaRangeController controls the Fan Speed Percentage. + # For fans which only support on/off, no controller is added. This makes + # the fan impossible to turn on or off through Alexa, most likely due + # to a bug in Alexa. As a workaround, we add a range controller which + # can only be set to 0% or 100%. + if force_range_controller or supported & fan.FanEntityFeature.SET_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}" + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(remote.DOMAIN) +class RemoteCapabilities(AlexaEntity): + """Class to represent Remote capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.REMOTE] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] + if ( + activities + and (supported & remote.RemoteEntityFeature.ACTIVITY) + and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + ): + yield AlexaModeController( + self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" + ) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(humidifier.DOMAIN) +class HumidifierCapabilities(AlexaEntity): + """Class to represent Humidifier capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + supported & humidifier.HumidifierEntityFeature.MODES + ) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES): + yield AlexaModeController( + self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" + ) + yield AlexaRangeController( + self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}" + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class LockCapabilities(AlexaEntity): + """Class to represent Lock capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.SMARTLOCK] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaLockController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(media_player.DOMAIN) +class MediaPlayerCapabilities(AlexaEntity): + """Class to represent MediaPlayer capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == media_player.MediaPlayerDeviceClass.SPEAKER: + return [DisplayCategory.SPEAKER] + + return [DisplayCategory.TV] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.MediaPlayerEntityFeature.VOLUME_SET: + yield AlexaSpeaker(self.entity) + elif supported & media_player.MediaPlayerEntityFeature.VOLUME_STEP: + yield AlexaStepSpeaker(self.entity) + + playback_features = ( + media_player.MediaPlayerEntityFeature.PLAY + | media_player.MediaPlayerEntityFeature.PAUSE + | media_player.MediaPlayerEntityFeature.STOP + | media_player.MediaPlayerEntityFeature.NEXT_TRACK + | media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK + ) + if supported & playback_features: + yield AlexaPlaybackController(self.entity) + yield AlexaPlaybackStateReporter(self.entity) + + if supported & media_player.MediaPlayerEntityFeature.SEEK: + yield AlexaSeekController(self.entity) + + if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE: + inputs = AlexaInputController.get_valid_inputs( + self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) + ) + if len(inputs) > 0: + yield AlexaInputController(self.entity) + + if supported & media_player.MediaPlayerEntityFeature.PLAY_MEDIA: + yield AlexaChannelController(self.entity) + + # AlexaEqualizerController is disabled for denonavr + # since it blocks alexa from discovering any devices. + entity_info = entity_sources(self.hass).get(self.entity_id) + domain = entity_info["domain"] if entity_info else None + if ( + supported & media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE + and domain != "denonavr" + ): + inputs = AlexaEqualizerController.get_valid_inputs( + self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or [] + ) + if len(inputs) > 0: + yield AlexaEqualizerController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class SceneCapabilities(AlexaEntity): + """Class to represent Scene capabilities.""" + + def description(self) -> str: + """Return the Alexa API description.""" + description = AlexaEntity.description(self) + if "scene" not in description.casefold(): + return f"{description} (Scene)" + return description + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.SCENE_TRIGGER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaSceneController(self.entity, supports_deactivation=False) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class ScriptCapabilities(AlexaEntity): + """Class to represent Script capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaSceneController(self.entity, supports_deactivation=True) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class SensorCapabilities(AlexaEntity): + """Class to represent Sensor capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + attrs = self.entity.attributes + if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in { + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.CELSIUS, + }: + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class BinarySensorCapabilities(AlexaEntity): + """Class to represent BinarySensor capabilities.""" + + TYPE_CONTACT = "contact" + TYPE_MOTION = "motion" + TYPE_PRESENCE = "presence" + + def default_display_categories(self) -> list[str] | None: + """Return the display categories for this entity.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + return [DisplayCategory.CONTACT_SENSOR] + if sensor_type is self.TYPE_MOTION: + return [DisplayCategory.MOTION_SENSOR] + if sensor_type is self.TYPE_PRESENCE: + return [DisplayCategory.CAMERA] + return None + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + yield AlexaContactSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_MOTION: + yield AlexaMotionSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_PRESENCE: + yield AlexaEventDetectionSensor(self.hass, self.entity) + + # yield additional interfaces based on specified display category in config. + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + if entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.DOORBELL: + yield AlexaDoorbellEventSource(self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.CONTACT_SENSOR: + yield AlexaContactSensor(self.hass, self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.MOTION_SENSOR: + yield AlexaMotionSensor(self.hass, self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.CAMERA: + yield AlexaEventDetectionSensor(self.hass, self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + def get_type(self) -> str | None: + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + binary_sensor.BinarySensorDeviceClass.DOOR, + binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, + binary_sensor.BinarySensorDeviceClass.OPENING, + binary_sensor.BinarySensorDeviceClass.WINDOW, + ): + return self.TYPE_CONTACT + + if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.BinarySensorDeviceClass.MOTION: + return self.TYPE_MOTION + + if ( + attrs.get(ATTR_DEVICE_CLASS) + == binary_sensor.BinarySensorDeviceClass.PRESENCE + ): + return self.TYPE_PRESENCE + + return None + + +@ENTITY_ADAPTERS.register(alarm_control_panel.DOMAIN) +class AlarmControlPanelCapabilities(AlexaEntity): + """Class to represent Alarm capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.SECURITY_PANEL] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + if not self.entity.attributes.get("code_arm_required"): + yield AlexaSecurityPanelController(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(image_processing.DOMAIN) +class ImageProcessingCapabilities(AlexaEntity): + """Class to represent image_processing capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.CAMERA] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaEventDetectionSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(input_number.DOMAIN) +@ENTITY_ADAPTERS.register(number.DOMAIN) +class InputNumberCapabilities(AlexaEntity): + """Class to represent number and input_number capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + domain = self.entity.domain + yield AlexaRangeController(self.entity, instance=f"{domain}.value") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(timer.DOMAIN) +class TimerCapabilities(AlexaEntity): + """Class to represent Timer capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaTimeHoldController(self.entity, allow_remote_resume=True) + yield AlexaPowerController(self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(vacuum.DOMAIN) +class VacuumCapabilities(AlexaEntity): + """Class to represent vacuum capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.VACUUM_CLEANER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + (supported & vacuum.VacuumEntityFeature.TURN_ON) + or (supported & vacuum.VacuumEntityFeature.START) + ) and ( + (supported & vacuum.VacuumEntityFeature.TURN_OFF) + or (supported & vacuum.VacuumEntityFeature.RETURN_HOME) + ): + yield AlexaPowerController(self.entity) + + if supported & vacuum.VacuumEntityFeature.FAN_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}" + ) + + if supported & vacuum.VacuumEntityFeature.PAUSE: + support_resume = bool(supported & vacuum.VacuumEntityFeature.START) + yield AlexaTimeHoldController( + self.entity, allow_remote_resume=support_resume + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(valve.DOMAIN) +class ValveCapabilities(AlexaEntity): + """Class to represent Valve capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & valve.ValveEntityFeature.SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{valve.DOMAIN}.{valve.ATTR_POSITION}" + ) + elif supported & ( + valve.ValveEntityFeature.CLOSE | valve.ValveEntityFeature.OPEN + ): + yield AlexaModeController(self.entity, instance=f"{valve.DOMAIN}.state") + if supported & valve.ValveEntityFeature.STOP: + yield AlexaToggleController(self.entity, instance=f"{valve.DOMAIN}.stop") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(camera.DOMAIN) +class CameraCapabilities(AlexaEntity): + """Class to represent Camera capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.CAMERA] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + if self._check_requirements(): + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & camera.CameraEntityFeature.STREAM: + yield AlexaCameraStreamController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + def _check_requirements(self) -> bool: + """Check the hass URL for HTTPS scheme.""" + if "stream" not in self.hass.config.components: + _LOGGER.debug( + "%s requires stream component for AlexaCameraStreamController", + self.entity_id, + ) + return False + + try: + network.get_url( + self.hass, + allow_internal=False, + allow_ip=False, + require_ssl=True, + require_standard_port=True, + ) + except network.NoURLAvailableError: + _LOGGER.debug( + "%s requires HTTPS for AlexaCameraStreamController", self.entity_id + ) + return False + + return True diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..120b165 --- /dev/null +++ b/errors.py @@ -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" diff --git a/flash_briefings.py b/flash_briefings.py new file mode 100644 index 0000000..a37a95e --- /dev/null +++ b/flash_briefings.py @@ -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) diff --git a/handlers.py b/handlers.py new file mode 100644 index 0000000..6451141 --- /dev/null +++ b/handlers.py @@ -0,0 +1,1965 @@ +"""Alexa message handlers.""" + +import asyncio +from collections.abc import Callable, Coroutine +import logging +import math +from typing import Any + +from homeassistant import core as ha +from homeassistant.components import ( + alarm_control_panel, + button, + camera, + climate, + cover, + fan, + group, + humidifier, + input_button, + input_number, + light, + media_player, + number, + remote, + timer, + vacuum, + valve, + water_heater, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_UNLOCK, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + UnitOfTemperature, +) +from homeassistant.helpers import network +from homeassistant.util import color as color_util, dt as dt_util +from homeassistant.util.decorator import Registry +from homeassistant.util.unit_conversion import ( + TemperatureConverter, + TemperatureDeltaConverter, +) + +from .config import AbstractConfig +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + API_THERMOSTAT_MODES_CUSTOM, + API_THERMOSTAT_PRESETS, + DATE_FORMAT, + PRESET_MODE_NA, + Cause, + Inputs, +) +from .entities import async_get_entities +from .errors import ( + AlexaInvalidDirectiveError, + AlexaInvalidValueError, + AlexaSecurityPanelAuthorizationRequired, + AlexaTempRangeError, + AlexaUnsupportedThermostatModeError, + AlexaUnsupportedThermostatTargetStateError, + AlexaVideoActionNotPermittedForContentError, +) +from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode + +_LOGGER = logging.getLogger(__name__) +DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive" + +MIN_MAX_TEMP = { + climate.DOMAIN: { + "min_temp": climate.ATTR_MIN_TEMP, + "max_temp": climate.ATTR_MAX_TEMP, + }, + water_heater.DOMAIN: { + "min_temp": water_heater.ATTR_MIN_TEMP, + "max_temp": water_heater.ATTR_MAX_TEMP, + }, +} + +SERVICE_SET_TEMPERATURE = { + climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE, + water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE, +} + +HANDLERS: Registry[ + tuple[str, str], + Callable[ + [ha.HomeAssistant, AbstractConfig, AlexaDirective, ha.Context], + Coroutine[Any, Any, AlexaResponse], + ], +] = Registry() + + +@HANDLERS.register(("Alexa.Discovery", "Discover")) +async def async_api_discovery( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints: list[dict[str, Any]] = [] + for alexa_entity in async_get_entities(hass, config): + if not config.should_expose(alexa_entity.entity_id): + continue + try: + discovered_serialized_entity = alexa_entity.serialize_discovery() + except Exception: + _LOGGER.exception( + "Unable to serialize %s for discovery", alexa_entity.entity_id + ) + else: + discovery_endpoints.append(discovered_serialized_entity) + + return directive.response( + name="Discover.Response", + namespace="Alexa.Discovery", + payload={"endpoints": discovery_endpoints}, + ) + + +@HANDLERS.register(("Alexa.Authorization", "AcceptGrant")) +async def async_api_accept_grant( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Create a API formatted AcceptGrant response. + + Async friendly. + """ + auth_code: str = directive.payload["grant"]["code"] + + if config.supports_auth: + await config.async_accept_grant(auth_code) + + if config.should_report_state: + await async_enable_proactive_mode(hass, config) + + return directive.response( + name="AcceptGrant.Response", namespace="Alexa.Authorization", payload={} + ) + + +@HANDLERS.register(("Alexa.PowerController", "TurnOn")) +async def async_api_turn_on( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a turn on request.""" + entity = directive.entity + if (domain := entity.domain) == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_ON + if domain == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + elif domain == climate.DOMAIN: + service = climate.SERVICE_TURN_ON + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_ON + elif domain == humidifier.DOMAIN: + service = humidifier.SERVICE_TURN_ON + elif domain == remote.DOMAIN: + service = remote.SERVICE_TURN_ON + elif domain == vacuum.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + not supported & vacuum.VacuumEntityFeature.TURN_ON + and supported & vacuum.VacuumEntityFeature.START + ): + service = vacuum.SERVICE_START + elif domain == timer.DOMAIN: + service = timer.SERVICE_START + elif domain == media_player.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + power_features = ( + media_player.MediaPlayerEntityFeature.TURN_ON + | media_player.MediaPlayerEntityFeature.TURN_OFF + ) + if not supported & power_features: + service = media_player.SERVICE_MEDIA_PLAY + + await hass.services.async_call( + domain, + service, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PowerController", "TurnOff")) +async def async_api_turn_off( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a turn off request.""" + entity = directive.entity + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_OFF + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + elif domain == climate.DOMAIN: + service = climate.SERVICE_TURN_OFF + elif domain == fan.DOMAIN: + service = fan.SERVICE_TURN_OFF + elif domain == remote.DOMAIN: + service = remote.SERVICE_TURN_OFF + elif domain == humidifier.DOMAIN: + service = humidifier.SERVICE_TURN_OFF + elif domain == vacuum.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + not supported & vacuum.VacuumEntityFeature.TURN_OFF + and supported & vacuum.VacuumEntityFeature.RETURN_HOME + ): + service = vacuum.SERVICE_RETURN_TO_BASE + elif domain == timer.DOMAIN: + service = timer.SERVICE_CANCEL + elif domain == media_player.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + power_features = ( + media_player.MediaPlayerEntityFeature.TURN_ON + | media_player.MediaPlayerEntityFeature.TURN_OFF + ) + if not supported & power_features: + service = media_player.SERVICE_MEDIA_STOP + + await hass.services.async_call( + domain, + service, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.BrightnessController", "SetBrightness")) +async def async_api_set_brightness( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set brightness request.""" + entity = directive.entity + brightness = int(directive.payload["brightness"]) + + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.BrightnessController", "AdjustBrightness")) +async def async_api_adjust_brightness( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an adjust brightness request.""" + entity = directive.entity + brightness_delta = int(directive.payload["brightnessDelta"]) + + # set brightness + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_STEP_PCT: brightness_delta, + }, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorController", "SetColor")) +async def async_api_set_color( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set color request.""" + entity = directive.entity + rgb = color_util.color_hsb_to_RGB( + float(directive.payload["color"]["hue"]), + float(directive.payload["color"]["saturation"]), + float(directive.payload["color"]["brightness"]), + ) + + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_RGB_COLOR: rgb}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorTemperatureController", "SetColorTemperature")) +async def async_api_set_color_temperature( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set color temperature request.""" + entity = directive.entity + kelvin = int(directive.payload["colorTemperatureInKelvin"]) + + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: kelvin}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorTemperatureController", "DecreaseColorTemperature")) +async def async_api_decrease_color_temp( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a decrease color temperature request.""" + entity = directive.entity + current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) + min_kelvin = int(entity.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN]) + + value = max(min_kelvin, current - 500) + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ColorTemperatureController", "IncreaseColorTemperature")) +async def async_api_increase_color_temp( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an increase color temperature request.""" + entity = directive.entity + current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) + max_kelvin = int(entity.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN]) + + value = min(max_kelvin, current + 500) + await hass.services.async_call( + entity.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value}, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.SceneController", "Activate")) +async def async_api_activate( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an activate request.""" + entity = directive.entity + domain = entity.domain + + service = SERVICE_TURN_ON + if domain == button.DOMAIN: + service = button.SERVICE_PRESS + elif domain == input_button.DOMAIN: + service = input_button.SERVICE_PRESS + + await hass.services.async_call( + domain, + service, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + payload: dict[str, Any] = { + "cause": {"type": Cause.VOICE_INTERACTION}, + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), + } + + return directive.response( + name="ActivationStarted", namespace="Alexa.SceneController", payload=payload + ) + + +@HANDLERS.register(("Alexa.SceneController", "Deactivate")) +async def async_api_deactivate( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a deactivate request.""" + entity = directive.entity + domain = entity.domain + + await hass.services.async_call( + domain, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + payload: dict[str, Any] = { + "cause": {"type": Cause.VOICE_INTERACTION}, + "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), + } + + return directive.response( + name="DeactivationStarted", namespace="Alexa.SceneController", payload=payload + ) + + +@HANDLERS.register(("Alexa.LockController", "Lock")) +async def async_api_lock( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a lock request.""" + entity = directive.entity + await hass.services.async_call( + entity.domain, + SERVICE_LOCK, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + response = directive.response() + response.add_context_property( + {"name": "lockState", "namespace": "Alexa.LockController", "value": "LOCKED"} + ) + return response + + +@HANDLERS.register(("Alexa.LockController", "Unlock")) +async def async_api_unlock( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an unlock request.""" + if config.locale not in { + "ar-SA", + "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", + }: + msg = ( + "The unlock directive is not supported for the following locales:" + f" {config.locale}" + ) + raise AlexaInvalidDirectiveError(msg) + + entity = directive.entity + await hass.services.async_call( + entity.domain, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + response = directive.response() + response.add_context_property( + {"namespace": "Alexa.LockController", "name": "lockState", "value": "UNLOCKED"} + ) + + return response + + +@HANDLERS.register(("Alexa.Speaker", "SetVolume")) +async def async_api_set_volume( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set volume request.""" + volume = round(float(directive.payload["volume"] / 100), 2) + entity = directive.entity + + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.InputController", "SelectInput")) +async def async_api_select_input( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set input request.""" + media_input = directive.payload["input"] + entity = directive.entity + + # Attempt to map the ALL UPPERCASE payload name to a source. + # Strips trailing 1 to match single input devices. + source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] + for source in source_list: + formatted_source = ( + source.lower().replace("-", "").replace("_", "").replace(" ", "") + ) + media_input = media_input.lower().replace(" ", "") + if ( + formatted_source in Inputs.VALID_SOURCE_NAME_MAP + and formatted_source == media_input + ) or ( + media_input.endswith("1") and formatted_source == media_input.rstrip("1") + ): + media_input = source + break + else: + msg = ( + f"failed to map input {media_input} to a media source on {entity.entity_id}" + ) + raise AlexaInvalidValueError(msg) + + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_INPUT_SOURCE: media_input, + } + + await hass.services.async_call( + entity.domain, + media_player.SERVICE_SELECT_SOURCE, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.Speaker", "AdjustVolume")) +async def async_api_adjust_volume( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an adjust volume request.""" + volume_delta = int(directive.payload["volume"]) + + entity = directive.entity + current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL] + + # read current state + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + + volume = float(max(0, volume_delta + current) / 100) + + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.StepSpeaker", "AdjustVolume")) +async def async_api_adjust_volume_step( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an adjust volume step request.""" + # media_player volume up/down service does not support specifying steps + # each component handles it differently e.g. via config. + # This workaround will simply call the volume up/Volume down the amount of + # steps asked for. When no steps are called in the request, Alexa sends + # a default of 10 steps which for most purposes is too high. The default + # is set 1 in this case. + entity = directive.entity + volume_int = int(directive.payload["volumeSteps"]) + is_default = bool(directive.payload["volumeStepsDefault"]) + default_steps = 1 + + if volume_int < 0: + service_volume = SERVICE_VOLUME_DOWN + if is_default: + volume_int = -default_steps + else: + service_volume = SERVICE_VOLUME_UP + if is_default: + volume_int = default_steps + + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + for _ in range(abs(volume_int)): + await hass.services.async_call( + entity.domain, service_volume, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.StepSpeaker", "SetMute")) +@HANDLERS.register(("Alexa.Speaker", "SetMute")) +async def async_api_set_mute( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set mute request.""" + mute = bool(directive.payload["mute"]) + entity = directive.entity + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_MUTE, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Play")) +async def async_api_play( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a play request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Pause")) +async def async_api_pause( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a pause request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Stop")) +async def async_api_stop( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a stop request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == cover.DOMAIN: + supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + feature_services: dict[int, str] = { + cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER, + cover.CoverEntityFeature.STOP_TILT.value: cover.SERVICE_STOP_COVER_TILT, + } + await asyncio.gather( + *( + hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + for feature, service in feature_services.items() + if feature & supported + ) + ) + else: + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Next")) +async def async_api_next( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a next request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PlaybackController", "Previous")) +async def async_api_previous( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a previous request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + await hass.services.async_call( + entity.domain, + SERVICE_MEDIA_PREVIOUS_TRACK, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +def temperature_from_object( + hass: ha.HomeAssistant, temp_obj: dict[str, Any], interval: bool = False +) -> float: + """Get temperature from Temperature object in requested unit.""" + to_unit = hass.config.units.temperature_unit + from_unit = UnitOfTemperature.CELSIUS + temp = float(temp_obj["value"]) + + if temp_obj["scale"] == "FAHRENHEIT": + from_unit = UnitOfTemperature.FAHRENHEIT + elif temp_obj["scale"] == "KELVIN" and not interval: + # convert to Celsius if absolute temperature + temp -= 273.15 + + if interval: + return TemperatureDeltaConverter.convert(temp, from_unit, to_unit) + return TemperatureConverter.convert(temp, from_unit, to_unit) + + +@HANDLERS.register(("Alexa.ThermostatController", "SetTargetTemperature")) +async def async_api_set_target_temp( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set target temperature request.""" + entity = directive.entity + domain = entity.domain + + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes["max_temp"] + unit = hass.config.units.temperature_unit + + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + payload = directive.payload + response = directive.response() + if "targetSetpoint" in payload: + temp = temperature_from_object(hass, payload["targetSetpoint"]) + if temp < min_temp or temp > max_temp: + raise AlexaTempRangeError(hass, temp, min_temp, max_temp) + data[ATTR_TEMPERATURE] = temp + response.add_context_property( + { + "name": "targetSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": temp, "scale": API_TEMP_UNITS[unit]}, + } + ) + if "lowerSetpoint" in payload: + temp_low = temperature_from_object(hass, payload["lowerSetpoint"]) + if temp_low < min_temp or temp_low > max_temp: + raise AlexaTempRangeError(hass, temp_low, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + response.add_context_property( + { + "name": "lowerSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": temp_low, "scale": API_TEMP_UNITS[unit]}, + } + ) + if "upperSetpoint" in payload: + temp_high = temperature_from_object(hass, payload["upperSetpoint"]) + if temp_high < min_temp or temp_high > max_temp: + raise AlexaTempRangeError(hass, temp_high, min_temp, max_temp) + data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + response.add_context_property( + { + "name": "upperSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": temp_high, "scale": API_TEMP_UNITS[unit]}, + } + ) + + service = SERVICE_SET_TEMPERATURE[domain] + + await hass.services.async_call( + entity.domain, + service, + data, + blocking=False, + context=context, + ) + + return response + + +@HANDLERS.register(("Alexa.ThermostatController", "AdjustTargetTemperature")) +async def async_api_adjust_target_temp( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process an adjust target temperature request for climates and water heaters.""" + data: dict[str, Any] + entity = directive.entity + domain = entity.domain + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes[MIN_MAX_TEMP[domain]["max_temp"]] + unit = hass.config.units.temperature_unit + + temp_delta = temperature_from_object( + hass, directive.payload["targetSetpointDelta"], interval=True + ) + + response = directive.response() + + current_target_temp_high = entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) + current_target_temp_low = entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + if current_target_temp_high is not None and current_target_temp_low is not None: + target_temp_high = float(current_target_temp_high) + temp_delta + if target_temp_high < min_temp or target_temp_high > max_temp: + raise AlexaTempRangeError(hass, target_temp_high, min_temp, max_temp) + + target_temp_low = float(current_target_temp_low) + temp_delta + if target_temp_low < min_temp or target_temp_low > max_temp: + raise AlexaTempRangeError(hass, target_temp_low, min_temp, max_temp) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_TARGET_TEMP_HIGH: target_temp_high, + climate.ATTR_TARGET_TEMP_LOW: target_temp_low, + } + + response.add_context_property( + { + "name": "upperSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp_high, "scale": API_TEMP_UNITS[unit]}, + } + ) + response.add_context_property( + { + "name": "lowerSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp_low, "scale": API_TEMP_UNITS[unit]}, + } + ) + else: + current_target_temp: str | None = entity.attributes.get(ATTR_TEMPERATURE) + if current_target_temp is None: + raise AlexaUnsupportedThermostatTargetStateError( + "The current target temperature is not set, " + "cannot adjust target temperature" + ) + target_temp = float(current_target_temp) + temp_delta + + if target_temp < min_temp or target_temp > max_temp: + raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) + + data = {ATTR_ENTITY_ID: entity.entity_id, ATTR_TEMPERATURE: target_temp} + response.add_context_property( + { + "name": "targetSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp, "scale": API_TEMP_UNITS[unit]}, + } + ) + + service = SERVICE_SET_TEMPERATURE[domain] + + await hass.services.async_call( + entity.domain, + service, + data, + blocking=False, + context=context, + ) + + return response + + +@HANDLERS.register(("Alexa.ThermostatController", "SetThermostatMode")) +async def async_api_set_thermostat_mode( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a set thermostat mode request.""" + operation_list: list[str] + + entity = directive.entity + mode = directive.payload["thermostatMode"] + mode = mode if isinstance(mode, str) else mode["value"] + + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None) + + if ha_preset: + presets = entity.attributes.get(climate.ATTR_PRESET_MODES) or [] + + if ha_preset not in presets: + msg = f"The requested thermostat mode {ha_preset} is not supported" + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_PRESET_MODE + data[climate.ATTR_PRESET_MODE] = ha_preset + + elif mode == "CUSTOM": + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or [] + custom_mode = directive.payload["thermostatMode"]["customName"] + custom_mode = next( + (k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode), + None, + ) + if custom_mode not in operation_list: + msg = ( + f"The requested thermostat mode {mode}: {custom_mode} is not supported" + ) + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_HVAC_MODE + data[climate.ATTR_HVAC_MODE] = custom_mode + + else: + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or [] + ha_modes: dict[str, str] = { + k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode + } + ha_mode: str | None = next( + iter(set(ha_modes).intersection(operation_list)), None + ) + if ha_mode not in operation_list: + msg = f"The requested thermostat mode {mode} is not supported" + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_HVAC_MODE + data[climate.ATTR_HVAC_MODE] = ha_mode + + response = directive.response() + await hass.services.async_call( + climate.DOMAIN, service, data, blocking=False, context=context + ) + response.add_context_property( + { + "name": "thermostatMode", + "namespace": "Alexa.ThermostatController", + "value": mode, + } + ) + + return response + + +@HANDLERS.register(("Alexa", "ReportState")) +async def async_api_reportstate( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a ReportState request.""" + return directive.response(name="StateReport") + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) +async def async_api_arm( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a Security Panel Arm request.""" + entity = directive.entity + service = None + arm_state = directive.payload["armState"] + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + # Per Alexa Documentation: users are not allowed to switch from armed_away + # directly to another armed state without first disarming the system. + # https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming + if ( + entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY + and arm_state != "ARMED_AWAY" + ): + msg = "You must disarm the system before you can set the requested arm state." + raise AlexaSecurityPanelAuthorizationRequired(msg) + + if arm_state == "ARMED_AWAY": + service = SERVICE_ALARM_ARM_AWAY + elif arm_state == "ARMED_NIGHT": + service = SERVICE_ALARM_ARM_NIGHT + elif arm_state == "ARMED_STAY": + service = SERVICE_ALARM_ARM_HOME + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + # return 0 until alarm integration supports an exit delay + payload: dict[str, Any] = {"exitDelayInSeconds": 0} + + response = directive.response( + name="Arm.Response", namespace="Alexa.SecurityPanelController", payload=payload + ) + + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": arm_state, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Disarm")) +async def async_api_disarm( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a Security Panel Disarm request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + response = directive.response() + + # Per Alexa Documentation: If you receive a Disarm directive, and the + # system is already disarmed, respond with a success response, + # not an error response. + if entity.state == alarm_control_panel.AlarmControlPanelState.DISARMED: + return response + + payload = directive.payload + if "authorization" in payload: + value = payload["authorization"]["value"] + if payload["authorization"]["type"] == "FOUR_DIGIT_PIN": + data["code"] = value + + await hass.services.async_call( + entity.domain, SERVICE_ALARM_DISARM, data, blocking=True, context=context + ) + + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": "DISARMED", + } + ) + + return response + + +@HANDLERS.register(("Alexa.ModeController", "SetMode")) +async def async_api_set_mode( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a SetMode directive.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + mode = directive.payload["mode"] + + # Fan Direction + if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + direction = mode.split(".")[1] + if direction in (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD): + service = fan.SERVICE_SET_DIRECTION + data[fan.ATTR_DIRECTION] = direction + + # Fan preset_mode + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + preset_mode = mode.split(".")[1] + preset_modes: list[str] | None = entity.attributes.get(fan.ATTR_PRESET_MODES) + if ( + preset_mode != PRESET_MODE_NA + and preset_modes + and preset_mode in preset_modes + ): + service = fan.SERVICE_SET_PRESET_MODE + data[fan.ATTR_PRESET_MODE] = preset_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Preset '{preset_mode}'" + raise AlexaInvalidValueError(msg) + + # Humidifier mode + elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": + mode = mode.split(".")[1] + modes: list[str] | None = entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) + if mode != PRESET_MODE_NA and modes and mode in modes: + service = humidifier.SERVICE_SET_MODE + data[humidifier.ATTR_MODE] = mode + else: + msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" + raise AlexaInvalidValueError(msg) + + # Remote Activity + elif instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}": + activity = mode.split(".")[1] + activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + if activity != PRESET_MODE_NA and activities and activity in activities: + service = remote.SERVICE_TURN_ON + data[remote.ATTR_ACTIVITY] = activity + else: + msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" + raise AlexaInvalidValueError(msg) + + # Water heater operation mode + elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = mode.split(".")[1] + operation_modes: list[str] | None = entity.attributes.get( + water_heater.ATTR_OPERATION_LIST + ) + if ( + operation_mode != PRESET_MODE_NA + and operation_modes + and operation_mode in operation_modes + ): + service = water_heater.SERVICE_SET_OPERATION_MODE + data[water_heater.ATTR_OPERATION_MODE] = operation_mode + else: + msg = ( + f"Entity '{entity.entity_id}' does not support" + f" Operation mode '{operation_mode}'" + ) + raise AlexaInvalidValueError(msg) + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + position = mode.split(".")[1] + + if position == cover.CoverState.CLOSED: + service = cover.SERVICE_CLOSE_COVER + elif position == cover.CoverState.OPEN: + service = cover.SERVICE_OPEN_COVER + elif position == "custom": + service = cover.SERVICE_STOP_COVER + + # Valve position state + elif instance == f"{valve.DOMAIN}.state": + position = mode.split(".")[1] + + if position == valve.STATE_CLOSED: + service = valve.SERVICE_CLOSE_VALVE + elif position == valve.STATE_OPEN: + service = valve.SERVICE_OPEN_VALVE + + if not service: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ModeController", + "instance": instance, + "name": "mode", + "value": mode, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ModeController", "AdjustMode")) +async def async_api_adjust_mode( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a AdjustMode request. + + Requires capabilityResources supportedModes to be ordered. + Only supportedModes with ordered=True support the adjustMode directive. + """ + + # Currently no supportedModes are configured with ordered=True + # to support this request. + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOn")) +async def async_api_toggle_on( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a toggle on request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + + data: dict[str, Any] + + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_OSCILLATING: True, + } + elif instance == f"{valve.DOMAIN}.stop": + service = valve.SERVICE_STOP_VALVE + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ToggleController", + "instance": instance, + "name": "toggleState", + "value": "ON", + } + ) + + return response + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOff")) +async def async_api_toggle_off( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a toggle off request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + + # Fan Oscillating + if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + service = fan.SERVICE_OSCILLATE + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_OSCILLATING: False, + } + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ToggleController", + "instance": instance, + "name": "toggleState", + "value": "OFF", + } + ) + + return response + + +@HANDLERS.register(("Alexa.RangeController", "SetRangeValue")) +async def async_api_set_range( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + range_value = directive.payload["rangeValue"] + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # Cover Position + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + range_value = int(range_value) + if supported & cover.CoverEntityFeature.CLOSE and range_value == 0: + service = cover.SERVICE_CLOSE_COVER + elif supported & cover.CoverEntityFeature.OPEN and range_value == 100: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = range_value + + # Cover Tilt + elif instance == f"{cover.DOMAIN}.tilt": + range_value = int(range_value) + if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0: + service = cover.SERVICE_CLOSE_COVER_TILT + elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100: + service = cover.SERVICE_OPEN_COVER_TILT + else: + service = cover.SERVICE_SET_COVER_TILT_POSITION + data[cover.ATTR_TILT_POSITION] = range_value + + # Fan Speed + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + range_value = int(range_value) + if range_value == 0: + service = fan.SERVICE_TURN_OFF + elif supported & fan.FanEntityFeature.SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value + else: + service = fan.SERVICE_TURN_ON + + # Humidifier target humidity + elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + range_value = int(range_value) + service = humidifier.SERVICE_SET_HUMIDITY + data[humidifier.ATTR_HUMIDITY] = range_value + + # Input Number Value + elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + range_value = float(range_value) + service = input_number.SERVICE_SET_VALUE + min_value = float(entity.attributes[input_number.ATTR_MIN]) + max_value = float(entity.attributes[input_number.ATTR_MAX]) + data[input_number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) + + # Input Number Value + elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}": + range_value = float(range_value) + service = number.SERVICE_SET_VALUE + min_value = float(entity.attributes[number.ATTR_MIN]) + max_value = float(entity.attributes[number.ATTR_MAX]) + data[number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) + + # Vacuum Fan Speed + elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + service = vacuum.SERVICE_SET_FAN_SPEED + speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + speed = next( + (v for i, v in enumerate(speed_list) if i == int(range_value)), None + ) + + if not speed: + msg = "Entity does not support value" + raise AlexaInvalidValueError(msg) + + data[vacuum.ATTR_FAN_SPEED] = speed + + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_value = int(range_value) + if supported & valve.ValveEntityFeature.CLOSE and range_value == 0: + service = valve.SERVICE_CLOSE_VALVE + elif supported & valve.ValveEntityFeature.OPEN and range_value == 100: + service = valve.SERVICE_OPEN_VALVE + else: + service = valve.SERVICE_SET_VALVE_POSITION + data[valve.ATTR_POSITION] = range_value + + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.RangeController", + "instance": instance, + "name": "rangeValue", + "value": range_value, + } + ) + + return response + + +@HANDLERS.register(("Alexa.RangeController", "AdjustRangeValue")) +async def async_api_adjust_range( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + range_delta = directive.payload["rangeValueDelta"] + range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) + response_value: float | None = 0 + + # Cover Position + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = SERVICE_SET_COVER_POSITION + if not (current := entity.attributes.get(cover.ATTR_CURRENT_POSITION)): + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = cover.SERVICE_OPEN_COVER + elif position == 0: + service = cover.SERVICE_CLOSE_COVER + else: + data[cover.ATTR_POSITION] = position + + # Cover Tilt + elif instance == f"{cover.DOMAIN}.tilt": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = SERVICE_SET_COVER_TILT_POSITION + current = entity.attributes.get(cover.ATTR_TILT_POSITION) + if not current: + msg = f"Unable to determine {entity.entity_id} current tilt position" + raise AlexaInvalidValueError(msg) + tilt_position = response_value = min(100, max(0, range_delta + current)) + if tilt_position == 100: + service = cover.SERVICE_OPEN_COVER_TILT + elif tilt_position == 0: + service = cover.SERVICE_CLOSE_COVER_TILT + else: + data[cover.ATTR_TILT_POSITION] = tilt_position + + # Fan speed percentage + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}": + percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20 + range_delta = ( + int(range_delta * percentage_step) + if range_delta_default + else int(range_delta) + ) + service = fan.SERVICE_SET_PERCENTAGE + if not (current := entity.attributes.get(fan.ATTR_PERCENTAGE)): + msg = f"Unable to determine {entity.entity_id} current fan speed" + raise AlexaInvalidValueError(msg) + percentage = response_value = min(100, max(0, range_delta + current)) + if percentage: + data[fan.ATTR_PERCENTAGE] = percentage + else: + service = fan.SERVICE_TURN_OFF + + # Humidifier target humidity + elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + percentage_step = 5 + range_delta = ( + int(range_delta * percentage_step) + if range_delta_default + else int(range_delta) + ) + service = humidifier.SERVICE_SET_HUMIDITY + if not (current := entity.attributes.get(humidifier.ATTR_HUMIDITY)): + msg = f"Unable to determine {entity.entity_id} current target humidity" + raise AlexaInvalidValueError(msg) + min_value = entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10) + max_value = entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90) + percentage = response_value = min( + max_value, max(min_value, range_delta + current) + ) + if percentage: + data[humidifier.ATTR_HUMIDITY] = percentage + + # Input Number Value + elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + range_delta = float(range_delta) + service = input_number.SERVICE_SET_VALUE + min_value = float(entity.attributes[input_number.ATTR_MIN]) + max_value = float(entity.attributes[input_number.ATTR_MAX]) + current = float(entity.state) + data[input_number.ATTR_VALUE] = response_value = min( + max_value, max(min_value, range_delta + current) + ) + + # Number Value + elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}": + range_delta = float(range_delta) + service = number.SERVICE_SET_VALUE + min_value = float(entity.attributes[number.ATTR_MIN]) + max_value = float(entity.attributes[number.ATTR_MAX]) + current = float(entity.state) + data[number.ATTR_VALUE] = response_value = min( + max_value, max(min_value, range_delta + current) + ) + + # Vacuum Fan Speed + elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + range_delta = int(range_delta) + service = vacuum.SERVICE_SET_FAN_SPEED + speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + current_speed = entity.attributes[vacuum.ATTR_FAN_SPEED] + current_speed_index = next( + (i for i, v in enumerate(speed_list) if v == current_speed), 0 + ) + new_speed_index = min( + len(speed_list) - 1, max(0, current_speed_index + range_delta) + ) + speed = next( + (v for i, v in enumerate(speed_list) if i == new_speed_index), None + ) + data[vacuum.ATTR_FAN_SPEED] = response_value = speed + + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = valve.SERVICE_SET_VALVE_POSITION + if not (current := entity.attributes.get(valve.ATTR_POSITION)): + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = valve.SERVICE_OPEN_VALVE + elif position == 0: + service = valve.SERVICE_CLOSE_VALVE + else: + data[valve.ATTR_POSITION] = position + + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.RangeController", + "instance": instance, + "name": "rangeValue", + "value": response_value, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ChannelController", "ChangeChannel")) +async def async_api_changechannel( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a change channel request.""" + channel = "0" + entity = directive.entity + channel_payload = directive.payload["channel"] + metadata_payload = directive.payload["channelMetadata"] + payload_name = "number" + + if "number" in channel_payload: + channel = channel_payload["number"] + payload_name = "number" + elif "callSign" in channel_payload: + channel = channel_payload["callSign"] + payload_name = "callSign" + elif "affiliateCallSign" in channel_payload: + channel = channel_payload["affiliateCallSign"] + payload_name = "affiliateCallSign" + elif "uri" in channel_payload: + channel = channel_payload["uri"] + payload_name = "uri" + elif "name" in metadata_payload: + channel = metadata_payload["name"] + payload_name = "callSign" + + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_CONTENT_ID: channel, + media_player.ATTR_MEDIA_CONTENT_TYPE: (media_player.MediaType.CHANNEL), + } + + await hass.services.async_call( + entity.domain, + media_player.SERVICE_PLAY_MEDIA, + data, + blocking=False, + context=context, + ) + + response = directive.response() + + response.add_context_property( + { + "namespace": "Alexa.ChannelController", + "name": "channel", + "value": {payload_name: channel}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ChannelController", "SkipChannels")) +async def async_api_skipchannel( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a skipchannel request.""" + channel = int(directive.payload["channelCount"]) + entity = directive.entity + + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + if channel < 0: + service_media = SERVICE_MEDIA_PREVIOUS_TRACK + else: + service_media = SERVICE_MEDIA_NEXT_TRACK + + for _ in range(abs(channel)): + await hass.services.async_call( + entity.domain, service_media, data, blocking=False, context=context + ) + + response = directive.response() + + response.add_context_property( + { + "namespace": "Alexa.ChannelController", + "name": "channel", + "value": {"number": ""}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SeekController", "AdjustSeekPosition")) +async def async_api_seek( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a seek request.""" + entity = directive.entity + position_delta = int(directive.payload["deltaPositionMilliseconds"]) + + current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION) + if not current_position: + msg = f"{entity} did not return the current media position." + raise AlexaVideoActionNotPermittedForContentError(msg) + + seek_position = max(int(current_position) + int(position_delta / 1000), 0) + + media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION) + if media_duration and 0 < int(media_duration) < seek_position: + seek_position = media_duration + + data: dict[str, Any] = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_SEEK_POSITION: seek_position, + } + + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_MEDIA_SEEK, + data, + blocking=False, + context=context, + ) + + # convert seconds to milliseconds for StateReport. + seek_position = int(seek_position * 1000) + + payload: dict[str, Any] = { + "properties": [{"name": "positionMilliseconds", "value": seek_position}] + } + return directive.response( + name="StateReport", namespace="Alexa.SeekController", payload=payload + ) + + +@HANDLERS.register(("Alexa.EqualizerController", "SetMode")) +async def async_api_set_eq_mode( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a SetMode request for EqualizerController.""" + mode: str = directive.payload["mode"] + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) + if sound_mode_list and mode.lower() in sound_mode_list: + data[media_player.ATTR_SOUND_MODE] = mode.lower() + else: + msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" + raise AlexaInvalidValueError(msg) + + await hass.services.async_call( + entity.domain, + media_player.SERVICE_SELECT_SOUND_MODE, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.EqualizerController", "AdjustBands")) +@HANDLERS.register(("Alexa.EqualizerController", "ResetBands")) +@HANDLERS.register(("Alexa.EqualizerController", "SetBands")) +async def async_api_bands_directive( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Handle an AdjustBands, ResetBands, SetBands request. + + Only mode directives are currently supported for the EqualizerController. + """ + # Currently bands directives are not supported. + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + +@HANDLERS.register(("Alexa.TimeHoldController", "Hold")) +async def async_api_hold( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a TimeHoldController Hold request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == timer.DOMAIN: + service = timer.SERVICE_PAUSE + + elif entity.domain == vacuum.DOMAIN: + service = vacuum.SERVICE_START_PAUSE + + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.TimeHoldController", "Resume")) +async def async_api_resume( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a TimeHoldController Resume request.""" + entity = directive.entity + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == timer.DOMAIN: + service = timer.SERVICE_START + + elif entity.domain == vacuum.DOMAIN: + service = vacuum.SERVICE_START_PAUSE + + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.CameraStreamController", "InitializeCameraStreams")) +async def async_api_initialize_camera_stream( + hass: ha.HomeAssistant, + config: AbstractConfig, + directive: AlexaDirective, + context: ha.Context, +) -> AlexaResponse: + """Process a InitializeCameraStreams request.""" + entity = directive.entity + stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls") + state = hass.states.get(entity.entity_id) + assert state + camera_image = state.attributes[ATTR_ENTITY_PICTURE] + + try: + external_url = network.get_url( + hass, + allow_internal=False, + allow_ip=False, + require_ssl=True, + require_standard_port=True, + ) + except network.NoURLAvailableError as err: + raise AlexaInvalidValueError( + "Failed to find suitable URL to serve to Alexa" + ) from err + + payload: dict[str, Any] = { + "cameraStreams": [ + { + "uri": f"{external_url}{stream_source}", + "protocol": "HLS", + "resolution": {"width": 1280, "height": 720}, + "authorizationType": "NONE", + "videoCodec": "H264", + "audioCodec": "AAC", + } + ], + "imageUri": f"{external_url}{camera_image}", + } + return directive.response( + name="Response", namespace="Alexa.CameraStreamController", payload=payload + ) diff --git a/intent.py b/intent.py new file mode 100644 index 0000000..634455d --- /dev/null +++ b/intent.py @@ -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, + } diff --git a/logbook.py b/logbook.py new file mode 100644 index 0000000..3e641e7 --- /dev/null +++ b/logbook.py @@ -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) diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..de59d28 --- /dev/null +++ b/manifest.json @@ -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" +} diff --git a/resources.py b/resources.py new file mode 100644 index 0000000..4541801 --- /dev/null +++ b/resources.py @@ -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 diff --git a/services.yaml b/services.yaml new file mode 100644 index 0000000..e69de29 diff --git a/smart_home.py b/smart_home.py new file mode 100644 index 0000000..d7bcfa5 --- /dev/null +++ b/smart_home.py @@ -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() diff --git a/state_report.py b/state_report.py new file mode 100644 index 0000000..e9c778c --- /dev/null +++ b/state_report.py @@ -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"], + )