Home Assistant Unofficial Reference 2024.12.1
alexa_config.py
Go to the documentation of this file.
1 """Alexa configuration for Home Assistant Cloud."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable
7 from contextlib import suppress
8 from datetime import datetime, timedelta
9 from http import HTTPStatus
10 import logging
11 from typing import TYPE_CHECKING, Any
12 
13 import aiohttp
14 from hass_nabucasa import Cloud, cloud_api
15 from yarl import URL
16 
17 from homeassistant.components import persistent_notification
19  DOMAIN as ALEXA_DOMAIN,
20  config as alexa_config,
21  entities as alexa_entities,
22  errors as alexa_errors,
23  state_report as alexa_state_report,
24 )
25 from homeassistant.components.binary_sensor import BinarySensorDeviceClass
27  async_expose_entity,
28  async_get_assistant_settings,
29  async_listen_entity_updates,
30  async_should_expose,
31 )
32 from homeassistant.components.sensor import SensorDeviceClass
33 from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
34 from homeassistant.core import Event, HomeAssistant, callback, split_entity_id
35 from homeassistant.exceptions import HomeAssistantError
36 from homeassistant.helpers import entity_registry as er, start
37 from homeassistant.helpers.entity import get_device_class
38 from homeassistant.helpers.entityfilter import EntityFilter
39 from homeassistant.helpers.event import async_call_later
40 from homeassistant.setup import async_setup_component
41 from homeassistant.util.dt import utcnow
42 
43 from .const import (
44  CONF_ENTITY_CONFIG,
45  CONF_FILTER,
46  DOMAIN as CLOUD_DOMAIN,
47  PREF_ALEXA_REPORT_STATE,
48  PREF_ENABLE_ALEXA,
49  PREF_SHOULD_EXPOSE,
50 )
51 from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences
52 
53 if TYPE_CHECKING:
54  from .client import CloudClient
55 
56 _LOGGER = logging.getLogger(__name__)
57 
58 CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
59 
60 # Time to wait when entity preferences have changed before syncing it to
61 # the cloud.
62 SYNC_DELAY = 1
63 
64 
65 SUPPORTED_DOMAINS = {
66  "alarm_control_panel",
67  "alert",
68  "automation",
69  "button",
70  "camera",
71  "climate",
72  "cover",
73  "fan",
74  "group",
75  "humidifier",
76  "image_processing",
77  "input_boolean",
78  "input_button",
79  "input_number",
80  "light",
81  "lock",
82  "media_player",
83  "number",
84  "scene",
85  "script",
86  "switch",
87  "timer",
88  "vacuum",
89 }
90 
91 SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = {
92  BinarySensorDeviceClass.DOOR,
93  BinarySensorDeviceClass.GARAGE_DOOR,
94  BinarySensorDeviceClass.MOTION,
95  BinarySensorDeviceClass.OPENING,
96  BinarySensorDeviceClass.PRESENCE,
97  BinarySensorDeviceClass.WINDOW,
98 }
99 
100 SUPPORTED_SENSOR_DEVICE_CLASSES = {
101  SensorDeviceClass.TEMPERATURE,
102 }
103 
104 
105 def entity_supported(hass: HomeAssistant, entity_id: str) -> bool:
106  """Return if the entity is supported.
107 
108  This is called when migrating from legacy config format to avoid exposing
109  all binary sensors and sensors.
110  """
111  domain = split_entity_id(entity_id)[0]
112  if domain in SUPPORTED_DOMAINS:
113  return True
114 
115  try:
116  device_class = get_device_class(hass, entity_id)
117  except HomeAssistantError:
118  # The entity no longer exists
119  return False
120  if (
121  domain == "binary_sensor"
122  and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
123  ):
124  return True
125 
126  if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
127  return True
128 
129  return False
130 
131 
132 class CloudAlexaConfig(alexa_config.AbstractConfig):
133  """Alexa Configuration."""
134 
135  def __init__(
136  self,
137  hass: HomeAssistant,
138  config: dict,
139  cloud_user: str,
140  prefs: CloudPreferences,
141  cloud: Cloud[CloudClient],
142  ) -> None:
143  """Initialize the Alexa config."""
144  super().__init__(hass)
145  self._config_config = config
146  self._cloud_user_cloud_user = cloud_user
147  self._prefs_prefs = prefs
148  self._cloud_cloud = cloud
149  self._token_token = None
150  self._token_valid_token_valid: datetime | None = None
151  self._cur_entity_prefs_cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
152  self._alexa_sync_unsub_alexa_sync_unsub: Callable[[], None] | None = None
153  self._endpoint_endpoint: str | URL | None = None
154 
155  @property
156  def enabled(self) -> bool:
157  """Return if Alexa is enabled."""
158  return (
159  self._cloud_cloud.is_logged_in
160  and not self._cloud_cloud.subscription_expired
161  and self._prefs_prefs.alexa_enabled
162  )
163 
164  @property
165  def supports_auth(self) -> bool:
166  """Return if config supports auth."""
167  return True
168 
169  @property
170  def should_report_state(self) -> bool:
171  """Return if states should be proactively reported."""
172  return (
173  self._prefs_prefs.alexa_enabled
174  and self._prefs_prefs.alexa_report_state
175  and self.authorized
176  )
177 
178  @property
179  def endpoint(self) -> str | URL | None:
180  """Endpoint for report state."""
181  if self._endpoint_endpoint is None:
182  raise ValueError("No endpoint available. Fetch access token first")
183 
184  return self._endpoint_endpoint
185 
186  @property
187  def locale(self) -> str:
188  """Return config locale."""
189  # Not clear how to determine locale atm.
190  return "en-US"
191 
192  @property
193  def entity_config(self) -> dict[str, Any]:
194  """Return entity config."""
195  return self._config_config.get(CONF_ENTITY_CONFIG) or {}
196 
197  @callback
198  def user_identifier(self) -> str:
199  """Return an identifier for the user that represents this config."""
200  return self._cloud_user_cloud_user
201 
203  """Migrate alexa entity settings to entity registry options."""
204  if not self._config_config[CONF_FILTER].empty_filter:
205  # Don't migrate if there's a YAML config
206  return
207 
208  for entity_id in {
209  *self.hass.states.async_entity_ids(),
210  *self._prefs_prefs.alexa_entity_configs,
211  }:
213  self.hass,
214  CLOUD_ALEXA,
215  entity_id,
216  self._should_expose_legacy_should_expose_legacy(entity_id),
217  )
218 
219  async def async_initialize(self) -> None:
220  """Initialize the Alexa config."""
221  await super().async_initialize()
222 
223  async def on_hass_started(hass: HomeAssistant) -> None:
224  if self._prefs_prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
225  _LOGGER.info(
226  "Start migration of Alexa settings from v%s to v%s",
227  self._prefs_prefs.alexa_settings_version,
228  ALEXA_SETTINGS_VERSION,
229  )
230  if self._prefs_prefs.alexa_settings_version < 2 or (
231  # Recover from a bug we had in 2023.5.0 where entities didn't get exposed
232  self._prefs_prefs.alexa_settings_version < 3
233  and not any(
234  settings.get("should_expose", False)
235  for settings in async_get_assistant_settings(
236  hass, CLOUD_ALEXA
237  ).values()
238  )
239  ):
240  self._migrate_alexa_entity_settings_v1_migrate_alexa_entity_settings_v1()
241 
242  _LOGGER.info(
243  "Finished migration of Alexa settings from v%s to v%s",
244  self._prefs_prefs.alexa_settings_version,
245  ALEXA_SETTINGS_VERSION,
246  )
247  await self._prefs_prefs.async_update(
248  alexa_settings_version=ALEXA_SETTINGS_VERSION
249  )
250  self._on_deinitialize.append(
252  self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated_async_exposed_entities_updated
253  )
254  )
255 
256  async def on_hass_start(hass: HomeAssistant) -> None:
257  if self.enabledenabled and ALEXA_DOMAIN not in self.hass.config.components:
258  await async_setup_component(self.hass, ALEXA_DOMAIN, {})
259 
260  self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start))
261  self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started))
262 
263  self._on_deinitialize.append(
264  self._prefs_prefs.async_listen_updates(self._async_prefs_updated_async_prefs_updated)
265  )
266  self._on_deinitialize.append(
267  self.hass.bus.async_listen(
268  er.EVENT_ENTITY_REGISTRY_UPDATED,
269  self._handle_entity_registry_updated_handle_entity_registry_updated,
270  )
271  )
272 
273  def _should_expose_legacy(self, entity_id: str) -> bool:
274  """If an entity should be exposed."""
275  if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
276  return False
277 
278  entity_configs = self._prefs_prefs.alexa_entity_configs
279  entity_config = entity_configs.get(entity_id, {})
280  entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
281  if entity_expose is not None:
282  return entity_expose
283 
284  entity_registry = er.async_get(self.hass)
285  if registry_entry := entity_registry.async_get(entity_id):
286  auxiliary_entity = (
287  registry_entry.entity_category is not None
288  or registry_entry.hidden_by is not None
289  )
290  else:
291  auxiliary_entity = False
292 
293  # Backwards compat
294  if (default_expose := self._prefs_prefs.alexa_default_expose) is None:
295  return not auxiliary_entity and entity_supported(self.hass, entity_id)
296 
297  return (
298  not auxiliary_entity
299  and split_entity_id(entity_id)[0] in default_expose
300  and entity_supported(self.hass, entity_id)
301  )
302 
303  @callback
304  def should_expose(self, entity_id: str) -> bool:
305  """If an entity should be exposed."""
306  entity_filter: EntityFilter = self._config_config[CONF_FILTER]
307  if not entity_filter.empty_filter:
308  if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
309  return False
310  return entity_filter(entity_id)
311 
312  return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
313 
314  @callback
315  def async_invalidate_access_token(self) -> None:
316  """Invalidate access token."""
317  self._token_valid_token_valid = None
318 
319  async def async_get_access_token(self) -> str | None:
320  """Get an access token."""
321  if self._token_valid_token_valid is not None and self._token_valid_token_valid > utcnow():
322  return self._token_token
323 
324  resp = await cloud_api.async_alexa_access_token(self._cloud_cloud)
325  body = await resp.json()
326 
327  if resp.status == HTTPStatus.BAD_REQUEST:
328  if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"):
329  if self.should_report_stateshould_report_state:
330  persistent_notification.async_create(
331  self.hass,
332  (
333  "There was an error reporting state to Alexa"
334  f" ({body['reason']}). Please re-link your Alexa skill via"
335  " the Alexa app to continue using it."
336  ),
337  "Alexa state reporting disabled",
338  "cloud_alexa_report",
339  )
340  raise alexa_errors.RequireRelink
341 
342  raise alexa_errors.NoTokenAvailable
343 
344  self._token_token = body["access_token"]
345  self._endpoint_endpoint = body["event_endpoint"]
346  self._token_valid_token_valid = utcnow() + timedelta(seconds=body["expires_in"])
347  return self._token_token
348 
349  async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:
350  """Handle updated preferences."""
351  if not self._cloud_cloud.is_logged_in:
352  if self.is_reporting_states:
353  await self.async_disable_proactive_mode()
354 
355  if self._alexa_sync_unsub_alexa_sync_unsub:
356  self._alexa_sync_unsub_alexa_sync_unsub()
357  self._alexa_sync_unsub_alexa_sync_unsub = None
358  return
359 
360  updated_prefs = prefs.last_updated
361 
362  if (
363  ALEXA_DOMAIN not in self.hass.config.components
364  and self.enabledenabled
365  and self.hass.is_running
366  ):
367  await async_setup_component(self.hass, ALEXA_DOMAIN, {})
368 
369  if self.should_report_stateshould_report_state != self.is_reporting_states:
370  if self.should_report_stateshould_report_state:
371  try:
372  await self.async_enable_proactive_mode()
373  except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink):
374  await self.set_authorized(False)
375  else:
376  await self.async_disable_proactive_mode()
377 
378  # State reporting is reported as a property on entities.
379  # So when we change it, we need to sync all entities.
380  await self.async_sync_entitiesasync_sync_entities()
381  return
382 
383  # Nothing to do if no Alexa related things have changed
384  if not any(
385  key in updated_prefs
386  for key in (
387  PREF_ALEXA_REPORT_STATE,
388  PREF_ENABLE_ALEXA,
389  )
390  ):
391  return
392 
393  await self.async_sync_entitiesasync_sync_entities()
394 
395  @callback
397  """Handle updated preferences."""
398  # Delay updating as we might update more
399  if self._alexa_sync_unsub_alexa_sync_unsub:
400  self._alexa_sync_unsub_alexa_sync_unsub()
401 
402  self._alexa_sync_unsub_alexa_sync_unsub = async_call_later(
403  self.hass, SYNC_DELAY, self._sync_prefs_sync_prefs
404  )
405 
406  async def _sync_prefs(self, _now: datetime) -> None:
407  """Sync the updated preferences to Alexa."""
408  self._alexa_sync_unsub_alexa_sync_unsub = None
409  old_prefs = self._cur_entity_prefs_cur_entity_prefs
410  new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA)
411 
412  seen = set()
413  to_update = []
414  to_remove = []
415  is_enabled = self.enabledenabled
416 
417  for entity_id, info in old_prefs.items():
418  seen.add(entity_id)
419 
420  if not is_enabled:
421  to_remove.append(entity_id)
422 
423  old_expose = info.get(PREF_SHOULD_EXPOSE)
424 
425  if entity_id in new_prefs:
426  new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE)
427  else:
428  new_expose = None
429 
430  if old_expose == new_expose:
431  continue
432 
433  if new_expose:
434  to_update.append(entity_id)
435  else:
436  to_remove.append(entity_id)
437 
438  # Now all the ones that are in new prefs but never were in old prefs
439  for entity_id, info in new_prefs.items():
440  if entity_id in seen:
441  continue
442 
443  new_expose = info.get(PREF_SHOULD_EXPOSE)
444 
445  if new_expose is None:
446  continue
447 
448  # Only test if we should expose. It can never be a remove action,
449  # as it didn't exist in old prefs object.
450  if new_expose:
451  to_update.append(entity_id)
452 
453  # We only set the prefs when update is successful, that way we will
454  # retry when next change comes in.
455  if await self._sync_helper_sync_helper(to_update, to_remove):
456  self._cur_entity_prefs_cur_entity_prefs = new_prefs
457 
458  async def async_sync_entities(self) -> bool:
459  """Sync all entities to Alexa."""
460  # Remove any pending sync
461  if self._alexa_sync_unsub_alexa_sync_unsub:
462  self._alexa_sync_unsub_alexa_sync_unsub()
463  self._alexa_sync_unsub_alexa_sync_unsub = None
464 
465  to_update = []
466  to_remove = []
467 
468  is_enabled = self.enabledenabled
469 
470  for entity in alexa_entities.async_get_entities(self.hass, self):
471  if is_enabled and self.should_exposeshould_expose(entity.entity_id):
472  to_update.append(entity.entity_id)
473  else:
474  to_remove.append(entity.entity_id)
475 
476  return await self._sync_helper_sync_helper(to_update, to_remove)
477 
478  async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool:
479  """Sync entities to Alexa.
480 
481  Return boolean if it was successful.
482  """
483  if not to_update and not to_remove:
484  return True
485 
486  # Make sure it's valid.
487  await self.async_get_access_tokenasync_get_access_token()
488 
489  tasks = []
490 
491  if to_update:
492  tasks.append(
493  asyncio.create_task(
494  alexa_state_report.async_send_add_or_update_message(
495  self.hass, self, to_update
496  )
497  )
498  )
499 
500  if to_remove:
501  tasks.append(
502  asyncio.create_task(
503  alexa_state_report.async_send_delete_message(
504  self.hass, self, to_remove
505  )
506  )
507  )
508 
509  try:
510  async with asyncio.timeout(10):
511  await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
512  except TimeoutError:
513  _LOGGER.warning("Timeout trying to sync entities to Alexa")
514  return False
515  except aiohttp.ClientError as err:
516  _LOGGER.warning("Error trying to sync entities to Alexa: %s", err)
517  return False
518  return True
519 
521  self, event: Event[er.EventEntityRegistryUpdatedData]
522  ) -> None:
523  """Handle when entity registry updated."""
524  if not self.enabledenabled or not self._cloud_cloud.is_logged_in:
525  return
526 
527  entity_id = event.data["entity_id"]
528 
529  if not self.should_exposeshould_expose(entity_id):
530  return
531 
532  to_update: list[str] = []
533  to_remove: list[str] = []
534 
535  if event.data["action"] == "create":
536  to_update.append(entity_id)
537  elif event.data["action"] == "remove":
538  to_remove.append(entity_id)
539  elif event.data["action"] == "update" and bool(
540  set(event.data["changes"]) & er.ENTITY_DESCRIBING_ATTRIBUTES
541  ):
542  to_update.append(entity_id)
543  if "old_entity_id" in event.data:
544  to_remove.append(event.data["old_entity_id"])
545 
546  with suppress(alexa_errors.NoTokenAvailable):
547  await self._sync_helper_sync_helper(to_update, to_remove)
None _handle_entity_registry_updated(self, Event[er.EventEntityRegistryUpdatedData] event)
None __init__(self, HomeAssistant hass, dict config, str cloud_user, CloudPreferences prefs, Cloud[CloudClient] cloud)
None _async_prefs_updated(self, CloudPreferences prefs)
bool _sync_helper(self, list[str] to_update, list[str] to_remove)
bool entity_supported(HomeAssistant hass, str entity_id)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
CALLBACK_TYPE async_listen_entity_updates(HomeAssistant hass, str assistant, Callable[[], None] listener)
bool async_should_expose(HomeAssistant hass, str assistant, str entity_id)
None async_expose_entity(HomeAssistant hass, str assistant, str entity_id, bool should_expose)
dict[str, Mapping[str, Any]] async_get_assistant_settings(HomeAssistant hass, str assistant)
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
str|None get_device_class(HomeAssistant hass, str entity_id)
Definition: entity.py:154
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
bool async_setup_component(core.HomeAssistant hass, str domain, ConfigType config)
Definition: setup.py:147