Home Assistant Unofficial Reference 2024.12.1
state_report.py
Go to the documentation of this file.
1 """Alexa state report code."""
2 
3 from __future__ import annotations
4 
5 from asyncio import timeout
6 from http import HTTPStatus
7 import json
8 import logging
9 from types import MappingProxyType
10 from typing import TYPE_CHECKING, Any, cast
11 from uuid import uuid4
12 
13 import aiohttp
14 
15 from homeassistant.components import event
16 from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
17 from homeassistant.core import (
18  CALLBACK_TYPE,
19  Event,
20  EventStateChangedData,
21  HomeAssistant,
22  State,
23  callback,
24 )
25 from homeassistant.helpers.aiohttp_client import async_get_clientsession
26 from homeassistant.helpers.significant_change import create_checker
27 import homeassistant.util.dt as dt_util
28 from homeassistant.util.json import JsonObjectType, json_loads_object
29 
30 from .const import (
31  API_CHANGE,
32  API_CONTEXT,
33  API_DIRECTIVE,
34  API_ENDPOINT,
35  API_EVENT,
36  API_HEADER,
37  API_PAYLOAD,
38  API_SCOPE,
39  DATE_FORMAT,
40  DOMAIN,
41  Cause,
42 )
43 from .diagnostics import async_redact_auth_data
44 from .entities import ENTITY_ADAPTERS, AlexaEntity
45 from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink
46 
47 if TYPE_CHECKING:
48  from .config import AbstractConfig
49 
50 _LOGGER = logging.getLogger(__name__)
51 DEFAULT_TIMEOUT = 10
52 
53 TO_REDACT = {"correlationToken", "token"}
54 
55 
57  """An incoming Alexa directive."""
58 
59  entity: State
60  entity_id: str | None
61  endpoint: AlexaEntity
62  instance: str | None
63 
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
71  self.instanceinstance = None
72  self.entity_identity_id = None
73 
74  def load_entity(self, hass: HomeAssistant, config: AbstractConfig) -> None:
75  """Set attributes related to the entity for this request.
76 
77  Sets these attributes when self.has_endpoint is True:
78 
79  - entity
80  - entity_id
81  - endpoint
82  - instance (when header includes instance property)
83 
84  Behavior when self.has_endpoint is False is undefined.
85 
86  Will raise AlexaInvalidEndpointError if the endpoint in the request is
87  malformed or nonexistent.
88  """
89  _endpoint_id: str = self._directive[API_ENDPOINT]["endpointId"]
90  self.entity_identity_id = _endpoint_id.replace("#", ".")
91 
92  entity: State | None = hass.states.get(self.entity_identity_id)
93  if not entity or not config.should_expose(self.entity_identity_id):
94  raise AlexaInvalidEndpointError(_endpoint_id)
95  self.entityentity = entity
96 
97  self.endpointendpoint = ENTITY_ADAPTERS[self.entityentity.domain](hass, config, self.entityentity)
98  if "instance" in self._directive[API_HEADER]:
99  self.instanceinstance = self._directive[API_HEADER]["instance"]
100 
101  def response(
102  self,
103  name: str = "Response",
104  namespace: str = "Alexa",
105  payload: dict[str, Any] | None = None,
106  ) -> AlexaResponse:
107  """Create an API formatted response.
108 
109  Async friendly.
110  """
111  response = AlexaResponse(name, namespace, payload)
112 
113  token = self._directive[API_HEADER].get("correlationToken")
114  if token:
115  response.set_correlation_token(token)
116 
117  if self.has_endpoint:
118  response.set_endpoint(self._directive[API_ENDPOINT].copy())
119 
120  return response
121 
122  def error(
123  self,
124  namespace: str = "Alexa",
125  error_type: str = "INTERNAL_ERROR",
126  error_message: str = "",
127  payload: dict[str, Any] | None = None,
128  ) -> AlexaResponse:
129  """Create a API formatted error response.
130 
131  Async friendly.
132  """
133  payload = payload or {}
134  payload["type"] = error_type
135  payload["message"] = error_message
136 
137  _LOGGER.info(
138  "Request %s/%s error %s: %s",
139  self._directive[API_HEADER]["namespace"],
140  self._directive[API_HEADER]["name"],
141  error_type,
142  error_message,
143  )
144 
145  return self.responseresponse(name="ErrorResponse", namespace=namespace, payload=payload)
146 
147 
149  """Class to hold a response."""
150 
151  def __init__(
152  self, name: str, namespace: str, payload: dict[str, Any] | None = None
153  ) -> None:
154  """Initialize the response."""
155  payload = payload or {}
156  self._response: dict[str, Any] = {
157  API_EVENT: {
158  API_HEADER: {
159  "namespace": namespace,
160  "name": name,
161  "messageId": str(uuid4()),
162  "payloadVersion": "3",
163  },
164  API_PAYLOAD: payload,
165  }
166  }
167 
168  @property
169  def name(self) -> str:
170  """Return the name of this response."""
171  name: str = self._response[API_EVENT][API_HEADER]["name"]
172  return name
173 
174  @property
175  def namespace(self) -> str:
176  """Return the namespace of this response."""
177  namespace: str = self._response[API_EVENT][API_HEADER]["namespace"]
178  return namespace
179 
180  def set_correlation_token(self, token: str) -> None:
181  """Set the correlationToken.
182 
183  This should normally mirror the value from a request, and is set by
184  AlexaDirective.response() usually.
185  """
186  self._response[API_EVENT][API_HEADER]["correlationToken"] = token
187 
189  self, bearer_token: str | None, endpoint_id: str | None
190  ) -> None:
191  """Set the endpoint dictionary.
192 
193  This is used to send proactive messages to Alexa.
194  """
195  self._response[API_EVENT][API_ENDPOINT] = {
196  API_SCOPE: {"type": "BearerToken", "token": bearer_token}
197  }
198 
199  if endpoint_id is not None:
200  self._response[API_EVENT][API_ENDPOINT]["endpointId"] = endpoint_id
201 
202  def set_endpoint(self, endpoint: dict[str, Any]) -> None:
203  """Set the endpoint.
204 
205  This should normally mirror the value from a request, and is set by
206  AlexaDirective.response() usually.
207  """
208  self._response[API_EVENT][API_ENDPOINT] = endpoint
209 
210  def _properties(self) -> list[dict[str, Any]]:
211  context: dict[str, Any] = self._response.setdefault(API_CONTEXT, {})
212  properties: list[dict[str, Any]] = context.setdefault("properties", [])
213  return properties
214 
215  def add_context_property(self, prop: dict[str, Any]) -> None:
216  """Add a property to the response context.
217 
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".
223 
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.
229  """
230  self._properties_properties().append(prop)
231 
232  def merge_context_properties(self, endpoint: AlexaEntity) -> None:
233  """Add all properties from given endpoint if not already set.
234 
235  Handlers should be using .add_context_property().
236  """
237  properties = self._properties_properties()
238  already_set = {(p["namespace"], p["name"]) for p in properties}
239 
240  for prop in endpoint.serialize_properties():
241  if (prop["namespace"], prop["name"]) not in already_set:
242  self.add_context_propertyadd_context_property(prop)
243 
244  def serialize(self) -> dict[str, Any]:
245  """Return response as a JSON-able data structure."""
246  return self._response
247 
248 
250  hass: HomeAssistant, smart_home_config: AbstractConfig
251 ) -> CALLBACK_TYPE | None:
252  """Enable the proactive mode.
253 
254  Proactive mode makes this component report state changes to Alexa.
255  """
256  # Validate we can get access token.
257  await smart_home_config.async_get_access_token()
258 
259  @callback
260  def extra_significant_check(
261  hass: HomeAssistant,
262  old_state: str,
263  old_attrs: dict[Any, Any] | MappingProxyType[Any, Any],
264  old_extra_arg: Any,
265  new_state: str,
266  new_attrs: dict[str, Any] | MappingProxyType[Any, Any],
267  new_extra_arg: Any,
268  ) -> bool:
269  """Check if the serialized data has changed."""
270  return old_extra_arg is not None and old_extra_arg != new_extra_arg
271 
272  checker = await create_checker(hass, DOMAIN, extra_significant_check)
273 
274  @callback
275  def _async_entity_state_filter(data: EventStateChangedData) -> bool:
276  if not hass.is_running:
277  return False
278 
279  if not (new_state := data["new_state"]):
280  return False
281 
282  if new_state.domain not in ENTITY_ADAPTERS:
283  return False
284 
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)
288  return False
289 
290  return True
291 
292  async def _async_entity_state_listener(
293  event_: Event[EventStateChangedData],
294  ) -> None:
295  data = event_.data
296  new_state = data["new_state"]
297  if TYPE_CHECKING:
298  assert new_state is not None
299 
300  alexa_changed_entity: AlexaEntity = ENTITY_ADAPTERS[new_state.domain](
301  hass, smart_home_config, new_state
302  )
303  # Determine how entity should be reported on
304  should_report = False
305  should_doorbell = False
306 
307  for interface in alexa_changed_entity.interfaces():
308  if not should_report and interface.properties_proactively_reported():
309  should_report = True
310 
311  if interface.name() == "Alexa.DoorbellEventSource":
312  should_doorbell = True
313  break
314 
315  if not should_report and not should_doorbell:
316  return
317 
318  if should_doorbell:
319  old_state = data["old_state"]
320  if (
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)
324  ):
326  hass, smart_home_config, alexa_changed_entity
327  )
328  return
329 
330  alexa_properties = list(alexa_changed_entity.serialize_properties())
331 
332  if not checker.async_is_significant_change(
333  new_state, extra_arg=alexa_properties
334  ):
335  return
336 
338  hass, smart_home_config, alexa_changed_entity, alexa_properties
339  )
340 
341  return hass.bus.async_listen(
342  EVENT_STATE_CHANGED,
343  _async_entity_state_listener,
344  event_filter=_async_entity_state_filter,
345  )
346 
347 
349  hass: HomeAssistant,
350  config: AbstractConfig,
351  alexa_entity: AlexaEntity,
352  alexa_properties: list[dict[str, Any]],
353  *,
354  invalidate_access_token: bool = True,
355 ) -> None:
356  """Send a ChangeReport message for an Alexa entity.
357 
358  https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events
359  """
360  try:
361  token = await config.async_get_access_token()
362  except (RequireRelink, NoTokenAvailable):
363  await config.set_authorized(False)
364  _LOGGER.error(
365  "Error when sending ChangeReport to Alexa, could not get access token"
366  )
367  return
368 
369  headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
370 
371  endpoint = alexa_entity.alexa_id()
372 
373  payload: dict[str, Any] = {
374  API_CHANGE: {
375  "cause": {"type": Cause.APP_INTERACTION},
376  "properties": alexa_properties,
377  }
378  }
379 
380  message = AlexaResponse(name="ChangeReport", namespace="Alexa", payload=payload)
381  message.set_endpoint_full(token, endpoint)
382 
383  message_serialized = message.serialize()
384  session = async_get_clientsession(hass)
385 
386  assert config.endpoint is not None
387  try:
388  async with timeout(DEFAULT_TIMEOUT):
389  response = await session.post(
390  config.endpoint,
391  headers=headers,
392  json=message_serialized,
393  allow_redirects=True,
394  )
395 
396  except (TimeoutError, aiohttp.ClientError):
397  _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
398  return
399 
400  response_text = await response.text()
401 
402  if _LOGGER.isEnabledFor(logging.DEBUG):
403  _LOGGER.debug(
404  "Sent: %s", json.dumps(async_redact_auth_data(message_serialized))
405  )
406  _LOGGER.debug("Received (%s): %s", response.status, response_text)
407 
408  if response.status == HTTPStatus.ACCEPTED:
409  return
410 
411  response_json = json_loads_object(response_text)
412  response_payload = cast(JsonObjectType, response_json["payload"])
413 
414  if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION":
415  if invalidate_access_token:
416  # Invalidate the access token and try again
417  config.async_invalidate_access_token()
419  hass,
420  config,
421  alexa_entity,
422  alexa_properties,
423  invalidate_access_token=False,
424  )
425  return
426  await config.set_authorized(False)
427 
428  _LOGGER.error(
429  "Error when sending ChangeReport for %s to Alexa: %s: %s",
430  alexa_entity.entity_id,
431  response_payload["code"],
432  response_payload["description"],
433  )
434 
435 
437  hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
438 ) -> aiohttp.ClientResponse:
439  """Send an AddOrUpdateReport message for entities.
440 
441  https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report
442  """
443  token = await config.async_get_access_token()
444 
445  headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
446 
447  endpoints: list[dict[str, Any]] = []
448 
449  for entity_id in entity_ids:
450  if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS:
451  continue
452 
453  if (state := hass.states.get(entity_id)) is None:
454  continue
455 
456  alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state)
457  endpoints.append(alexa_entity.serialize_discovery())
458 
459  payload: dict[str, Any] = {
460  "endpoints": endpoints,
461  "scope": {"type": "BearerToken", "token": token},
462  }
463 
464  message = AlexaResponse(
465  name="AddOrUpdateReport", namespace="Alexa.Discovery", payload=payload
466  )
467 
468  message_serialized = message.serialize()
469  session = async_get_clientsession(hass)
470 
471  assert config.endpoint is not None
472  return await session.post(
473  config.endpoint, headers=headers, json=message_serialized, allow_redirects=True
474  )
475 
476 
478  hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str]
479 ) -> aiohttp.ClientResponse:
480  """Send an DeleteReport message for entities.
481 
482  https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event
483  """
484  token = await config.async_get_access_token()
485 
486  headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
487 
488  endpoints: list[dict[str, Any]] = []
489 
490  for entity_id in entity_ids:
491  domain = entity_id.split(".", 1)[0]
492 
493  if domain not in ENTITY_ADAPTERS:
494  continue
495 
496  endpoints.append({"endpointId": config.generate_alexa_id(entity_id)})
497 
498  payload: dict[str, Any] = {
499  "endpoints": endpoints,
500  "scope": {"type": "BearerToken", "token": token},
501  }
502 
503  message = AlexaResponse(
504  name="DeleteReport", namespace="Alexa.Discovery", payload=payload
505  )
506 
507  message_serialized = message.serialize()
508  session = async_get_clientsession(hass)
509 
510  assert config.endpoint is not None
511  return await session.post(
512  config.endpoint, headers=headers, json=message_serialized, allow_redirects=True
513  )
514 
515 
517  hass: HomeAssistant, config: AbstractConfig, alexa_entity: AlexaEntity
518 ) -> None:
519  """Send a DoorbellPress event message for an Alexa entity.
520 
521  https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-doorbelleventsource.html
522  """
523  token = await config.async_get_access_token()
524 
525  headers: dict[str, Any] = {"Authorization": f"Bearer {token}"}
526 
527  endpoint = alexa_entity.alexa_id()
528 
529  message = AlexaResponse(
530  name="DoorbellPress",
531  namespace="Alexa.DoorbellEventSource",
532  payload={
533  "cause": {"type": Cause.PHYSICAL_INTERACTION},
534  "timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
535  },
536  )
537 
538  message.set_endpoint_full(token, endpoint)
539 
540  message_serialized = message.serialize()
541  session = async_get_clientsession(hass)
542 
543  assert config.endpoint is not None
544  try:
545  async with timeout(DEFAULT_TIMEOUT):
546  response = await session.post(
547  config.endpoint,
548  headers=headers,
549  json=message_serialized,
550  allow_redirects=True,
551  )
552 
553  except (TimeoutError, aiohttp.ClientError):
554  _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
555  return
556 
557  response_text = await response.text()
558 
559  if _LOGGER.isEnabledFor(logging.DEBUG):
560  _LOGGER.debug(
561  "Sent: %s", json.dumps(async_redact_auth_data(message_serialized))
562  )
563  _LOGGER.debug("Received (%s): %s", response.status, response_text)
564 
565  if response.status == HTTPStatus.ACCEPTED:
566  return
567 
568  response_json = json_loads_object(response_text)
569  response_payload = cast(JsonObjectType, response_json["payload"])
570 
571  _LOGGER.error(
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"],
576  )
AlexaResponse response(self, str name="Response", str namespace="Alexa", dict[str, Any]|None payload=None)
None load_entity(self, HomeAssistant hass, AbstractConfig config)
Definition: state_report.py:74
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 set_endpoint(self, dict[str, Any] endpoint)
None set_endpoint_full(self, str|None bearer_token, str|None endpoint_id)
None __init__(self, str name, str namespace, dict[str, Any]|None payload=None)
dict[str, str] async_redact_auth_data(Mapping[Any, Any] mapping)
Definition: diagnostics.py:32
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)
Definition: view.py:88
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)
Definition: json.py:54