Files
Home-Assistant/custom_components/alexa_media/services.py
T
2026-07-17 10:12:15 -04:00

422 lines
15 KiB
Python

"""
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.<name>
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,
)