Initial Home Assistant commit
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Helper functions for Alexa Media Player.
|
||||
|
||||
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
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, TypeVar, overload
|
||||
|
||||
from alexapy import AlexapyLoginCloseRequested, AlexapyLoginError, hide_email
|
||||
from alexapy.alexalogin import AlexaLogin
|
||||
from dictor import dictor
|
||||
from homeassistant.const import CONF_EMAIL, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConditionErrorMessage
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
import wrapt
|
||||
|
||||
from .const import DATA_ALEXAMEDIA, EXCEPTION_TEMPLATE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ArgType = TypeVar("ArgType")
|
||||
|
||||
|
||||
def _norm_filter_token(value: Any) -> str | None:
|
||||
"""Normalize a single filter token for reliable matching."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s.casefold()
|
||||
|
||||
|
||||
def _coerce_filter(value: Any) -> set[str]:
|
||||
"""Coerce include/exclude filter input into a normalized set[str].
|
||||
|
||||
Accepts:
|
||||
- None / empty -> empty set
|
||||
- comma-separated str -> split on commas
|
||||
- list/set/tuple -> per-item normalization
|
||||
- anything else -> single token (best effort)
|
||||
"""
|
||||
if not value:
|
||||
return set()
|
||||
|
||||
# Legacy/back-compat: allow comma-separated string
|
||||
if isinstance(value, str):
|
||||
out = set()
|
||||
for part in value.split(","):
|
||||
token = _norm_filter_token(part)
|
||||
if token:
|
||||
out.add(token)
|
||||
return out
|
||||
|
||||
if isinstance(value, (list, set, tuple)):
|
||||
out = set()
|
||||
for v in value:
|
||||
token = _norm_filter_token(v)
|
||||
if token:
|
||||
out.add(token)
|
||||
return out
|
||||
|
||||
token = _norm_filter_token(value)
|
||||
return {token} if token else set()
|
||||
|
||||
|
||||
async def add_devices(
|
||||
account: str,
|
||||
devices: list[Entity],
|
||||
add_devices_callback: Callable[[list[Entity], bool], None],
|
||||
include_filter: str | list[str] | set[str] | tuple[str, ...] | None = None,
|
||||
exclude_filter: str | list[str] | set[str] | tuple[str, ...] | None = None,
|
||||
) -> bool:
|
||||
"""Add devices using add_devices_callback."""
|
||||
include_filter_set = _coerce_filter(include_filter)
|
||||
exclude_filter_set = _coerce_filter(exclude_filter)
|
||||
if include_filter_set:
|
||||
_LOGGER.debug(
|
||||
"%s: include_filter_set: %s",
|
||||
account,
|
||||
include_filter_set,
|
||||
)
|
||||
if exclude_filter_set:
|
||||
_LOGGER.debug(
|
||||
"%s: exclude_filter_set: %s",
|
||||
account,
|
||||
exclude_filter_set,
|
||||
)
|
||||
|
||||
def _device_name(dev: Entity) -> str | None:
|
||||
"""Best-effort name before entity_id is assigned.
|
||||
|
||||
For AMP switches, reconstruct the legacy "<device> <suffix> switch"
|
||||
name only if those attributes were explicitly set.
|
||||
"""
|
||||
|
||||
# First prefer explicitly set name attributes (works for tests + most entities)
|
||||
name = (
|
||||
getattr(dev, "name", None)
|
||||
or getattr(dev, "_attr_name", None)
|
||||
or getattr(dev, "_name", None)
|
||||
or getattr(dev, "_device_name", None)
|
||||
or getattr(dev, "_friendly_name", None)
|
||||
)
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Only attempt switch reconstruction if attributes were explicitly defined
|
||||
# (avoids MagicMock auto-attribute trap in tests)
|
||||
dev_dict = getattr(dev, "__dict__", {})
|
||||
|
||||
client = dev_dict.get("_client")
|
||||
suffix = dev_dict.get("_unique_id_suffix")
|
||||
|
||||
if client and suffix:
|
||||
client_dict = getattr(client, "__dict__", {})
|
||||
base = (
|
||||
client_dict.get("name")
|
||||
or client_dict.get("_attr_name")
|
||||
or client_dict.get("_name")
|
||||
or client_dict.get("_device_name")
|
||||
)
|
||||
if base:
|
||||
return f"{base} {suffix} switch"
|
||||
|
||||
return None
|
||||
|
||||
def _device_label(dev: Entity) -> str:
|
||||
"""Return a compact, stable identifier for logging."""
|
||||
name = _device_name(dev)
|
||||
entity_id = getattr(dev, "entity_id", None) # often not set yet
|
||||
dev_type = type(dev).__name__
|
||||
|
||||
if name and entity_id:
|
||||
return f"{name} ({dev_type}, {entity_id})"
|
||||
if name:
|
||||
return f"{name} ({dev_type})"
|
||||
return f"<unnamed> ({dev_type})"
|
||||
|
||||
def _devices_preview(devs: list[Entity]) -> str:
|
||||
max_items = 8
|
||||
labels = [_device_label(d) for d in devs[:max_items]]
|
||||
suffix = f" …(+{len(devs) - max_items} more)" if len(devs) > max_items else ""
|
||||
return ", ".join(labels) + suffix
|
||||
|
||||
def _filter_devices(
|
||||
devs: list[Entity],
|
||||
include_set: set[str],
|
||||
exclude_set: set[str],
|
||||
) -> list[Entity]:
|
||||
selected: list[Entity] = []
|
||||
|
||||
include_mode = bool(include_set)
|
||||
if include_mode and exclude_set:
|
||||
_LOGGER.debug(
|
||||
"%s: include_devices set; ignoring exclude_devices per documented precedence",
|
||||
account,
|
||||
)
|
||||
|
||||
for dev in devs:
|
||||
dev_name = _norm_filter_token(_device_name(dev))
|
||||
|
||||
# INCLUDE MODE: only include explicitly listed names
|
||||
if include_mode:
|
||||
if dev_name and dev_name in include_set:
|
||||
selected.append(dev)
|
||||
else:
|
||||
if not dev_name:
|
||||
_LOGGER.debug(
|
||||
"%s: Not including device (no name yet): %s",
|
||||
account,
|
||||
_device_label(dev),
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s: Not including device: %s (match key=%r)",
|
||||
account,
|
||||
_device_label(dev),
|
||||
dev_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# EXCLUDE MODE: exclude listed names
|
||||
if exclude_set and dev_name and dev_name in exclude_set:
|
||||
_LOGGER.debug(
|
||||
"%s: Excluding device: %s (match key=%r)",
|
||||
account,
|
||||
_device_label(dev),
|
||||
dev_name,
|
||||
)
|
||||
continue
|
||||
|
||||
selected.append(dev)
|
||||
|
||||
return selected
|
||||
|
||||
devices = _filter_devices(devices, include_filter_set, exclude_filter_set)
|
||||
if not devices:
|
||||
return True
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Adding %d device(s): %s",
|
||||
account,
|
||||
len(devices),
|
||||
_devices_preview(devices),
|
||||
)
|
||||
|
||||
try:
|
||||
add_devices_callback(devices, False)
|
||||
except ConditionErrorMessage as exception_:
|
||||
message: str = exception_.message
|
||||
if message.startswith("Entity id already exists"):
|
||||
_LOGGER.debug("%s: Device already added: %s", account, message)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s: Unable to add %d device(s): %s",
|
||||
account,
|
||||
len(devices),
|
||||
message,
|
||||
)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.debug(
|
||||
"%s: Unable to add %d device(s): %s",
|
||||
account,
|
||||
len(devices),
|
||||
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
||||
)
|
||||
else:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def retry_async(
|
||||
limit: int = 5, delay: float = 1, catch_exceptions: bool = True
|
||||
) -> Callable:
|
||||
"""Wrap function with retry logic.
|
||||
|
||||
The function will retry until true or the limit is reached. It will delay
|
||||
for the period of time specified exponentially increasing the delay.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
limit : int
|
||||
The max number of retries.
|
||||
delay : float
|
||||
The delay in seconds between retries.
|
||||
catch_exceptions : bool
|
||||
Whether exceptions should be caught and treated as failures or thrown.
|
||||
|
||||
Returns
|
||||
-------
|
||||
def
|
||||
Wrapped function.
|
||||
|
||||
"""
|
||||
|
||||
def wrap(func) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
_LOGGER.debug(
|
||||
"%s.%s: Trying with limit %s delay %s catch_exceptions %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
limit,
|
||||
delay,
|
||||
catch_exceptions,
|
||||
)
|
||||
retries: int = 0
|
||||
result: bool = False
|
||||
next_try: int = 0
|
||||
while not result and retries < limit:
|
||||
if retries != 0:
|
||||
next_try = delay * 2**retries
|
||||
await asyncio.sleep(next_try)
|
||||
retries += 1
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
if not catch_exceptions:
|
||||
raise
|
||||
_LOGGER.debug(
|
||||
"%s.%s: failure caught due to exception: %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s.%s: Try: %s/%s after waiting %s seconds result: %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
retries,
|
||||
limit,
|
||||
next_try,
|
||||
result,
|
||||
)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
@wrapt.decorator
|
||||
async def _catch_login_errors(func, instance, args, kwargs) -> Any:
|
||||
"""Detect AlexapyLoginError and attempt relogin."""
|
||||
|
||||
result = None
|
||||
if instance is None and args:
|
||||
instance = args[0]
|
||||
if hasattr(instance, "check_login_changes"):
|
||||
# _LOGGER.debug(
|
||||
# "%s checking for login changes", instance,
|
||||
# )
|
||||
instance.check_login_changes()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
except AlexapyLoginCloseRequested:
|
||||
_LOGGER.debug(
|
||||
"%s.%s: Ignoring attempt to access Alexa after HA shutdown",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
)
|
||||
return None
|
||||
except AlexapyLoginError as ex:
|
||||
login = None
|
||||
email = None
|
||||
all_args = list(args) + list(kwargs.values())
|
||||
# _LOGGER.debug("Func %s instance %s %s %s", func, instance, args, kwargs)
|
||||
if instance:
|
||||
if hasattr(instance, "_login"):
|
||||
login = instance._login # pylint: disable=protected-access
|
||||
hass = instance.hass
|
||||
else:
|
||||
for arg in all_args:
|
||||
_LOGGER.debug("Checking %s", arg)
|
||||
|
||||
if isinstance(arg, AlexaLogin):
|
||||
login = arg
|
||||
break
|
||||
if hasattr(arg, "_login"):
|
||||
login = instance._login
|
||||
hass = instance.hass
|
||||
break
|
||||
|
||||
if login:
|
||||
# Try to re-login
|
||||
email = login.email
|
||||
if await login.test_loggedin():
|
||||
_LOGGER.info(
|
||||
"%s.%s: Successful re-login after a login error for %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
hide_email(email),
|
||||
)
|
||||
return None
|
||||
_LOGGER.debug(
|
||||
"%s.%s: detected bad login for %s: %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
hide_email(email),
|
||||
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
||||
)
|
||||
try:
|
||||
hass
|
||||
except NameError:
|
||||
hass = None
|
||||
report_relogin_required(hass, login, email)
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def report_relogin_required(hass, login, email) -> bool:
|
||||
"""Send message for relogin required."""
|
||||
if hass and login and email:
|
||||
if login.status:
|
||||
_LOGGER.debug(
|
||||
"Reporting need to relogin to %s with %s stats: %s",
|
||||
login.url,
|
||||
hide_email(email),
|
||||
login.stats,
|
||||
)
|
||||
hass.bus.async_fire(
|
||||
"alexa_media_relogin_required",
|
||||
event_data={
|
||||
"email": hide_email(email),
|
||||
"url": login.url,
|
||||
"stats": login.stats,
|
||||
},
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _existing_serials(hass, login_obj) -> list:
|
||||
"""Retrieve existing serial numbers for a given login object."""
|
||||
email: str = login_obj.email
|
||||
if (
|
||||
DATA_ALEXAMEDIA in hass.data
|
||||
and "accounts" in hass.data[DATA_ALEXAMEDIA]
|
||||
and email in hass.data[DATA_ALEXAMEDIA]["accounts"]
|
||||
):
|
||||
existing_serials = list(
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][
|
||||
"media_player"
|
||||
].keys()
|
||||
)
|
||||
device_data = (
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][email]
|
||||
.get("devices", {})
|
||||
.get("media_player", {})
|
||||
)
|
||||
for serial in existing_serials[:]:
|
||||
device = device_data.get(serial, {})
|
||||
if "appDeviceList" in device and device["appDeviceList"]:
|
||||
apps = [
|
||||
x["serialNumber"]
|
||||
for x in device["appDeviceList"]
|
||||
if "serialNumber" in x
|
||||
]
|
||||
existing_serials.extend(apps)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"No accounts data found for %s. Skipping serials retrieval.", email
|
||||
)
|
||||
existing_serials = []
|
||||
return existing_serials
|
||||
|
||||
|
||||
async def calculate_uuid(hass, email: str, url: str) -> dict:
|
||||
"""Return uuid and index of email/url.
|
||||
|
||||
Args
|
||||
hass (bool): Hass entity
|
||||
url (str): url for account
|
||||
email (str): email for account
|
||||
|
||||
Returns
|
||||
dict: dictionary with uuid and index
|
||||
|
||||
"""
|
||||
result = {}
|
||||
return_index = 0
|
||||
if hass.config_entries.async_entries(DATA_ALEXAMEDIA):
|
||||
for index, entry in enumerate(
|
||||
hass.config_entries.async_entries(DATA_ALEXAMEDIA)
|
||||
):
|
||||
if entry.data.get(CONF_EMAIL) == email and entry.data.get(CONF_URL) == url:
|
||||
return_index = index
|
||||
break
|
||||
uuid = await async_get_instance_id(hass)
|
||||
result["uuid"] = hex(
|
||||
int(uuid, 16)
|
||||
# increment uuid for second accounts
|
||||
+ return_index
|
||||
# hash email/url in case HA uuid duplicated
|
||||
+ int(
|
||||
hashlib.sha256((email.lower() + url.lower()).encode()).hexdigest(),
|
||||
16, # nosec
|
||||
)
|
||||
)[-32:]
|
||||
result["index"] = return_index
|
||||
_LOGGER.debug("%s: Returning uuid %s", hide_email(email), result)
|
||||
return result
|
||||
|
||||
|
||||
def alarm_just_dismissed(
|
||||
alarm: dict[str, Any],
|
||||
previous_status: Optional[str],
|
||||
previous_version: Optional[str],
|
||||
) -> bool:
|
||||
"""Given the previous state of an alarm, determine if it has just been dismissed."""
|
||||
|
||||
if (
|
||||
previous_status not in ("SNOOZED", "ON")
|
||||
# The alarm had to be in a status that supported being dismissed
|
||||
or previous_version is None
|
||||
# The alarm was probably just created
|
||||
or not alarm
|
||||
# The alarm that was probably just deleted.
|
||||
or alarm.get("status") not in ("OFF", "ON")
|
||||
# A dismissed alarm is guaranteed to be turned off(one-off alarm) or left on(recurring alarm)
|
||||
or previous_version == alarm.get("version")
|
||||
# A dismissal always has a changed version.
|
||||
or int(alarm.get("version", "0")) > 1 + int(previous_version)
|
||||
):
|
||||
# This is an absurd thing to check, but it solves many, many edge cases.
|
||||
# Experimentally, when an alarm is dismissed, the version always increases by 1
|
||||
# When an alarm is edited either via app or voice, its version always increases by 2+
|
||||
return False
|
||||
|
||||
# It seems obvious that a check involving time should be necessary. It is not.
|
||||
# We know there was a change and that it wasn't an edit.
|
||||
# We also know the alarm's status rules out a snooze.
|
||||
# The only remaining possibility is that this alarm was just dismissed.
|
||||
return True
|
||||
|
||||
|
||||
def is_http2_enabled(hass: HomeAssistant | None, login_email: str) -> bool:
|
||||
"""Whether HTTP2 push is enabled for the current account session"""
|
||||
if hass:
|
||||
return bool(
|
||||
safe_get(
|
||||
hass.data,
|
||||
[DATA_ALEXAMEDIA, "accounts", login_email, "http2"],
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@overload
|
||||
def safe_get(
|
||||
data: Any,
|
||||
path_list: list[str | int] | None = None,
|
||||
checknone: bool = False,
|
||||
ignorecase: bool = False,
|
||||
pathsep: str = ".",
|
||||
search: Any = None,
|
||||
pretty: bool = False,
|
||||
rtype: str | None = None,
|
||||
) -> Any | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def safe_get(
|
||||
data: Any, path_list: list[str | int] | None, default: ArgType, *args, **kwargs
|
||||
) -> ArgType: ...
|
||||
|
||||
|
||||
def safe_get(
|
||||
data: Any, path_list: list[str | int] | None = None, *args, **kwargs
|
||||
) -> None | Any:
|
||||
"""Safely get nested value using path segments with optional type checking.
|
||||
|
||||
Args:
|
||||
data: Source data structure
|
||||
path_list: List of path segments (dots in segment names are auto-escaped)
|
||||
*args: Positional arguments passed to dictor (e.g., default value)
|
||||
**kwargs: Keyword arguments passed to dictor (checknone, ignorecase)
|
||||
|
||||
Returns:
|
||||
The value at the specified path, or None if:
|
||||
- The path doesn't exist and no default is provided
|
||||
or default if:
|
||||
- A default is provided and the path doesn't exist
|
||||
- A default is provided and the retrieved value's type doesn't match the default's type
|
||||
|
||||
Note:
|
||||
- Do not pass 'pathsep' in kwargs as the path is pre-built.
|
||||
- Type checking: When a default value is provided and a non-None value is retrieved,
|
||||
the result is validated against the default's type. If types don't match, default is returned.
|
||||
This prevents silent type errors from malformed data structures.
|
||||
|
||||
Examples:
|
||||
>>> safe_get({"a": {"b": "value"}}, ["a", "b"])
|
||||
'value'
|
||||
|
||||
>>> safe_get({"a": {"b": 123}}, ["a", "b"], "default")
|
||||
'default' # Type mismatch: int vs str
|
||||
|
||||
>>> safe_get({"a": {"b": "value"}}, ["a", "b"], "default")
|
||||
'value' # Type matches
|
||||
"""
|
||||
if not path_list:
|
||||
raise ValueError("path_list cannot be empty")
|
||||
|
||||
if "pathsep" in kwargs:
|
||||
kwargs.pop("pathsep") # Ignore pathsep since we build the path
|
||||
|
||||
escaped_segments = (str(seg).replace(".", "\\.") for seg in path_list)
|
||||
path = ".".join(escaped_segments)
|
||||
default = args[0] if args else (kwargs.get("default") if kwargs else None)
|
||||
result = dictor(data, path, *args, **kwargs)
|
||||
if default is not None and result is not None:
|
||||
if not isinstance(result, type(default)):
|
||||
result = default
|
||||
return result
|
||||
Reference in New Issue
Block a user