1 """Alexa state report code."""
3 from __future__
import annotations
5 from asyncio
import timeout
6 from http
import HTTPStatus
9 from types
import MappingProxyType
10 from typing
import TYPE_CHECKING, Any, cast
11 from uuid
import uuid4
20 EventStateChangedData,
43 from .diagnostics
import async_redact_auth_data
44 from .entities
import ENTITY_ADAPTERS, AlexaEntity
45 from .errors
import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
48 from .config
import AbstractConfig
50 _LOGGER = logging.getLogger(__name__)
53 TO_REDACT = {
"correlationToken",
"token"}
57 """An incoming Alexa directive."""
64 def __init__(self, request: dict[str, Any]) ->
None:
65 """Initialize a directive."""
66 self._directive: dict[str, Any] = request[API_DIRECTIVE]
67 self.namespace: str = self._directive[API_HEADER][
"namespace"]
68 self.name: str = self._directive[API_HEADER][
"name"]
69 self.payload: dict[str, Any] = self._directive[API_PAYLOAD]
70 self.has_endpoint: bool = API_ENDPOINT
in self._directive
74 def load_entity(self, hass: HomeAssistant, config: AbstractConfig) ->
None:
75 """Set attributes related to the entity for this request.
77 Sets these attributes when self.has_endpoint is True:
82 - instance (when header includes instance property)
84 Behavior when self.has_endpoint is False is undefined.
86 Will raise AlexaInvalidEndpointError if the endpoint in the request is
87 malformed or nonexistent.
89 _endpoint_id: str = self._directive[API_ENDPOINT][
"endpointId"]
90 self.
entity_identity_id = _endpoint_id.replace(
"#",
".")
92 entity: State |
None = hass.states.get(self.
entity_identity_id)
93 if not entity
or not config.should_expose(self.
entity_identity_id):
98 if "instance" in self._directive[API_HEADER]:
99 self.
instanceinstance = self._directive[API_HEADER][
"instance"]
103 name: str =
"Response",
104 namespace: str =
"Alexa",
105 payload: dict[str, Any] |
None =
None,
107 """Create an API formatted response.
113 token = self._directive[API_HEADER].
get(
"correlationToken")
115 response.set_correlation_token(token)
117 if self.has_endpoint:
118 response.set_endpoint(self._directive[API_ENDPOINT].copy())
124 namespace: str =
"Alexa",
125 error_type: str =
"INTERNAL_ERROR",
126 error_message: str =
"",
127 payload: dict[str, Any] |
None =
None,
129 """Create a API formatted error response.
133 payload = payload
or {}
134 payload[
"type"] = error_type
135 payload[
"message"] = error_message
138 "Request %s/%s error %s: %s",
139 self._directive[API_HEADER][
"namespace"],
140 self._directive[API_HEADER][
"name"],
145 return self.
responseresponse(name=
"ErrorResponse", namespace=namespace, payload=payload)
149 """Class to hold a response."""
152 self, name: str, namespace: str, payload: dict[str, Any] |
None =
None
154 """Initialize the response."""
155 payload = payload
or {}
156 self._response: dict[str, Any] = {
159 "namespace": namespace,
161 "messageId":
str(uuid4()),
162 "payloadVersion":
"3",
164 API_PAYLOAD: payload,
170 """Return the name of this response."""
171 name: str = self._response[API_EVENT][API_HEADER][
"name"]
176 """Return the namespace of this response."""
177 namespace: str = self._response[API_EVENT][API_HEADER][
"namespace"]
181 """Set the correlationToken.
183 This should normally mirror the value from a request, and is set by
184 AlexaDirective.response() usually.
186 self._response[API_EVENT][API_HEADER][
"correlationToken"] = token
189 self, bearer_token: str |
None, endpoint_id: str |
None
191 """Set the endpoint dictionary.
193 This is used to send proactive messages to Alexa.
195 self._response[API_EVENT][API_ENDPOINT] = {
196 API_SCOPE: {
"type":
"BearerToken",
"token": bearer_token}
199 if endpoint_id
is not None:
200 self._response[API_EVENT][API_ENDPOINT][
"endpointId"] = endpoint_id
205 This should normally mirror the value from a request, and is set by
206 AlexaDirective.response() usually.
208 self._response[API_EVENT][API_ENDPOINT] = endpoint
211 context: dict[str, Any] = self._response.setdefault(API_CONTEXT, {})
212 properties: list[dict[str, Any]] = context.setdefault(
"properties", [])
216 """Add a property to the response context.
218 The Alexa response includes a list of properties which provides
219 feedback on how states have changed. For example if a user asks,
220 "Alexa, set thermostat to 20 degrees", the API expects a response with
221 the new value of the property, and Alexa will respond to the user
222 "Thermostat set to 20 degrees".
224 async_handle_message() will call .merge_context_properties() for every
225 request automatically, however often handlers will call services to
226 change state but the effects of those changes are applied
227 asynchronously. Thus, handlers should call this method to confirm
228 changes before returning.
233 """Add all properties from given endpoint if not already set.
235 Handlers should be using .add_context_property().
238 already_set = {(p[
"namespace"], p[
"name"])
for p
in properties}
240 for prop
in endpoint.serialize_properties():
241 if (prop[
"namespace"], prop[
"name"])
not in already_set:
245 """Return response as a JSON-able data structure."""
246 return self._response
250 hass: HomeAssistant, smart_home_config: AbstractConfig
251 ) -> CALLBACK_TYPE |
None:
252 """Enable the proactive mode.
254 Proactive mode makes this component report state changes to Alexa.
257 await smart_home_config.async_get_access_token()
260 def extra_significant_check(
263 old_attrs: dict[Any, Any] | MappingProxyType[Any, Any],
266 new_attrs: dict[str, Any] | MappingProxyType[Any, Any],
269 """Check if the serialized data has changed."""
270 return old_extra_arg
is not None and old_extra_arg != new_extra_arg
272 checker = await
create_checker(hass, DOMAIN, extra_significant_check)
275 def _async_entity_state_filter(data: EventStateChangedData) -> bool:
276 if not hass.is_running:
279 if not (new_state := data[
"new_state"]):
282 if new_state.domain
not in ENTITY_ADAPTERS:
285 changed_entity = data[
"entity_id"]
286 if not smart_home_config.should_expose(changed_entity):
287 _LOGGER.debug(
"Not exposing %s because filtered by config", changed_entity)
292 async
def _async_entity_state_listener(
293 event_: Event[EventStateChangedData],
296 new_state = data[
"new_state"]
298 assert new_state
is not None
300 alexa_changed_entity: AlexaEntity = ENTITY_ADAPTERS[new_state.domain](
301 hass, smart_home_config, new_state
304 should_report =
False
305 should_doorbell =
False
307 for interface
in alexa_changed_entity.interfaces():
308 if not should_report
and interface.properties_proactively_reported():
311 if interface.name() ==
"Alexa.DoorbellEventSource":
312 should_doorbell =
True
315 if not should_report
and not should_doorbell:
319 old_state = data[
"old_state"]
321 new_state.domain == event.DOMAIN
322 or new_state.state == STATE_ON
323 and (old_state
is None or old_state.state != STATE_ON)
326 hass, smart_home_config, alexa_changed_entity
330 alexa_properties =
list(alexa_changed_entity.serialize_properties())
332 if not checker.async_is_significant_change(
333 new_state, extra_arg=alexa_properties
338 hass, smart_home_config, alexa_changed_entity, alexa_properties
341 return hass.bus.async_listen(
343 _async_entity_state_listener,
344 event_filter=_async_entity_state_filter,
350 config: AbstractConfig,
351 alexa_entity: AlexaEntity,
352 alexa_properties: list[dict[str, Any]],
354 invalidate_access_token: bool =
True,
356 """Send a ChangeReport message for an Alexa entity.
358 https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events
361 token = await config.async_get_access_token()
362 except (RequireRelink, NoTokenAvailable):
363 await config.set_authorized(
False)
365 "Error when sending ChangeReport to Alexa, could not get access token"
369 headers: dict[str, Any] = {
"Authorization": f
"Bearer {token}"}
371 endpoint = alexa_entity.alexa_id()
373 payload: dict[str, Any] = {
375 "cause": {
"type": Cause.APP_INTERACTION},
376 "properties": alexa_properties,
380 message =
AlexaResponse(name=
"ChangeReport", namespace=
"Alexa", payload=payload)
381 message.set_endpoint_full(token, endpoint)
383 message_serialized = message.serialize()
386 assert config.endpoint
is not None
388 async
with timeout(DEFAULT_TIMEOUT):
389 response = await session.post(
392 json=message_serialized,
393 allow_redirects=
True,
396 except (TimeoutError, aiohttp.ClientError):
397 _LOGGER.error(
"Timeout sending report to Alexa for %s", alexa_entity.entity_id)
400 response_text = await response.text()
402 if _LOGGER.isEnabledFor(logging.DEBUG):
406 _LOGGER.debug(
"Received (%s): %s", response.status, response_text)
408 if response.status == HTTPStatus.ACCEPTED:
412 response_payload = cast(JsonObjectType, response_json[
"payload"])
414 if response_payload[
"code"] ==
"INVALID_ACCESS_TOKEN_EXCEPTION":
415 if invalidate_access_token:
417 config.async_invalidate_access_token()
423 invalidate_access_token=
False,
426 await config.set_authorized(
False)
429 "Error when sending ChangeReport for %s to Alexa: %s: %s",
430 alexa_entity.entity_id,
431 response_payload[
"code"],
432 response_payload[
"description"],
437 hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
438 ) -> aiohttp.ClientResponse:
439 """Send an AddOrUpdateReport message for entities.
441 https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report
443 token = await config.async_get_access_token()
445 headers: dict[str, Any] = {
"Authorization": f
"Bearer {token}"}
447 endpoints: list[dict[str, Any]] = []
449 for entity_id
in entity_ids:
450 if (domain := entity_id.split(
".", 1)[0])
not in ENTITY_ADAPTERS:
453 if (state := hass.states.get(entity_id))
is None:
456 alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state)
457 endpoints.append(alexa_entity.serialize_discovery())
459 payload: dict[str, Any] = {
460 "endpoints": endpoints,
461 "scope": {
"type":
"BearerToken",
"token": token},
465 name=
"AddOrUpdateReport", namespace=
"Alexa.Discovery", payload=payload
468 message_serialized = message.serialize()
471 assert config.endpoint
is not None
472 return await session.post(
473 config.endpoint, headers=headers, json=message_serialized, allow_redirects=
True
478 hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
479 ) -> aiohttp.ClientResponse:
480 """Send an DeleteReport message for entities.
482 https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event
484 token = await config.async_get_access_token()
486 headers: dict[str, Any] = {
"Authorization": f
"Bearer {token}"}
488 endpoints: list[dict[str, Any]] = []
490 for entity_id
in entity_ids:
491 domain = entity_id.split(
".", 1)[0]
493 if domain
not in ENTITY_ADAPTERS:
496 endpoints.append({
"endpointId": config.generate_alexa_id(entity_id)})
498 payload: dict[str, Any] = {
499 "endpoints": endpoints,
500 "scope": {
"type":
"BearerToken",
"token": token},
504 name=
"DeleteReport", namespace=
"Alexa.Discovery", payload=payload
507 message_serialized = message.serialize()
510 assert config.endpoint
is not None
511 return await session.post(
512 config.endpoint, headers=headers, json=message_serialized, allow_redirects=
True
517 hass: HomeAssistant, config: AbstractConfig, alexa_entity: AlexaEntity
519 """Send a DoorbellPress event message for an Alexa entity.
521 https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-doorbelleventsource.html
523 token = await config.async_get_access_token()
525 headers: dict[str, Any] = {
"Authorization": f
"Bearer {token}"}
527 endpoint = alexa_entity.alexa_id()
530 name=
"DoorbellPress",
531 namespace=
"Alexa.DoorbellEventSource",
533 "cause": {
"type": Cause.PHYSICAL_INTERACTION},
534 "timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
538 message.set_endpoint_full(token, endpoint)
540 message_serialized = message.serialize()
543 assert config.endpoint
is not None
545 async
with timeout(DEFAULT_TIMEOUT):
546 response = await session.post(
549 json=message_serialized,
550 allow_redirects=
True,
553 except (TimeoutError, aiohttp.ClientError):
554 _LOGGER.error(
"Timeout sending report to Alexa for %s", alexa_entity.entity_id)
557 response_text = await response.text()
559 if _LOGGER.isEnabledFor(logging.DEBUG):
563 _LOGGER.debug(
"Received (%s): %s", response.status, response_text)
565 if response.status == HTTPStatus.ACCEPTED:
569 response_payload = cast(JsonObjectType, response_json[
"payload"])
572 "Error when sending DoorbellPress event for %s to Alexa: %s: %s",
573 alexa_entity.entity_id,
574 response_payload[
"code"],
575 response_payload[
"description"],
None __init__(self, dict[str, Any] request)
AlexaResponse response(self, str name="Response", str namespace="Alexa", dict[str, Any]|None payload=None)
None load_entity(self, HomeAssistant hass, AbstractConfig config)
AlexaResponse error(self, str namespace="Alexa", str error_type="INTERNAL_ERROR", str error_message="", dict[str, Any]|None payload=None)
None merge_context_properties(self, AlexaEntity endpoint)
None add_context_property(self, dict[str, Any] prop)
list[dict[str, Any]] _properties(self)
None set_endpoint(self, dict[str, Any] endpoint)
None set_endpoint_full(self, str|None bearer_token, str|None endpoint_id)
None set_correlation_token(self, str token)
dict[str, Any] serialize(self)
None __init__(self, str name, str namespace, dict[str, Any]|None payload=None)
dict[str, str] async_redact_auth_data(Mapping[Any, Any] mapping)
None async_send_changereport_message(HomeAssistant hass, AbstractConfig config, AlexaEntity alexa_entity, list[dict[str, Any]] alexa_properties, *bool invalidate_access_token=True)
CALLBACK_TYPE|None async_enable_proactive_mode(HomeAssistant hass, AbstractConfig smart_home_config)
None async_send_doorbell_event_message(HomeAssistant hass, AbstractConfig config, AlexaEntity alexa_entity)
aiohttp.ClientResponse async_send_add_or_update_message(HomeAssistant hass, AbstractConfig config, list[str] entity_ids)
aiohttp.ClientResponse async_send_delete_message(HomeAssistant hass, AbstractConfig config, list[str] entity_ids)
web.Response get(self, web.Request request, str config_key)
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
SignificantlyChangedChecker create_checker(HomeAssistant hass, str _domain, ExtraCheckTypeFunc|None extra_significant_check=None)
JsonObjectType json_loads_object(bytes|bytearray|memoryview|str obj)