Added Alexa Music
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user