1 """Alexa configuration for Home Assistant Cloud."""
3 from __future__
import annotations
6 from collections.abc
import Callable
7 from contextlib
import suppress
8 from datetime
import datetime, timedelta
9 from http
import HTTPStatus
11 from typing
import TYPE_CHECKING, Any
14 from hass_nabucasa
import Cloud, cloud_api
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,
28 async_get_assistant_settings,
29 async_listen_entity_updates,
46 DOMAIN
as CLOUD_DOMAIN,
47 PREF_ALEXA_REPORT_STATE,
51 from .prefs
import ALEXA_SETTINGS_VERSION, CloudPreferences
54 from .client
import CloudClient
56 _LOGGER = logging.getLogger(__name__)
58 CLOUD_ALEXA = f
"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
66 "alarm_control_panel",
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,
100 SUPPORTED_SENSOR_DEVICE_CLASSES = {
101 SensorDeviceClass.TEMPERATURE,
106 """Return if the entity is supported.
108 This is called when migrating from legacy config format to avoid exposing
109 all binary sensors and sensors.
112 if domain
in SUPPORTED_DOMAINS:
117 except HomeAssistantError:
121 domain ==
"binary_sensor"
122 and device_class
in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
126 if domain ==
"sensor" and device_class
in SUPPORTED_SENSOR_DEVICE_CLASSES:
133 """Alexa Configuration."""
140 prefs: CloudPreferences,
141 cloud: Cloud[CloudClient],
143 """Initialize the Alexa config."""
153 self.
_endpoint_endpoint: str | URL |
None =
None
157 """Return if Alexa is enabled."""
159 self.
_cloud_cloud.is_logged_in
160 and not self.
_cloud_cloud.subscription_expired
161 and self.
_prefs_prefs.alexa_enabled
166 """Return if config supports auth."""
171 """Return if states should be proactively reported."""
173 self.
_prefs_prefs.alexa_enabled
174 and self.
_prefs_prefs.alexa_report_state
180 """Endpoint for report state."""
182 raise ValueError(
"No endpoint available. Fetch access token first")
188 """Return config locale."""
194 """Return entity config."""
195 return self.
_config_config.
get(CONF_ENTITY_CONFIG)
or {}
199 """Return an identifier for the user that represents this config."""
203 """Migrate alexa entity settings to entity registry options."""
204 if not self.
_config_config[CONF_FILTER].empty_filter:
209 *self.hass.states.async_entity_ids(),
210 *self.
_prefs_prefs.alexa_entity_configs,
220 """Initialize the Alexa config."""
223 async
def on_hass_started(hass: HomeAssistant) ->
None:
224 if self.
_prefs_prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
226 "Start migration of Alexa settings from v%s to v%s",
227 self.
_prefs_prefs.alexa_settings_version,
228 ALEXA_SETTINGS_VERSION,
230 if self.
_prefs_prefs.alexa_settings_version < 2
or (
232 self.
_prefs_prefs.alexa_settings_version < 3
234 settings.get(
"should_expose",
False)
243 "Finished migration of Alexa settings from v%s to v%s",
244 self.
_prefs_prefs.alexa_settings_version,
245 ALEXA_SETTINGS_VERSION,
248 alexa_settings_version=ALEXA_SETTINGS_VERSION
250 self._on_deinitialize.append(
256 async
def on_hass_start(hass: HomeAssistant) ->
None:
257 if self.
enabledenabled
and ALEXA_DOMAIN
not in self.hass.config.components:
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))
263 self._on_deinitialize.append(
266 self._on_deinitialize.append(
267 self.hass.bus.async_listen(
268 er.EVENT_ENTITY_REGISTRY_UPDATED,
274 """If an entity should be exposed."""
275 if entity_id
in CLOUD_NEVER_EXPOSED_ENTITIES:
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:
284 entity_registry = er.async_get(self.hass)
285 if registry_entry := entity_registry.async_get(entity_id):
287 registry_entry.entity_category
is not None
288 or registry_entry.hidden_by
is not None
291 auxiliary_entity =
False
294 if (default_expose := self.
_prefs_prefs.alexa_default_expose)
is None:
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:
310 return entity_filter(entity_id)
316 """Invalidate access token."""
320 """Get an access token."""
324 resp = await cloud_api.async_alexa_access_token(self.
_cloud_cloud)
325 body = await resp.json()
327 if resp.status == HTTPStatus.BAD_REQUEST:
328 if body[
"reason"]
in (
"RefreshTokenNotFound",
"UnknownRegion"):
330 persistent_notification.async_create(
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."
337 "Alexa state reporting disabled",
338 "cloud_alexa_report",
340 raise alexa_errors.RequireRelink
342 raise alexa_errors.NoTokenAvailable
344 self.
_token_token = body[
"access_token"]
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()
360 updated_prefs = prefs.last_updated
363 ALEXA_DOMAIN
not in self.hass.config.components
365 and self.hass.is_running
372 await self.async_enable_proactive_mode()
373 except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink):
374 await self.set_authorized(
False)
376 await self.async_disable_proactive_mode()
387 PREF_ALEXA_REPORT_STATE,
397 """Handle updated preferences."""
407 """Sync the updated preferences to Alexa."""
415 is_enabled = self.
enabledenabled
417 for entity_id, info
in old_prefs.items():
421 to_remove.append(entity_id)
423 old_expose = info.get(PREF_SHOULD_EXPOSE)
425 if entity_id
in new_prefs:
426 new_expose = new_prefs[entity_id].
get(PREF_SHOULD_EXPOSE)
430 if old_expose == new_expose:
434 to_update.append(entity_id)
436 to_remove.append(entity_id)
439 for entity_id, info
in new_prefs.items():
440 if entity_id
in seen:
443 new_expose = info.get(PREF_SHOULD_EXPOSE)
445 if new_expose
is None:
451 to_update.append(entity_id)
455 if await self.
_sync_helper_sync_helper(to_update, to_remove):
459 """Sync all entities to Alexa."""
468 is_enabled = self.
enabledenabled
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)
474 to_remove.append(entity.entity_id)
476 return await self.
_sync_helper_sync_helper(to_update, to_remove)
478 async
def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool:
479 """Sync entities to Alexa.
481 Return boolean if it was successful.
483 if not to_update
and not to_remove:
494 alexa_state_report.async_send_add_or_update_message(
495 self.hass, self, to_update
503 alexa_state_report.async_send_delete_message(
504 self.hass, self, to_remove
510 async
with asyncio.timeout(10):
511 await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
513 _LOGGER.warning(
"Timeout trying to sync entities to Alexa")
515 except aiohttp.ClientError
as err:
516 _LOGGER.warning(
"Error trying to sync entities to Alexa: %s", err)
521 self, event: Event[er.EventEntityRegistryUpdatedData]
523 """Handle when entity registry updated."""
524 if not self.
enabledenabled
or not self.
_cloud_cloud.is_logged_in:
527 entity_id = event.data[
"entity_id"]
532 to_update: list[str] = []
533 to_remove: list[str] = []
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
542 to_update.append(entity_id)
543 if "old_entity_id" in event.data:
544 to_remove.append(event.data[
"old_entity_id"])
546 with suppress(alexa_errors.NoTokenAvailable):
547 await self.
_sync_helper_sync_helper(to_update, to_remove)
None _migrate_alexa_entity_settings_v1(self)
None _handle_entity_registry_updated(self, Event[er.EventEntityRegistryUpdatedData] event)
bool should_expose(self, str entity_id)
None async_initialize(self)
str|URL|None endpoint(self)
bool should_report_state(self)
None async_invalidate_access_token(self)
dict[str, Any] entity_config(self)
bool _should_expose_legacy(self, str entity_id)
None __init__(self, HomeAssistant hass, dict config, str cloud_user, CloudPreferences prefs, Cloud[CloudClient] cloud)
None _async_prefs_updated(self, CloudPreferences prefs)
str user_identifier(self)
bool async_sync_entities(self)
str|None async_get_access_token(self)
None _async_exposed_entities_updated(self)
bool _sync_helper(self, list[str] to_update, list[str] to_remove)
None _sync_prefs(self, datetime _now)
bool entity_supported(HomeAssistant hass, str entity_id)
web.Response get(self, web.Request request, str config_key)
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)
str|None get_device_class(HomeAssistant hass, str entity_id)
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)
bool async_setup_component(core.HomeAssistant hass, str domain, ConfigType config)