""" Alexa Services. SPDX-License-Identifier: Apache-2.0 For more details about this platform, please refer to the documentation at https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ import asyncio from dataclasses import dataclass import logging from typing import Any, Callable from alexapy import AlexaAPI, AlexapyLoginError, hide_email from alexapy.errors import AlexapyConnectionError from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, entity_registry as er import voluptuous as vol from .const import ( ATTR_EMAIL, ATTR_ENTITY_ID, ATTR_NUM_ENTRIES, DATA_ALEXAMEDIA, DOMAIN, SERVICE_ENABLE_NETWORK_DISCOVERY, SERVICE_FORCE_LOGOUT, SERVICE_GET_HISTORY_RECORDS, SERVICE_RESTORE_VOLUME, SERVICE_UPDATE_LAST_CALLED, ) from .helpers import _catch_login_errors, report_relogin_required, safe_get _LOGGER = logging.getLogger(__name__) FORCE_LOGOUT_SCHEMA = vol.Schema( {vol.Optional(ATTR_EMAIL, default=[]): vol.All(cv.ensure_list, [cv.string])} ) LAST_CALL_UPDATE_SCHEMA = vol.Schema( {vol.Optional(ATTR_EMAIL, default=[]): vol.All(cv.ensure_list, [cv.string])} ) RESTORE_VOLUME_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) GET_HISTORY_RECORDS_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Optional(ATTR_NUM_ENTRIES, default=5): cv.positive_int, } ) ENABLE_NETWORK_DISCOVERY_SCHEMA = vol.Schema( { vol.Optional(ATTR_EMAIL, default=[]): vol.All( cv.ensure_list, [cv.string], ), } ) @dataclass(frozen=True) class AlexaServiceDef: """Definition for an Alexa Media custom service.""" name: str # service name as exposed in HA: alexa_media. schema: vol.Schema # voluptuous schema handler: str # method name on AlexaMediaServices SERVICE_DEFS: tuple[AlexaServiceDef, ...] = ( AlexaServiceDef( name=SERVICE_FORCE_LOGOUT, schema=FORCE_LOGOUT_SCHEMA, handler="force_logout", ), AlexaServiceDef( name=SERVICE_UPDATE_LAST_CALLED, schema=LAST_CALL_UPDATE_SCHEMA, handler="last_call_handler", ), AlexaServiceDef( name=SERVICE_RESTORE_VOLUME, schema=RESTORE_VOLUME_SCHEMA, handler="restore_volume", ), AlexaServiceDef( name=SERVICE_GET_HISTORY_RECORDS, schema=GET_HISTORY_RECORDS_SCHEMA, handler="get_history_records", ), AlexaServiceDef( name=SERVICE_ENABLE_NETWORK_DISCOVERY, schema=ENABLE_NETWORK_DISCOVERY_SCHEMA, handler="enable_network_discovery", ), ) class AlexaMediaServices: def __init__(self, hass: HomeAssistant, functions: dict[str, Callable[..., Any]]): self.hass = hass self._functions = functions async def register(self) -> None: """Register Alexa Media custom services.""" for service_def in SERVICE_DEFS: handler = getattr(self, service_def.handler) self.hass.services.async_register( DOMAIN, service_def.name, handler, schema=service_def.schema, ) async def unregister(self) -> None: """Unregister Alexa Media custom services.""" for service_def in SERVICE_DEFS: self.hass.services.async_remove(DOMAIN, service_def.name) async def force_logout(self, call: ServiceCall) -> bool: """Handle force logout service request. Arguments call.ATTR_EMAIL {List[str] | None}: List of case-sensitive Alexa emails. If None, all accounts are logged out. Returns bool -- True if at least one account was marked for relogin. """ requested_emails = call.data.get(ATTR_EMAIL) _LOGGER.debug("Service force_logout called for: %s", requested_emails) accounts = self.hass.data[DATA_ALEXAMEDIA]["accounts"] success = False for email, account_dict in accounts.items(): if requested_emails and email not in requested_emails: continue login_obj = account_dict["login_obj"] # This is the effective “force logout” for this account: mark it as # requiring reauthentication and notify the user/UI. report_relogin_required(self.hass, login_obj, email) success = True _LOGGER.debug( "Marked Alexa Media account %s for relogin via force_logout service", hide_email(email), ) if requested_emails and not success: _LOGGER.warning( "force_logout called for %s but no matching Alexa Media accounts were found", requested_emails, ) return success @_catch_login_errors async def last_call_handler(self, call: ServiceCall) -> None: """Handle last call service request. Arguments call.ATTR_EMAIL: {List[str: None]}: List of case-sensitive Alexa emails. If None, all accounts are updated. """ requested_emails = call.data.get(ATTR_EMAIL) update_last_called = self._functions.get("update_last_called") if not callable(update_last_called): _LOGGER.error( "update_last_called function not registered; cannot update last_called" ) return _LOGGER.debug("Service update_last_called called for: %s", requested_emails) for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items(): if requested_emails and email not in requested_emails: continue login_obj = account_dict["login_obj"] async def _run_update_last_called(email: str, login_obj) -> None: try: await update_last_called(login_obj) except asyncio.CancelledError: raise except AlexapyLoginError: report_relogin_required(self.hass, login_obj, email) except AlexapyConnectionError: _LOGGER.error( "Unable to connect to Alexa for %s;" " check your network connection and try again", hide_email(email), ) except Exception: # pragma: no cover _LOGGER.exception( "Unexpected error updating last_called for %s", hide_email(email), ) finally: # Clean up task reference when done if email in self.hass.data[DATA_ALEXAMEDIA]["accounts"]: self.hass.data[DATA_ALEXAMEDIA]["accounts"][email].pop( "service_update_last_called_task", None ) # Cancel any existing task for this account before creating a new one existing_task = account_dict.get("service_update_last_called_task") if existing_task and not existing_task.done(): existing_task.cancel() # Store task handle for proper cleanup on unload task = self.hass.async_create_task( _run_update_last_called(email, login_obj), name=f"alexa_media.update_last_called.{hide_email(email)}", ) account_dict["service_update_last_called_task"] = task async def restore_volume(self, call: ServiceCall) -> bool: """Handle restore volume service request. Arguments: call.ATTR_ENTITY_ID {str: None} -- Alexa Media Player entity. """ entity_id = call.data.get(ATTR_ENTITY_ID) _LOGGER.debug("Service restore_volume called for: %s", entity_id) # Retrieve the entity registry and entity entry entity_registry = er.async_get(self.hass) entity_entry = entity_registry.async_get(entity_id) if not entity_entry: _LOGGER.error("Entity %s not found in registry", entity_id) return False # Retrieve the state and attributes state = self.hass.states.get(entity_id) if not state: _LOGGER.warning("Entity %s has no state; cannot restore volume", entity_id) return False previous_volume = state.attributes.get("previous_volume") current_volume = state.attributes.get("volume_level") if previous_volume is None: _LOGGER.warning( "Previous volume not found for %s; attempting to use current volume level: %s", entity_id, current_volume, ) previous_volume = current_volume if previous_volume is None: _LOGGER.warning( "No valid volume levels found for entity %s; cannot restore volume", entity_id, ) return False # Call the volume_set service with the retrieved volume await self.hass.services.async_call( domain="media_player", service="volume_set", service_data={ "volume_level": previous_volume, }, target={"entity_id": entity_id}, blocking=True, ) _LOGGER.debug("Volume restored to %s for entity %s", previous_volume, entity_id) return True async def get_history_records(self, call: ServiceCall) -> bool: """Handle request to get history records and store them on the entity.""" entity_id = call.data.get(ATTR_ENTITY_ID) number_of_entries = call.data.get(ATTR_NUM_ENTRIES) # Validate number_of_entries try: number_of_entries_int = int(number_of_entries) except (TypeError, ValueError): _LOGGER.exception( "Service get_history_records for %s has invalid entries value: %s", entity_id, number_of_entries, ) return False if number_of_entries_int <= 0: _LOGGER.error( "Service get_history_records for %s with %s entries is invalid; must be > 0", entity_id, number_of_entries_int, ) return False _LOGGER.debug( "Service get_history_records for: %s with %s entries", entity_id, number_of_entries_int, ) # Validate the target entity entity_registry = er.async_get(self.hass) entity_entry = entity_registry.async_get(entity_id) if not entity_entry or entity_entry.platform != DOMAIN: _LOGGER.error("Entity %s not found or not part of %s", entity_id, DOMAIN) return False target_serial_number = entity_entry.unique_id history_data_total: list[dict[str, Any]] = [] async def _collect_history_for_account(login_obj) -> None: """Collect history entries for a single account matching the target device.""" # Get the history records. Input: time_from, time_to (both None here). history_data = await AlexaAPI.get_customer_history_records( login_obj, None, None ) if not history_data: return for item in history_data: summary = safe_get(item, ["description", "summary"], "") device_serial_number = item.get("deviceSerialNumber") timestamp = item.get("creationTimestamp") if ( not summary or summary == "," or device_serial_number != target_serial_number or timestamp is None ): continue entry = { "timestamp": timestamp, "summary": summary, "response": item.get("alexaResponse", ""), } history_data_total.append(entry) # Iterate accounts and collect history for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items(): login_obj = account_dict["login_obj"] try: await _collect_history_for_account(login_obj) except AlexapyConnectionError: _LOGGER.exception( "Error retrieving history for %s", hide_email(email), ) except AlexapyLoginError: _LOGGER.exception( "Login error retrieving history for %s", hide_email(email), ) report_relogin_required(self.hass, login_obj, email) except asyncio.CancelledError: # Let HA cancellation propagate raise except Exception: # Fallback for truly unexpected errors _LOGGER.exception( "Unexpected error retrieving history for %s", hide_email(email), ) # Sort and limit entries history_data_total.sort(key=lambda x: x["timestamp"], reverse=True) history_data_total = history_data_total[:number_of_entries_int] # Update the entity's attributes state = self.hass.states.get(entity_id) if state is not None: new_attributes = dict(state.attributes) new_attributes["history_records"] = history_data_total self.hass.states.async_set(entity_id, state.state, new_attributes) return True _LOGGER.error("Entity %s state not found", entity_id) return False async def enable_network_discovery(self, call: ServiceCall) -> None: """Re-enable network discovery for one or more Alexa accounts.""" data = call.data or {} target_emails: list[str] = data.get(ATTR_EMAIL, []) accounts = self.hass.data[DATA_ALEXAMEDIA]["accounts"] any_matched = False for email, account_dict in accounts.items(): if target_emails and email not in target_emails: continue any_matched = True if "should_get_network" not in account_dict: _LOGGER.debug( "Account %s has no 'should_get_network' flag; skipping", hide_email(email), ) continue account_dict["should_get_network"] = True _LOGGER.debug( "Re-enabled network discovery for Alexa Media account %s", hide_email(email), ) if target_emails and not any_matched: _LOGGER.warning( "enable_network_discovery called for %s but no matching Alexa Media accounts were found", target_emails, )