diff --git a/.cache/brands/integrations/alexa_media/logo.png b/.cache/brands/integrations/alexa_media/logo.png new file mode 100644 index 0000000..de3a926 Binary files /dev/null and b/.cache/brands/integrations/alexa_media/logo.png differ diff --git a/.cache/brands/integrations/music_assistant/logo.png b/.cache/brands/integrations/music_assistant/logo.png new file mode 100644 index 0000000..43a5c1b Binary files /dev/null and b/.cache/brands/integrations/music_assistant/logo.png differ diff --git a/.ha_mcp/logs/mcp_usage.jsonl b/.ha_mcp/logs/mcp_usage.jsonl new file mode 100644 index 0000000..9a61308 --- /dev/null +++ b/.ha_mcp/logs/mcp_usage.jsonl @@ -0,0 +1,9 @@ +{"timestamp": "2026-07-16T01:51:24.833520+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_boolean", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 265.3963565826416, "success": true, "error_message": null, "response_size_bytes": 2297, "user_context": null} +{"timestamp": "2026-07-16T01:51:24.844401+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_select", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 234.4343662261963, "success": true, "error_message": null, "response_size_bytes": 472, "user_context": null} +{"timestamp": "2026-07-16T01:51:24.849852+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_number", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 281.5418243408203, "success": true, "error_message": null, "response_size_bytes": 640, "user_context": null} +{"timestamp": "2026-07-16T01:51:25.011117+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "timer", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 184.76414680480957, "success": true, "error_message": null, "response_size_bytes": 465, "user_context": null} +{"timestamp": "2026-07-16T01:51:25.012519+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "counter", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 186.4025592803955, "success": true, "error_message": null, "response_size_bytes": 826, "user_context": null} +{"timestamp": "2026-07-16T01:51:27.603591+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_boolean", "area_filter": null, "search_types": null, "limit": 10, "offset": 10, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 78.72748374938965, "success": true, "error_message": null, "response_size_bytes": 818, "user_context": null} +{"timestamp": "2026-07-16T14:58:34.806520+00:00", "tool_name": "ha_list_floors_areas", "parameters": {"fields": null, "area_fields": null}, "execution_time_ms": 34.44170951843262, "success": true, "error_message": null, "response_size_bytes": 3757, "user_context": null} +{"timestamp": "2026-07-16T15:17:46.049602+00:00", "tool_name": "ha_get_overview", "parameters": {"detail_level": "minimal", "domains": null, "limit": null, "offset": 0, "max_entities_per_domain": null, "include_state": null, "include_entity_id": null, "include_notifications": true, "include_dismissed_repairs": false, "fields": null}, "execution_time_ms": 1456.7010402679443, "success": true, "error_message": null, "response_size_bytes": 14815, "user_context": null} +{"timestamp": "2026-07-16T15:20:28.060870+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "automation", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 113.4181022644043, "success": true, "error_message": null, "response_size_bytes": 2476, "user_context": null} diff --git a/.ha_run.lock b/.ha_run.lock index 3034dba..091dc1b 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784135767.6085248} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784294980.0427423} \ No newline at end of file diff --git a/automations.yaml b/automations.yaml index fb5c3e5..a8780ff 100644 --- a/automations.yaml +++ b/automations.yaml @@ -834,8 +834,6 @@ - condition: template value_template: '{{ states(''sensor.google_travel_time_work'') | float(0) > 35 }}' - - condition: template - value_template: '{{ states(''sensor.waze_travel_time'') | float(0) > 35 }}' actions: - action: camera.snapshot target: @@ -846,8 +844,7 @@ data: title: "\U0001F6A8 Commute Delay Alert!" message: "Traffic to Work is heavier than usual this morning. \n- Google Maps: - {{ states('sensor.google_travel_time_work') }} mins.\n- Waze: {{ states('sensor.waze_to_work_travel_time') - }} mins." + {{ states('sensor.google_travel_time_work') }} mins." data: image: /local/commute_snapshot.jpg enabled: false @@ -855,8 +852,7 @@ data: title: "\U0001F6A8 Commute Delay Alert!" message: "Traffic to Work is heavier than usual this morning. \n- Google Maps: - {{ states('sensor.google_travel_time_work') }} mins. \n- Waze: {{ states('sensor.waze_to_work_travel_time') - }} mins.\n![Commute Map](/local/commute_snapshot.jpg)" + {{ states('sensor.google_travel_time_work') }} mins. \n![Commute Map](/local/commute_snapshot.jpg)" mode: single - *id001 - *id001 @@ -898,17 +894,14 @@ - condition: numeric_state entity_id: sensor.google_travel_time_work above: 35 - - condition: numeric_state - entity_id: sensor.waze_travel_time - above: 35 actions: - action: notify.notify data: title: "\U0001F697 Morning Commute" - message: "Google: {{ states('sensor.google_travel_time_work') }} min\nWaze: - {{ states('sensor.waze_to_work_travel_time') }} min\n{% set g = states('sensor.google_travel_time_work') - | float(0) %} {% if g > 50 %} \U0001F6A8 Major traffic delays. {% elif g > - 40 %} ⚠️ Heavy traffic. {% else %} \U0001F7E1 Moderate traffic. {% endif %}" + message: "Google: {{ states('sensor.google_travel_time_work') }} min\n\n{% set + g = states('sensor.google_travel_time_work') | float(0) %} {% if g > 50 %} + \U0001F6A8 Major traffic delays. {% elif g > 40 %} ⚠️ Heavy traffic. {% else + %} \U0001F7E1 Moderate traffic. {% endif %}" mode: single - id: '1783162325964' alias: Motion Shed - AI Description @@ -972,11 +965,16 @@ - delay: hours: 0 minutes: 0 - seconds: 3 + seconds: 6 milliseconds: 0 - action: homeassistant.update_entity target: entity_id: camera.backyard_shed + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 - variables: snapshot_filename: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg - variables: @@ -1784,8 +1782,8 @@ mode: single - id: '1783737753660' alias: 'Office: Manual Meeting Status Busy Light - Working Hours' - description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours: - Pure motion night light (Toggle ignored).' + description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours + OR Away Mode: Pure motion night light (Toggle ignored).' triggers: - id: status_changed entity_id: input_boolean.in_a_meeting @@ -1807,6 +1805,9 @@ - condition: state entity_id: input_boolean.in_a_meeting state: 'on' + - condition: state + entity_id: input_boolean.away_mode + state: 'off' - condition: time after: 08:00:00 before: '18:00:00' @@ -1832,6 +1833,9 @@ - condition: state entity_id: input_boolean.in_a_meeting state: 'off' + - condition: state + entity_id: input_boolean.away_mode + state: 'off' - condition: time after: 08:00:00 before: '18:00:00' @@ -1854,17 +1858,22 @@ - conditions: - condition: trigger id: motion_detected - - condition: not + - condition: or conditions: - - condition: time - after: 08:00:00 - before: '18:00:00' - weekday: - - mon - - tue - - wed - - thu - - fri + - condition: state + entity_id: input_boolean.away_mode + state: 'on' + - condition: not + conditions: + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri sequence: - target: entity_id: light.night_light @@ -1878,22 +1887,27 @@ - conditions: - condition: trigger id: motion_cleared - - condition: not + - condition: or conditions: - - condition: time - after: 08:00:00 - before: '18:00:00' - weekday: - - mon - - tue - - wed - - thu - - fri + - condition: state + entity_id: input_boolean.away_mode + state: 'on' + - condition: not + conditions: + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri sequence: - target: entity_id: light.night_light action: light.turn_off - mode: single + mode: restart - id: '1783738120074' alias: 'Office: Webhook Toggle Meeting Status' description: Toggles the meeting status boolean when the secret webhook URL is opened @@ -2316,6 +2330,13 @@ - light.office_lamp - light.playroom_light scroll_wheel_mode_ext_ch3: instant + on_hold_action_ch1: + - action: light.turn_on + metadata: {} + target: + entity_id: light.playroom_light + data: + brightness_pct: 30 - id: '1784073759556' alias: 'Office: Desk Lamp Meeting Alert' description: Flashes the desk lamp color temperature for a few seconds when a meeting @@ -2345,3 +2366,164 @@ entity_id: scene.desk_lamp_before_alert data: {} mode: restart +- id: '1784153893584' + alias: 'Office: Manual Meeting Status Busy Light - Working Hours v2' + description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours + OR Away Mode: Pure motion night light (Toggle ignored).' + triggers: + - id: status_changed + entity_id: input_boolean.in_a_meeting + trigger: state + - id: motion_detected + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'on' + - id: motion_cleared + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'off' + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: status_changed + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + - condition: state + entity_id: input_boolean.away_mode + state: 'off' + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + - conditions: + - condition: trigger + id: status_changed + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'off' + - condition: state + entity_id: input_boolean.away_mode + state: 'off' + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 20 + rgb_color: + - 5 + - 245 + - 45 + - conditions: + - condition: trigger + id: motion_detected + - condition: or + conditions: + - condition: state + entity_id: input_boolean.away_mode + state: 'on' + - condition: not + conditions: + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 30 + - conditions: + - condition: trigger + id: motion_cleared + - condition: or + conditions: + - condition: state + entity_id: input_boolean.away_mode + state: 'on' + - condition: not + conditions: + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - delay: 00:00:02 + - action: light.turn_off + target: + entity_id: light.night_light + mode: restart +- id: '1784246257858' + alias: Sync Alexa Volume Slider + description: Syncs the Alexa Master Volume slider with the currently selected speaker. + triggers: + - entity_id: input_number.alexa_master_volume + id: slider_changed + trigger: state + - entity_id: input_select.alexa_target_device + id: speaker_changed + trigger: state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: slider_changed + sequence: + - action: media_player.volume_set + target: + entity_id: '{{ states(''input_select.alexa_target_device'') }}' + data: + volume_level: '{{ states(''input_number.alexa_master_volume'') | float / + 100 }}' + - conditions: + - condition: trigger + id: speaker_changed + sequence: + - action: input_number.set_value + target: + entity_id: input_number.alexa_master_volume + data: + value: "{% set target = states('input_select.alexa_target_device') %} {% + if state_attr(target, 'volume_level') != none %}\n {{ (state_attr(target, + 'volume_level') | float * 100) | round(0) }}\n{% else %}\n 20\n{% endif + %}" + mode: restart diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py new file mode 100644 index 0000000..0e12998 --- /dev/null +++ b/custom_components/alexa_media/__init__.py @@ -0,0 +1,3298 @@ +""" +Support to interface with Alexa Devices. + +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 datetime import datetime, timedelta +from json import JSONDecodeError, loads +import logging +import os +import random +import time +from typing import Optional +from urllib.parse import urlparse + +import aiohttp +from alexapy import ( + AlexaAPI, + AlexaLogin, + AlexapyConnectionError, + AlexapyLoginError, + HTTP2EchoClient, + __version__ as alexapy_version, + hide_serial, + obfuscate, +) +from alexapy.errors import AlexapyTooManyRequestsError +from alexapy.helpers import delete_cookie as alexapy_delete_cookie +import async_timeout +from homeassistant.components.persistent_notification import ( + async_create as async_create_persistent_notification, + async_dismiss as async_dismiss_persistent_notification, +) +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import ( + CONF_EMAIL, + CONF_PASSWORD, + CONF_URL, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import UnknownFlow +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.loader import async_get_integration +from homeassistant.util import dt, slugify +import voluptuous as vol + +from .alexa_entity import AlexaEntityData, get_entity_data, parse_alexa_entities +from .config_flow import in_progress_instances +from .const import ( + ALEXA_COMPONENTS, + CONF_ACCOUNTS, + CONF_DEBUG, + CONF_EXCLUDE_DEVICES, + CONF_EXTENDED_ENTITY_DISCOVERY, + CONF_INCLUDE_DEVICES, + CONF_OAUTH, + CONF_OTPSECRET, + CONF_PUBLIC_URL, + CONF_QUEUE_DELAY, + CONF_SCAN_INTERVAL, + DATA_ALEXAMEDIA, + DATA_LISTENER, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_PUBLIC_URL, + DEFAULT_QUEUE_DELAY, + DEFAULT_SCAN_INTERVAL, + DEPENDENT_ALEXA_COMPONENTS, + DOMAIN, + HTTP2_ERROR_THRESHOLD, + ISSUE_URL, + LAST_CALLED_429_BACKOFF_INITIAL_S, + LAST_CALLED_429_BACKOFF_MAX_S, + LAST_CALLED_COALESCE_WINDOW_MS, + LAST_CALLED_CONN_BACKOFF_S, + LAST_CALLED_DEBOUNCE_S, + LAST_CALLED_ITEMS, + LAST_CALLED_LOGIN_BACKOFF_S, + LAST_CALLED_LOOKBACK_MS, + LAST_CALLED_RETRY_DELAY_S, + LAST_CALLED_RETRY_LIMIT, + LAST_CALLED_STALE_FUDGE_MS, + LAST_CALLED_SUCCESS_PACE_S, + LAST_PING_MAX_AGE_SECONDS, + LAST_PUSH_INACTIVITY_SECONDS, + MIN_TIME_BETWEEN_FORCED_SCANS, + MIN_TIME_BETWEEN_SCANS, + NOTIFICATION_COOLDOWN, + NOTIFY_REFRESH_BACKOFF, + NOTIFY_REFRESH_MAX_RETRIES, + SCAN_INTERVAL, + STARTUP_MESSAGE, +) +from .coordinator import AlexaMediaCoordinator +from .exceptions import TimeoutException +from .helpers import ( + _catch_login_errors, + _existing_serials, + alarm_just_dismissed, + calculate_uuid, + hide_email, + report_relogin_required, + safe_get, +) +from .metrics import AlexaMetrics, get_metrics +from .notify import async_unload_entry as notify_async_unload_entry +from .runtime_data import AlexaRuntimeData +from .services import AlexaMediaServices + +_LOGGER = logging.getLogger(__name__) + + +ACCOUNT_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_INCLUDE_DEVICES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EXCLUDE_DEVICES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_QUEUE_DELAY, default=DEFAULT_QUEUE_DELAY): cv.positive_float, + vol.Optional(CONF_EXTENDED_ENTITY_DISCOVERY, default=False): cv.boolean, + vol.Optional(CONF_DEBUG, default=False): cv.boolean, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_ACCOUNTS): vol.All( + cv.ensure_list, [ACCOUNT_CONFIG_SCHEMA] + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def _valid_voice_summary(summary: object) -> bool: + """Return True if summary looks like a real spoken utterance.""" + if not isinstance(summary, str): + return False + summary = summary.strip() + return bool(summary) and any(ch.isalnum() for ch in summary) + + +def _queue_last_called_activity( + account: dict, + *, + device_serial: str, + customer_id: str | None, + activity_ts: int | None, + command: str, +) -> None: + """Queue or refresh a last_called activity candidate keyed by device/customer.""" + if not device_serial: + return + + try: + ts = int(activity_ts) if activity_ts is not None else 0 + except (TypeError, ValueError): + ts = 0 + + queue: list[dict] = account.setdefault("last_called_activity_queue", []) + + for item in queue: + if ( + item.get("serial") == device_serial + and item.get("customer_id") == customer_id + ): + # Keep earliest timestamp like alexa-remote does for the queued burst. + prev_ts = int(item.get("activity_ts") or 0) + if ts and (prev_ts == 0 or ts < prev_ts): + item["activity_ts"] = ts + item["command"] = command + return + + queue.append( + { + "serial": device_serial, + "customer_id": customer_id, + "activity_ts": ts, + "command": command, + } + ) + + +def _snapshot_last_called_activity_queue(account: dict) -> list[dict]: + """Return a shallow snapshot of queued activity entries.""" + queue = account.get("last_called_activity_queue") or [] + return [dict(item) for item in queue if isinstance(item, dict)] + + +def _remove_last_called_activity_queue_entries( + account: dict, resolved_keys: set[tuple[str, str | None]] +) -> None: + """Remove resolved queue entries by (serial, customer_id).""" + queue = account.get("last_called_activity_queue") or [] + account["last_called_activity_queue"] = [ + item + for item in queue + if ( + item.get("serial"), + item.get("customer_id"), + ) + not in resolved_keys + ] + + +def _valid_utterance_type(record: dict) -> bool: + """Filter utterance types similar to Node-RED/alexa-remote logic.""" + utterance_type = record.get("utteranceType") + if utterance_type in { + "DEVICE_ARBITRATION", + "ASR_TIMEOUT", + "WAKE_WORD_ONLY", + }: + return False + return True + + +def _push_healthy(account: dict) -> bool: + """Return True if HTTP2 push is likely usable (enough to skip polling last_called).""" + http2 = account.get("http2") + if not http2: + return False + + # Hard negative: the underlying transport is closed. + client = getattr(http2, "client", None) + if client is not None and getattr(client, "is_closed", False): + return False + + # If alexapy has already driven error count to "give up", treat as down. + if int(account.get("http2error") or 0) >= HTTP2_ERROR_THRESHOLD: + return False + + last_push = float(account.get("last_push_activity") or 0.0) + if last_push and (time.time() - last_push) > LAST_PUSH_INACTIVITY_SECONDS: + return False + + # If we have a recent ping, that's a strong positive. + last_ping_dt = getattr(http2, "_last_ping", None) # private, best-effort + if last_ping_dt: + try: + age = time.time() - last_ping_dt.timestamp() + # ping is ~299s; allow generous slack for scheduler jitter. + if age <= LAST_PING_MAX_AGE_SECONDS: + return True + # If ping is *very* stale, treat as suspicious but not definitive. + # Don't force False here unless you also have other negative signals. + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug("Could not evaluate http2 ping age: %s", exc) + + # Unknown state: object exists and client isn't closed -> assume usable. + return True + + +def _network_allowed(login_obj) -> bool: + if login_obj.close_requested: + return False + if login_obj.session.closed: + return False + if not login_obj.status.get("login_successful"): + return False + return True + + +def _entity_backed_device_identifiers(account_dict: dict) -> set[str]: + """Collect device identifier strings for devices that are backed by entities. + + Alexa Media Player historically prunes stale HA Device Registry entries by comparing + device identifiers against the current *media_player* serials. That works for Echoes, + but fails for entity-only devices (e.g., Amazon Indoor Air Quality Monitor) which have + no media_player entity. Those devices would get pruned unless we also consider the + identifiers referenced by entities we created. + """ + identifiers: set[str] = set() + + def _collect_from_device_info(device_info) -> None: + if not device_info: + return + try: + # dr.DeviceInfo + di_idents = getattr(device_info, "identifiers", None) + if di_idents: + for ident in di_idents: + if isinstance(ident, tuple) and len(ident) == 2: + identifiers.add(ident[1]) + return + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug("Could not extract identifiers from device_info: %s", exc) + # dict-style device_info + if isinstance(device_info, dict): + di_idents = device_info.get("identifiers") + if di_idents: + for ident in di_idents: + if isinstance(ident, tuple) and len(ident) == 2: + identifiers.add(ident[1]) + + # Recursively walk nested entity structures (dict/list/tuple/set) and collect any device_info found. + def _walk(obj) -> None: + if obj is None: + return + + # Entity-ish object + _collect_from_device_info(getattr(obj, "device_info", None)) + + if isinstance(obj, dict): + for v in obj.values(): + _walk(v) + elif isinstance(obj, (list, tuple, set)): + for v in obj: + _walk(v) + + _walk(account_dict.get("entities", {})) + return identifiers + + +def _entity_backed_serials(account: dict) -> set[str]: + """Return serials that exist only because we created entity-backed devices. + + These serials may not be discoverable via media player inventory, but should + still be considered 'current' so we don't prune/ignore them. + """ + serials: set[str] = set() + entities = account.get("entities") + if not isinstance(entities, dict): + return serials + + # Sensors are stored keyed by serial; this is where AIAQM lives. + sensors = entities.get("sensor") + if isinstance(sensors, dict): + serials.update(s for s in sensors.keys() if isinstance(s, str) and s) + + return serials + + +def _select_last_called_payload_from_records( + records: list[dict], + queue_snapshot: list[dict], + account: dict, + existing_serials_local: set[str], +) -> tuple[dict | None, set[tuple[str, str | None]]]: + """Select the best last_called payload from raw customer history records.""" + if not records or not queue_snapshot: + return None, set() + + watermark = int(account.get("last_called_customer_history_ts") or 0) + last_pushed_activity = account.get("last_called_last_pushed_activity") or {} + + queue_by_key = { + (item.get("serial"), item.get("customer_id")): item + for item in queue_snapshot + if item.get("serial") + } + + def _record_ts(record: dict) -> int: + try: + return int(record.get("creationTimestamp") or 0) + except (TypeError, ValueError): + return 0 + + sorted_records = sorted( + (r for r in records if isinstance(r, dict)), + key=_record_ts, + reverse=True, + ) + + for record in sorted_records: + serial = record.get("deviceSerialNumber") + if not serial or serial not in existing_serials_local: + continue + + if not _valid_utterance_type(record): + continue + + summary = ((record.get("description") or {}).get("summary") or "").strip() + if not _valid_voice_summary(summary): + continue + + try: + ts = int(record.get("creationTimestamp") or 0) + except (TypeError, ValueError): + continue + + if ts <= watermark: + continue + + if ts <= int(last_pushed_activity.get(serial) or 0): + continue + + for key, queued in queue_by_key.items(): + queued_serial, _queued_customer = key + queued_ts = int(queued.get("activity_ts") or 0) + + if serial != queued_serial: + continue + + # Future enhancement: customer/user matching. + # The push payload includes destinationUserId, but the current + # get_customer_history_records() helper does not expose a matching + # user identifier from the raw RVH records, so this check is currently + # ineffective. Leave in place in case the API helper is extended later. + # + # if queued_customer and record.get("customerId") not in (None, queued_customer): + # continue + + if queued_ts and ts < (queued_ts - LAST_CALLED_STALE_FUDGE_MS): + continue + + payload = { + "serialNumber": serial, + "timestamp": ts, + "summary": summary, + "response": (record.get("alexaResponse") or "").strip() or None, + } + return payload, {key} + + return None, set() + + +async def async_setup(hass, config): + """Set up the Alexa domain.""" + # Initialize metrics + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["metrics"] = AlexaMetrics(hass) + metrics = hass.data[DOMAIN]["metrics"] + metrics.start_boot_tracking() + + integration = await async_get_integration(hass, DOMAIN) + integration_name = integration.name or "" + _LOGGER.info( + STARTUP_MESSAGE.format( + name=integration_name, + ISSUE_URL=ISSUE_URL, + DOMAIN=DOMAIN, + version=integration.version, + alexapy_version=alexapy_version, + ) + ) + metrics.record_boot_stage("domain_setup") + if DOMAIN in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_configuration", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_configuration", + learn_more_url="https://github.com/alandtse/alexa_media_player/wiki/Configuration#configurationyaml", + ) + _LOGGER.error( + "YAML configuration of Alexa Media Player is no longer supported. " + "Please remove `alexa_media` from your configuration, " + "restart Home Assistant and use the UI to configure it instead. " + "Settings > Devices & services > Integrations > ADD INTEGRATION" + ) + return False + return True + + +def _store_and_dispatch_last_called( + hass: HomeAssistant, + email: str, + last_called: dict, + force: bool = False, +) -> None: + """Store last_called data and dispatch change event if needed. + + Shared helper used by both the closure-based update_last_called + and the module-level _async_update_last_called_global. + """ + accounts = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) + if email not in accounts: + _LOGGER.debug("%s: Account removed during update, skipping", hide_email(email)) + return + stored_data = accounts[email] + payload = dict(last_called) if isinstance(last_called, dict) else {} + + ts_raw = payload.get("timestamp") + try: + ts = int(ts_raw or 0) + except (TypeError, ValueError): + ts = 0 + + if 0 < ts < 10_000_000_000: + ts *= 1000 + if ts > 0: + payload["timestamp"] = ts + + prev = stored_data.get("last_called") + changed = prev != payload + + if ts > 0: + stored_data["last_called_customer_history_ts"] = max( + int(stored_data.get("last_called_customer_history_ts") or 0), + ts, + ) + + stored_data["last_called"] = payload + + if ( + force + or (prev is None and last_called is not None) + or (prev is not None and changed) + ): + _LOGGER.debug( + "%s: last_called changed", + hide_email(email), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"last_called_change": payload}, + ) + + +async def _async_update_last_called_global( + hass: HomeAssistant, + login_obj, + email: str, + last_called: dict | None = None, + force: bool = False, +) -> None: + """Update the last called device globally (standalone function). + + This version is defined at module level for use in background tasks. + It delegates storage/dispatch to _store_and_dispatch_last_called. + """ + if not _network_allowed(login_obj): + return + + accounts = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) + account = accounts.get(email) + + # If we're doing a "refresh" (no payload) and not forcing, prefer the probe worker + # whenever it's available, regardless of http2 connection. + if ( + (not isinstance(last_called, dict) or not last_called.get("summary")) + and not force + and account + ): + trigger = account.get("last_called_probe_trigger") + if callable(trigger): + trigger("GLOBAL_REFRESH", None) + return + + if not isinstance(last_called, dict) or not last_called.get("summary"): + try: + # Do not timebox this call; alexapy may back off/sleep on 429. + # Serialize RVH calls per account to avoid parallel rate-limited requests. + api_lock = None + if account: + api_lock = account.get("last_called_api_lock") + if api_lock is None: + last_called = await AlexaAPI.get_last_device_serial(login_obj) + else: + async with api_lock: + last_called = await AlexaAPI.get_last_device_serial(login_obj) + except asyncio.CancelledError: + # Task cancelled during unload/shutdown; propagate cancellation. + raise + except AlexapyTooManyRequestsError: + _LOGGER.debug( + "%s: Rate limited during last_called update; skipping", + hide_email(email), + ) + return + except AlexapyConnectionError as exc: + _LOGGER.debug( + "%s: Connection error during last_called update: %s", + hide_email(email), + exc, + ) + return + except AlexapyLoginError: + _LOGGER.debug( + "%s: Login error during last_called update", hide_email(email) + ) + report_relogin_required(hass, login_obj, email) + return + except TypeError: + _LOGGER.debug( + "%s: Error updating last_called: %s", + hide_email(email), + repr(last_called), + ) + return + if not isinstance(last_called, dict): + _LOGGER.debug( + "%s: Error updating last_called: unexpected response %s", + hide_email(email), + repr(last_called), + ) + return + + if not _valid_voice_summary(last_called.get("summary")): + _LOGGER.debug( + "%s: Ignoring last_called with invalid summary", + hide_email(email), + ) + return + + _LOGGER.debug( + "%s: Updated last_called: %s", hide_email(email), hide_serial(last_called) + ) + _store_and_dispatch_last_called(hass, email, last_called, force) + + +# @retry_async(limit=5, delay=5, catch_exceptions=True) +async def async_setup_entry(hass, config_entry): + """Set up Alexa Media Player as config entry. + + This function uses the new runtime_data pattern for type-safe data storage. + Legacy hass.data[DATA_ALEXAMEDIA] is maintained for backward compatibility + during the migration period. + """ + _boot_start = time.monotonic() + + async def close_alexa_media(event=None) -> None: + """Clean up Alexa connections.""" + _LOGGER.debug("Received shutdown request: %s", event) + if accounts := safe_get(hass.data, [DATA_ALEXAMEDIA, "accounts"], {}): + for email, _ in accounts.items(): + await close_connections(hass, email) + + async def complete_startup(event=None) -> None: + # pylint: disable=unused-argument + """Run final tasks after startup.""" + _LOGGER.debug("Completing remaining startup tasks.") + await asyncio.sleep(10) + if hass.data[DATA_ALEXAMEDIA].get("notify_service"): + notify = hass.data[DATA_ALEXAMEDIA].get("notify_service") + _LOGGER.debug("Refreshing notify targets") + await notify.async_register_services() + + async def relogin(event=None) -> None: + """Relogin to Alexa.""" + if hide_email(email) == event.data.get("email"): + _LOGGER.debug("%s: Received relogin request: %s", hide_email(email), event) + login_obj: AlexaLogin = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "login_obj" + ) + uuid = (await calculate_uuid(hass, email, url))["uuid"] + if login_obj is None: + login_obj = AlexaLogin( + url=url, + email=email, + password=password, + outputpath=hass.config.path, + debug=account.get(CONF_DEBUG), + otp_secret=account.get(CONF_OTPSECRET, ""), + oauth=account.get(CONF_OAUTH, {}), + uuid=uuid, + oauth_login=True, + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login_obj + else: + login_obj.oauth_login = True + await login_obj.reset() + # await login_obj.login() + if await test_login_status(hass, config_entry, login_obj): + await setup_alexa(hass, config_entry, login_obj) + + async def login_success(event=None) -> None: + """Relogin to Alexa.""" + if hide_email(email) == event.data.get("email"): + _LOGGER.debug("Received Login success: %s", event) + login_obj: AlexaLogin = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "login_obj" + ) + await setup_alexa(hass, config_entry, login_obj) + + hass.data.setdefault(DATA_ALEXAMEDIA, {}) + hass.data[DATA_ALEXAMEDIA].setdefault("accounts", {}) + hass.data[DATA_ALEXAMEDIA].setdefault("config_flows", {}) + hass.data[DATA_ALEXAMEDIA].setdefault("notify_service", None) + account = config_entry.data + email = account.get(CONF_EMAIL) + password = account.get(CONF_PASSWORD) + url = account.get(CONF_URL) + hass.data[DATA_ALEXAMEDIA]["accounts"].setdefault( + email, + { + "coordinator": None, + "config_entry": config_entry, + "setup_alexa": setup_alexa, + "devices": { + "media_player": {}, + "switch": {}, + "guard": [], + "light": [], + "binary_sensor": [], + "temperature": [], + "smart_switch": [], + }, + "entities": { + "media_player": {}, + "switch": {}, + "sensor": {}, + "light": [], + "binary_sensor": [], + "alarm_control_panel": {}, + "smart_switch": [], + }, + "excluded": {}, + "new_devices": True, + "http2_lastattempt": 0, + "http2error": 0, + "http2_commands": {}, + "http2_activity": {"serials": {}, "refreshed": {}}, + "http2": None, + "auth_info": None, + "second_account_index": 0, + "should_get_network": True, + "first_run": True, + "notifications": {}, # already used for the raw notifications dict + "notifications_pending": set(), # doppler serials that need a refresh + "notifications_refresh_task": None, # running task or None + "notifications_retry_count": 0, # simple backoff counter + "options": { + CONF_INCLUDE_DEVICES: config_entry.data.get(CONF_INCLUDE_DEVICES, ""), + CONF_EXCLUDE_DEVICES: config_entry.data.get(CONF_EXCLUDE_DEVICES, ""), + CONF_QUEUE_DELAY: config_entry.data.get( + CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY + ), + CONF_SCAN_INTERVAL: config_entry.data.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + CONF_PUBLIC_URL: config_entry.data.get( + CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL + ), + CONF_EXTENDED_ENTITY_DISCOVERY: config_entry.data.get( + CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY + ), + CONF_DEBUG: config_entry.data.get(CONF_DEBUG, False), + }, + DATA_LISTENER: [config_entry.add_update_listener(update_listener)], + }, + ) + uuid_dict = await calculate_uuid(hass, email, url) + uuid = uuid_dict["uuid"] + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["second_account_index"] = uuid_dict[ + "index" + ] + login: AlexaLogin = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "login_obj", + AlexaLogin( + url=url, + email=email, + password=password, + outputpath=hass.config.path, + debug=account.get(CONF_DEBUG), + otp_secret=account.get(CONF_OTPSECRET, ""), + oauth=account.get(CONF_OAUTH, {}), + uuid=uuid, + oauth_login=True, + ), + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["last_push_activity"] = 0 + + # Create runtime_data for optimized architecture + # This provides type-safe access to account data + if not hasattr(config_entry, "runtime_data") or config_entry.runtime_data is None: + config_entry.runtime_data = AlexaRuntimeData( + login_obj=login, + config_entry=config_entry, + second_account_index=uuid_dict["index"], + ) + if not hass.data[DATA_ALEXAMEDIA]["accounts"][email]["second_account_index"]: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_alexa_media) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, complete_startup) + hass.bus.async_listen("alexa_media_relogin_required", relogin) + hass.bus.async_listen("alexa_media_relogin_success", login_success) + try: + _t = time.monotonic() + cookies = await login.load_cookie() + cookie_login_ok = False + if cookies: + try: + if login._session is None or getattr(login._session, "closed", False): + login._create_session(True) + async with login._session.get( + "https://alexa.amazon.com/api/bootstrap", + cookies=cookies, + ssl=login._ssl, + allow_redirects=False, + ) as response: + if response.status == 200: + data = loads(await response.text()) + auth = (data or {}).get("authentication") or {} + customer_email = (auth.get("customerEmail") or "").lower() + if ( + auth.get("authenticated") + and customer_email == email.lower() + ): + _LOGGER.debug( + "[BOOT] Cookie auth confirmed via /api/bootstrap" + ) + login.status["login_successful"] = True + login.customer_id = auth.get("customerId") + login.stats["login_timestamp"] = datetime.now() + login.stats["api_calls"] = 0 + await login.check_domain() + await login.finalize_login() + cookie_login_ok = True + except (JSONDecodeError, ValueError, aiohttp.ClientError) as ex: + _LOGGER.debug("[BOOT] Bootstrap cookie auth check failed: %s", ex) + if not cookie_login_ok: + await login.login(cookies=cookies) + _LOGGER.debug("[BOOT] login completed in %.2fs", time.monotonic() - _t) + _t = time.monotonic() + if await test_login_status(hass, config_entry, login): + _LOGGER.debug("[BOOT] test_login_status in %.2fs", time.monotonic() - _t) + _t = time.monotonic() + await setup_alexa(hass, config_entry, login) + _LOGGER.debug( + "[BOOT] setup_entry total: %.2fs", time.monotonic() - _boot_start + ) + return True + return False + except AlexapyConnectionError as err: + raise ConfigEntryNotReady(str(err) or "Connection Error during login") from err + + +async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): + # pylint: disable=too-many-statements,too-many-locals + """Set up a alexa api based on host parameter.""" + + debug = config_entry.data.get(CONF_DEBUG, False) + + # Record metrics + metrics = get_metrics(hass) + email = login_obj.email + if metrics: + metrics.record_boot_stage(f"setup_alexa_start_{hide_email(email)}") + + # Initialize throttling state and lock + dnd_update_lock = asyncio.Lock() + last_dnd_update_times: dict[str, datetime] = {} + pending_dnd_updates: dict[str, bool] = {} + scheduled_dnd_tasks: dict[str, asyncio.Task] = {} + + async def async_update_data() -> Optional[AlexaEntityData]: + # noqa pylint: disable=too-many-branches + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + + This will ping Alexa API to identify all devices, bluetooth, and the last + called device. + + If any guards, sensors, switches or lights are configured, their current state will be acquired. + This data is returned directly so that it is available on the coordinator. + + This will add new devices and services when discovered. By default this + runs every SCAN_INTERVAL seconds unless another method calls it. if + push is connected, it will increase the delay 10-fold between updates. + While throttled at MIN_TIME_BETWEEN_SCANS, care should be taken to + reduce the number of runs to avoid flooding. Slow changing states + should be checked here instead of in spawned components like + media_player since this object is one per account. + Each AlexaAPI call generally results in two webpage requests. + """ + email = config.get(CONF_EMAIL) + accounts = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) + account = accounts.get(email) + if not account: + return None + + login_obj = account.get("login_obj") + if not login_obj or not _network_allowed(login_obj): + return None + account = hass.data[DATA_ALEXAMEDIA]["accounts"][email] + existing_serials = set(_existing_serials(hass, login_obj)) + existing_serials |= _entity_backed_serials(account) + existing_entities = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "media_player" + ].values() + auth_info = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("auth_info") + new_devices = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] + extended_entity_discovery = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "options" + ].get(CONF_EXTENDED_ENTITY_DISCOVERY) + should_get_network = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "should_get_network" + ] + first_run = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["first_run"] + devices = {} + bluetooth = {} + preferences = {} + dnd = {} + entity_state = {} + + # Try to get cached data for faster boot + entry_id = config_entry.entry_id if config_entry else "" + cache_key_prefix = f"{email}_{entry_id}" + cached_devices = None + _used_cached_devices = False + if metrics: + cached_devices = metrics.api_cache.get(f"{cache_key_prefix}_devices") + + if cached_devices and not new_devices: + _LOGGER.debug("%s: Using cached devices data", hide_email(email)) + # NOTE: DataCache returns direct references. We intentionally enrich device dicts + # in-place each refresh cycle (bluetooth_state/locale/dnd/etc.). + devices = cached_devices + _used_cached_devices = True + # Still need fresh bluetooth, preferences, and DND + tasks = [ + AlexaAPI.get_bluetooth(login_obj), + AlexaAPI.get_device_preferences(login_obj), + AlexaAPI.get_dnd_state(login_obj), + ] + else: + tasks = [ + AlexaAPI.get_devices(login_obj), + AlexaAPI.get_bluetooth(login_obj), + AlexaAPI.get_device_preferences(login_obj), + AlexaAPI.get_dnd_state(login_obj), + ] + if new_devices: + tasks.append(AlexaAPI.get_authentication(login_obj)) + + entities_to_monitor = set() + + # Temperature sensors (stored as entities["sensor"][serial]["Temperature"] = sensor) + for per_serial in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "sensor" + ].values(): + if not isinstance(per_serial, dict): + continue + + temp = per_serial.get("Temperature") + if temp and temp.enabled: + entities_to_monitor.add(temp.alexa_entity_id) + + # Air Quality sensors: + # entities["sensor"][serial]["Air_Quality"][unique_id] = sensor + airq = per_serial.get("Air_Quality") + if isinstance(airq, dict): + for aq_sensor in airq.values(): + if aq_sensor and aq_sensor.enabled: + entities_to_monitor.add(aq_sensor.alexa_entity_id) + elif airq and getattr(airq, "enabled", False): + # Backwards compat if some installs still have a single sensor stored + entities_to_monitor.add(airq.alexa_entity_id) + + for light in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"].get( + "light", [] + ): + if light.enabled: + entities_to_monitor.add(light.alexa_entity_id) + + for binary_sensor in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "entities" + ].get("binary_sensor", []): + if binary_sensor.enabled: + entities_to_monitor.add(binary_sensor.alexa_entity_id) + + for guard in ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"] + .get("alarm_control_panel", {}) + .values() + ): + if guard.enabled: + entities_to_monitor.add(guard.unique_id) + + for smart_switch in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "entities" + ].get("smart_switch", []): + if smart_switch.enabled: + entities_to_monitor.add(smart_switch.alexa_entity_id) + + if entities_to_monitor: + tasks.append(get_entity_data(login_obj, list(entities_to_monitor))) + + if should_get_network: + tasks.append(AlexaAPI.get_network_details(login_obj)) + + optional_task_results = [] + try: + if tasks: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + # Increase timeout from 30s to 45s to permit + # get_network_details() retries which could up to 30s. + async with async_timeout.timeout(45): + start_fetch = time.monotonic() + if _used_cached_devices: + ( + bluetooth, + preferences, + dnd, + *optional_task_results, + ) = await asyncio.gather(*tasks) + else: + ( + devices, + bluetooth, + preferences, + dnd, + *optional_task_results, + ) = await asyncio.gather(*tasks) + + fetch_time = time.monotonic() - start_fetch + _LOGGER.debug( + "[BOOT] API fetch (%d tasks, cached=%s) in %.2fs", + len(tasks) if tasks else 0, + _used_cached_devices, + fetch_time, + ) + # Record API call metrics + if metrics: + metrics.record_api_call("initial_fetch", fetch_time) + # Cache the devices for faster next boot (only freshly fetched) + if not _used_cached_devices: + metrics.api_cache.cache_set( + f"{cache_key_prefix}_devices", devices + ) + + _t_post = time.monotonic() + if should_get_network and optional_task_results: + # First run is a special case. Get the state of all entities(including disabled) + # This ensures all entities have state during startup without needing to request coordinator refresh + + _LOGGER.info( + "%s: Network Discovery: Checking", hide_email(email) + ) + api_devices = optional_task_results.pop() + if not api_devices: + _LOGGER.warning( + "%s: Network Discovery: AlexaAPI returned an unexpected response. Retrying on next polling cycle", + hide_email(email), + ) + else: + _LOGGER.debug( + "%s: Network Discovery: Success, processing response", + hide_email(email), + ) + # Only process this once after success + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "should_get_network" + ] = False + + # Discard the entities_to_monitor results since we now have full network details + if entities_to_monitor and optional_task_results: + optional_task_results.pop() + entities_to_monitor.clear() + + alexa_entities = parse_alexa_entities( + api_devices, + debug=hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "options" + ].get(CONF_DEBUG, False), + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"].update( + alexa_entities + ) + + _entities_to_monitor = set() + for type_of_entity, entities in alexa_entities.items(): + if ( + type_of_entity + in {"guard", "temperature", "air_quality", "aiaqm"} + or extended_entity_discovery + ): + for entity in entities: + _entities_to_monitor.add(entity.get("id")) + _LOGGER.debug("Monitoring: %s", entity.get("name")) + _LOGGER.debug( + "%s: Network Discovery: %s entities will be monitored", + hide_email(email), + len(list(_entities_to_monitor)), + ) + # Use shorter timeout for entity data to avoid blocking + _t_ed = time.monotonic() + try: + entity_state = await asyncio.wait_for( + get_entity_data(login_obj, list(_entities_to_monitor)), + timeout=10.0, + ) + except asyncio.TimeoutError: + _LOGGER.warning( + "%s: get_entity_data timed out after 10s, " + "entity states will be fetched on next cycle", + hide_email(email), + ) + _LOGGER.debug( + "[BOOT] get_entity_data (network) in %.2fs", + time.monotonic() - _t_ed, + ) + + if entities_to_monitor and optional_task_results: + entity_state = optional_task_results.pop() + _LOGGER.debug( + "%s: Processing %s entities to monitor", + hide_email(email), + len(list(entities_to_monitor)), + ) + + if new_devices and optional_task_results: + auth_info = optional_task_results.pop() + _LOGGER.debug( + "%s: Found %s devices, %s bluetooth", + hide_email(email), + len(devices) if devices is not None else "", + ( + len(bluetooth.get("bluetoothStates", [])) + if bluetooth is not None + else "" + ), + ) + + # Process notifications in background to avoid blocking boot + # (process_notifications has a 4s sleep + API call) + _LOGGER.debug( + "[BOOT] post-fetch processing in %.2fs", time.monotonic() - _t_post + ) + + existing_notif_task = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "notifications_init_task" + ) + if existing_notif_task and not existing_notif_task.done(): + _LOGGER.debug( + "%s: Notifications background task already running, skipping", + hide_email(email), + ) + else: + + async def _bg_process_notifications(): + try: + await process_notifications(login_obj) + except ( + AlexapyConnectionError, + AlexapyLoginError, + asyncio.TimeoutError, + JSONDecodeError, + ): + _LOGGER.debug( + "%s: Background notifications failed, retrying once", + hide_email(email), + ) + try: + await asyncio.sleep(5) + await process_notifications(login_obj) + except ( + AlexapyConnectionError, + AlexapyLoginError, + asyncio.TimeoutError, + JSONDecodeError, + ): + _LOGGER.debug( + "%s: Background notifications retry failed", + hide_email(email), + ) + + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "notifications_init_task" + ] = hass.async_create_background_task( + _bg_process_notifications(), + f"{DOMAIN}_notifications_init", + ) + + except (AlexapyLoginError, JSONDecodeError): + _LOGGER.debug( + "%s: Alexa API disconnected; attempting to relogin : status %s", + hide_email(email), + login_obj.status, + ) + if login_obj.status: + hass.bus.async_fire( + "alexa_media_relogin_required", + event_data={"email": hide_email(email), "url": login_obj.url}, + ) + return None + except asyncio.CancelledError: + # Task cancelled during unload/shutdown; propagate cancellation. + raise + + _t_proc = time.monotonic() + new_alexa_clients = [] # list of newly discovered device names + exclude_filter = [] + include_filter = [] + + for device in devices: + serial = device["serialNumber"] + dev_name = device["accountName"] + if include and dev_name not in include: + include_filter.append(dev_name) + if "appDeviceList" in device: + for app in device["appDeviceList"]: + ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["excluded"][ + app["serialNumber"] + ] + ) = device + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["excluded"][ + serial + ] = device + continue + if exclude and dev_name in exclude: + exclude_filter.append(dev_name) + if "appDeviceList" in device: + for app in device["appDeviceList"]: + ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["excluded"][ + app["serialNumber"] + ] + ) = device + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["excluded"][ + serial + ] = device + continue + + if ( + dev_name not in include_filter + and device.get("capabilities") + and not any( + x in device["capabilities"] + for x in ["MUSIC_SKILL", "TIMERS_AND_ALARMS", "REMINDERS"] + ) + ): + # skip devices without music or notification skill + _LOGGER.debug("Excluding %s for lacking capability", dev_name) + continue + + if bluetooth is not None and "bluetoothStates" in bluetooth: + for b_state in bluetooth["bluetoothStates"]: + if serial == b_state["deviceSerialNumber"]: + device["bluetooth_state"] = b_state + break + + if preferences is not None and "devicePreferences" in preferences: + for dev in preferences["devicePreferences"]: + if dev["deviceSerialNumber"] == serial: + device["locale"] = dev["locale"] + device["timeZoneId"] = dev["timeZoneId"] + _LOGGER.debug( + "%s: Locale %s timezone %s", + dev_name, + device["locale"], + device["timeZoneId"], + ) + break + + if dnd is not None and "doNotDisturbDeviceStatusList" in dnd: + for dev in dnd["doNotDisturbDeviceStatusList"]: + if dev["deviceSerialNumber"] == serial: + device["dnd"] = dev["enabled"] + _LOGGER.debug("%s: DND %s", dev_name, device["dnd"]) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ + "switch" + ].setdefault(serial, {"dnd": True}) + break + + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["auth_info"] = device[ + "auth_info" + ] = auth_info + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"]["media_player"][ + serial + ] = device + + if serial not in existing_serials: + new_alexa_clients.append(dev_name) + elif ( + serial in existing_serials + and hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "media_player" + ].get(serial) + and hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "media_player" + ] + .get(serial) + .enabled + ): + await ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "media_player" + ] + .get(serial) + .refresh(device, skip_api=True) + ) + _LOGGER.debug( + "%s: Existing: %s New: %s;" + " Filtered out by not being in include: %s " + "or in exclude: %s", + hide_email(email), + list(existing_entities), + new_alexa_clients, + include_filter, + exclude_filter, + ) + + _LOGGER.debug("[BOOT] device processing in %.2fs", time.monotonic() - _t_proc) + + if new_alexa_clients: + cleaned_config = config.copy() + cleaned_config.pop(CONF_PASSWORD, None) + # CONF_PASSWORD contains sensitive info which is no longer needed + # Load multiple platforms in parallel using async_forward_entry_setups + _LOGGER.debug("Loading platforms: %s", ", ".join(ALEXA_COMPONENTS)) + try: + _t = time.monotonic() + await hass.config_entries.async_forward_entry_setups( + config_entry, ALEXA_COMPONENTS + ) + _LOGGER.debug("[BOOT] platform loading in %.2fs", time.monotonic() - _t) + if metrics: + metrics.record_boot_stage(f"platforms_loaded_{hide_email(email)}") + except (asyncio.TimeoutError, TimeoutException) as ex: + _LOGGER.error(f"Error while loading platforms: {ex}") + raise ConfigEntryNotReady( + f"Timeout while loading platforms: {ex}" + ) from ex + + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = False + # prune stale devices + device_registry = dr.async_get(hass) + entity_backed_ids = _entity_backed_device_identifiers( + hass.data[DATA_ALEXAMEDIA]["accounts"][email] + ) + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + for _, identifier in device_entry.identifiers: + if ( + identifier + in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ + "media_player" + ] + or identifier + in map( + lambda x: slugify(f"{x}_{email}"), + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ + "media_player" + ].keys(), + ) + or identifier in entity_backed_ids + ): + break + else: + device_registry.async_remove_device(device_entry.id) + _LOGGER.debug( + "%s: Removing stale device %s", + hide_email(email), + device_entry.name, + ) + + await login_obj.save_cookiefile() + if login_obj.access_token: + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + CONF_OAUTH: { + "access_token": login_obj.access_token, + "refresh_token": login_obj.refresh_token, + "expires_in": login_obj.expires_in, + "mac_dms": login_obj.mac_dms, + "code_verifier": login_obj.code_verifier, + "authorization_code": login_obj.authorization_code, + }, + }, + ) + + if first_run or not _push_healthy(account): + if _network_allowed(login_obj): + trigger = account.get("last_called_probe_trigger") + if callable(trigger): + trigger("POLL_REFRESH", None) + else: + # fallback if probe not initialized for some reason + hass.async_create_background_task( + _async_update_last_called_global(hass, login_obj, email), + f"{DOMAIN}_last_called_poll_{hide_email(email)}", + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["first_run"] = False + + return entity_state + + @_catch_login_errors + async def process_notifications(login_obj, raw_notifications=None) -> bool: + """Process raw notifications json. + + Returns True if notifications were updated, False if we skipped + (e.g. due to cooldown or alexapy returned None). + """ + email: str = login_obj.email + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][email] + + if raw_notifications is None: + now = time.time() + last = account_dict.get("last_notif_poll", 0.0) + delta = now - last + + if delta < NOTIFICATION_COOLDOWN: + _LOGGER.debug( + "%s: Skipping get_notifications; last poll %.1fs ago " + "(cooldown %ss).", + hide_email(email), + delta, + NOTIFICATION_COOLDOWN, + ) + return False + + account_dict["last_notif_poll"] = now + + # Small delay to let Alexa settle if we're polling explicitly + await asyncio.sleep(4) + raw_notifications = await AlexaAPI.get_notifications(login_obj) + + previous = account_dict.get("notifications", {}) + notifications = {"process_timestamp": dt.utcnow()} + + if raw_notifications is not None: + for notification in raw_notifications: + n_dev_id = notification.get("deviceSerialNumber") + if n_dev_id is None: + # skip notifications untied to a device for now + # https://github.com/alandtse/alexa_media_player/issues/633#issuecomment-610705651 + continue + n_type = notification.get("type") + if n_type is None: + continue + if n_type == "MusicAlarm": + n_type = "Alarm" + n_id = notification["notificationIndex"] + if n_type == "Alarm": + n_date = notification.get("originalDate") + n_time = notification.get("originalTime") + notification["date_time"] = ( + f"{n_date} {n_time}" if n_date and n_time else None + ) + previous_alarm = safe_get(previous, [n_dev_id, "Alarm", n_id], {}) + if previous_alarm and alarm_just_dismissed( + notification, + previous_alarm.get("status"), + previous_alarm.get("version"), + ): + hass.bus.async_fire( + "alexa_media_alarm_dismissal_event", + event_data={ + "device": {"id": n_dev_id}, + "event": notification, + }, + ) + + if n_dev_id not in notifications: + notifications[n_dev_id] = {} + if n_type not in notifications[n_dev_id]: + notifications[n_dev_id][n_type] = {} + notifications[n_dev_id][n_type][n_id] = notification + + account_dict["notifications"] = notifications + _LOGGER.debug( + "%s: Updated %s notifications for %s devices at %s", + hide_email(email), + len(raw_notifications) if raw_notifications is not None else 0, + len(notifications), + dt.as_local(account_dict["notifications"]["process_timestamp"]), + ) + # Notify sensors that the notifications snapshot has been refreshed + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"notifications_refreshed": True}, + ) + return True + + # --------------------------------------------------------------------- + # Near-real-time last_called probing (customer history), single worker + # Initialize ONCE per account (setup_alexa), not inside http2_handler. + # --------------------------------------------------------------------- + def _init_last_called_probe_worker(account: dict) -> None: + """Initialize per-account last_called probe worker trigger function.""" + account.setdefault("last_called_api_lock", asyncio.Lock()) + account.setdefault( + "last_called_customer_history_ts", 0 + ) # ms epoch last applied + account.setdefault("last_called_probe_backoff_s", 0.0) + account.setdefault("last_called_probe_event", asyncio.Event()) + account.setdefault("last_called_probe_next_allowed", 0.0) # monotonic seconds + account.setdefault("last_called_probe_task", None) + account.setdefault("last_called_probe_trigger_cmd", "") + account.setdefault("last_called_probe_trigger_serial", None) + account.setdefault("last_called_probe_trigger_ts", 0) # newest push ts (ms) + account.setdefault("last_called_probe_trigger", None) + account.setdefault("last_called_activity_queue", []) + account.setdefault("last_called_last_pushed_activity", {}) + account.setdefault("last_volumes", {}) + account.setdefault("last_equalizer", {}) + + if callable(account.get("last_called_probe_trigger")): + return + + async def _last_called_probe_worker() -> None: + """Single worker per account: debounce bursts, then quick-retry until history >= trigger_ts.""" + skip_debounce = False + try: + while True: + accounts = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) + account_live = accounts.get(email) + if not account_live: + return + + def _debug(msg, *args): + if debug: + _LOGGER.debug("%s: " + msg, hide_email(email), *args) + + login_live = account_live.get("login_obj") + if not login_live or not _network_allowed(login_live): + await asyncio.sleep(5) + continue + # Stop this worker if the login object has been torn down (entry + # reload/unload) so it cannot orphan onto a dead login and keep + # hammering the API across reloads. + if getattr(login_live, "close_requested", False) or ( + getattr(login_live, "session", None) is not None + and login_live.session.closed + ): + return + # Skip probing while the login is unhealthy: the request would be + # built with a missing csrf / None header and raise a serialization + # TypeError, and hammering the history API while logged-out can + # hinder re-auth. Poll quietly until the login recovers. + _status = getattr(login_live, "status", None) + if not (_status and _status.get("login_successful")): + await asyncio.sleep(15) + continue + # Bounded wait so that if the login is torn down while we are + # idle here, the next iteration's teardown guard exits promptly + # instead of parking on the event forever. + try: + await asyncio.wait_for( + account_live["last_called_probe_event"].wait(), + timeout=15, + ) + except asyncio.TimeoutError: + continue + account_live["last_called_probe_event"].clear() + + if not skip_debounce: + await asyncio.sleep( + LAST_CALLED_DEBOUNCE_S + + random.uniform(0.0, 0.05) # nosec B311 # noqa: S311 + ) + if account_live["last_called_probe_event"].is_set(): + continue + skip_debounce = False + + preempted = False + while True: + now = time.monotonic() + next_allowed = float( + account_live.get("last_called_probe_next_allowed", 0.0) + or 0.0 + ) + delay = max(0.0, next_allowed - now) + if delay <= 0: + break + try: + await asyncio.wait_for( + account_live["last_called_probe_event"].wait(), + timeout=delay, + ) + account_live["last_called_probe_event"].clear() + preempted = True + break + except asyncio.TimeoutError: + break + if preempted: + continue + + trigger_cmd = str( + account_live.get("last_called_probe_trigger_cmd") or "push" + ) + + for attempt in range(LAST_CALLED_RETRY_LIMIT + 1): + if account_live["last_called_probe_event"].is_set(): + break + + try: + async with account_live["last_called_api_lock"]: + queue_snapshot = _snapshot_last_called_activity_queue( + account_live + ) + if not queue_snapshot: + if trigger_cmd in ( + "GLOBAL_REFRESH", + "SERVICE_REFRESH", + "POLL_REFRESH", + ): + last_called = ( + await AlexaAPI.get_last_device_serial( + login_live, items=LAST_CALLED_ITEMS + ) + ) + if isinstance( + last_called, dict + ) and _valid_voice_summary( + last_called.get("summary") + ): + await update_last_called( + login_live, last_called + ) + account_live["last_called_probe_trigger_ts"] = 0 + break + + earliest_ts = min( + ( + int(item.get("activity_ts") or 0) + for item in queue_snapshot + if item.get("activity_ts") + ), + default=0, + ) + + rvh_window_ms = max( + LAST_CALLED_LOOKBACK_MS, 15 * 60 * 1000 + ) + start_time = ( + max(0, earliest_ts - rvh_window_ms) + if earliest_ts + else int((time.time() * 1000) - rvh_window_ms) + ) + end_time = int(time.time() * 1000) + rvh_window_ms + max_record_size = max( + LAST_CALLED_ITEMS, len(queue_snapshot) + 2 + ) + + try: + records = await AlexaAPI.get_customer_history_records( + login_live, + start_time=start_time, + end_time=end_time, + max_record_size=max_record_size, + ) + except TypeError as exc: + # A serialization TypeError (e.g. a None header key + # when the login is half-torn-down) is not transient + # within a session: back off and do NOT re-arm. The + # old behavior crash-looped ~every 11s, hammering the + # API. Wait for the next genuine push trigger instead. + backoff = max(LAST_CALLED_CONN_BACKOFF_S, 60.0) + account_live["last_called_probe_next_allowed"] = ( + time.monotonic() + backoff + ) + _LOGGER.warning( + "%s: last_called probe API TypeError (%s): %s; " + "backing off, not re-arming", + hide_email(email), + trigger_cmd, + exc, + ) + break + + if records is None: + _LOGGER.warning( + "%s: last_called probe API returned None (%s)", + hide_email(email), + trigger_cmd, + ) + records = [] + + # 🔎 DEBUG: inspect raw history result + _LOGGER.debug( + "%s: last_called probe retrieved %s history records", + hide_email(email), + len(records or []), + ) + if isinstance(records, list): + _debug( + "last_called probe raw records (%s): %s", + trigger_cmd, + [ + { + "summary": ( + item.get("description") or {} + ).get("summary"), + "response": item.get("alexaResponse"), + "serial": item.get("deviceSerialNumber"), + "ts": item.get("creationTimestamp"), + "utteranceType": item.get("utteranceType"), + } + for item in records[:10] + if isinstance(item, dict) + ], + ) + else: + _debug( + "last_called probe returned unexpected type (%s): %r", + trigger_cmd, + records, + ) + + except asyncio.CancelledError: + raise + except AlexapyTooManyRequestsError: + uk_floor = random.uniform( # noqa: S311 + 30.0, 63.0 + ) # nosec B311 + prev = float( + account_live.get("last_called_probe_backoff_s", 0.0) + or 0.0 + ) + backoff = ( + LAST_CALLED_429_BACKOFF_INITIAL_S + if prev <= 0.0 + else min(prev * 2.0, LAST_CALLED_429_BACKOFF_MAX_S) + ) + backoff = max(backoff, uk_floor) + jitter = random.uniform( # noqa: S311 + 0.0, min(5.0, backoff * 0.1) + ) # nosec B311 + + account_live["last_called_probe_backoff_s"] = backoff + account_live["last_called_probe_next_allowed"] = ( + time.monotonic() + backoff + jitter + ) + + _LOGGER.debug( + "%s: last_called probe rate-limited (%s); backing off %.1fs (%.1fs jitter) then self-retry", + hide_email(email), + trigger_cmd, + backoff, + jitter, + ) + skip_debounce = True + account_live["last_called_probe_event"].set() + break + except AlexapyLoginError: + account_live["last_called_probe_next_allowed"] = ( + time.monotonic() + LAST_CALLED_LOGIN_BACKOFF_S + ) + _LOGGER.debug( + "%s: last_called probe login error (%s); skipping", + hide_email(email), + trigger_cmd, + ) + report_relogin_required(hass, login_live, email) + break + except AlexapyConnectionError as exc: + account_live["last_called_probe_next_allowed"] = ( + time.monotonic() + LAST_CALLED_CONN_BACKOFF_S + ) + _LOGGER.debug( + "%s: last_called probe connection error (%s): %s", + hide_email(email), + trigger_cmd, + exc, + ) + break + + existing_serials_local = set( + _existing_serials(hass, login_live) + ) + existing_serials_local |= _entity_backed_serials(account_live) + + payload, resolved_keys = ( + _select_last_called_payload_from_records( + records, + queue_snapshot, + account_live, + existing_serials_local, + ) + ) + + _debug( + "last_called queue match (%s): queued=%s matched=%s resolved=%s", + trigger_cmd, + [ + { + "serial": item.get("serial"), + "customer_id": item.get("customer_id"), + "activity_ts": item.get("activity_ts"), + "command": item.get("command"), + } + for item in queue_snapshot + ], + ( + { + "serialNumber": payload.get("serialNumber"), + "timestamp": payload.get("timestamp"), + "summary": payload.get("summary"), + "response": payload.get("response"), + } + if payload + else None + ), + sorted(resolved_keys), + ) + + if not payload: + if attempt < LAST_CALLED_RETRY_LIMIT: + await asyncio.sleep(LAST_CALLED_RETRY_DELAY_S) + continue + + if trigger_cmd in ( + "GLOBAL_REFRESH", + "SERVICE_REFRESH", + "POLL_REFRESH", + ): + _LOGGER.debug( + "%s: queued activity unresolved after retries; falling back to direct refresh", + hide_email(email), + ) + + unresolved_keys = { + (item.get("serial"), item.get("customer_id")) + for item in queue_snapshot + if item.get("serial") + } + _remove_last_called_activity_queue_entries( + account_live, unresolved_keys + ) + + try: + async with account_live["last_called_api_lock"]: + last_called = ( + await AlexaAPI.get_last_device_serial( + login_live, + items=LAST_CALLED_ITEMS, + ) + ) + if isinstance( + last_called, dict + ) and _valid_voice_summary( + last_called.get("summary") + ): + await update_last_called( + login_live, last_called + ) + except asyncio.CancelledError: + raise + except AlexapyLoginError as exc: + _LOGGER.debug( + "%s: fallback last_called refresh failed (%s): %s", + hide_email(email), + trigger_cmd, + exc, + ) + report_relogin_required(hass, login_live, email) + except ( + AlexapyTooManyRequestsError, + AlexapyConnectionError, + ) as exc: + _LOGGER.debug( + "%s: fallback last_called refresh failed (%s): %s", + hide_email(email), + trigger_cmd, + exc, + ) + account_live["last_called_probe_trigger_ts"] = 0 + account_live["last_called_probe_event"].clear() + + break + + account_live["last_called_probe_backoff_s"] = 0.0 + account_live["last_called_probe_next_allowed"] = ( + time.monotonic() + + LAST_CALLED_SUCCESS_PACE_S + + random.uniform(0.0, 0.25) # nosec B311 # noqa: S311 + ) + + trigger_serial = account_live.get( + "last_called_probe_trigger_serial" + ) + _LOGGER.debug( + "%s: Updating last_called via %s (triggered by %s): %s", + hide_email(email), + trigger_cmd, + ( + hide_serial(trigger_serial) + if trigger_serial + else "unknown" + ), + hide_serial(payload["serialNumber"]), + ) + + await update_last_called(login_live, payload) + account_live["last_called_last_pushed_activity"][ + payload["serialNumber"] + ] = payload["timestamp"] + _remove_last_called_activity_queue_entries( + account_live, resolved_keys + ) + account_live["last_called_probe_trigger_ts"] = 0 + account_live["last_called_probe_event"].clear() + break + except asyncio.CancelledError: + raise + + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "%s: last_called probe worker crashed", + hide_email(email), + ) + + def _trigger_last_called_probe( + trigger_command: str, trigger_ts_ms: int | None + ) -> None: + """Record newest trigger + wake worker. Does NOT cancel running worker.""" + accounts = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) + account_live = accounts.get(email) + if not account_live: + return + + if trigger_ts_ms is not None: + try: + ts = int(trigger_ts_ms) + except (TypeError, ValueError): + ts = 0 + + prev = int(account_live.get("last_called_probe_trigger_ts") or 0) + if ts > prev: + account_live["last_called_probe_trigger_ts"] = ts + account_live["last_called_probe_trigger_cmd"] = trigger_command + else: + # Manual refresh triggers clear any push watermark + if trigger_command in ( + "GLOBAL_REFRESH", + "SERVICE_REFRESH", + "POLL_REFRESH", + ): + account_live["last_called_probe_trigger_ts"] = 0 + account_live["last_called_probe_trigger_serial"] = None + account_live["last_called_probe_trigger_cmd"] = trigger_command + + task = account_live.get("last_called_probe_task") + if task is None or task.done(): + account_live["last_called_probe_task"] = ( + hass.async_create_background_task( + _last_called_probe_worker(), + name=f"{DOMAIN}_last_called_probe_{hide_email(email)}", + ) + ) + + account_live["last_called_probe_event"].set() + + # Store the trigger on the live account as well (so reload swaps don't strand it) + account["last_called_probe_trigger"] = _trigger_last_called_probe + + def _is_dnd_voice_toggle(last_called: dict) -> bool: + summary = " ".join(((last_called.get("summary") or "").strip().lower()).split()) + response = " ".join( + ((last_called.get("response") or "").strip().lower()).split() + ) + + # Normalize apostrophes (replace smart quotes with ASCII) + summary = summary.replace("\u2019", "'").replace("\u2018", "'") + response = response.replace("\u2019", "'").replace("\u2018", "'") + + return ( + "do not disturb" in summary + or "won't disturb you" in response + or "do not disturb is now off" in response + ) + + @_catch_login_errors + async def update_last_called(login_obj, last_called=None, force=False): + """Update the last called device for the login_obj. + + Stores the last_called in hass.data and fires an event to notify listeners. + Delegates storage/dispatch to the module-level _store_and_dispatch_last_called helper. + """ + if not isinstance(last_called, dict) or not last_called.get("summary"): + try: + # Serialize calls per account to avoid parallel rate-limited requests. + account = ( + hass.data.get(DATA_ALEXAMEDIA, {}) + .get("accounts", {}) + .get(email, {}) + ) + api_lock = account.get("last_called_api_lock") if account else None + + if api_lock is None: + last_called = await AlexaAPI.get_last_device_serial(login_obj) + else: + async with api_lock: + last_called = await AlexaAPI.get_last_device_serial(login_obj) + + except asyncio.CancelledError: + raise + + except AlexapyTooManyRequestsError: + _LOGGER.debug( + "%s: Rate limited during last_called update; skipping", + hide_email(email), + ) + return + + except AlexapyLoginError: + _LOGGER.debug( + "%s: Login error during last_called update", + hide_email(email), + ) + report_relogin_required(hass, login_obj, email) + return + + except AlexapyConnectionError as exc: + _LOGGER.debug( + "%s: Connection error during last_called update: %s", + hide_email(email), + exc, + ) + return + + except TypeError: + _LOGGER.debug( + "%s: Error updating last_called: %s", + hide_email(email), + repr(last_called), + ) + return + + if not isinstance(last_called, dict): + _LOGGER.debug( + "%s: Error updating last_called: unexpected response %s", + hide_email(email), + repr(last_called), + ) + return + + # Central voice-only gate + if not _valid_voice_summary(last_called.get("summary")): + _LOGGER.debug( + "%s: Ignoring last_called with invalid/non-voice summary: %s", + hide_email(email), + repr(last_called.get("summary")), + ) + return + + _LOGGER.debug( + "%s: Updated last_called: %s", hide_email(email), hide_serial(last_called) + ) + _store_and_dispatch_last_called(hass, email, last_called, force) + + if _is_dnd_voice_toggle(last_called): + _LOGGER.debug( + "%s: last_called indicates DND voice toggle", hide_email(email) + ) + await update_dnd_state(login_obj) + + @_catch_login_errors + async def update_bluetooth_state(login_obj, device_serial): + """Update the bluetooth state on ws bluetooth event.""" + bluetooth = await AlexaAPI.get_bluetooth(login_obj) + device = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ + "media_player" + ][device_serial] + + if bluetooth is not None and "bluetoothStates" in bluetooth: + for b_state in bluetooth["bluetoothStates"]: + if device_serial == b_state["deviceSerialNumber"]: + _LOGGER.debug( + "%s: setting value for: %s to %s", + hide_email(email), + hide_serial(device_serial), + hide_serial(b_state), + ) + device["bluetooth_state"] = b_state + return device["bluetooth_state"] + _LOGGER.debug( + "%s: get_bluetooth for: %s failed with %s", + hide_email(email), + hide_serial(device_serial), + hide_serial(bluetooth), + ) + return None + + async def schedule_update_dnd_state(email: str) -> None: + """Run one deferred DND refresh after the cooldown expires.""" + try: + while True: + async with dnd_update_lock: + if not pending_dnd_updates.get(email, False): + scheduled_dnd_tasks.pop(email, None) + return + + last_run = last_dnd_update_times.get(email) + now = datetime.utcnow() + + remaining = 0.0 + if last_run is not None: + elapsed = now - last_run + if elapsed < MIN_TIME_BETWEEN_SCANS: + remaining = ( + MIN_TIME_BETWEEN_SCANS - elapsed + ).total_seconds() + + if remaining > 0: + _LOGGER.debug( + "%s: Deferred DND update sleeping %.3fs until cooldown expires", + hide_email(email), + remaining, + ) + await asyncio.sleep(remaining) + + async with dnd_update_lock: + if not pending_dnd_updates.get(email, False): + scheduled_dnd_tasks.pop(email, None) + return + + last_run = last_dnd_update_times.get(email) + now = datetime.utcnow() + if ( + last_run is not None + and (now - last_run) < MIN_TIME_BETWEEN_SCANS + ): + # Another update snuck in or timing was slightly early; loop and re-evaluate. + continue + + pending_dnd_updates[email] = False + scheduled_dnd_tasks.pop(email, None) + + login_obj = ( + hass.data.get(DATA_ALEXAMEDIA, {}) + .get("accounts", {}) + .get(email, {}) + .get("login_obj") + ) + if not login_obj: + _LOGGER.debug( + "%s: Skipping scheduled forced DND update: login_obj missing", + hide_email(email), + ) + return + + _LOGGER.debug( + "%s: Executing scheduled forced DND update", + hide_email(email), + ) + await update_dnd_state(login_obj) + return + + except asyncio.CancelledError: + _LOGGER.debug("%s: Deferred DND update task cancelled", hide_email(email)) + raise + finally: + async with dnd_update_lock: + task = scheduled_dnd_tasks.get(email) + if task is asyncio.current_task(): + scheduled_dnd_tasks.pop(email, None) + + @_catch_login_errors + async def update_dnd_state(login_obj) -> None: + """Update the DND state on websocket DND combo event.""" + email = login_obj.email + now = datetime.utcnow() + + async with dnd_update_lock: + last_run = last_dnd_update_times.get(email) + + if last_run is not None and (now - last_run) < MIN_TIME_BETWEEN_SCANS: + pending_dnd_updates[email] = True + + if ( + email not in scheduled_dnd_tasks + or scheduled_dnd_tasks[email].done() + ): + _LOGGER.debug( + "%s: Throttling active; scheduling deferred DND update.", + hide_email(email), + ) + scheduled_dnd_tasks[email] = asyncio.create_task( + schedule_update_dnd_state(email) + ) + else: + _LOGGER.debug( + "%s: Throttling active; deferred DND update already scheduled.", + hide_email(email), + ) + return + + last_dnd_update_times[email] = now + + _LOGGER.debug("%s: Updating DND state", hide_email(email)) + try: + dnd = await AlexaAPI.get_dnd_state(login_obj) + except asyncio.TimeoutError: + _LOGGER.error( + "%s: Timeout occurred while fetching DND state", + hide_email(email), + ) + return + except Exception as err: # pylint: disable=broad-except + _LOGGER.error( + "%s: Unexpected error while fetching DND state: %s", + hide_email(email), + err, + ) + return + + if dnd is not None and "doNotDisturbDeviceStatusList" in dnd: + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"dnd_update": dnd["doNotDisturbDeviceStatusList"]}, + ) + return + + _LOGGER.debug("%s: get_dnd_state failed: dnd:%s", hide_email(email), dnd) + + def _schedule_notifications_refresh( + hass, + email: str, + device_serial: str | None = None, + reason: str = "", + ) -> None: + """Mark notifications as needing refresh and ensure worker task is running. + + device_serial is just for debug; we track a set of pending devices but + we always refresh the full notifications payload once. + """ + + account = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}).get(email) + if not account: + return + + if device_serial: + account["notifications_pending"].add(device_serial) + else: + # Special marker for "global" changes if you want one + account["notifications_pending"].add("*") + + if reason: + _LOGGER.debug( + "%s: Scheduling notifications refresh (reason=%s, pending=%s)", + hide_email(email), + reason, + account["notifications_pending"], + ) + + task = account.get("notifications_refresh_task") + if task is not None and not task.done(): + # Already have a running worker; it'll see the new pending set + return + + # Start new worker + account["notifications_refresh_task"] = hass.async_create_task( + _run_notifications_refresh(hass, email) + ) + + async def _run_notifications_refresh(hass, email: str) -> None: + """Worker task: refresh notifications for an account if pending. + + - Uses alexapy.AlexaAPI.get_notifications(login) + - Retries a few times if we only get None (cooldown/throttle) + - Clears notifications_pending when successful or when we give up + """ + account = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}).get(email) + if not account: + return + login = account.get("login_obj") + if not login: + return + + try: + retries = 0 + while ( + account.get("notifications_pending", set()) + and retries <= NOTIFY_REFRESH_MAX_RETRIES + ): + try: + data = await AlexaAPI.get_notifications(login) + except Exception as ex: + _LOGGER.warning( + "%s: get_notifications raised %s; treating as None. This may indicate an unexpected error.", + hide_email(email), + ex, + ) + data = None + + if data is not None: + # Success: update through the normal processing path + await process_notifications(login, raw_notifications=data) + account["notifications_retry_count"] = 0 + account["notifications_pending"].clear() + + _LOGGER.debug( + "%s: Refreshed notifications snapshot (pending cleared)", + hide_email(email), + ) + return + + # If we get here, alexapy side returned None (cooldown / throttle) + retries += 1 + account["notifications_retry_count"] = retries + + if not account["notifications_pending"]: + # Nothing to do anymore, bail early + break + + _LOGGER.debug( + "%s: Notifications refresh returned None (retry %s/%s); " + "pending=%s; sleeping %.1fs", + hide_email(email), + retries, + NOTIFY_REFRESH_MAX_RETRIES, + account["notifications_pending"], + NOTIFY_REFRESH_BACKOFF, + ) + await asyncio.sleep(NOTIFY_REFRESH_BACKOFF) + + # If we fall through, give up for now but leave pending set alone + if account["notifications_pending"]: + _LOGGER.debug( + "%s: Giving up notifications refresh after %s attempts; " + "still pending=%s", + hide_email(email), + retries, + account["notifications_pending"], + ) + + finally: + # Always clear the task pointer so future pushes can schedule again + account["notifications_refresh_task"] = None + + async def http2_connect() -> HTTP2EchoClient: + """Open HTTP2 Push connection. + + This will only attempt one login before failing. + """ + http2: Optional[HTTP2EchoClient] = None + email = login_obj.email + try: + if login_obj.session.closed: + _LOGGER.debug( + "%s: HTTP2 creation aborted. Session is closed.", + hide_email(email), + ) + return + http2 = HTTP2EchoClient( + login_obj, + msg_callback=http2_handler, + open_callback=http2_open_handler, + close_callback=http2_close_handler, + error_callback=http2_error_handler, + loop=hass.loop, + ) + _LOGGER.debug("%s: Starting HTTP2: %s", hide_email(email), http2) + await http2.async_run() + except AlexapyLoginError as exception_: + _LOGGER.debug( + "%s: Login Error detected from http2: %s", + hide_email(email), + exception_, + ) + hass.bus.async_fire( + "alexa_media_relogin_required", + event_data={"email": hide_email(email), "url": login_obj.url}, + ) + return + except BaseException as exception_: # pylint: disable=broad-except + _LOGGER.debug( + "%s: HTTP2 creation failed: %s", hide_email(email), exception_ + ) + return + _LOGGER.debug("%s: HTTP2 created: %s", hide_email(email), http2) + return http2 + + @callback + async def http2_handler(message_obj): + # pylint: disable=too-many-branches,too-many-statements + """Handle http2 push messages. + + This allows push notifications from Alexa to update last_called and media state. + """ + + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") + account = hass.data[DATA_ALEXAMEDIA]["accounts"][email] + + def _now_ms() -> int: + return int(time.time() * 1000) + + def simulate_activity( + device_serial: str, + customer_id: str | None, + trigger_command: str, + trigger_ts_ms: int | None, + ) -> None: + _queue_last_called_activity( + account, + device_serial=device_serial, + customer_id=customer_id, + activity_ts=trigger_ts_ms, + command=trigger_command, + ) + account["last_called_probe_trigger_serial"] = device_serial + trigger = account.get("last_called_probe_trigger") + if callable(trigger): + trigger(trigger_command, trigger_ts_ms) + + def _handle_volume_change_activity( + serial: str, + json_payload: dict, + trigger_ts_ms: int | None, + ) -> None: + """Mirror alexa-remote.ts PUSH_VOLUME_CHANGE -> simulateActivity() conditions.""" + last_volumes: dict = account["last_volumes"] + last_equalizer: dict = account["last_equalizer"] + + vol = json_payload.get("volumeSetting") + muted = json_payload.get("isMuted") + + prev = last_volumes.get(serial) + eq_prev = last_equalizer.get(serial) + + should_simulate = ( + eq_prev is not None + and abs(_now_ms() - int(eq_prev.get("updated", 0))) + < LAST_CALLED_COALESCE_WINDOW_MS + ) + + if should_simulate: + _LOGGER.debug( + "[_handle_volume_change_activity] Simulating activity", + ) + simulate_activity( + serial, + json_payload.get("destinationUserId"), + "PUSH_VOLUME_CHANGE", + trigger_ts_ms, + ) + else: + _LOGGER.debug( + "[_handle_volume_change_activity] Not simulating activity", + ) + + last_volumes[serial] = { + "volumeSetting": vol, + "isMuted": muted, + "updated": _now_ms(), + } + + def _handle_equalizer_change_activity( + serial: str, + json_payload: dict, + trigger_ts_ms: int | None, + ) -> None: + """Mirror alexa-remote.ts PUSH_EQUALIZER_STATE_CHANGE -> simulateActivity() conditions.""" + last_volumes: dict = account["last_volumes"] + last_equalizer: dict = account["last_equalizer"] + + bass = json_payload.get("bass") + treble = json_payload.get("treble") + midrange = json_payload.get("midrange") + + prev = last_equalizer.get(serial) + vol_prev = last_volumes.get(serial) + + should_simulate = ( + prev is not None + and prev.get("bass") == bass + and prev.get("treble") == treble + and prev.get("midrange") == midrange + ) or ( + vol_prev is not None + and abs(_now_ms() - int(vol_prev.get("updated", 0))) + < LAST_CALLED_COALESCE_WINDOW_MS + ) + + if should_simulate: + simulate_activity( + serial, + json_payload.get("destinationUserId"), + "PUSH_EQUALIZER_STATE_CHANGE", + trigger_ts_ms, + ) + + last_equalizer[serial] = { + "bass": bass, + "treble": treble, + "midrange": midrange, + "updated": _now_ms(), + } + + # --------------------------------------------------------------------- + # Main http2push parsing / dispatch + # --------------------------------------------------------------------- + updates = ( + message_obj.get("directive", {}) + .get("payload", {}) + .get("renderingUpdates", []) + ) + existing_serials = set(_existing_serials(hass, login_obj)) + existing_serials |= _entity_backed_serials(account) + for item in updates: + try: + resource = loads(item.get("resourceMetadata", "")) + except JSONDecodeError: + continue + + command = ( + resource["command"] + if isinstance(resource, dict) and "command" in resource + else None + ) + try: + json_payload = ( + loads(resource["payload"]) + if isinstance(resource, dict) and "payload" in resource + else None + ) + except (JSONDecodeError, TypeError): + _LOGGER.debug( + "%s: Skipping malformed push payload for command %s", + hide_email(email), + command, + ) + continue + seen_commands = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2_commands" + ] + + if command and json_payload: + _LOGGER.debug( + "%s: Received http2push command: %s : %s", + hide_email(email), + command, + hide_serial(json_payload), + ) + + account["last_push_activity"] = time.time() + serial = None + command_time = time.time() + if command not in seen_commands: + _LOGGER.debug( + "Adding %s to seen_commands: %s", command, seen_commands + ) + seen_commands[command] = command_time + + if ( + "dopplerId" in json_payload + and "deviceSerialNumber" in json_payload["dopplerId"] + ): + serial = json_payload["dopplerId"]["deviceSerialNumber"] + elif ( + "key" in json_payload + and "entryId" in json_payload["key"] + and json_payload["key"]["entryId"].find("#") != -1 + ): + serial = (json_payload["key"]["entryId"]).split("#")[2] + json_payload["key"]["serialNumber"] = serial + else: + serial = None + + if command in ( + "PUSH_AUDIO_PLAYER_STATE", + "PUSH_MEDIA_CHANGE", + "PUSH_MEDIA_PROGRESS_CHANGE", + "NotifyMediaSessionsUpdated", + "NotifyNowPlayingUpdated", + ): + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player: %s", hide_serial(json_payload) + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + elif command == "NotifyNowPlayingUpdated": + _LOGGER.debug("Send NowPlaying: %s", hide_serial(json_payload)) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"now_playing": json_payload}, + ) + + elif command == "PUSH_VOLUME_CHANGE": + try: + ts = resource.get("timeStamp") + ts_ms = int(ts) if ts else None + + if serial and isinstance(json_payload, dict): + _handle_volume_change_activity(serial, json_payload, ts_ms) + + except Exception: + _LOGGER.exception( + "%s: http2_handler failed processing %s", + hide_email(email), + command, + ) + + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player volume: %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + + elif command == "PUSH_DOPPLER_CONNECTION_CHANGE": + # Player availability update + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player availability %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + + elif command == "PUSH_EQUALIZER_STATE_CHANGE": + # Player equalizer update + try: + ts = resource.get("timeStamp") + ts_ms = int(ts) if ts else None + + if serial and isinstance(json_payload, dict): + _handle_equalizer_change_activity( + serial, json_payload, ts_ms + ) + + except Exception: + _LOGGER.exception( + "%s: http2_handler failed processing %s", + hide_email(email), + command, + ) + + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player equalizer state %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + + elif command == "PUSH_BLUETOOTH_STATE_CHANGE": + # Player bluetooth update + bt_event = ( + json_payload.get("bluetoothEvent") + if isinstance(json_payload, dict) + else None + ) + _LOGGER.debug("bt_event: %s", bt_event) + bt_success = ( + json_payload.get("bluetoothEventSuccess") + if isinstance(json_payload, dict) + else None + ) + _LOGGER.debug("bt_success: %s", bt_success) + if ( + serial + and serial in existing_serials + and bt_success + and bt_event + and bt_event + in { + "DEVICE_CONNECTED", + "DEVICE_DISCONNECTED", + "STREAMING_STATE_CHANGED", + } + ): + _LOGGER.debug( + "Updating media_player bluetooth %s", + hide_serial(json_payload), + ) + bluetooth_state = await update_bluetooth_state( + login_obj, serial + ) + _LOGGER.debug( + "bluetooth_state %s", hide_serial(bluetooth_state) + ) + if bluetooth_state: + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"bluetooth_change": bluetooth_state}, + ) + + elif command == "PUSH_MEDIA_QUEUE_CHANGE": + # Player availability update + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player queue %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"queue_state": json_payload}, + ) + + elif command == "PUSH_NOTIFICATION_CHANGE": + # Notification/alarm state changed on this device. + # Queue a refresh with backoff to ride out alexa-side cooldowns. + _schedule_notifications_refresh( + hass, + email, + device_serial=serial, + reason="PUSH_NOTIFICATION_CHANGE", + ) + + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating mediaplayer notifications: %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"notification_update": json_payload}, + ) + + elif command in [ + "PUSH_DELETE_DOPPLER_ACTIVITIES", # Delete Alexa history, + ]: + pass + + elif command in [ + "PUSH_TODO_CHANGE", # Update To-Do List + "PUSH_LIST_CHANGE", # Clear a shopping list https://github.com/alandtse/alexa_media_player/issues/1190 + "PUSH_LIST_ITEM_CHANGE", # Update shopping list + ]: + # To-do + _LOGGER.debug("%s currently not supported", command) + pass + + elif command in [ + "PUSH_CONTENT_FOCUS_CHANGE", # Likely prime related refocus + "PUSH_DEVICE_SETUP_STATE_CHANGE", # Likely device changes mid setup + ]: + _LOGGER.debug("%s currently not supported", command) + pass + + elif command in [ + "PUSH_MEDIA_PREFERENCE_CHANGE", # Disliking or liking songs, https://github.com/alandtse/alexa_media_player/issues/1599 + ]: + _LOGGER.debug("%s currently not supported", command) + pass + + elif command in [ + "MATTER_SETUP_NOTIFICATION", # New command observed 2026-02-20 + ]: + _LOGGER.debug("%s: New command; currently not supported", command) + pass + + else: + _LOGGER.debug( + "Unhandled command: %s with data %s. Please report at %s", + command, + hide_serial(json_payload), + ISSUE_URL, + ) + + # Preserve existing http2 activity tracking + new-device discovery + if serial in existing_serials: + history = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2_activity" + ]["serials"].get(serial) + if history is None or ( + history and command_time - history[len(history) - 1][1] > 2 + ): + history = [(command, command_time)] + else: + history.append([command, command_time]) + + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2_activity"][ + "serials" + ][serial] = history + + events = [] + for old_command, old_command_time in history: + if ( + old_command + in {"PUSH_VOLUME_CHANGE", "PUSH_EQUALIZER_STATE_CHANGE"} + and command_time - old_command_time < 0.25 + ): + events.append( + (old_command, round(command_time - old_command_time, 2)) + ) + elif old_command in {"PUSH_AUDIO_PLAYER_STATE"}: + events = [] + + if len(events) >= 4: + _LOGGER.debug( + "%s: Detected potential DND http2push change with %s events %s", + hide_serial(serial), + len(events), + events, + ) + await update_dnd_state(login_obj) + + if ( + serial + and serial not in existing_serials + and serial + not in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "excluded" + ].keys() + ): + _LOGGER.debug("Discovered new media_player %s", hide_serial(serial)) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = True + if coordinator: + await coordinator.async_request_refresh() + + @callback + async def http2_open_handler(): + """Handle http2 open.""" + + email: str = login_obj.email + _LOGGER.debug("%s: HTTP2push successfully connected", hide_email(email)) + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2error" + ] = 0 # set errors to 0 + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2_lastattempt"] = time.time() + + @callback + async def http2_close_handler(): + """Handle http2 close. + + This should attempt to reconnect up to 5 times + """ + email: str = login_obj.email + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] = None + if login_obj.close_requested: + _LOGGER.debug( + "%s: Close requested; will not reconnect http2", hide_email(email) + ) + return + if not login_obj.status.get("login_successful"): + _LOGGER.debug( + "%s: Login error; will not reconnect http2", hide_email(email) + ) + return + errors: int = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] + delay: int = 5 * 2**errors + last_attempt = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2_lastattempt" + ] + now = time.time() + if (now - last_attempt) < delay: + return + http2_client = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] + http2_enabled = bool(http2_client) + while errors < 5 and not (http2_enabled): + _LOGGER.debug( + "%s: HTTP2 push closed; reconnect #%i in %is", + hide_email(email), + errors, + delay, + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2_lastattempt" + ] = time.time() + http2_client = await http2_connect() + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] = http2_client + http2_enabled = bool(http2_client) + if http2_enabled: + break + errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] = ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] + 1 + ) + delay = 5 * 2**errors + errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] + await asyncio.sleep(delay) + if not http2_enabled: + _LOGGER.debug( + "%s: HTTP2Push connection closed; retries exceeded; polling", + hide_email(email), + ) + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") + if coordinator: + coordinator.update_interval = timedelta( + seconds=scan_interval * 10 if http2_enabled else scan_interval + ) + _LOGGER.debug( + "HTTP2push: %s, Polling interval: %s", + http2_enabled, + coordinator.update_interval, + ) + await coordinator.async_request_refresh() + + @callback + async def http2_error_handler(message): + """Handle http2push error. + + This currently logs the error. In the future, this should invalidate + the http2push and determine if a reconnect should be done. + """ + email: str = login_obj.email + errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] + _LOGGER.debug( + "%s: Received http2push error #%i %s: type %s", + hide_email(email), + errors, + message, + type(message), + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] = None + if not login_obj.close_requested and ( + login_obj.session.closed or isinstance(message, AlexapyLoginError) + ): + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] = 5 + _LOGGER.debug("%s: Login error detected.", hide_email(email)) + hass.bus.async_fire( + "alexa_media_relogin_required", + event_data={"email": hide_email(email), "url": login_obj.url}, + ) + return + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] = errors + 1 + + _LOGGER.debug("Setting up Alexa devices for %s", hide_email(login_obj.email)) + config = config_entry.data + email = config.get(CONF_EMAIL) + include = ( + cv.ensure_list_csv(config[CONF_INCLUDE_DEVICES]) + if config[CONF_INCLUDE_DEVICES] + else "" + ) + _LOGGER.debug("include: %s", include) + exclude = ( + cv.ensure_list_csv(config[CONF_EXCLUDE_DEVICES]) + if config[CONF_EXCLUDE_DEVICES] + else "" + ) + _LOGGER.debug("exclude: %s", exclude) + scan_interval: float = ( + config.get(CONF_SCAN_INTERVAL).total_seconds() + if isinstance(config.get(CONF_SCAN_INTERVAL), timedelta) + else config.get(CONF_SCAN_INTERVAL) + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login_obj + + # Initialize the per-account probe worker exactly once here (not per push message). + _init_last_called_probe_worker(hass.data[DATA_ALEXAMEDIA]["accounts"][email]) + + _t = time.monotonic() + http2_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] = ( + await http2_connect() + ) + _LOGGER.debug("[BOOT] http2_connect in %.2fs", time.monotonic() - _t) + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") + + # Get runtime_data for optimized coordinator + runtime_data = ( + config_entry.runtime_data if hasattr(config_entry, "runtime_data") else None + ) + + if coordinator is None: + _LOGGER.debug("%s: Creating optimized coordinator", hide_email(email)) + + # Use optimized coordinator with debouncing + coordinator = AlexaMediaCoordinator( + hass=hass, + runtime_data=runtime_data, + update_method=async_update_data, + scan_interval=scan_interval, + ) + + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["coordinator"] = coordinator + # Set correct interval now that http2 status is known + coordinator.set_http2_status(bool(http2_enabled)) + + # Also store in runtime_data for type-safe access + if runtime_data: + runtime_data.coordinator = coordinator + else: + _LOGGER.debug("%s: setup_alexa: Reusing coordinator", hide_email(email)) + # Use the optimized set_http2_status method if available + if isinstance(coordinator, AlexaMediaCoordinator): + coordinator.set_http2_status(bool(http2_enabled)) + else: + coordinator.update_interval = timedelta( + seconds=scan_interval * 10 if http2_enabled else scan_interval + ) + # Fetch initial data + _LOGGER.debug("%s: setup_alexa: Starting coordinator refresh", hide_email(email)) + _t = time.monotonic() + await coordinator.async_config_entry_first_refresh() + _LOGGER.debug("[BOOT] first_refresh in %.2fs", time.monotonic() - _t) + + # Register services (fast - just registers callbacks) + hass.data[DATA_ALEXAMEDIA]["services"] = alexa_services = AlexaMediaServices( + hass, functions={"update_last_called": update_last_called} + ) + await alexa_services.register() + + # Update last_called in background to avoid blocking + _LOGGER.debug("%s: setup_alexa: Scheduling last_called update", hide_email(email)) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["last_called_init_task"] = ( + hass.async_create_background_task( + _async_update_last_called_background(hass, login_obj, email), + f"{DOMAIN}_last_called_init", + ) + ) + + return True + + +async def _async_update_last_called_background( + hass: HomeAssistant, login_obj, email: str +) -> None: + """Update last_called in background to avoid blocking startup.""" + try: + await _async_update_last_called_global(hass, login_obj, email) + _LOGGER.debug("%s: Background last_called update completed", hide_email(email)) + + # Record metrics + metrics = get_metrics(hass) + if metrics: + metrics.record_boot_stage(f"last_called_{hide_email(email)}") + except asyncio.CancelledError: + # Task cancelled during unload/shutdown; propagate cancellation. + raise + except Exception as err: + _LOGGER.debug( + "%s: Background last_called update failed: %s", hide_email(email), err + ) + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry""" + email = entry.data["email"] + _LOGGER.debug("Unloading entry: %s", hide_email(email)) + for task_key in ( + "notifications_refresh_task", + "notifications_init_task", + "last_called_init_task", + "service_update_last_called_task", + ): + accounts = hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) + account = accounts.get(email) + if not account: + return True + task = account.get(task_key) + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + # Expected during unload/shutdown + pass + last_called_task = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "last_called_probe_task" + ) + if last_called_task and not last_called_task.done(): + last_called_task.cancel() + try: + await last_called_task + except asyncio.CancelledError: + # Expected during unload/shutdown + pass + except Exception as err: # pragma: no cover + _LOGGER.debug( + "%s: Exception while cancelling last_called_probe_task: %s", + hide_email(email), + err, + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["last_called_probe_task"] = None + + debouncer = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "confirm_refresh_debouncer" + ) + if debouncer: + debouncer.async_cancel() + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "confirm_refresh_debouncer" + ] = None + + for component in ALEXA_COMPONENTS + DEPENDENT_ALEXA_COMPONENTS: + try: + if component == "notify": + await notify_async_unload_entry(hass, entry) + else: + _LOGGER.debug("Forwarding unload entry to %s", component) + await hass.config_entries.async_forward_entry_unload(entry, component) + except Exception: + _LOGGER.error("Error unloading: %s", component) + await close_connections(hass, email) + for listener in hass.data[DATA_ALEXAMEDIA]["accounts"][email][DATA_LISTENER]: + listener() + hass.data[DATA_ALEXAMEDIA]["accounts"].pop(email) + # Clean up config flows in progress + flows_to_remove = [] + if hass.data[DATA_ALEXAMEDIA].get("config_flows"): + for key, flow in hass.data[DATA_ALEXAMEDIA]["config_flows"].items(): + if key.startswith(email) and flow: + _LOGGER.debug("Aborting flow %s %s", key, flow) + flows_to_remove.append(key) + try: + hass.config_entries.flow.async_abort(flow.get("flow_id")) + except UnknownFlow: + pass + for flow in flows_to_remove: + hass.data[DATA_ALEXAMEDIA]["config_flows"].pop(flow) + # Clean up hass.data + if not hass.data[DATA_ALEXAMEDIA].get("accounts"): + _LOGGER.debug("Removing accounts data and services") + hass.data[DATA_ALEXAMEDIA].pop("accounts") + alexa_services = hass.data[DATA_ALEXAMEDIA].get("services") + if alexa_services: + await alexa_services.unregister() + hass.data[DATA_ALEXAMEDIA].pop("services") + if hass.data[DATA_ALEXAMEDIA].get("config_flows") == {}: + _LOGGER.debug("Removing config_flows data") + async_dismiss_persistent_notification( + hass, f"alexa_media_{slugify(email)}{slugify((entry.data['url'])[7:])}" + ) + hass.data[DATA_ALEXAMEDIA].pop("config_flows") + if not hass.data[DATA_ALEXAMEDIA]: + _LOGGER.debug("Removing alexa_media data structure") + if hass.data.get(DATA_ALEXAMEDIA): + hass.data.pop(DATA_ALEXAMEDIA) + else: + _LOGGER.debug( + "Unable to remove alexa_media data structure: %s", + hass.data.get(DATA_ALEXAMEDIA), + ) + _LOGGER.debug("Unloaded entry for %s", hide_email(email)) + return True + + +async def async_remove_entry(hass, entry) -> bool: + """Handle removal of an entry.""" + email = entry.data["email"] + obfuscated_email = hide_email(email) + _LOGGER.debug("Removing config entry: %s", hide_email(email)) + login_obj = AlexaLogin( + url="", + email=email, + password="", # nosec + outputpath=hass.config.path, + ) + # Delete cookiefile + cookiefile = hass.config.path(f".storage/{DOMAIN}.{email}.pickle") + obfuscated_cookiefile = hass.config.path( + f".storage/{DOMAIN}.{obfuscated_email}.pickle" + ) + if callable(getattr(AlexaLogin, "delete_cookiefile", None)): + try: + await login_obj.delete_cookiefile() + _LOGGER.debug("Cookiefile %s deleted.", obfuscated_cookiefile) + except Exception as ex: + _LOGGER.error( + "delete_cookiefile() exception: %s;" + " Manually delete cookiefile before re-adding the integration: %s", + ex, + obfuscated_cookiefile, + ) + else: + if os.path.exists(cookiefile): + try: + await alexapy_delete_cookie(cookiefile) + _LOGGER.debug( + "Successfully deleted cookiefile: %s", obfuscated_cookiefile + ) + except (OSError, EOFError, TypeError, AttributeError) as ex: + _LOGGER.error( + "alexapy_delete_cookie() exception: %s;" + " Manually delete cookiefile before re-adding the integration: %s", + ex, + obfuscated_cookiefile, + ) + else: + _LOGGER.error("Cookiefile not found: %s", obfuscated_cookiefile) + _LOGGER.debug("Config entry %s removed.", obfuscated_email) + return True + + +async def close_connections(hass, email: str) -> None: + """Clear open aiohttp connections for email.""" + if ( + email not in hass.data[DATA_ALEXAMEDIA]["accounts"] + or "login_obj" not in hass.data[DATA_ALEXAMEDIA]["accounts"][email] + ): + return + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][email] + login_obj = account_dict["login_obj"] + await login_obj.save_cookiefile() + await login_obj.close() + _LOGGER.debug( + "%s: Connection closed: %s", hide_email(email), login_obj.session.closed + ) + + +async def update_listener(hass, config_entry): + """Update when config_entry options update.""" + account = config_entry.data + email = account.get(CONF_EMAIL) + reload_needed: bool = False + for key, old_value in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "options" + ].items(): + new_value = config_entry.data.get(key) + if new_value is not None and new_value != old_value: + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["options"][key] = new_value + _LOGGER.debug( + "Option %s changed from %s to %s", + key, + old_value, + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["options"][key], + ) + reload_needed = True + if reload_needed: + await hass.config_entries.async_reload(config_entry.entry_id) + _LOGGER.debug( + "%s options reloaded", + hass.data[DATA_ALEXAMEDIA]["accounts"][email], + ) + + +async def test_login_status(hass, config_entry, login) -> bool: + """Test the login status and spawn requests for info.""" + + _LOGGER.debug("Testing login status: %s", login.status) + if login.status and login.status.get("login_successful"): + return True + account = config_entry.data + _LOGGER.debug("Logging in: %s %s", obfuscate(account), in_progress_instances(hass)) + _LOGGER.debug("Login stats: %s", login.stats) + message: str = ( + f"Reauthenticate {login.email} on the [Integrations](/config/integrations) page. " + ) + if login.stats.get("login_timestamp") != datetime(1, 1, 1): + elaspsed_time: str = str(datetime.now() - login.stats.get("login_timestamp")) + api_calls: int = login.stats.get("api_calls") + message += f"Relogin required after {elaspsed_time} and {api_calls} api calls." + host = urlparse(login.url).hostname or login.url + async_create_persistent_notification( + hass, + title="Alexa Media Reauthentication Required", + message=message, + notification_id=f"alexa_media_{slugify(login.email)}_{slugify(host)}", + ) + flow = hass.data[DATA_ALEXAMEDIA]["config_flows"].get( + f"{account[CONF_EMAIL]} - {account[CONF_URL]}" + ) + if flow: + if flow.get("flow_id") in in_progress_instances(hass): + _LOGGER.debug("Existing config flow detected") + return False + _LOGGER.debug("Stopping orphaned config flow %s", flow.get("flow_id")) + try: + hass.config_entries.flow.async_abort(flow.get("flow_id")) + except UnknownFlow: + pass + hass.data[DATA_ALEXAMEDIA]["config_flows"][ + f"{account[CONF_EMAIL]} - {account[CONF_URL]}" + ] = None + _LOGGER.debug("Creating new config flow to login") + config_entry.async_start_reauth( + hass, + context={"source": SOURCE_REAUTH}, + data={ + CONF_EMAIL: account[CONF_EMAIL], + CONF_PASSWORD: account[CONF_PASSWORD], + CONF_URL: account[CONF_URL], + CONF_DEBUG: account[CONF_DEBUG], + CONF_INCLUDE_DEVICES: account[CONF_INCLUDE_DEVICES], + CONF_EXCLUDE_DEVICES: account[CONF_EXCLUDE_DEVICES], + CONF_SCAN_INTERVAL: ( + account[CONF_SCAN_INTERVAL].total_seconds() + if isinstance(account[CONF_SCAN_INTERVAL], timedelta) + else account[CONF_SCAN_INTERVAL] + ), + CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), + }, + ) + try: + flow_obj = config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}).__next__() + hass.data[DATA_ALEXAMEDIA]["config_flows"][ + f"{account[CONF_EMAIL]} - {account[CONF_URL]}" + ] = flow_obj + except StopIteration: + _LOGGER.debug("A new config flow could not be created.") + return False diff --git a/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc b/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..d81640f Binary files /dev/null and b/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc new file mode 100644 index 0000000..5f1f2ad Binary files /dev/null and b/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc new file mode 100644 index 0000000..e25cacf Binary files /dev/null and b/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc new file mode 100644 index 0000000..752cf27 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc b/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc new file mode 100644 index 0000000..68459a9 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc b/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc new file mode 100644 index 0000000..fe91f7a Binary files /dev/null and b/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/const.cpython-314.pyc b/custom_components/alexa_media/__pycache__/const.cpython-314.pyc new file mode 100644 index 0000000..cf00f4c Binary files /dev/null and b/custom_components/alexa_media/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc b/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc new file mode 100644 index 0000000..a51f5cd Binary files /dev/null and b/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc b/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc new file mode 100644 index 0000000..e8cd81a Binary files /dev/null and b/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc b/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc new file mode 100644 index 0000000..620c7d2 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc b/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc new file mode 100644 index 0000000..6a776d4 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/light.cpython-314.pyc b/custom_components/alexa_media/__pycache__/light.cpython-314.pyc new file mode 100644 index 0000000..a9b8357 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/light.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc b/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc new file mode 100644 index 0000000..0e32963 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc b/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc new file mode 100644 index 0000000..bfb39c1 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc b/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc new file mode 100644 index 0000000..223e109 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc b/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc new file mode 100644 index 0000000..cd75399 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc b/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc new file mode 100644 index 0000000..1ac31e8 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/services.cpython-314.pyc b/custom_components/alexa_media/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..a41f6aa Binary files /dev/null and b/custom_components/alexa_media/__pycache__/services.cpython-314.pyc differ diff --git a/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc b/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc new file mode 100644 index 0000000..8fd4063 Binary files /dev/null and b/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc differ diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py new file mode 100644 index 0000000..fdcff8d --- /dev/null +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -0,0 +1,261 @@ +""" +Alexa Devices Alarm Control Panel using Guard Mode. + +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 +""" + +from asyncio import sleep +import logging +from typing import Optional + +from alexapy import hide_email, hide_serial +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.const import CONF_EMAIL, STATE_UNAVAILABLE +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .alexa_entity import parse_guard_state_from_coordinator +from .alexa_media import AlexaMedia +from .const import ( + CONF_EXCLUDE_DEVICES, + CONF_INCLUDE_DEVICES, + CONF_QUEUE_DELAY, + DATA_ALEXAMEDIA, + DEFAULT_QUEUE_DELAY, + DOMAIN as ALEXA_DOMAIN, +) +from .helpers import _catch_login_errors, add_devices, safe_get + +try: + from homeassistant.components.alarm_control_panel import AlarmControlPanelState + + STATE_ALARM_ARMED_AWAY = AlarmControlPanelState.ARMED_AWAY + STATE_ALARM_DISARMED = AlarmControlPanelState.DISARMED +except ImportError: + from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = [ALEXA_DOMAIN] + + +async def async_setup_platform( + hass, config, add_devices_callback, discovery_info=None +) -> bool: + """Set up the Alexa alarm control panel platform.""" + devices: list[AlexaAlarmControlPanel] = [] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = safe_get(discovery_info, ["config", CONF_EMAIL]) + if account is None: + raise ConfigEntryNotReady + include_filter = config.get(CONF_INCLUDE_DEVICES, []) + exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + guard_media_players = {} + for key, device in account_dict["devices"]["media_player"].items(): + if key not in account_dict["entities"]["media_player"]: + _LOGGER.debug( + "%s: Media player %s not loaded yet; delaying load", + hide_email(account), + hide_serial(key), + ) + raise ConfigEntryNotReady + if "GUARD_EARCON" in device["capabilities"]: + guard_media_players[key] = account_dict["entities"]["media_player"][key] + if "alarm_control_panel" not in (account_dict["entities"]): + ( + hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][ + "alarm_control_panel" + ] + ) = {} + alexa_client: Optional[AlexaAlarmControlPanel] = None + guard_entities = safe_get(account_dict, ["devices", "guard"], []) + if guard_entities: + alexa_client = AlexaAlarmControlPanel( + account_dict["login_obj"], + account_dict["coordinator"], + guard_entities[0], + guard_media_players, + ) + else: + _LOGGER.debug("%s: No Alexa Guard entity found", hide_email(account)) + if not (alexa_client and alexa_client.unique_id): + _LOGGER.debug( + "%s: Skipping creation of uninitialized device: %s", + hide_email(account), + alexa_client, + ) + elif alexa_client.unique_id not in ( + account_dict["entities"]["alarm_control_panel"] + ): + devices.append(alexa_client) + ( + hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][ + "alarm_control_panel" + ][alexa_client.unique_id] + ) = alexa_client + else: + _LOGGER.debug( + "%s: Skipping already added device: %s", hide_email(account), alexa_client + ) + return await add_devices( + hide_email(account), + devices, + add_devices_callback, + include_filter, + exclude_filter, + ) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alexa alarm control panel platform by config_entry.""" + return await async_setup_platform( + hass, config_entry.data, async_add_devices, discovery_info=None + ) + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry.""" + account = entry.data[CONF_EMAIL] + _LOGGER.debug("Attempting to unload alarm control panel") + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + for device in account_dict["entities"]["alarm_control_panel"].values(): + _LOGGER.debug("Removing %s", device) + await device.async_remove() + return True + + +class AlexaAlarmControlPanel(AlarmControlPanelEntity, AlexaMedia, CoordinatorEntity): + """Implementation of Alexa Media Player alarm control panel.""" + + def __init__(self, login, coordinator, guard_entity, media_players=None) -> None: + """Initialize the Alexa device.""" + AlexaMedia.__init__(self, None, login) + CoordinatorEntity.__init__(self, coordinator) + _LOGGER.debug("%s: Initiating alarm control panel", hide_email(login.email)) + # AlexaAPI requires a AlexaClient object, need to clean this up + + # Guard info + self._appliance_id = guard_entity["appliance_id"] + self._guard_entity_id = guard_entity["id"] + self._friendly_name = "Alexa Guard " + self._appliance_id[-5:] + self._media_players = {} or media_players + self._attrs: dict[str, str] = {} + _LOGGER.debug( + "%s: Guard Discovered %s: %s %s", + self.account, + self._friendly_name, + hide_serial(self._appliance_id), + hide_serial(self._guard_entity_id), + ) + + @_catch_login_errors + async def _async_alarm_set( + self, + command: str = "", + code=None, # pylint: disable=unused-argument + ) -> None: + """Send command.""" + try: + if not self.enabled: + return + except AttributeError: + pass + if command not in (STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED): + _LOGGER.error("Invalid command: %s", command) + return + command_map = {STATE_ALARM_ARMED_AWAY: "AWAY", STATE_ALARM_DISARMED: "HOME"} + available_media_players = list( + filter(lambda x: x.state != STATE_UNAVAILABLE, self._media_players.values()) + ) + if available_media_players: + _LOGGER.debug("Sending guard command to: %s", available_media_players[0]) + available_media_players[0].check_login_changes() + # Extract appliance ID safely to prevent IndexError if format is unexpected + appliance_parts = self._appliance_id.split("_") + appliance_id = ( + appliance_parts[2] if len(appliance_parts) > 2 else self._appliance_id + ) + await available_media_players[0].alexa_api.set_guard_state( + appliance_id, + command_map[command], + queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][ + "options" + ].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), + ) + await sleep(2) # delay + else: + _LOGGER.debug("Performing static guard command") + await self.alexa_api.static_set_guard_state( + self._login, self._guard_entity_id, command + ) + await self.coordinator.async_request_refresh() + + async def async_alarm_disarm( + self, + code=None, # pylint:disable=unused-argument + ) -> None: + """Send disarm command.""" + await self._async_alarm_set(STATE_ALARM_DISARMED) + + async def async_alarm_arm_away( + self, + code=None, # pylint:disable=unused-argument + ) -> None: + """Send arm away command.""" + await self._async_alarm_set(STATE_ALARM_ARMED_AWAY) + + @property + def unique_id(self): + """Return the unique ID.""" + return self._guard_entity_id + + @property + def name(self): + """Return the name of the device.""" + return self._friendly_name + + @property + def state(self): + """Return the state of the device.""" + _state = parse_guard_state_from_coordinator( + self.coordinator, self._guard_entity_id + ) + if _state == "ARMED_AWAY": + return STATE_ALARM_ARMED_AWAY + return STATE_ALARM_DISARMED + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + # pylint: disable=import-outside-toplevel + try: + from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntityFeature, + ) + except ImportError: + return 0 + return AlarmControlPanelEntityFeature.ARM_AWAY + + @property + def assumed_state(self) -> bool: + """Return assumed state. + + Returns + bool: Whether the state is assumed + + """ + last_refresh_success = ( + self.coordinator.data and self._guard_entity_id in self.coordinator.data + ) + return not last_refresh_success + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return self._attrs diff --git a/custom_components/alexa_media/alexa_entity.py b/custom_components/alexa_media/alexa_entity.py new file mode 100644 index 0000000..b13cf93 --- /dev/null +++ b/custom_components/alexa_media/alexa_entity.py @@ -0,0 +1,764 @@ +""" +Alexa Devices Entities. + +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 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import json +import logging +import re +from typing import Any, Optional, TypedDict + +from alexapy import AlexaAPI, AlexaLogin +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .helpers import safe_get + +_LOGGER = logging.getLogger(__name__) + +# How long we keep "requested state" protected from stale coordinator values +# when capabilityStates do not include a usable timeOfSample. +_REQUESTED_STATE_TTL = timedelta(seconds=15) + + +def has_capability( + appliance: dict[str, Any], interface_name: str, property_name: str +) -> bool: + """Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant. + + Args: + appliance(dict[str, Any]): An appliance from a call to AlexaAPI.get_network_details + interface_name(str): One of the interfaces documented by the Alexa Smart Home Skills API + property_name(str): The property that matches the interface name. + + """ + for cap in appliance["capabilities"]: + props = cap.get("properties") + if ( + cap["interfaceName"] == interface_name + and props + and (props["retrievable"] or props["proactivelyReported"]) + ): + for prop in props["supported"]: + if prop["name"] == property_name: + return True + return False + + +def is_hue_v1(appliance: dict[str, Any]) -> bool: + """Determine if an appliance is managed via the Philips Hue v1 Hub. + + This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older + Philips Hue bulbs and hubs. This includes things exposed by HA to Alexa using the emulated_hue integration. + """ + return appliance.get("manufacturerName") == "Royal Philips Electronics" + + +def is_skill(appliance: dict[str, Any]) -> bool: + namespace = safe_get(appliance, ["driverIdentity", "namespace"], "") + return namespace and namespace == "SKILL" + + +def is_known_ha_bridge(appliance: dict[str, Any] | None) -> bool: + """Test whether a bridge appliance is a known HA bridge to avoid creating loops.""" + + if appliance is None: + return False + + if appliance.get("manufacturerName") in ("t0bst4r", "Matterbridge"): + return True + + # Identify Matter bridge hubs regardless of manufacturerName + if "HUB" in appliance.get("applianceTypes", []): + driver_ns = safe_get(appliance, ["driverIdentity", "namespace"], "") + driver_id = safe_get(appliance, ["driverIdentity", "identifier"], "") + if driver_ns == "AAA" and driver_id == "SonarCloudService": + interfaces = { + cap.get("interfaceName") for cap in appliance.get("capabilities", []) + } + if ( + "Alexa.Matter.NodeOperationalCredentials.FabricManagement" in interfaces + or "Alexa.Commissionable" in interfaces + ): + return True + + return False + + +def is_local(appliance: dict[str, Any]) -> bool: + """Test whether locally connected. + + This is mainly present to prevent loops with the official Alexa integration. + There is probably a better way to prevent that, but this works. + """ + + if appliance.get("connectedVia"): + # connectedVia is a flag that determines which Echo devices holds the connection. Its blank for + # skill derived devices and includes an Echo name for zigbee and local devices. + return True + + # This catches the Echo/AVS devices. connectedVia isn't reliable in this case. + # Only the first appears to get that set. + if "ALEXA_VOICE_ENABLED" in appliance.get("applianceTypes", []): + return not is_skill(appliance) + + # Ledvance/Sengled bulbs connected via bluetooth are hard to detect as locally connected + # Amazon devices are not local but bypassing the local check allows for control by the integration + # There is probably a better way, but this works for now. + manufacturerNames = ["Ledvance", "Sengled", "Amazon"] + if appliance.get("manufacturerName") in manufacturerNames: + return not is_skill(appliance) + + # Zigbee devices are guaranteed to be local and have a particular pattern of id + zigbee_pattern = re.compile( + "AAA_SonarCloudService_([0-9A-F][0-9A-F]:){7}[0-9A-F][0-9A-F]", flags=re.I + ) + return zigbee_pattern.fullmatch(appliance.get("applianceId", "")) is not None + + +def is_alexa_guard(appliance: dict[str, Any]) -> bool: + """Is the given appliance the guard alarm system of an echo.""" + return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability( + appliance, "Alexa.SecurityPanelController", "armState" + ) + + +def is_temperature_sensor(appliance: dict[str, Any]) -> bool: + """Is the given appliance the temperature sensor of an Echo.""" + return ( + is_local(appliance) + and has_capability(appliance, "Alexa.TemperatureSensor", "temperature") + and appliance["friendlyDescription"] != "Amazon Indoor Air Quality Monitor" + ) + + +# Checks if air quality sensor +def is_air_quality_sensor(appliance: dict[str, Any]) -> bool: + """Is the given appliance the Amazon Indoor Air Quality Monitor (AIAQM).""" + return ( + appliance.get("friendlyDescription") == "Amazon Indoor Air Quality Monitor" + and "AIR_QUALITY_MONITOR" in appliance.get("applianceTypes", []) + and has_capability(appliance, "Alexa.RangeController", "rangeValue") + ) + + +def is_light(appliance: dict[str, Any]) -> bool: + """Is the given appliance a light controlled locally by an Echo.""" + return ( + is_local(appliance) + and ( + "LIGHT" in appliance.get("applianceTypes", []) + or ( + "SMARTPLUG" in appliance.get("applianceTypes", []) + and appliance.get("customerDefinedDeviceType") == "LIGHT" + ) + ) + and has_capability(appliance, "Alexa.PowerController", "powerState") + ) + + +def is_contact_sensor(appliance: dict[str, Any]) -> bool: + """Is the given appliance a contact sensor controlled locally by an Echo.""" + return ( + is_local(appliance) + and "CONTACT_SENSOR" in appliance.get("applianceTypes", []) + and has_capability(appliance, "Alexa.ContactSensor", "detectionState") + ) + + +def is_switch(appliance: dict[str, Any]) -> bool: + """Is the given appliance a switch controlled locally by an Echo, which is not redeclared as a light.""" + return ( + is_local(appliance) + and ( + "SMARTPLUG" in appliance.get("applianceTypes", []) + or "SWITCH" in appliance.get("applianceTypes", []) + ) + and appliance.get("customerDefinedDeviceType") != "LIGHT" + and has_capability(appliance, "Alexa.PowerController", "powerState") + ) + + +def get_friendliest_name(appliance: dict[str, Any]) -> str: + """Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one.""" + aliases = appliance.get("aliases", []) + for alias in aliases: + friendly = alias.get("friendlyName") + if friendly: + return friendly + return appliance["friendlyName"] + + +def get_device_serial(appliance: dict[str, Any]) -> str | None: + """Find the device serial id if it is present.""" + alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", []) + for alexa_device_id in alexa_device_id_list: + if isinstance(alexa_device_id, dict): + return alexa_device_id.get("dmsDeviceSerialNumber") + return None + + +def get_device_bridge( + appliance: dict[str, Any], appliances: dict[str, dict[str, Any]] +) -> dict[str, Any] | None: + """Find the bridge device for an appliance connected through e.g. a Matter bridge.""" + + appliance_id = appliance.get("applianceId") + if not isinstance(appliance_id, str) or "#" not in appliance_id: + return None + + # HA Matter Hub bridged endpoints are identified by applianceId prefixes + # of the form AAA_SonarCloudService_#. + bridge_id, _sep, _child = appliance_id.partition("#") + + if not bridge_id.startswith("AAA_SonarCloudService_"): + return None + + bridge = appliances.get(bridge_id) + return bridge if isinstance(bridge, dict) else None + + +AlexaEntityData = dict[str, list["AlexaCapabilityState"]] + + +class AlexaEntity(TypedDict): + """Class for Alexaentity.""" + + id: str + appliance_id: str + name: str + is_hue_v1: bool + + +class AlexaLightEntity(AlexaEntity): + """Class for AlexaLightEntity.""" + + brightness: bool + color: bool + color_temperature: bool + + +class AlexaTemperatureEntity(TypedDict, total=False): + device_serial: str + is_aiaqm: bool + + +class AlexaAirQualityEntity(AlexaEntity): + """Class for AlexaAirQualityEntity.""" + + device_serial: str + + +class AlexaAIAQMEntity(AlexaEntity): + """Entity-backed "device" representing an Amazon Indoor Air Quality Monitor.""" + + device_serial: str + sensors: list[dict[str, str]] + + +class AlexaBinaryEntity(AlexaEntity): + """Class for AlexaBinaryEntity.""" + + battery_level: bool + + +class AlexaEntities(TypedDict): + """Class for Alexa Entities.""" + + light: list[AlexaLightEntity] + guard: list[AlexaEntity] + temperature: list[AlexaTemperatureEntity] + air_quality: list[AlexaAirQualityEntity] + aiaqm: list[AlexaAIAQMEntity] + binary_sensor: list[AlexaBinaryEntity] + smart_switch: list[AlexaEntity] + + +class AlexaCapabilityState(TypedDict, total=False): + """Class for AlexaCapabilityState.""" + + name: str + namespace: str + value: int | float | str | dict[str, Any] + instance: str + timeOfSample: str + uncertaintyInMilliseconds: int + + +def parse_alexa_entities( + network_details: list[dict[str, Any]] | None, + debug: bool = False, +) -> AlexaEntities: + # pylint: disable=too-many-locals + """Turn the network details into a list of useful entities with the important details extracted.""" + temperature_sensors: list[AlexaTemperatureEntity] = [] + air_quality_sensors: list[AlexaAirQualityEntity] = [] + aiaqm_entities: list[AlexaAIAQMEntity] = [] + contact_sensors: list[AlexaBinaryEntity] = [] + switches: list[AlexaEntity] = [] + guards: list[AlexaEntity] = [] + lights: list[AlexaLightEntity] = [] + + function_name = "parse_alexa_entities()" + + if not network_details: + return { + "light": lights, + "guard": guards, + "temperature": temperature_sensors, + "air_quality": air_quality_sensors, + "aiaqm": aiaqm_entities, + "binary_sensor": contact_sensors, + "smart_switch": switches, + } + + network_dict: dict[str, dict[str, Any]] = {} + if debug: + _LOGGER.debug("Processing network_details") + + # Build an applianceId → appliance map first so bridged devices + # can resolve their bridge regardless of list ordering. + for appliance in network_details: + appliance_id = appliance.get("applianceId") + if appliance_id: + network_dict[appliance_id] = appliance + + for appliance in network_details: + device_bridge = get_device_bridge(appliance, network_dict) + + bridge_label = ( + device_bridge.get("friendlyName") or device_bridge.get("manufacturerName") + if device_bridge + else None + ) + + appliance_id = str(appliance.get("applianceId", "")) + + # Only log a bridge check when: + # - we found a bridge, OR + # - ADV debug is enabled AND the appliance looks like a bridge candidate + if bridge_label is not None or (debug and "#" in appliance_id): + _LOGGER.debug( + "%s: Checking device bridge: %s", + appliance.get("friendlyName"), + bridge_label or "", + ) + + # ADV-only: only log resolution for cases where it might apply + if debug and "#" in appliance_id: + bridge_id = device_bridge.get("applianceId") if device_bridge else None + _LOGGER.debug( + "[%s] [ADV] Matter bridge resolution: appliance=%s → bridge=%s (connectedVia=%s, bridge=%s)", + function_name, + appliance_id, + bridge_id, + appliance.get("connectedVia"), + bridge_label, + ) + + if is_known_ha_bridge(device_bridge): + if debug: + _LOGGER.debug( + '[%s] [ADV] Skipping bridged Matter device "%s" (%s) via known bridge: %s (%s)', + function_name, + appliance.get("friendlyName"), + appliance.get("applianceId"), + bridge_label, + device_bridge.get("applianceId") if device_bridge else None, + ) + else: + _LOGGER.debug( + 'Skipping bridged Matter device "%s" via known bridge "%s"', + appliance.get("friendlyName"), + bridge_label or "", + ) + continue + + processed_appliance: AlexaEntity = { + "id": appliance["entityId"], + "appliance_id": appliance["applianceId"], + "name": get_friendliest_name(appliance), + "is_hue_v1": is_hue_v1(appliance), + } + + if is_alexa_guard(appliance): + _LOGGER.debug("Added Alexa Guard: %s", processed_appliance["name"]) + guards.append(processed_appliance) + + elif is_temperature_sensor(appliance): + if debug: + _LOGGER.debug( + "Added temperature sensor: %s", processed_appliance["name"] + ) + serial = get_device_serial(appliance) + temp_entity: AlexaTemperatureEntity = { + **processed_appliance, + "device_serial": serial if serial else appliance["entityId"], + } + temperature_sensors.append(temp_entity) + + elif is_air_quality_sensor(appliance): + if debug: + _LOGGER.debug("Added AIAQM sensor: %s", processed_appliance["name"]) + + serial = get_device_serial(appliance) + device_serial = serial if serial else appliance["entityId"] + + # Build a list of sub-sensors we can read via AlexaAPI.get_entity_state. + # AIAQM metrics are exposed via Alexa.RangeController(rangeValue) with an + # instance per metric. Some accounts/devices use numeric instances, so + # we derive the sensor type from the friendlyName assetId/text. + sensors: list[dict[str, str]] = [] + for cap in appliance.get("capabilities", []): + if cap.get("interfaceName") != "Alexa.RangeController": + continue + + # Must support numeric rangeValue to be a sensor. + supported = safe_get(cap, ["properties", "supported"], []) + if not isinstance(supported, list) or not any( + isinstance(p, dict) and p.get("name") == "rangeValue" + for p in supported + ): + continue + + instance = cap.get("instance") + if instance is None or instance == "": + continue + if not isinstance(instance, str): + if isinstance(instance, (int, float)): + instance = str(instance) + else: + continue + + unit = safe_get(cap, ["configuration", "unitOfMeasure"], "") or "" + + resources = ( + cap.get("resources", {}) + if isinstance(cap.get("resources"), dict) + else {} + ) + friendly = ( + resources.get("friendlyNames", []) + if isinstance(resources.get("friendlyNames"), list) + else [] + ) + + sensor_type: str | None = None + for entry in friendly: + if not isinstance(entry, dict): + continue + value_obj = entry.get("value") + asset_id = None + if isinstance(value_obj, dict): + asset_id = value_obj.get("assetId") + else: + asset_id = entry.get("assetId") + + # Only treat Alexa.AirQuality assetIds as real AIAQM sensors. + # Text-only friendlyNames (e.g. @type "text") must be ignored to avoid + # creating extra sensors such as PM10. + if isinstance(asset_id, str) and asset_id.startswith( + "Alexa.AirQuality." + ): + sensor_type = asset_id + break + + if not sensor_type: + continue + sensors.append( + { + "sensorType": str(sensor_type), + "instance": instance, + "unit": str(unit), + } + ) + + # Always register the AIAQM device (even if no sub-sensors are exposed). + aiaqm_entity: AlexaAIAQMEntity = { + **processed_appliance, + "device_serial": device_serial, + "sensors": sensors, + } + aiaqm_entities.append(aiaqm_entity) + + # Backwards compatibility: also expose as air_quality for existing paths. + aq_entity: AlexaAirQualityEntity = { + **processed_appliance, + "device_serial": device_serial, + } + air_quality_sensors.append(aq_entity) + + # AIAQM also has temperature; ensure it gets created and grouped with AIAQM. + temp_entity: AlexaTemperatureEntity = { + **processed_appliance, + "device_serial": device_serial, + "is_aiaqm": True, + } + temperature_sensors.append(temp_entity) + elif is_switch(appliance): + if debug: + _LOGGER.debug("Added switch: %s", processed_appliance["name"]) + switches.append(processed_appliance) + + elif is_light(appliance): + if debug: + _LOGGER.debug("Added light %s", processed_appliance["name"]) + processed_appliance["brightness"] = has_capability( + appliance, "Alexa.BrightnessController", "brightness" + ) + processed_appliance["color"] = has_capability( + appliance, "Alexa.ColorController", "color" + ) + processed_appliance["color_temperature"] = has_capability( + appliance, + "Alexa.ColorTemperatureController", + "colorTemperatureInKelvin", + ) + light_entity: AlexaLightEntity = { + **processed_appliance, + "brightness": processed_appliance["brightness"], + "color": processed_appliance["color"], + "color_temperature": processed_appliance["color_temperature"], + } + lights.append(light_entity) + + elif is_contact_sensor(appliance): + if debug: + _LOGGER.debug("Added contact sensor: %s", processed_appliance["name"]) + processed_appliance["battery_level"] = has_capability( + appliance, "Alexa.BatteryLevelSensor", "batteryLevel" + ) + binary_entity: AlexaBinaryEntity = { + **processed_appliance, + "battery_level": processed_appliance["battery_level"], + } + contact_sensors.append(binary_entity) + + else: + if debug: + _LOGGER.debug("Unsupported entity: %s", processed_appliance["name"]) + + return { + "light": lights, + "guard": guards, + "temperature": temperature_sensors, + "air_quality": air_quality_sensors, + "aiaqm": aiaqm_entities, + "binary_sensor": contact_sensors, + "smart_switch": switches, + } + + +async def get_entity_data( + login_obj: AlexaLogin, entity_ids: list[str] +) -> AlexaEntityData: + """Get and process the entity data into a more usable format.""" + + entities = {} + if entity_ids: + raw = await AlexaAPI.get_entity_state(login_obj, entity_ids=entity_ids) + device_states = raw.get("deviceStates", []) if isinstance(raw, dict) else None + if device_states: + for device_state in device_states: + entity_id = safe_get(device_state, ["entity", "entityId"]) + if entity_id: + entities[entity_id] = [] + cap_states = device_state.get("capabilityStates", []) + for cap_state in cap_states: + entities[entity_id].append(json.loads(cap_state)) + return entities + + +def parse_temperature_from_coordinator( + coordinator: DataUpdateCoordinator, + entity_id: str, + debug: bool = False, +) -> dict[str, Any] | None: + """Get the temperature of an entity from the coordinator data.""" + temperature = parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.TemperatureSensor", + "temperature", + debug=debug, + ) + if debug: + _LOGGER.debug("parse_temperature_from_coordinator: %s", temperature) + return temperature + + +def parse_air_quality_from_coordinator( + coordinator: DataUpdateCoordinator, + entity_id: str, + instance_id: str, + debug: bool = False, +) -> int | float | str | None: + """Get the air quality of an entity from the coordinator data.""" + value = parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.RangeController", + "rangeValue", + instance=instance_id, + debug=debug, + ) + return value + + +def parse_brightness_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None +) -> int | None: + """Get the brightness in the range 0-100.""" + return parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.BrightnessController", + "brightness", + since=since, + ) + + +def parse_color_temp_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None +) -> int | None: + """Get the color temperature in kelvin.""" + return parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.ColorTemperatureController", + "colorTemperatureInKelvin", + since=since, + ) + + +def parse_color_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None +) -> tuple[float, float, float] | None: + """Get the color as a tuple of (hue, saturation, brightness).""" + value = parse_value_from_coordinator( + coordinator, entity_id, "Alexa.ColorController", "color", since + ) + if value is not None: + hue = value.get("hue", 0) + saturation = value.get("saturation", 0) + return hue, saturation, 1 + return None + + +def parse_power_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None +) -> str | None: + """Get the power state of the entity.""" + return parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.PowerController", + "powerState", + since=since, + ) + + +def parse_guard_state_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str +) -> str | None: + """Get the guard state from the coordinator data.""" + return parse_value_from_coordinator( + coordinator, entity_id, "Alexa.SecurityPanelController", "armState" + ) + + +def parse_detection_state_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str +) -> bool | None: + """Get the detection state from the coordinator data.""" + return parse_value_from_coordinator( + coordinator, entity_id, "Alexa.ContactSensor", "detectionState" + ) + + +def parse_value_from_coordinator( + coordinator: DataUpdateCoordinator, + entity_id: str, + namespace: str, + name: str, + since: datetime | None = None, + instance: str | None = None, + *, + debug: bool = False, +) -> Any: + """Parse out values from coordinator for Alexa Entities.""" + if coordinator.data and entity_id in coordinator.data: + found_match = False + for cap_state in coordinator.data[entity_id]: + cap_instance = cap_state.get("instance") + instance_match = instance is None or ( + cap_instance is not None and str(cap_instance) == str(instance) + ) + if ( + cap_state.get("namespace") == namespace + and cap_state.get("name") == name + and instance_match + ): + found_match = True + if is_cap_state_still_acceptable(cap_state, since): + return cap_state.get("value") + if debug: + _LOGGER.debug( + "Coordinator data for %s (%s/%s instance=%s) is too old; checking other matches.", + entity_id, + namespace, + name, + instance, + ) + # Keep searching in case a newer matching cap_state exists later. + continue + if debug and found_match: + _LOGGER.debug( + "No acceptable coordinator data found for %s (%s/%s instance=%s).", + entity_id, + namespace, + name, + instance, + ) + else: + if debug: + _LOGGER.debug( + "Coordinator has no data yet for %s, %s, %s, %s", + entity_id, + namespace, + name, + instance, + ) + return None + + +def is_cap_state_still_acceptable( + cap_state: dict[str, Any], since: datetime | None +) -> bool: + """Determine if a particular capability state is still usable given its age.""" + if since is None: + return True + + # Don't protect requested state forever; after TTL fall back to coordinator + # even if timeOfSample is missing/unparsable. + if datetime.now(timezone.utc) - since > _REQUESTED_STATE_TTL: + return True + + formatted_time_of_sample = cap_state.get("timeOfSample") + if not formatted_time_of_sample: + # If we can't prove the sample is newer than the requested state, + # do not allow it to override optimistic/requested values. + return False + + try: + time_of_sample = datetime.fromisoformat(formatted_time_of_sample) + except ValueError: + return False + + return time_of_sample >= since diff --git a/custom_components/alexa_media/alexa_media.py b/custom_components/alexa_media/alexa_media.py new file mode 100644 index 0000000..7c96ac9 --- /dev/null +++ b/custom_components/alexa_media/alexa_media.py @@ -0,0 +1,48 @@ +""" +Alexa Devices Base Class. + +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 logging + +from alexapy import AlexaAPI, hide_email + +from .const import DATA_ALEXAMEDIA + +_LOGGER = logging.getLogger(__name__) + + +class AlexaMedia: + """Implementation of Alexa Media Base object.""" + + def __init__(self, device, login) -> None: + """Initialize the Alexa device.""" + + # Class info + self._login = login + self.alexa_api = AlexaAPI(device, login) + self.email = login.email + self.account = hide_email(login.email) + + def check_login_changes(self): + """Update Login object if it has changed.""" + # _LOGGER.debug("Checking if Login object has changed") + try: + login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email]["login_obj"] + except (AttributeError, KeyError): + return + # _LOGGER.debug("Login object %s closed status: %s", login, login.session.closed) + # _LOGGER.debug( + # "Alexaapi %s closed status: %s", + # self.alexa_api, + # self.alexa_api._session.closed, + # ) + if self.alexa_api.update_login(login): + _LOGGER.debug("Login object has changed; updating") + self._login = login + self.email = login.email + self.account = hide_email(login.email) diff --git a/custom_components/alexa_media/binary_sensor.py b/custom_components/alexa_media/binary_sensor.py new file mode 100644 index 0000000..dcfe054 --- /dev/null +++ b/custom_components/alexa_media/binary_sensor.py @@ -0,0 +1,128 @@ +""" +Alexa Devices Sensors. + +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 logging + +from alexapy import hide_serial +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + CONF_EMAIL, + CONF_EXCLUDE_DEVICES, + CONF_INCLUDE_DEVICES, + DATA_ALEXAMEDIA, + hide_email, +) +from .alexa_entity import parse_detection_state_from_coordinator +from .const import CONF_EXTENDED_ENTITY_DISCOVERY +from .helpers import add_devices, safe_get + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Set up the Alexa sensor platform.""" + devices: list[BinarySensorEntity] = [] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = safe_get(discovery_info, ["config", CONF_EMAIL]) + if account is None: + raise ConfigEntryNotReady + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + include_filter = config.get(CONF_INCLUDE_DEVICES, []) + exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) + coordinator = account_dict["coordinator"] + binary_entities = safe_get(account_dict, ["devices", "binary_sensor"], []) + if binary_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): + for binary_entity in binary_entities: + _LOGGER.debug( + "Creating entity %s for a binary_sensor with name %s", + hide_serial(binary_entity["id"]), + binary_entity["name"], + ) + contact_sensor = AlexaContact(coordinator, binary_entity) + account_dict["entities"]["binary_sensor"].append(contact_sensor) + devices.append(contact_sensor) + + return await add_devices( + hide_email(account), + devices, + add_devices_callback, + include_filter, + exclude_filter, + ) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alexa sensor platform by config_entry.""" + return await async_setup_platform( + hass, config_entry.data, async_add_devices, discovery_info=None + ) + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry.""" + account = entry.data[CONF_EMAIL] + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + _LOGGER.debug("Attempting to unload binary sensors") + for binary_sensor in account_dict["entities"]["binary_sensor"]: + await binary_sensor.async_remove() + return True + + +class AlexaContact(CoordinatorEntity, BinarySensorEntity): + """A contact sensor controlled by an Echo.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, coordinator: CoordinatorEntity, details: dict): + """Initialize alexa contact sensor. + + Args + coordinator (CoordinatorEntity): Coordinator + details (dict): Details dictionary + + """ + super().__init__(coordinator) + self.alexa_entity_id = details["id"] + self._name = details["name"] + + @property + def name(self): + """Return name.""" + return self._name + + @property + def unique_id(self): + """Return unique id.""" + return self.alexa_entity_id + + @property + def is_on(self): + """Return whether on.""" + detection = parse_detection_state_from_coordinator( + self.coordinator, self.alexa_entity_id + ) + + return detection == "DETECTED" if detection is not None else None + + @property + def assumed_state(self) -> bool: + """Return assumed state.""" + last_refresh_success = ( + self.coordinator.data and self.alexa_entity_id in self.coordinator.data + ) + return not last_refresh_success diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py new file mode 100644 index 0000000..350a1e2 --- /dev/null +++ b/custom_components/alexa_media/config_flow.py @@ -0,0 +1,1180 @@ +""" +Alexa Config Flow. + +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 +""" + +from asyncio import sleep +from collections import OrderedDict +import datetime +from datetime import timedelta +from functools import reduce +import html as html_lib +import logging +from typing import Any, Optional +from urllib.parse import urlparse + +from aiohttp import ClientConnectionError, ClientSession, InvalidURL, web, web_response +from aiohttp.web_exceptions import HTTPBadRequest +from alexapy import ( + AlexaLogin, + AlexaProxy, + AlexapyConnectionError, + AlexapyPyotpInvalidKey, + hide_email, + obfuscate, +) +from awesomeversion import AwesomeVersion +from homeassistant import config_entries +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.components.persistent_notification import ( + async_dismiss as async_dismiss_persistent_notification, +) +from homeassistant.const import ( + CONF_EMAIL, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_URL, + __version__ as HAVERSION, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult, UnknownFlow +from homeassistant.exceptions import Unauthorized +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.util import slugify +import httpx +import voluptuous as vol +from yarl import URL + +from .const import ( + AUTH_CALLBACK_NAME, + AUTH_CALLBACK_PATH, + AUTH_PROXY_NAME, + AUTH_PROXY_PATH, + CONF_DEBUG, + CONF_EXCLUDE_DEVICES, + CONF_EXTENDED_ENTITY_DISCOVERY, + CONF_HASS_URL, + CONF_INCLUDE_DEVICES, + CONF_OAUTH, + CONF_OTPSECRET, + CONF_PROXY_WARNING, + CONF_PUBLIC_URL, + CONF_QUEUE_DELAY, + CONF_SECURITYCODE, + CONF_TOTP_REGISTER, + DATA_ALEXAMEDIA, + DEFAULT_DEBUG, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_HASS_URL, + DEFAULT_PUBLIC_URL, + DEFAULT_QUEUE_DELAY, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + ISSUE_URL, +) +from .helpers import calculate_uuid + +_LOGGER = logging.getLogger(__name__) + +CONFIG_VERSION = 1 + + +@callback +def configured_instances(hass): + """Return a set of configured Alexa Media instances.""" + return {entry.title for entry in hass.config_entries.async_entries(DOMAIN)} + + +@callback +def in_progress_instances(hass): + """Return a set of in-progress Alexa Media flows.""" + return { + entry["flow_id"] + for entry in hass.config_entries.flow.async_progress() + if entry["handler"] == DOMAIN # Ensure only Alexa Media flows are included + } + + +@config_entries.HANDLERS.register(DOMAIN) +class AlexaMediaFlowHandler(config_entries.ConfigFlow): + """Handle a Alexa Media config flow.""" + + VERSION = CONFIG_VERSION + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + proxy: AlexaProxy = None + proxy_view: "AlexaMediaAuthorizationProxyView" = None + + def _update_ord_dict(self, old_dict: OrderedDict, new_dict: dict) -> OrderedDict: + result: OrderedDict = OrderedDict() + for k, v in old_dict.items(): # pylint: disable=invalid-name + for key, value in new_dict.items(): + if k == key: + result.update([(key, value)]) + break + if k not in result: + result.update([(k, v)]) + return result + + def __init__(self): + """Initialize the config flow.""" + self.login = None + self.securitycode: Optional[str] = None + self.automatic_steps: int = 0 + self.config = OrderedDict() + self.proxy_schema = None + self.data_schema = OrderedDict( + [ + (vol.Required(CONF_URL, default="amazon.com"), str), + (vol.Required(CONF_HASS_URL), str), + (vol.Required(CONF_EMAIL), str), + (vol.Required(CONF_PASSWORD), str), + (vol.Optional(CONF_OTPSECRET), str), + (vol.Optional(CONF_SECURITYCODE), str), + (vol.Optional(CONF_PUBLIC_URL), str), + (vol.Optional(CONF_INCLUDE_DEVICES, default=""), str), + (vol.Optional(CONF_EXCLUDE_DEVICES, default=""), str), + (vol.Optional(CONF_SCAN_INTERVAL, default=60), int), + (vol.Optional(CONF_QUEUE_DELAY, default=1.5), float), + (vol.Optional(CONF_EXTENDED_ENTITY_DISCOVERY, default=False), bool), + (vol.Optional(CONF_DEBUG, default=False), bool), + ] + ) + self.totp_register = OrderedDict( + [(vol.Optional(CONF_TOTP_REGISTER, default=False), bool)] + ) + self.proxy_warning = OrderedDict( + [(vol.Optional(CONF_PROXY_WARNING, default=False), bool)] + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user_legacy(import_config) + + async def async_step_user(self, user_input=None): + # pylint: disable=too-many-branches + + """Provide a proxy for login.""" + self._save_user_input_to_config(user_input=user_input) + """ Internal URL for proxy authentication """ + try: + hass_url: str = get_url(self.hass, allow_external=False) + except NoURLAvailableError: + hass_url = DEFAULT_HASS_URL + + """ External URL for cloud connected services """ + try: + url: str = get_url(self.hass, allow_internal=False) + except NoURLAvailableError: + DEFAULT_PUBLIC_URL = "" + else: + DEFAULT_PUBLIC_URL = url if url.endswith("/") else url + "/" + + self.proxy_schema = OrderedDict( + [ + ( + vol.Required( + CONF_URL, default=self.config.get(CONF_URL, "amazon.com") + ), + str, + ), + ( + vol.Required(CONF_EMAIL, default=self.config.get(CONF_EMAIL, "")), + str, + ), + ( + vol.Required( + CONF_PASSWORD, default=self.config.get(CONF_PASSWORD, "") + ), + str, + ), + ( + vol.Optional( + CONF_OTPSECRET, default=self.config.get(CONF_OTPSECRET, "") + ), + str, + ), + ( + vol.Optional( + CONF_HASS_URL, + default=self.config.get(CONF_HASS_URL, hass_url), + ), + str, + ), + ( + vol.Optional( + CONF_PUBLIC_URL, + default=self.config.get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL), + ), + str, + ), + ( + vol.Optional( + CONF_INCLUDE_DEVICES, + default=self.config.get(CONF_INCLUDE_DEVICES, ""), + ), + str, + ), + ( + vol.Optional( + CONF_EXCLUDE_DEVICES, + default=self.config.get(CONF_EXCLUDE_DEVICES, ""), + ), + str, + ), + ( + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ), + int, + ), + ( + vol.Optional( + CONF_QUEUE_DELAY, + default=self.config.get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), + ), + float, + ), + ( + vol.Optional( + CONF_EXTENDED_ENTITY_DISCOVERY, + default=self.config.get( + CONF_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, + ), + ), + bool, + ), + ( + vol.Optional( + CONF_DEBUG, default=self.config.get(CONF_DEBUG, DEFAULT_DEBUG) + ), + bool, + ), + ] + ) + if not user_input: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.proxy_schema), + description_placeholders={"message": ""}, + ) + if self.login is None: + try: + self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self.config[CONF_EMAIL] + ].get("login_obj") + except KeyError: + self.login = None + try: + if not self.login or self.login.session.closed: + _LOGGER.debug("Creating new login") + uuid_dict = await calculate_uuid( + self.hass, self.config.get(CONF_EMAIL), self.config[CONF_URL] + ) + uuid = uuid_dict["uuid"] + self.login = AlexaLogin( + url=self.config[CONF_URL], + email=self.config.get(CONF_EMAIL, ""), + password=self.config.get(CONF_PASSWORD, ""), + outputpath=self.hass.config.path, + debug=self.config[CONF_DEBUG], + otp_secret=self.config.get(CONF_OTPSECRET, ""), + oauth=self.config.get(CONF_OAUTH, {}), + uuid=uuid, + oauth_login=True, + ) + else: + _LOGGER.debug("Using existing login") + if self.config.get(CONF_EMAIL): + self.login.email = self.config.get(CONF_EMAIL) + if self.config.get(CONF_PASSWORD): + self.login.password = self.config.get(CONF_PASSWORD) + if self.config.get(CONF_OTPSECRET): + self.login.set_totp(self.config.get(CONF_OTPSECRET, "")) + except AlexapyPyotpInvalidKey: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.proxy_schema), + errors={"base": "2fa_key_invalid"}, + description_placeholders={ + "otp_secret": self.config.get(CONF_OTPSECRET, ""), + }, + ) + hass_url: str = user_input.get(CONF_HASS_URL) + if hass_url is None: + try: + hass_url = get_url(self.hass, prefer_external=True) + except NoURLAvailableError: + _LOGGER.debug( + "No Home Assistant URL found in config or detected; forcing user form" + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.proxy_schema), + description_placeholders={"message": ""}, + ) + hass_url_valid: bool = False + hass_url_error: str = "" + async with ClientSession() as session: + try: + async with session.get(hass_url) as resp: + hass_url_valid = resp.status == 200 + except ClientConnectionError as err: + hass_url_valid = False + hass_url_error = str(err) + except InvalidURL as err: + hass_url_valid = False + hass_url_error = str(err.__cause__) + if not hass_url_valid: + _LOGGER.debug( + "Unable to connect to provided Home Assistant url: %s", hass_url + ) + return self.async_show_form( + step_id="proxy_warning", + data_schema=vol.Schema(self.proxy_warning), + errors={}, + description_placeholders={ + "email": self.login.email, + "hass_url": hass_url, + "error": hass_url_error, + }, + ) + if ( + user_input + and user_input.get(CONF_OTPSECRET) + and user_input.get(CONF_OTPSECRET).replace(" ", "") + ): + otp: str = self.login.get_totp_token() + if otp: + _LOGGER.debug("Generated TOTP: %s", otp) + return self.async_show_form( + step_id="totp_register", + data_schema=vol.Schema(self.totp_register), + errors={}, + description_placeholders={ + "email": self.login.email, + "url": self.login.url, + "message": otp, + }, + ) + return await self.async_step_start_proxy(user_input) + + async def async_step_start_proxy(self, user_input=None): + """Start proxy for login.""" + # pylint: disable=unused-argument + _LOGGER.debug( + "Starting proxy for %s - %s", + hide_email(self.login.email), + self.login.url, + ) + if not self.proxy: + try: + self.proxy = AlexaProxy( + self.login, + str(URL(self.config.get(CONF_HASS_URL)).with_path(AUTH_PROXY_PATH)), + ) + self.proxy.session_factory = lambda: create_async_httpx_client( + self.hass, + verify_ssl=True, + follow_redirects=False, + timeout=httpx.Timeout( + connect=30.0, + read=120.0, + write=30.0, + pool=30.0, + ), + ) + except ValueError as ex: + return self.async_show_form( + step_id="user", + errors={"base": "invalid_url"}, + description_placeholders={"message": str(ex)}, + ) + # Swap the login object + self.proxy.change_login(self.login) + # Increase timeout for Amazon authentication (default 5s is too short) + if hasattr(self.proxy, "session") and self.proxy.session: + self.proxy.session.timeout = httpx.Timeout( + connect=30.0, + read=120.0, + write=30.0, + pool=30.0, + ) + _LOGGER.debug( + "Proxy session timeout set to: %s", + self.proxy.session.timeout, + ) + else: + _LOGGER.warning( + "Proxy: No session found on proxy object. Attrs: %s", + dir(self.proxy), + ) + if not self.proxy_view: + self.proxy_view = AlexaMediaAuthorizationProxyView(self.proxy.all_handler) + else: + _LOGGER.debug("Found existing proxy_view") + self.proxy_view.handler = self.proxy.all_handler + self.hass.http.register_view(AlexaMediaAuthorizationCallbackView()) + self.hass.http.register_view(self.proxy_view) + callback_url = ( + URL(self.config[CONF_HASS_URL]) + .with_path(AUTH_CALLBACK_PATH) + .with_query({"flow_id": self.flow_id}) + ) + + proxy_url = self.proxy.access_url().with_query( + {"config_flow_id": self.flow_id, "callback_url": str(callback_url)} + ) + self.login._session.cookie_jar.clear() # pylint: disable=protected-access + self.login.proxy_url = proxy_url + return self.async_external_step(step_id="check_proxy", url=str(proxy_url)) + + async def async_step_check_proxy(self, user_input=None): + # pylint: disable=unused-argument + """Check status of proxy for login.""" + _LOGGER.debug( + "Checking proxy response for %s - %s", + hide_email(self.login.email), + self.login.url, + ) + self.proxy_view.reset() + return self.async_external_step_done(next_step_id="finish_proxy") + + async def async_step_finish_proxy(self, user_input=None): + # pylint: disable=unused-argument + """Finish auth.""" + if await self.login.test_loggedin(): + await self.login.finalize_login() + self.config[CONF_EMAIL] = self.login.email + self.config[CONF_PASSWORD] = self.login.password + return await self._test_login() + return self.async_abort(reason="login_failed") + + async def async_step_user_legacy(self, user_input=None): + """Handle legacy input for the config flow.""" + # pylint: disable=too-many-return-statements + self._save_user_input_to_config(user_input=user_input) + self.data_schema = self._update_schema_defaults() + if not user_input: + self.automatic_steps = 0 + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.data_schema), + description_placeholders={"message": ""}, + ) + if ( + not self.config.get("reauth") + and f"{self.config[CONF_EMAIL]} - {self.config[CONF_URL]}" + in configured_instances(self.hass) + and not self.hass.data[DATA_ALEXAMEDIA]["config_flows"].get( + f"{self.config[CONF_EMAIL]} - {self.config[CONF_URL]}" + ) + ): + _LOGGER.debug("Existing account found") + self.automatic_steps = 0 + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.data_schema), + errors={CONF_EMAIL: "identifier_exists"}, + description_placeholders={"message": ""}, + ) + if self.login is None: + try: + self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self.config[CONF_EMAIL] + ].get("login_obj") + except KeyError: + self.login = None + try: + if not self.login or self.login.session.closed: + _LOGGER.debug("Creating new login") + uuid_dict = await calculate_uuid( + self.hass, self.config.get(CONF_EMAIL), self.config[CONF_URL] + ) + uuid = uuid_dict["uuid"] + self.login = AlexaLogin( + url=self.config[CONF_URL], + email=self.config[CONF_EMAIL], + password=self.config[CONF_PASSWORD], + outputpath=self.hass.config.path, + debug=self.config[CONF_DEBUG], + otp_secret=self.config.get(CONF_OTPSECRET, ""), + uuid=uuid, + oauth_login=True, + ) + else: + _LOGGER.debug("Using existing login") + if ( + not self.config.get("reauth") + and user_input + and user_input.get(CONF_OTPSECRET) + and user_input.get(CONF_OTPSECRET).replace(" ", "") + ): + otp: str = self.login.get_totp_token() + if otp: + _LOGGER.debug("Generated TOTP: %s", otp) + return self.async_show_form( + step_id="totp_register", + data_schema=vol.Schema(self.totp_register), + errors={}, + description_placeholders={ + "email": self.login.email, + "url": self.login.url, + "message": otp, + }, + ) + return self.async_show_form( + step_id="user", + errors={"base": "2fa_key_invalid"}, + description_placeholders={ + "otp_secret": user_input.get(CONF_OTPSECRET), + }, + ) + if self.login.status: + _LOGGER.debug("Resuming existing flow") + return await self._test_login() + _LOGGER.debug("Trying to login %s", self.login.status) + await self.login.login( + data=self.config, + ) + return await self._test_login() + except AlexapyConnectionError: + self.automatic_steps = 0 + return self.async_show_form( + step_id="user_legacy", + errors={"base": "connection_error"}, + description_placeholders={"message": ""}, + ) + except AlexapyPyotpInvalidKey: + self.automatic_steps = 0 + return self.async_show_form( + step_id="user_legacy", + errors={"base": "2fa_key_invalid"}, + description_placeholders={ + "otp_secret": user_input.get(CONF_OTPSECRET), + }, + ) + except BaseException as ex: # pylint: disable=broad-except + _LOGGER.warning("Unknown error: %s", ex) + if self.config[CONF_DEBUG]: + raise + self.automatic_steps = 0 + return self.async_show_form( + step_id="user_legacy", + errors={"base": "unknown_error"}, + description_placeholders={"message": str(ex)}, + ) + + async def async_step_proxy_warning(self, user_input=None): + """Handle the proxy_warning for the config flow.""" + self._save_user_input_to_config(user_input=user_input) + if user_input and user_input.get(CONF_PROXY_WARNING) is False: + _LOGGER.debug("User is not accepting warning, go back") + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.proxy_schema), + description_placeholders={"message": ""}, + ) + _LOGGER.debug("User is ignoring proxy warning; starting proxy anyway") + return await self.async_step_start_proxy(user_input) + + async def async_step_totp_register(self, user_input=None): + """Handle the input processing of the config flow.""" + self._save_user_input_to_config(user_input=user_input) + if user_input and user_input.get(CONF_TOTP_REGISTER) is False: + _LOGGER.debug("Not registered, regenerating") + otp: str = self.login.get_totp_token() + if otp: + _LOGGER.debug("Generated TOTP: %s", otp) + return self.async_show_form( + step_id="totp_register", + data_schema=vol.Schema(self.totp_register), + errors={}, + description_placeholders={ + "email": self.login.email, + "url": self.login.url, + "message": otp, + }, + ) + return await self.async_step_start_proxy(user_input) + + async def async_step_process(self, step_id, user_input=None): + """Handle the input processing of the config flow.""" + _LOGGER.debug( + "Processing input for %s: %s", + step_id, + obfuscate(user_input), + ) + self._save_user_input_to_config(user_input=user_input) + if user_input: + return await self.async_step_user(user_input=None) + return await self._test_login() + + async def async_step_reauth(self, user_input=None): + """Handle reauth processing for the config flow.""" + self._save_user_input_to_config(user_input) + self.config["reauth"] = True + reauth_schema = self._update_schema_defaults() + _LOGGER.debug( + "Creating reauth form with %s", + obfuscate(self.config), + ) + self.automatic_steps = 0 + if self.login is None: + try: + self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self.config[CONF_EMAIL] + ].get("login_obj") + except KeyError: + self.login = None + seconds_since_login: int = ( + (datetime.datetime.now() - self.login.stats["login_timestamp"]).seconds + if self.login + else 60 + ) + if seconds_since_login < 60: + _LOGGER.debug( + "Relogin requested within %s seconds; manual login required", + seconds_since_login, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(reauth_schema), + description_placeholders={"message": "REAUTH"}, + ) + _LOGGER.debug("Attempting automatic relogin") + await sleep(15) + return await self.async_step_user_legacy(self.config) + + async def _test_login(self): + login = self.login + email = login.email + _LOGGER.debug("Testing login status: %s", login.status) + if login.status and login.status.get("login_successful"): + existing_entry = await self.async_set_unique_id(f"{email} - {login.url}") + if self.config.get("reauth"): + self.config.pop("reauth") + if self.config.get(CONF_SECURITYCODE): + self.config.pop(CONF_SECURITYCODE) + if self.config.get("hass_url"): + self.config.pop("hass_url") + self.config[CONF_OAUTH] = { + "access_token": login.access_token, + "refresh_token": login.refresh_token, + "expires_in": login.expires_in, + "mac_dms": login.mac_dms, + "code_verifier": login.code_verifier, + "authorization_code": login.authorization_code, + } + self.hass.data.setdefault( + DATA_ALEXAMEDIA, + {"accounts": {}, "config_flows": {}, "notify_service": None}, + ) + self.hass.data[DATA_ALEXAMEDIA].setdefault("accounts", {}) + self.hass.data[DATA_ALEXAMEDIA].setdefault("config_flows", {}) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=self.config + ) + _LOGGER.debug("Reauth successful for %s", hide_email(email)) + self.hass.bus.async_fire( + "alexa_media_relogin_success", + event_data={"email": hide_email(email), "url": login.url}, + ) + host = urlparse(login.url).hostname or login.url + notification_id = f"alexa_media_{slugify(email)}_{slugify(host)}" + async_dismiss_persistent_notification( + self.hass, + notification_id, + ) + if not self.hass.data[DATA_ALEXAMEDIA]["accounts"].get( + self.config[CONF_EMAIL] + ): + self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self.config[CONF_EMAIL] + ] = {} + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.config[CONF_EMAIL]][ + "login_obj" + ] = self.login + self.hass.data[DATA_ALEXAMEDIA]["config_flows"][ + f"{email} - {login.url}" + ] = None + # Reload the integration to apply new credentials and clear error state + try: + _LOGGER.debug("Reloading integration for %s", hide_email(email)) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Failed to reload integration for %s; restart may be needed", + hide_email(email), + ) + return self.async_abort(reason="reauth_successful") + _LOGGER.debug( + "Setting up Alexa devices with %s", dict(obfuscate(self.config)) + ) + self._abort_if_unique_id_configured(self.config) + return self.async_create_entry( + title=f"{login.email} - {login.url}", data=self.config + ) + if login.status and login.status.get("securitycode_required"): + _LOGGER.debug( + "Creating config_flow to request 2FA. Saved security code %s", + self.securitycode, + ) + generated_securitycode: str = login.get_totp_token() + if ( + self.securitycode or generated_securitycode + ) and self.automatic_steps < 2: + if self.securitycode: + _LOGGER.debug( + "Automatically submitting securitycode %s", self.securitycode + ) + else: + _LOGGER.debug( + "Automatically submitting generated securitycode %s", + generated_securitycode, + ) + self.automatic_steps += 1 + await sleep(5) + if generated_securitycode: + return await self.async_step_user_legacy( + user_input={CONF_SECURITYCODE: generated_securitycode} + ) + return await self.async_step_user_legacy( + user_input={CONF_SECURITYCODE: self.securitycode} + ) + if login.status and (login.status.get("login_failed")): + _LOGGER.debug("Login failed: %s", login.status.get("login_failed")) + host = urlparse(login.url).hostname or login.url + notification_id = f"alexa_media_{slugify(email)}_{slugify(host)}" + await login.close() + async_dismiss_persistent_notification( + self.hass, + notification_id, + ) + return self.async_abort(reason="login_failed") + new_schema = self._update_schema_defaults() + if login.status and login.status.get("error_message"): + _LOGGER.debug("Login error detected: %s", login.status.get("error_message")) + if ( + login.status.get("error_message") + in { + "There was a problem\n Enter a valid email or mobile number\n " + } + and self.automatic_steps < 2 + ): + _LOGGER.debug( + "Trying automatic resubmission %s for error_message 'valid email'", + self.automatic_steps, + ) + self.automatic_steps += 1 + await sleep(5) + return await self.async_step_user_legacy(user_input=self.config) + _LOGGER.debug( + "Done with automatic resubmission for error_message 'valid email'; returning error message", + ) + self.automatic_steps = 0 + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(new_schema), + description_placeholders={ + "message": f" \n> {login.status.get('error_message', '')}" + }, + ) + + def _save_user_input_to_config(self, user_input=None) -> None: + # pylint: disable=too-many-branches + """Process user_input to save to self.config. + + user_input can be a dictionary of strings or an internally + saved config_entry data entry. This function will convert all to internal strings. + + """ + if user_input is None: + return + if CONF_HASS_URL in user_input: + self.config[CONF_HASS_URL] = user_input[CONF_HASS_URL] + self.securitycode = user_input.get(CONF_SECURITYCODE) + if self.securitycode is not None: + self.config[CONF_SECURITYCODE] = self.securitycode + elif CONF_SECURITYCODE in self.config: + self.config.pop(CONF_SECURITYCODE) + if user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace( + " ", "" + ): + self.config[CONF_OTPSECRET] = user_input[CONF_OTPSECRET].replace(" ", "") + elif user_input.get(CONF_OTPSECRET): + # a blank line + self.config.pop(CONF_OTPSECRET) + if CONF_EMAIL in user_input: + self.config[CONF_EMAIL] = user_input[CONF_EMAIL] + if CONF_PASSWORD in user_input: + self.config[CONF_PASSWORD] = user_input[CONF_PASSWORD] + if CONF_URL in user_input: + self.config[CONF_URL] = user_input[CONF_URL] + if CONF_PUBLIC_URL in user_input: + if not user_input[CONF_PUBLIC_URL].endswith("/"): + user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/" + self.config[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + if CONF_SCAN_INTERVAL in user_input: + self.config[CONF_SCAN_INTERVAL] = ( + user_input[CONF_SCAN_INTERVAL] + if not isinstance(user_input[CONF_SCAN_INTERVAL], timedelta) + else user_input[CONF_SCAN_INTERVAL].total_seconds() + ) + if CONF_QUEUE_DELAY in user_input: + self.config[CONF_QUEUE_DELAY] = user_input[CONF_QUEUE_DELAY] + if CONF_INCLUDE_DEVICES in user_input: + if isinstance(user_input[CONF_INCLUDE_DEVICES], list): + self.config[CONF_INCLUDE_DEVICES] = ( + reduce(lambda x, y: f"{x},{y}", user_input[CONF_INCLUDE_DEVICES]) + if user_input[CONF_INCLUDE_DEVICES] + else "" + ) + else: + self.config[CONF_INCLUDE_DEVICES] = user_input[CONF_INCLUDE_DEVICES] + if CONF_EXCLUDE_DEVICES in user_input: + if isinstance(user_input[CONF_EXCLUDE_DEVICES], list): + self.config[CONF_EXCLUDE_DEVICES] = ( + reduce(lambda x, y: f"{x},{y}", user_input[CONF_EXCLUDE_DEVICES]) + if user_input[CONF_EXCLUDE_DEVICES] + else "" + ) + else: + self.config[CONF_EXCLUDE_DEVICES] = user_input[CONF_EXCLUDE_DEVICES] + if CONF_EXTENDED_ENTITY_DISCOVERY in user_input: + self.config[CONF_EXTENDED_ENTITY_DISCOVERY] = user_input[ + CONF_EXTENDED_ENTITY_DISCOVERY + ] + if CONF_DEBUG in user_input: + self.config[CONF_DEBUG] = user_input[CONF_DEBUG] + + def _update_schema_defaults(self) -> Any: + new_schema = self._update_ord_dict( + self.data_schema, + { + vol.Required( + CONF_URL, default=self.config.get(CONF_URL, "amazon.com") + ): str, + vol.Required(CONF_EMAIL, default=self.config.get(CONF_EMAIL, "")): str, + vol.Required( + CONF_PASSWORD, default=self.config.get(CONF_PASSWORD, "") + ): str, + vol.Required( + CONF_SECURITYCODE, + default=self.securitycode if self.securitycode else "", + ): str, + vol.Required( + CONF_OTPSECRET, default=self.config.get(CONF_OTPSECRET, "") + ): str, + vol.Optional( + CONF_PUBLIC_URL, + default=self.config.get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL), + ): str, + vol.Optional( + CONF_INCLUDE_DEVICES, + default=self.config.get(CONF_INCLUDE_DEVICES, ""), + ): str, + vol.Optional( + CONF_EXCLUDE_DEVICES, + default=self.config.get(CONF_EXCLUDE_DEVICES, ""), + ): str, + vol.Optional( + CONF_SCAN_INTERVAL, default=self.config.get(CONF_SCAN_INTERVAL, 60) + ): int, + vol.Optional( + CONF_QUEUE_DELAY, default=self.config.get(CONF_QUEUE_DELAY, 1.5) + ): float, + vol.Optional( + CONF_EXTENDED_ENTITY_DISCOVERY, + default=self.config.get( + CONF_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, + ), + ): bool, + vol.Optional( + CONF_DEBUG, default=self.config.get(CONF_DEBUG, False) + ): bool, + }, + ) + return new_schema + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Alexa Media.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config = OrderedDict() + if AwesomeVersion(HAVERSION) < "2024.12": + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options""" + + self.options_schema = OrderedDict( + [ + ( + vol.Optional( + CONF_PUBLIC_URL, + default=self.config_entry.data.get( + CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL + ), + ), + str, + ), + ( + vol.Optional( + CONF_INCLUDE_DEVICES, + default=self.config_entry.data.get(CONF_INCLUDE_DEVICES, ""), + ), + str, + ), + ( + vol.Optional( + CONF_EXCLUDE_DEVICES, + default=self.config_entry.data.get(CONF_EXCLUDE_DEVICES, ""), + ), + str, + ), + ( + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.data.get(CONF_SCAN_INTERVAL, 120), + ), + int, + ), + ( + vol.Optional( + CONF_QUEUE_DELAY, + default=self.config_entry.data.get( + CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY + ), + ), + float, + ), + ( + vol.Optional( + CONF_EXTENDED_ENTITY_DISCOVERY, + default=self.config_entry.data.get( + CONF_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, + ), + ), + bool, + ), + ( + vol.Optional( + CONF_DEBUG, + default=self.config_entry.data.get(CONF_DEBUG, DEFAULT_DEBUG), + ), + bool, + ), + ] + ) + + if user_input is not None: + """Preserve these parameters""" + if CONF_URL in self.config_entry.data: + user_input[CONF_URL] = self.config_entry.data[CONF_URL] + if CONF_EMAIL in self.config_entry.data: + user_input[CONF_EMAIL] = self.config_entry.data[CONF_EMAIL] + if CONF_PASSWORD in self.config_entry.data: + user_input[CONF_PASSWORD] = self.config_entry.data[CONF_PASSWORD] + if CONF_SECURITYCODE in self.config_entry.data: + user_input[CONF_SECURITYCODE] = self.config_entry.data[ + CONF_SECURITYCODE + ] + if CONF_OTPSECRET in self.config_entry.data: + user_input[CONF_OTPSECRET] = self.config_entry.data[CONF_OTPSECRET] + if CONF_OAUTH in self.config_entry.data: + user_input[CONF_OAUTH] = self.config_entry.data[CONF_OAUTH] + """Ensure public_url ends with trailing slash""" + if CONF_PUBLIC_URL in self.config_entry.data: + if not user_input[CONF_PUBLIC_URL].endswith("/"): + user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/" + """Remove leading/trailing spaces in device strings""" + if CONF_INCLUDE_DEVICES in self.config_entry.data: + user_input[CONF_INCLUDE_DEVICES] = user_input[ + CONF_INCLUDE_DEVICES + ].strip() + if CONF_EXCLUDE_DEVICES in self.config_entry.data: + user_input[CONF_EXCLUDE_DEVICES] = user_input[ + CONF_EXCLUDE_DEVICES + ].strip() + + self.hass.config_entries.async_update_entry( + self.config_entry, data=user_input, options=self.config_entry.options + ) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(self.options_schema), + description_placeholders={"message": ""}, + ) + + +class AlexaMediaAuthorizationCallbackView(HomeAssistantView): + """Handle callback from external auth.""" + + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + requires_auth = False + + async def get(self, request: web.Request): + """Receive authorization confirmation.""" + hass = request.app["hass"] + try: + await hass.config_entries.flow.async_configure( + flow_id=request.query["flow_id"], user_input=None + ) + except (KeyError, UnknownFlow) as ex: + _LOGGER.debug("Callback flow_id is invalid.") + raise HTTPBadRequest() from ex + return web_response.Response( + headers={"content-type": "text/html"}, + text="Success! This window can be closed", + ) + + +class AlexaMediaAuthorizationProxyView(HomeAssistantView): + """Handle proxy connections.""" + + url: str = AUTH_PROXY_PATH + extra_urls: list[str] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] + name: str = AUTH_PROXY_NAME + requires_auth: bool = False + handler: web.RequestHandler = None + known_ips: dict[str, datetime.datetime] = {} + auth_seconds: int = 300 + + def __init__(self, handler: web.RequestHandler): + """Initialize routes for view. + + Args: + handler (web.RequestHandler): Handler to apply to all method types + + """ + AlexaMediaAuthorizationProxyView.handler = handler + for method in ("get", "post", "delete", "put", "patch", "head", "options"): + setattr(self, method, self.check_auth()) + + @classmethod + def check_auth(cls): + """Wrap authentication into the handler.""" + + async def wrapped(request, **kwargs): + """Notify that the API is running.""" + hass = request.app["hass"] + success = False + if ( + request.remote not in cls.known_ips + or (datetime.datetime.now() - cls.known_ips.get(request.remote)).seconds + > cls.auth_seconds + ): + try: + flow_id = request.url.query["config_flow_id"] + except KeyError as ex: + raise Unauthorized() from ex + for flow in hass.config_entries.flow.async_progress(): + if flow["flow_id"] == flow_id: + _LOGGER.debug( + "Found flow_id; adding %s to known_ips for %s seconds", + request.remote, + cls.auth_seconds, + ) + success = True + if not success: + raise Unauthorized() + cls.known_ips[request.remote] = datetime.datetime.now() + _sensitive_keys = { + "authorization", + "cookie", + "set-cookie", + "x-amz-security-token", + } + if _LOGGER.isEnabledFor(logging.DEBUG): + _safe_req_headers = { + k: ("***" if k.lower() in _sensitive_keys else v) + for k, v in request.headers.items() + } + _LOGGER.debug( + "Proxy request: %s %s | Remote: %s | Headers: %s", + request.method, + request.url, + request.remote, + _safe_req_headers, + ) + try: + result = await cls.handler(request, **kwargs) + if _LOGGER.isEnabledFor(logging.DEBUG): + _safe_resp_headers = ( + { + k: ("***" if k.lower() in _sensitive_keys else v) + for k, v in result.headers.items() + } + if hasattr(result, "headers") + else "unknown" + ) + _LOGGER.debug( + "Proxy response: %s %s | Status: %s | Response headers: %s", + request.method, + request.url, + result.status if hasattr(result, "status") else "unknown", + _safe_resp_headers, + ) + return result + except httpx.ConnectError as ex: + _LOGGER.warning("Detected Connection error: %s", ex) + return web_response.Response( + headers={"content-type": "text/html"}, + text="Connection Error! Please try refreshing. " + + "If this persists, please report this error to " + + f"here.", + ) + except web.HTTPException: + raise # Let aiohttp handle redirects (HTTPFound) and other HTTP exceptions + except Exception as ex: # pylint: disable=broad-except + _LOGGER.warning( + "Proxy exception at %s %s: %s - %s", + request.method, + request.url, + type(ex).__name__, + ex, + exc_info=True, + ) + return web_response.Response( + headers={"content-type": "text/html"}, + text="An unexpected error occurred during login. " + + "Please try refreshing. " + + "If this persists, please report this error to " + + f"here:" + + f"
{html_lib.escape(type(ex).__name__)}
", + ) + + return wrapped + + @classmethod + def reset(cls) -> None: + """Reset the view.""" + cls.known_ips = {} diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py new file mode 100644 index 0000000..bf63ee1 --- /dev/null +++ b/custom_components/alexa_media/const.py @@ -0,0 +1,419 @@ +""" +Support to interface with Alexa Devices. + +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 +""" + +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, +) + +PROJECT_URL = "https://github.com/alandtse/alexa_media_player/" +ISSUE_URL = f"{PROJECT_URL}issues" +NOTIFY_URL = f"{PROJECT_URL}wiki/Configuration%3A-Notification-Component#use-the-notifyalexa_media-service" + +DOMAIN = "alexa_media" +DATA_ALEXAMEDIA = "alexa_media" + +PLAY_SCAN_INTERVAL = 20 +SCAN_INTERVAL = timedelta(seconds=60) +MIN_TIME_BETWEEN_SCANS = SCAN_INTERVAL +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) + +ALEXA_COMPONENTS = [ + "media_player", +] +DEPENDENT_ALEXA_COMPONENTS = [ + "notify", + "switch", + "sensor", + "alarm_control_panel", + "light", + "binary_sensor", +] + +HTTP_COOKIE_HEADER = "# HTTP Cookie File" +CONF_ACCOUNTS = "accounts" +CONF_DEBUG = "debug" +CONF_HASS_URL = "hass_url" +CONF_INCLUDE_DEVICES = "include_devices" +CONF_EXCLUDE_DEVICES = "exclude_devices" +CONF_QUEUE_DELAY = "queue_delay" +CONF_PUBLIC_URL = "public_url" +CONF_EXTENDED_ENTITY_DISCOVERY = "extended_entity_discovery" +CONF_SECURITYCODE = "securitycode" +CONF_OTPSECRET = "otp_secret" +CONF_PROXY = "proxy" +CONF_PROXY_WARNING = "proxy_warning" +CONF_SCAN_INTERVAL = ( + "scan_interval" # local definition; HA's CONF_SCAN_INTERVAL is deprecated +) +CONF_TOTP_REGISTER = "registered" +CONF_OAUTH = "oauth" +DATA_LISTENER = "listener" + +EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}" + +DEFAULT_DEBUG = False +DEFAULT_EXTENDED_ENTITY_DISCOVERY = False +DEFAULT_HASS_URL = "http://homeassistant.local:8123" +DEFAULT_PUBLIC_URL = "" +DEFAULT_QUEUE_DELAY = 1.5 +DEFAULT_SCAN_INTERVAL = 60 + +EPOCH_MS_THRESHOLD = 10_000_000_000 + +# Service name constants used by services.py SERVICE_DEFS +SERVICE_UPDATE_LAST_CALLED = "update_last_called" +SERVICE_RESTORE_VOLUME = "restore_volume" +SERVICE_GET_HISTORY_RECORDS = "get_history_records" +SERVICE_FORCE_LOGOUT = "force_logout" +SERVICE_ENABLE_NETWORK_DISCOVERY = "enable_network_discovery" + +# Backoff durations for the last-called probe worker +LAST_CALLED_429_BACKOFF_INITIAL_S = 30.0 +LAST_CALLED_429_BACKOFF_MAX_S = 15 * 60.0 +LAST_CALLED_CONN_BACKOFF_S = 10.0 +LAST_CALLED_LOGIN_BACKOFF_S = 30.0 + +# Tuning constants for the per-account last-called probe worker +LAST_CALLED_DEBOUNCE_S = 3.5 # coalesce bursty pushes, but stay snappy +LAST_CALLED_RETRY_DELAY_S = 4.0 # wider retry cadence for delayed routine history +LAST_CALLED_RETRY_LIMIT = 2 # total attempts = 1 + retries (3 attempts) +LAST_CALLED_STALE_FUDGE_MS = 5_000 # allow some clock/ordering jitter +LAST_CALLED_SUCCESS_PACE_S = 4.0 # post-success pacing to avoid hammering +LAST_CALLED_LOOKBACK_MS = 60_000 +LAST_CALLED_ITEMS = 10 +LAST_CALLED_COALESCE_WINDOW_MS = 2000 + +# Tuning constants for notification retries +NOTIFICATION_COOLDOWN = 60 +NOTIFY_REFRESH_BACKOFF = 15.0 +NOTIFY_REFRESH_MAX_RETRIES = 3 + +# push-health magic numbers +HTTP2_ERROR_THRESHOLD = 5 +LAST_PUSH_INACTIVITY_SECONDS = 600.0 +LAST_PING_MAX_AGE_SECONDS = 900.0 + +RECURRING_PATTERN = { + None: "Never Repeat", + "P1D": "Every day", + "P1M": "Every month", + "XXXX-WE": "Weekends", + "XXXX-WD": "Weekdays", + "XXXX-WXX-1": "Every Monday", + "XXXX-WXX-2": "Every Tuesday", + "XXXX-WXX-3": "Every Wednesday", + "XXXX-WXX-4": "Every Thursday", + "XXXX-WXX-5": "Every Friday", + "XXXX-WXX-6": "Every Saturday", + "XXXX-WXX-7": "Every Sunday", +} + +RECURRING_DAY = { + "MO": 1, + "TU": 2, + "WE": 3, + "TH": 4, + "FR": 5, + "SA": 6, + "SU": 7, +} +RECURRING_PATTERN_ISO_SET = { + None: {}, + "P1D": {1, 2, 3, 4, 5, 6, 7}, + "XXXX-WE": {6, 7}, + "XXXX-WD": {1, 2, 3, 4, 5}, + "XXXX-WXX-1": {1}, + "XXXX-WXX-2": {2}, + "XXXX-WXX-3": {3}, + "XXXX-WXX-4": {4}, + "XXXX-WXX-5": {5}, + "XXXX-WXX-6": {6}, + "XXXX-WXX-7": {7}, +} + +ATTR_MESSAGE = "message" +ATTR_EMAIL = "email" +ATTR_ENTITY_ID = "entity_id" +ATTR_NUM_ENTRIES = "entries" +COMMON_BUCKET_COUNTS = ( + "accounts", + "devices", + "media_players", + "players", + "notifications", + "entities", +) +COMMON_DIAGNOSTIC_BUCKETS = ( + "account", + "accounts", + "login", + "logins", + "session", + "sessions", +) +COMMON_DIAGNOSTIC_NAMES = ( + "name", + "deviceName", + "accountName", + "friendlyName", + "title", +) +DEVICE_PLAYER_BUCKETS = ("devices", "media_players", "players") +TO_REDACT: set[str] = { + "email", + "password", + "access_token", + "refresh_token", + "token", + "csrf", + "cookie", + "cookies", + "session", + "sessionid", + "macDms", + "mac_dms", + "otp_secret", + "authorization_code", + "securitycode", + "code_verifier", + "adp_token", + "device_private_key", + "customerId", +} +STREAMING_ERROR_MESSAGE = ( + "Sorry, direct music streaming isn't supported. " + "This limitation is set by Amazon, and not by Alexa-Media-Player, Music-Assistant, nor Home-Assistant." +) +PUBLIC_URL_ERROR_MESSAGE = ( + "To send TTS, please set the public URL in integration configuration." +) +STARTUP_MESSAGE = """ +{name} Version Info +{DOMAIN}: v{version} +alexapy API: v{alexapy_version} +If you have any issues with this custom component, you need to open an issue here: {ISSUE_URL} +""" + +AUTH_CALLBACK_PATH = "/auth/alexamedia/callback" +AUTH_CALLBACK_NAME = "auth:alexamedia:callback" +AUTH_PROXY_PATH = "/auth/alexamedia/proxy" +AUTH_PROXY_NAME = "auth:alexamedia:proxy" + +ALEXA_UNIT_CONVERSION = { + "Alexa.Unit.Percent": PERCENTAGE, + "Alexa.Unit.PartsPerMillion": CONCENTRATION_PARTS_PER_MILLION, + "Alexa.Unit.Density.MicroGramsPerCubicMeter": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, +} + +ALEXA_ICON_CONVERSION = { + "Alexa.AirQuality.CarbonMonoxide": "mdi:molecule-co", + "Alexa.AirQuality.Humidity": "mdi:water-percent", + "Alexa.AirQuality.IndoorAirQuality": "mdi:numeric", + "Alexa.AirQuality.ParticulateMatter": "mdi:blur", + "Alexa.AirQuality.VolatileOrganicCompounds": "mdi:air-filter", +} +ALEXA_ICON_DEFAULT = "mdi:molecule" + +# Device class mapping for air quality sensors +# Maps Alexa sensor types to Home Assistant SensorDeviceClass +ALEXA_AIR_QUALITY_DEVICE_CLASS = { + "Alexa.AirQuality.ParticulateMatter": "pm25", + "Alexa.AirQuality.CarbonMonoxide": "carbon_monoxide", + "Alexa.AirQuality.IndoorAirQuality": "aqi", + "Alexa.AirQuality.VolatileOrganicCompounds": "aqi", + "Alexa.AirQuality.Humidity": "humidity", +} + +UPLOAD_PATH = "www/alexa_tts" + +# Note: Some of these are likely wrong +MODEL_IDS = { + "A10A33FOX2NUBK": "Echo Spot (Gen1)", + "A10L5JEZTKKCZ8": "Vobot Bunny", + "A11QM4H9HGV71H": "Echo Show 5 (Gen3)", + "A12GXV8XMS007S": "Fire TV (Gen1)", + "A12IZU8NMHSY5U": "Generic Device", + "A132LT22WVG6X5": "Samsung Soundbar Q700A", + "A13B2WB920IZ7X": "Samsung HW-Q70T Soundbar", + "A13W6HQIHKEN3Z": "Echo Auto", + "A14ZH95E6SE9Z1": "Bose Home Speaker 300", + "A15996VY63BQ2D": "Echo Show 8 (Gen2)", + "A15ERDAKK5HQQG": "Sonos", + "A15QWUTQ6FSMYX": "Echo Buds (Gen2)", + "A16MZVIFVHX6P6": "Generic Echo", + "A17LGWINFBUTZZ": "Anker Roav Viva", + "A18BI6KPKDOEI4": "Ecobee4", + "A18O6U1UQFJ0XK": "Echo Plus (Gen2)", + "A18TCD9FP10WJ9": "Orbi Voice", + "A18X8OBWBCSLD8": "Samsung Soundbar", + "A195TXHV1M5D4A": "Echo Auto", + "A1C66CX2XD756O": "Fire Tablet HD", + "A1D54LQEG0OXJ2": "Denon Home 250", + "A1EIANJ7PNB0Q7": "Echo Show 15 (Gen1)", + "A1ENT81UXFMNNO": "Unknown", + "A1ETW4IXK2PYBP": "Talk to Alexa", + "A1F1F76XIW4DHQ": "Unknown TV", + "A1F8D55J0FWDTN": "Fire TV (Toshiba)", + "A1H0CMF1XM0ZP4": "Bose SoundTouch 30", + "A1J16TEDOYCZTN": "Fire Tablet", + "A1JJ0KFC4ZPNJ3": "Echo Input", + "A1L4KDRIILU6N9": "Sony Speaker", + "A1LOQ8ZHF4G510": "Samsung Soundbar Q990B", + "A1M0A9L9HDBID3": "One-Link Safe and Sound", + "A1MKGHX5VQBDWX": "Denon Home 150", + "A1MUORL8FP149X": "Unknown", + "A1N9SW0I0LUX5Y": "Ford/Lincoln Alexa App", + "A1NL4BVLQ4L3N3": "Echo Show (Gen1)", + "A1NQ0LXWBGVQS9": "2021 Samsung QLED TV", + "A1P31Q3MOWSHOD": "Zolo Halo Speaker", + "A1P7E7V3FCZKU6": "Fire TV (Gen3)", + "A1Q69AKRWLJC0F": "TV", + "A1Q7QCGNMXAKYW": "Generic Tablet", + "A1QKZ9D0IJY332": "Samsung TV 2020-U", + "A1RABVCI4QCIKC": "Echo Dot (Gen3)", + "A1RTAM01W29CUP": "Windows App", + "A1SCI5MODUBAT1": "Pioneer DMH-W466NEX", + "A1TD5Z1R8IWBHA": "Tablet", + "A1VGB7MHSIEYFK": "Fire TV Cube Gen3", + "A1W2YILXTG9HA7": "Nextbase 522GW Dashcam", + "A1W46V57KES4B5": "Cable TV box Brazil", + "A1WZKXFLI43K86": "Fire TV Stick MAX", + "A1XWJRHALS1REP": "Echo Show 5 (Gen2)", + "A1Z88NGR2BK6A2": "Echo Show 8 (Gen1)", + "A25EC4GIHFOCSG": "Unrecognized Media Player", + "A25OJWHZA1MWNB": "2021 Samsung QLED TV", + "A265XOI9586NML": "Fire TV Stick", + "A27VEYGQBW3YR5": "Echo Link", + "A2A3XFQ1AVYLHZ": "SONY WF-1000XM5", + "A2BRQDVMSZD13S": "SURE Universal Remote", + "A2C8J6UHV0KFCV": "Alexa Gear", + "A2DS1Q2TPDJ48U": "Echo Dot Clock (Gen5)", + "A2E0SNTXJVT7WK": "Fire TV (Gen2)", + "A2E5N6DMWCW8MZ": "Brilliant Smart Switch", + "A2EZ3TS0L1S2KV": "Sonos Beam", + "A2GFL5ZMWNE0PX": "Fire TV (Gen3)", + "A2H4LV5GIZ1JFT": "Echo Dot Clock (Gen4)", + "A2HZENIFNYTXZD": "Facebook Portal", + "A2I0SCCU3561Y8": "Samsung Soundbar Q800A", + "A2IS7199CJBT71": "TV", + "A2IVLV5VM2W81": "Alexa Mobile Voice iOS", + "A2J0R2SD7G9LPA": "Lenovo SmartTab M10", + "A2JKHJ0PX4J3L3": "Fire TV Cube (Gen2)", + "A2LH725P8DQR2A": "Fabriq Riff", + "A2LLN0UXRW4N50": "Echo Show 11 (Gen1)", + "A2LWARUGJLBYEW": "Fire TV Stick (Gen2)", + "A2M35JJZWCQOMZ": "Echo Plus (Gen1)", + "A2M4YX06LWP8WI": "Fire Tablet", + "A2N49KXGVA18AR": "Fire Tablet HD 10 Plus", + "A2OSP3UA4VC85F": "Sonos", + "A2R2GLZH1DFYQO": "Zolo Halo Speaker", + "A2RU4B77X9R9NZ": "Echo Link Amp", + "A2TF17PFR55MTB": "Alexa Mobile Voice Android", + "A2TTLILJHVNI9X": "LG TV", + "A2U21SRK4QGSE1": "Echo Dot (Gen4)", + "A2UONLFQW0PADH": "Echo Show 8 (Gen3)", + "A2V9UEGZ82H4KZ": "Fire Tablet HD 10", + "A2VAXZ7UNGY4ZH": "Wyze Headphones", + "A2WFDCBDEXOXR8": "Bose Soundbar 700", + "A2WJ2CM9ARLMRH": "Rivian Electric Vehicle", + "A2WN1FJ2HG09UN": "Ultimate Alexa App", + "A2X8WT9JELC577": "Ecobee5", + "A2XPGY5LRKB9BE": "Fitbit Versa 2", + "A2Y04QPFCANLPQ": "Bose QuietComfort 35 II", + "A303PJF6ISQ7IC": "Echo Auto", + "A30YDR2MK8HMRV": "Echo (Gen3)", + "A31DTMEEVDDOIV": "Fire TV Stick Lite", + "A324YMIUSWQDGE": "Samsung 8K TV", + "A32DDESGESSHZA": "Echo Dot (Gen3)", + "A32DOYMUN6DTXA": "Echo Dot (Gen3)", + "A339L426Y220I4": "Teufel Radio", + "A347G2JC8I4HC7": "Roav Car Charger Pro", + "A37CFAHI1O0CXT": "Logitech Blast", + "A37M7RU8Z6ZFB": "Garmin Speak", + "A37SHHQ3NUL7B5": "Bose Home Speaker 500", + "A38949IHXHRQ5P": "Echo Tap", + "A38BPK7OW001EX": "Raspberry Alexa", + "A38EHHIB10L47V": "Fire Tablet HD 8", + "A39BU42XNMN516": "Generic Device", + "A3B50IC5QPZPWP": "Polk Command Bar", + "A3B5K1G3EITBIF": "Facebook Portal", + "A3BRT6REMPQWA8": "Bose Home Speaker 450", + "A3BW5ZVFHRCQPO": "BMW Alexa Integration", + "A3C9PE6TNYLTCH": "Speaker Group", + "A3CY98NH016S5F": "Facebook Portal Mini", + "A3D4YURNTARP5K": "Facebook Portal TV", + "A3EH2E0YZ30OD6": "Echo Spot (Gen2)", + "A3EVMLQTU6WL1W": "Fire TV Stick 4K Max (Gen1)", + "A3F1S88NTZZXS9": "Dash Wand", + "A3FX4UWTP28V1P": "Echo (Gen3)", + "A3GFRGUNIGG1I5": "Samsung TV QN50Q60CAGXZD", + "A3HF4YRA2L7XGC": "Fire TV Cube", + "A3IYPH06PH1HRA": "Echo Frames", + "A3K69RS3EIMXPI": "Hisense Smart TV", + "A3KULB3NQN7Z1F": "Unknown TV", + "A3L0T0VL9A921N": "Fire Tablet HD 8", + "A3NPD82ABCPIDP": "Sonos Beam", + "A3QPPX1R9W5RJV": "Fabriq Chorus", + "A3QS1XP2U6UJX9": "SONY WF-1000XM4", + "A3R9S4ZZECZ6YL": "Fire Tablet HD 10", + "A3RBAYBE7VM004": "Echo Studio", + "A3RCTOK2V0A4ZG": "LG TV", + "A3RMGO6LYLH7YN": "Echo Dot (Gen4)", + "A3S5BH2HU6VAYF": "Echo Dot (Gen2)", + "A3SSG6GR8UU7SN": "Echo Sub", + "A3SSWQ04XYPXBH": "Generic Tablet", + "A3TCJ8RTT3NVI7": "Alexa Listens", + "A3VRME03NAXFUB": "Echo Flex", + "A4ZP7ZC4PI6TO": "Echo Show 5 (Gen1)", + "A4ZXE0RM7LQ7A": "Echo Dot (Gen5)", + "A52ARKF0HM2T4": "Facebook Portal+", + "A6SIQKETF3L2E": "Unknown Device", + "A7WXQPH584YP": "Echo (Gen2)", + "A81PNL0A63P93": "Home Remote", + "A8DM4FYR6D3HT": "TV", + "AA1IN44SS3X6O": "Ecobee Thermostat Premium", + "AB72C64C86AW2": "Echo (Gen1)", + "ABJ2EHL7HQT4L": "Unknown Amplifier", + "ADVBD696BHNV5": "Fire TV Stick (Gen1)", + "AE7X7Z227NFNS": "HiMirror Mini", + "AF473ZSOIRKFJ": "Onkyo VC-PX30", + "AFF50AL5E3DIU": "Fire TV (Insignia)", + "AFF5OAL5E3DIU": "Fire TV", + "AGHZIK8D6X7QR": "Fire TV", + "AHJYKVA63YCAQ": "Sonos", + "AIPK7MM90V7TB": "Echo Show 10 (Gen3)", + "AKKLQD9FZWWQS": "Jabra Elite", + "AKNO1N0KSFN8L": "Echo Dot (Gen1)", + "AKO51L5QAQKL2": "Alexa Jams", + "AKPGW064GI9HE": "Fire TV Stick 4K (Gen3)", + "ALCIV0P5M8TZ0": "Samsung Soundbar S800B", + "ALT9P69K6LORD": "Echo Auto", + "AMCZ48H33RCDF": "Samsung HW-Q910B 9.1.2 ch Soundbar", + "AN630UQPG2CA4": "Fire TV (Toshiba)", + "AO6HHP9UE6EOF": "Unknown Media Device", + "AP1F6KUH00XPV": "Stereo/Subwoofer Pair", + "AP4RS91ZQ0OOI": "Fire TV (Toshiba)", + "APHEAY6LX7T13": "Samsung Smart Refrigerator", + "AQCGW9PSYWRF": "TV", + "AR6X0XNIME80V": "Unknown TV", + "ASQZWP4GPYUT7": "Echo Pop", + "ATNLRCEBX3W4P": "Generic Tablet", + "AUPUQSVCVHXP0": "Ecobee Switch+", + "AVD3HM0HOJAAL": "Sonos", + "AVE5HX13UR5NO": "Logitech Zero Touch", + "AVN2TMX8MU2YM": "Bose Home Speaker 500", + "AVU7CPPF2ZRAS": "Fire Tablet HD 8", + "AWZZ5CVHX2CD": "Echo Show (Gen2)", +} diff --git a/custom_components/alexa_media/coordinator.py b/custom_components/alexa_media/coordinator.py new file mode 100644 index 0000000..2cdc886 --- /dev/null +++ b/custom_components/alexa_media/coordinator.py @@ -0,0 +1,99 @@ +"""Optimized DataUpdateCoordinator for Alexa Media Player. + +Optimizations: +- Debouncer for request coalescing +- Type-safe runtime data integration +""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any, Callable + +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, SCAN_INTERVAL + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from .runtime_data import AlexaRuntimeData + +_LOGGER = logging.getLogger(__name__) + +# Debounce cooldown in seconds - prevents API hammering during push bursts +REQUEST_REFRESH_DEBOUNCE_COOLDOWN = 1.5 + + +class AlexaMediaCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for Alexa Media Player. + + Features: + - Debounced refresh requests to avoid API hammering + - Type-safe integration with runtime_data + """ + + def __init__( + self, + hass: HomeAssistant, + runtime_data: AlexaRuntimeData | None, + update_method: Callable, + scan_interval: float | None = None, + ) -> None: + """Initialize the coordinator. + + Args: + hass: Home Assistant instance + runtime_data: Runtime data for this config entry + update_method: Async method to fetch data + scan_interval: Polling interval in seconds (default: SCAN_INTERVAL) + """ + self.runtime_data = runtime_data + self._scan_interval = scan_interval or SCAN_INTERVAL.total_seconds() + + # Calculate update interval based on HTTP2 status + http2_enabled = runtime_data.http2 is not None if runtime_data else False + update_interval = timedelta( + seconds=self._scan_interval * 10 if http2_enabled else self._scan_interval + ) + + # Initialize debouncer for request coalescing + # This prevents multiple rapid refresh requests from hammering the API + debouncer = Debouncer( + hass, + _LOGGER, + cooldown=REQUEST_REFRESH_DEBOUNCE_COOLDOWN, + immediate=True, + ) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=( + runtime_data.config_entry + if runtime_data and runtime_data.config_entry + else None + ), + update_method=update_method, + update_interval=update_interval, + request_refresh_debouncer=debouncer, + ) + + def set_http2_status(self, enabled: bool) -> None: + """Update polling interval based on HTTP2 connection status. + + When HTTP2 is enabled, we can poll less frequently since we get push updates. + """ + new_interval = timedelta( + seconds=self._scan_interval * 10 if enabled else self._scan_interval + ) + if self.update_interval != new_interval: + self.update_interval = new_interval + _LOGGER.debug( + "Updated polling interval: %s (HTTP2: %s)", + new_interval, + enabled, + ) diff --git a/custom_components/alexa_media/diagnostics.py b/custom_components/alexa_media/diagnostics.py new file mode 100644 index 0000000..61fe775 --- /dev/null +++ b/custom_components/alexa_media/diagnostics.py @@ -0,0 +1,445 @@ +"""Diagnostics support for Alexa Media Player.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import fields, is_dataclass +from datetime import datetime +from itertools import islice +import re +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.redact import async_redact_data +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + COMMON_BUCKET_COUNTS, + COMMON_DIAGNOSTIC_BUCKETS, + COMMON_DIAGNOSTIC_NAMES, + DEVICE_PLAYER_BUCKETS, + DOMAIN, + TO_REDACT, +) + + +# -------------------- +# Local Functions +# -------------------- +def _safe_dt(val: Any) -> str | None: + """Serialize datetimes safely for JSON diagnostics.""" + if isinstance(val, datetime): + return val.isoformat() + return None + + +def _maybe_len(val: Any) -> int | None: + """Return the length of common container types or None if not applicable.""" + if isinstance(val, (list, tuple, dict, set)): + return len(val) + return None + + +def _maybe_keys(val: Any, limit: int = 50) -> list[str] | None: + """Return a sanitized sample of mapping keys for diagnostics. + + If ``val`` is a mapping, return up to ``limit`` obfuscated keys to provide + structural insight without exposing sensitive data. Email-like keys are + redacted when possible; otherwise keys are shortened to a non-identifying + form. Returns ``None`` if ``val`` is not a mapping or keys cannot be read. + """ + + if isinstance(val, Mapping): + try: + # Sample up to `limit` keys to keep diagnostics small. + def _safe_key(k: Any) -> str: + s = str(k) + # Emails/titles/tokens sometimes appear as keys in AMP structures. + if re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", s): + try: + from alexapy import ( # pylint: disable=import-outside-toplevel + hide_email, + ) + + return hide_email(s) + except (ImportError, AttributeError, TypeError, ValueError): + pass + return _obfuscate_identifier(s) + + return sorted(_safe_key(k) for k in islice(val.keys(), limit)) + except (TypeError, AttributeError): + return None + return None + + +def _sample_names(val: Any, *, limit: int = 5) -> list[str] | None: + """Try to sample human-friendly names from a list/dict of device-like objects.""" + names: list[str] = [] + + def add_name(x: Any) -> None: + if isinstance(x, Mapping): + for key in COMMON_DIAGNOSTIC_NAMES: + v = x.get(key) + if isinstance(v, str) and v: + names.append(v) + return + v = getattr(x, "name", None) + if isinstance(v, str) and v: + names.append(v) + + if isinstance(val, Mapping): + for v in islice(val.values(), limit * 2): + add_name(v) + if len(names) >= limit: + break + return names[:limit] if names else None + + if isinstance(val, (list, tuple)): + for v in val[: limit * 2]: + add_name(v) + if len(names) >= limit: + break + return names[:limit] if names else None + + return None + + +# -------------------- +# Coordinator discovery + summary +# -------------------- +def _find_coordinators(obj: Any) -> list[DataUpdateCoordinator]: + """Recursively find DataUpdateCoordinator instances in an object tree.""" + found: list[DataUpdateCoordinator] = [] + visited: set[int] = set() + + def walk(x: Any) -> None: + obj_id = id(x) + if obj_id in visited: + return + visited.add(obj_id) + + if isinstance(x, DataUpdateCoordinator): + found.append(x) + return + if is_dataclass(x): + try: + # Walk dataclass attributes directly; asdict() can lose/mangle objects. + for f in fields(x): + try: + walk(getattr(x, f.name)) + except (AttributeError, TypeError, ValueError): + # Skip fields that can't be read safely + pass + except (TypeError, ValueError): + # Fallback: vars() can work for some dataclass/slots variations + try: + for v in vars(x).values(): + walk(v) + except (AttributeError, TypeError, ValueError): + # Ignore attributes that cannot be introspected via vars() + pass + return + if isinstance(x, Mapping): + for v in x.values(): + walk(v) + return + if isinstance(x, (list, tuple, set)): + for v in x: + walk(v) + return + # Ignore everything else. + + walk(obj) + return found + + +def _summarize_coordinator_data(cdata: Any) -> dict: + """ + Allowlisted summary of coordinator.data. + + Never dump raw coordinator data. Only return counts + small samples. + Optimized for AMP: coordinator.data is often a mapping keyed by UUIDs. + """ + out: dict[str, Any] = {} + + if isinstance(cdata, Mapping): + out["data_key_count"] = len(cdata) + + key_sample = list(islice(cdata.keys(), 10)) + + out["data_key_types_sample"] = [type(k).__name__ for k in key_sample] + + sample_vals = [type(cdata.get(k)).__name__ for k in key_sample[:3]] + if sample_vals: + out["data_value_types_sample"] = sample_vals + + # If coordinator.data sometimes contains named buckets (future-proof), + # include just counts (but only if those keys actually exist). + for key in COMMON_DIAGNOSTIC_BUCKETS: + if key in cdata: + out[f"{key}_count"] = _maybe_len(cdata.get(key)) + + # If AMP ever exposes last_called through coordinator.data, include only safe fields. + last_called = cdata.get("last_called") + if isinstance(last_called, Mapping): + ts = last_called.get("timestamp") + out["last_called"] = { + "timestamp": _safe_dt(ts) or ts, + "summary": last_called.get("summary"), + } + + # If there are device/player buckets, sample friendly names (no IDs). + for key in DEVICE_PLAYER_BUCKETS: + if key in cdata: + sample = _sample_names(cdata.get(key)) + if sample: + out[f"{key}_sample_names"] = sample + break + + return out + + if isinstance(cdata, (list, tuple)): + out["data_len"] = len(cdata) + sample = _sample_names(cdata) + if sample: + out["sample_names"] = sample + return out + + if cdata is not None: + out["data_type"] = type(cdata).__name__ + return out + + +def _summarize_coordinator(coordinator: DataUpdateCoordinator) -> dict: + """Return a safe, compact view of a coordinator.""" + exc = getattr(coordinator, "last_exception", None) + + data = { + "name": getattr(coordinator, "name", None), + "last_update_success": getattr(coordinator, "last_update_success", None), + "has_exception": exc is not None, + "last_exception_type": type(exc).__name__ if exc else None, + "update_interval": ( + str(getattr(coordinator, "update_interval", None)) + if getattr(coordinator, "update_interval", None) is not None + else None + ), + "last_update": _safe_dt(getattr(coordinator, "last_update", None)), + } + + try: + data["data_summary"] = _summarize_coordinator_data( + getattr(coordinator, "data", None) + ) + except ( + Exception + ) as exc: # noqa: BLE001 - intentionally broad; diagnostics must not crash + data["data_summary_error"] = type(exc).__name__ + data["data_summary_error_present"] = True + + return data + + +# -------------------- +# AMP-specific (non-coordinator) runtime summaries +# -------------------- +def _summarize_amp_entry_runtime(entry_runtime: Any) -> dict: + """ + Best-effort summary of hass.data[DOMAIN][entry_id] runtime. + + AMP may not store anything here; keep robust. + """ + out: dict[str, Any] = {"present": entry_runtime is not None} + + if isinstance(entry_runtime, Mapping): + out["runtime_type"] = "mapping" + out["runtime_keys"] = _maybe_keys(entry_runtime) + # Common “bucket” counts if they happen to exist. + for key in COMMON_BUCKET_COUNTS: + if key in entry_runtime: + out[f"{key}_count"] = _maybe_len(entry_runtime.get(key)) + # Small sample of names + for key in DEVICE_PLAYER_BUCKETS: + if key in entry_runtime: + sample = _sample_names(entry_runtime.get(key)) + if sample: + out[f"{key}_sample_names"] = sample + break + else: + if entry_runtime is not None: + out["runtime_type"] = type(entry_runtime).__name__ + + return out + + +def _obfuscate_identifier(val: Any) -> str: + """Return a shortened, non-identifying representation of a value. + + Non-string, empty, or very short values are fully masked. Longer strings + are reduced to a minimal prefix and suffix to aid debugging without + exposing the original identifier. + """ + if not isinstance(val, str) or not val or len(val) <= 4: + return "****" + return f"{val[:2]}...{val[-2:]}" + + +def _obfuscate_title_with_email(title: str | None, email: str | None) -> str | None: + """Obfuscate email in config entry title using the same mechanism as AMP logs.""" + if not title or not email: + return title + + try: + # Lazy import to keep diagnostics import cheap + from alexapy import hide_email # pylint: disable=import-outside-toplevel + + redacted = hide_email(email) + except (ImportError, AttributeError, TypeError, ValueError): + redacted = _obfuscate_identifier(email) + + return title.replace(email, redacted) + + +def _get_safe_config_entry_title(config_entry: ConfigEntry) -> str | None: + """Get obfuscated config entry title.""" + email = config_entry.data.get("email") + return _obfuscate_title_with_email(config_entry.title, email) + + +def _summarize_amp_domain(domain_data: Any, config_entry: ConfigEntry) -> dict: + """ + Best-effort summary of hass.data[DOMAIN] for AMP. + + AMP historically stores account/login state in custom structures, not always + keyed by entry_id, and often not using DataUpdateCoordinator. + """ + out: dict[str, Any] = {} + out["domain_data_present"] = domain_data is not None + out["domain_data_type"] = ( + type(domain_data).__name__ if domain_data is not None else None + ) + + if not isinstance(domain_data, Mapping): + return out + + out["domain_keys"] = _maybe_keys(domain_data) + + # Try a few common/likely buckets without dumping contents. + # NOTE: We deliberately avoid copying values; only report counts/types/samples. + for key in COMMON_DIAGNOSTIC_BUCKETS: + if key in domain_data: + val = domain_data.get(key) + out[f"{key}_type"] = type(val).__name__ + out[f"{key}_len"] = _maybe_len(val) + sample = _sample_names(val) + if sample: + out[f"{key}_sample_names"] = sample + + # Try to locate the specific account blob by email/title if present. + # The config entry title often contains "email - url". We'll only use it to + # match keys; we won't add the email to diagnostics (redaction will remove it). + raw_title = config_entry.title or "" + email = config_entry.data.get("email") + out["entry_title_hint"] = _obfuscate_title_with_email(raw_title, email) + # Some integrations store per-entry runtime keyed by entry_id *or* by title/email. + # Report whether those keys exist. + out["has_entry_id_key"] = config_entry.entry_id in domain_data + out["has_title_key"] = raw_title in domain_data if raw_title else False + + return out + + +# -------------------- +# Diagnostics entry points +# -------------------- +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict: + """Return diagnostics for a config entry.""" + domain_data = hass.data.get(DOMAIN) + safe_title = _get_safe_config_entry_title(config_entry) + + # AMP currently doesn't store runtime under entry_id. + # This adds future-proofing for if and when it does. + entry_runtime = None + if isinstance(domain_data, Mapping): + entry_runtime = domain_data.get(config_entry.entry_id) + + # Coordinator discovery: + # 1) Try under entry_runtime (best practice) + # 2) If none found and domain_data is a mapping, try domain_data as a whole + coordinators: list[DataUpdateCoordinator] = [] + searched: list[str] = [] + + if entry_runtime is not None: + searched.append("hass.data[DOMAIN][entry_id]") + coordinators = _find_coordinators(entry_runtime) + + if not coordinators and isinstance(domain_data, Mapping): + searched.append("hass.data[DOMAIN]") + coordinators = _find_coordinators(domain_data) + + coordinator_summaries = [_summarize_coordinator(c) for c in coordinators] + + data: dict = { + "entry": { + "entry_id": config_entry.entry_id, + "title": safe_title, + "domain": config_entry.domain, + "version": config_entry.version, + "minor_version": config_entry.minor_version, + }, + # Include config + options; sensitive values are redacted below. + "data": dict(config_entry.data), + "options": dict(config_entry.options), + "account": { + "searched_for_coordinators_in": searched, + "coordinator_count": len(coordinator_summaries), + "coordinators": coordinator_summaries, + # AMP-specific summaries (useful when coordinator_count == 0) + "amp_entry_runtime_summary": _summarize_amp_entry_runtime(entry_runtime), + "amp_domain_summary": _summarize_amp_domain(domain_data, config_entry), + }, + } + + return async_redact_data(data, TO_REDACT) + + +async def async_get_device_diagnostics( + _hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry +) -> dict: + """Return diagnostics for a specific device.""" + safe_title = _get_safe_config_entry_title(config_entry) + + try: + # Lazy import to keep diagnostics import cheap + from alexapy import hide_serial # pylint: disable=import-outside-toplevel + + safe_serial = hide_serial(device.serial_number) + except (ImportError, AttributeError, TypeError, ValueError): + safe_serial = _obfuscate_identifier(device.serial_number) + + data: dict = { + "device": { + "id": _obfuscate_identifier(device.id), + "name": device.name, + "name_by_user": device.name_by_user, + "manufacturer": device.manufacturer, + "model": device.model, + "sw_version": device.sw_version, + "serial_number": safe_serial, + "identifiers": sorted( + (domain, _obfuscate_identifier(value)) + for domain, value in device.identifiers + ), + "via_device_id": _obfuscate_identifier(device.via_device_id), + }, + "config_entry": { + "entry_id": config_entry.entry_id, + "title": safe_title, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/custom_components/alexa_media/exceptions.py b/custom_components/alexa_media/exceptions.py new file mode 100644 index 0000000..a44b45c --- /dev/null +++ b/custom_components/alexa_media/exceptions.py @@ -0,0 +1,34 @@ +"""Alexa Media Exceptions""" + + +class EmptyDataException(Exception): + """Empty data exception""" + + +class ForbiddenException(Exception): + """Forbidden exception""" + + +class LoginForbiddenException(Exception): + """Login forbidden exception""" + + +class LoginInvalidException(Exception): + """Invalid login exception""" + + def __init__(self, attempts_remaining): + self.attempts_remaining = attempts_remaining + super().__init__( + f"Invalid login credentials. {attempts_remaining} attempts remaining." + ) + + +class TimeoutException(Exception): + """Timeout exception""" + + def __init__(self, message=""): + super().__init__(f"Timeout exception: {message}") + + +class UnexpectedApiException(Exception): + """Unexpected API exception""" diff --git a/custom_components/alexa_media/helpers.py b/custom_components/alexa_media/helpers.py new file mode 100644 index 0000000..cb09f92 --- /dev/null +++ b/custom_components/alexa_media/helpers.py @@ -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 " 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" ({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 diff --git a/custom_components/alexa_media/light.py b/custom_components/alexa_media/light.py new file mode 100644 index 0000000..3ad12bf --- /dev/null +++ b/custom_components/alexa_media/light.py @@ -0,0 +1,530 @@ +""" +Alexa Devices Lights. + +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 datetime +import logging +from math import sqrt +from typing import Optional + +from alexapy import AlexaAPI, hide_serial +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.color import ( + color_hs_to_RGB, + color_hsb_to_RGB, + color_name_to_rgb, + color_RGB_to_hs, +) + +from . import ( + CONF_EMAIL, + CONF_EXCLUDE_DEVICES, + CONF_INCLUDE_DEVICES, + DATA_ALEXAMEDIA, + hide_email, +) +from .alexa_entity import ( + parse_brightness_from_coordinator, + parse_color_from_coordinator, + parse_color_temp_from_coordinator, + parse_power_from_coordinator, +) +from .const import CONF_EXTENDED_ENTITY_DISCOVERY +from .helpers import add_devices, safe_get + +_LOGGER = logging.getLogger(__name__) + +LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo + + +async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Set up the Alexa sensor platform.""" + devices: list[LightEntity] = [] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = safe_get(discovery_info, ["config", CONF_EMAIL]) + if account is None: + raise ConfigEntryNotReady + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + include_filter = config.get(CONF_INCLUDE_DEVICES, []) + exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) + coordinator = account_dict["coordinator"] + hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get( + "components", set() + ) + light_entities = safe_get(account_dict, ["devices", "light"], []) + if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): + for light_entity in light_entities: + if not (light_entity["is_hue_v1"] and hue_emulated_enabled): + _LOGGER.debug( + "Creating entity %s for a light with name %s", + hide_serial(light_entity["id"]), + light_entity["name"], + ) + light = AlexaLight(coordinator, account_dict["login_obj"], light_entity) + account_dict["entities"]["light"].append(light) + devices.append(light) + else: + _LOGGER.debug( + "Light '%s' has not been added because it may originate from emulated_hue", + light_entity["name"], + ) + + return await add_devices( + hide_email(account), + devices, + add_devices_callback, + include_filter, + exclude_filter, + ) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alexa sensor platform by config_entry.""" + return await async_setup_platform( + hass, config_entry.data, async_add_devices, discovery_info=None + ) + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry.""" + account = entry.data[CONF_EMAIL] + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + _LOGGER.debug("Attempting to unload lights") + for light in account_dict["entities"]["light"]: + await light.async_remove() + return True + + +def color_modes(details) -> list: + """Return list of color modes.""" + if details["color"] and details["color_temperature"]: + return [ColorMode.HS, ColorMode.COLOR_TEMP] + if details["color"]: + return [ColorMode.HS] + if details["color_temperature"]: + return [ColorMode.COLOR_TEMP] + if details["brightness"]: + return [ColorMode.BRIGHTNESS] + return [ColorMode.ONOFF] + + +class AlexaLight(CoordinatorEntity, LightEntity): + """A light controlled by an Echo.""" + + def __init__(self, coordinator, login, details): + """Initialize alexa light entity.""" + super().__init__(coordinator) + self.alexa_entity_id = details["id"] + self._name = details["name"] + self._login = login + self._attr_supported_color_modes = color_modes(details) + self._attr_min_color_temp_kelvin = 2200 + self._attr_max_color_temp_kelvin = 6500 + + # Store the requested state from the last call to _set_state + # This is so that no new network call is needed just to get values that are already known + # This is useful because refreshing the full state can take a bit when many lights are in play. + # Especially since Alexa actually polls the lights and that appears to be error-prone with some Zigbee lights. + # That delay(1-5s in practice) causes the UI controls to jump all over the place after _set_state + self._requested_state_at = None # When was state last set in UTC + self._requested_power = None + self._requested_ha_brightness = None + self._requested_kelvin = None + self._requested_hs = None + + @property + def name(self): + """Return name.""" + return self._name + + @property + def unique_id(self): + """Return unique id.""" + return self.alexa_entity_id + + @property + def color_mode(self): + """Return color mode.""" + if ( + ColorMode.HS in self._attr_supported_color_modes + and ColorMode.COLOR_TEMP in self._attr_supported_color_modes + ): + hs_color = self.hs_color + if hs_color is None or (hs_color[0] == 0 and hs_color[1] == 0): + # (0,0) is white. When white, color temp is the better plan. + return ColorMode.COLOR_TEMP + return ColorMode.HS + return self._attr_supported_color_modes[0] + + @property + def is_on(self): + """Return whether on.""" + power = parse_power_from_coordinator( + self.coordinator, self.alexa_entity_id, self._requested_state_at + ) + if power is None: + return self._requested_power if self._requested_power is not None else False + return power == "ON" + + @property + def brightness(self): + """Return brightness.""" + bright = parse_brightness_from_coordinator( + self.coordinator, self.alexa_entity_id, self._requested_state_at + ) + if bright is None: + return self._requested_ha_brightness + return alexa_brightness_to_ha(bright) + + @property + def color_temp_kelvin(self): + """Return color temperature.""" + kelvin = parse_color_temp_from_coordinator( + self.coordinator, self.alexa_entity_id, self._requested_state_at + ) + if kelvin is None: + return self._requested_kelvin + return kelvin_to_alexa(kelvin)[0] + + @property + def hs_color(self): + """Return hs color.""" + hsb = parse_color_from_coordinator( + self.coordinator, self.alexa_entity_id, self._requested_state_at + ) + if hsb is None: + return self._requested_hs + ( + adjusted_hs, + color_name, # pylint:disable=unused-variable + ) = hsb_to_alexa_color(hsb) + return adjusted_hs + + @property + def assumed_state(self) -> bool: + """Return whether state is assumed.""" + last_refresh_success = ( + self.coordinator.data and self.alexa_entity_id in self.coordinator.data + ) + return not last_refresh_success + + async def _set_state(self, power_on, brightness=None, kelvin=None, hs_color=None): + # This is "rounding" on kelvin to the closest value Alexa is willing to acknowledge the existence of. + # The alternative implementation would be to use effects instead. + # That is far more non-standard, and would lock users out of things like the Flux integration. + # The downsides to this approach is that the UI is giving the user a slider + # When the user picks a slider value, the UI will "jump" to the closest possible value. + # This trade-off doesn't feel as bad in practice as it sounds. + adjusted_kelvin, color_temperature_name = kelvin_to_alexa(kelvin) + if color_temperature_name is None: + # This is "rounding" on HS color to closest value Alexa supports. + # The alexa color list is short, but covers a pretty broad spectrum. + # Like for kelvin above, this sounds bad but works ok in practice. + adjusted_hs, color_name = hs_to_alexa_color(hs_color) + else: + # If a color temperature is being set, it is not possible to also adjust the color. + adjusted_hs = None + color_name = None + + response = await AlexaAPI.set_light_state( + self._login, + self.alexa_entity_id, + power_on, + brightness=ha_brightness_to_alexa(brightness), + color_temperature_name=color_temperature_name, + color_name=color_name, + ) + if not isinstance(response, dict): + return await self.coordinator.async_request_refresh() + control_responses = response.get("controlResponses", []) + for response in control_responses: + if not response.get("code") == "SUCCESS": + # If something failed any state is possible, fallback to a full refresh + return await self.coordinator.async_request_refresh() + self._requested_power = power_on + self._requested_ha_brightness = ( + brightness if brightness is not None else self.brightness + ) + self._requested_kelvin = ( + adjusted_kelvin if adjusted_kelvin is not None else self.color_temp_kelvin + ) + if adjusted_hs is not None: + self._requested_hs = adjusted_hs + elif adjusted_kelvin is not None: + # If a kelvin value was set, it is critical that color is cleared out so that color mode is set properly + self._requested_hs = None + else: + self._requested_hs = self.hs_color + self._requested_state_at = datetime.datetime.now( + datetime.timezone.utc + ) # must be set last so that previous getters work properly + self.schedule_update_ha_state() + + # Confirm quickly, but debounce to avoid spamming during slider drags. + account = self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(self._login.email) + if account: + debouncer = account.get("confirm_refresh_debouncer") + if debouncer: + await debouncer.async_call() + + async def async_turn_on(self, **kwargs): + """Turn on.""" + brightness = None + kelvin = None + hs_color = None + if ( + ColorMode.ONOFF not in self._attr_supported_color_modes + and ATTR_BRIGHTNESS in kwargs + ): + brightness = kwargs[ATTR_BRIGHTNESS] + if ( + ColorMode.COLOR_TEMP in self._attr_supported_color_modes + and ATTR_COLOR_TEMP_KELVIN in kwargs + ): + kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + if ColorMode.HS in self._attr_supported_color_modes and ATTR_HS_COLOR in kwargs: + hs_color = kwargs[ATTR_HS_COLOR] + await self._set_state(True, brightness, kelvin, hs_color) + + async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument + """Turn off.""" + await self._set_state(False) + + +def kelvin_to_alexa(kelvin: Optional[float]) -> tuple[Optional[float], Optional[str]]: + """Convert a given color temperature in kelvin to the closest available value that Alexa has support for.""" + if kelvin is None: + return None, None + if kelvin <= 2400: + return 2200, "warm_white" + if kelvin <= 3200: + return 2700, "soft_white" + if kelvin <= 4400: + return 4000, "white" + if kelvin <= 6000: + return 5400, "daylight_white" + return 6500, "cool_white" + + +def ha_brightness_to_alexa(ha_brightness: Optional[float]) -> Optional[float]: + """Convert HA brightness to alexa brightness.""" + return (ha_brightness / 255 * 100) if ha_brightness is not None else None + + +def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: + """Convert Alexa brightness to HA brightness.""" + return (alexa / 100 * 255) if alexa is not None else None + + +# This is a fairly complete list of all the colors that Alexa will respond to and their associated RGB value. +ALEXA_COLORS = { + "alice_blue": (240, 248, 255), + "antique_white": (250, 235, 215), + "aqua": (0, 255, 255), + "aquamarine": (127, 255, 212), + "azure": (240, 255, 255), + "beige": (245, 245, 220), + "bisque": (255, 228, 196), + "black": (0, 0, 0), + "blanched_almond": (255, 235, 205), + "blue": (0, 0, 255), + "blue_violet": (138, 43, 226), + "brown": (165, 42, 42), + "burlywood": (222, 184, 135), + "cadet_blue": (95, 158, 160), + "chartreuse": (127, 255, 0), + "chocolate": (210, 105, 30), + "coral": (255, 127, 80), + "cornflower_blue": (100, 149, 237), + "cornsilk": (255, 248, 220), + "crimson": (220, 20, 60), + "cyan": (0, 255, 255), + "dark_blue": (0, 0, 139), + "dark_cyan": (0, 139, 139), + "dark_goldenrod": (184, 134, 11), + "dark_green": (0, 100, 0), + "dark_grey": (169, 169, 169), + "dark_khaki": (189, 183, 107), + "dark_magenta": (139, 0, 139), + "dark_olive_green": (85, 107, 47), + "dark_orange": (255, 140, 0), + "dark_orchid": (153, 50, 204), + "dark_red": (139, 0, 0), + "dark_salmon": (233, 150, 122), + "dark_sea_green": (143, 188, 143), + "dark_slate_blue": (72, 61, 139), + "dark_slate_grey": (47, 79, 79), + "dark_turquoise": (0, 206, 209), + "dark_violet": (148, 0, 211), + "deep_pink": (255, 20, 147), + "deep_sky_blue": (0, 191, 255), + "dim_grey": (105, 105, 105), + "dodger_blue": (30, 144, 255), + "firebrick": (178, 34, 34), + "floral_white": (255, 250, 240), + "forest_green": (34, 139, 34), + "fuchsia": (255, 0, 255), + "gainsboro": (220, 220, 220), + "ghost_white": (248, 248, 255), + "gold": (255, 215, 0), + "goldenrod": (218, 165, 32), + "green": (0, 128, 0), + "green_yellow": (173, 255, 47), + "grey": (128, 128, 128), + "honey_dew": (240, 255, 240), + "hot_pink": (255, 105, 180), + "indian_red": (205, 92, 92), + "indigo": (75, 0, 130), + "ivory": (255, 255, 240), + "khaki": (240, 230, 140), + "lavender": (230, 230, 250), + "lavender_blush": (255, 240, 245), + "lawn_green": (124, 252, 0), + "lemon_chiffon": (255, 250, 205), + "light_blue": (173, 216, 230), + "light_coral": (240, 128, 128), + "light_cyan": (224, 255, 255), + "light_goldenrod_yellow": (250, 250, 210), + "light_green": (144, 238, 144), + "light_grey": (211, 211, 211), + "light_pink": (255, 182, 193), + "light_salmon": (255, 160, 122), + "light_sea_green": (32, 178, 170), + "light_sky_blue": (135, 206, 250), + "light_slate_grey": (119, 136, 153), + "light_steel_blue": (176, 196, 222), + "light_yellow": (255, 255, 224), + "lime": (0, 255, 0), + "lime_green": (50, 205, 50), + "linen": (250, 240, 230), + "magenta": (255, 0, 255), + "maroon": (128, 0, 0), + "medium_aqua_marine": (102, 205, 170), + "medium_blue": (0, 0, 205), + "medium_orchid": (186, 85, 211), + "medium_purple": (147, 112, 219), + "medium_sea_green": (60, 179, 113), + "medium_slate_blue": (123, 104, 238), + "medium_spring_green": (0, 250, 154), + "medium_turquoise": (72, 209, 204), + "medium_violet_red": (199, 21, 133), + "midnight_blue": (25, 25, 112), + "mint_cream": (245, 255, 250), + "misty_rose": (255, 228, 225), + "moccasin": (255, 228, 181), + "navajo_white": (255, 222, 173), + "navy": (0, 0, 128), + "old_lace": (253, 245, 230), + "olive": (128, 128, 0), + "olive_drab": (107, 142, 35), + "orange": (255, 165, 0), + "orange_red": (255, 69, 0), + "orchid": (218, 112, 214), + "pale_goldenrod": (238, 232, 170), + "pale_green": (152, 251, 152), + "pale_turquoise": (175, 238, 238), + "pale_violet_red": (219, 112, 147), + "papaya_whip": (255, 239, 213), + "peach_puff": (255, 218, 185), + "peru": (205, 133, 63), + "pink": (255, 192, 203), + "plum": (221, 160, 221), + "powder_blue": (176, 224, 230), + "purple": (128, 0, 128), + "rebecca_purple": (102, 51, 153), + "red": (255, 0, 0), + "rosy_brown": (188, 143, 143), + "royal_blue": (65, 105, 225), + "saddle_brown": (139, 69, 19), + "salmon": (250, 128, 114), + "sandy_brown": (244, 164, 96), + "sea_green": (46, 139, 87), + "sea_shell": (255, 245, 238), + "sienna": (160, 82, 45), + "silver": (192, 192, 192), + "sky_blue": (135, 206, 235), + "slate_blue": (106, 90, 205), + "slate_grey": (112, 128, 144), + "snow": (255, 250, 250), + "spring_green": (0, 255, 127), + "steel_blue": (70, 130, 180), + "tan": (210, 180, 140), + "teal": (0, 128, 128), + "thistle": (216, 191, 216), + "tomato": (255, 99, 71), + "turquoise": (64, 224, 208), + "violet": (238, 130, 238), + "wheat": (245, 222, 179), + "white": (255, 255, 255), + "white_smoke": (245, 245, 245), + "yellow": (255, 255, 0), + "yellow_green": (154, 205, 50), +} + + +def red_mean(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float: + """Get an approximate 'distance' between two colors using red mean. + + Wikipedia says this method is "one of the better low-cost approximations". + """ + r_avg = (color2[0] + color1[0]) / 2 + r_delta = color2[0] - color1[0] + g_delta = color2[1] - color1[1] + b_delta = color2[2] - color1[2] + r_term = (2 + r_avg / 256) * pow(r_delta, 2) + g_term = 4 * pow(g_delta, 2) + b_term = (2 + (255 - r_avg) / 256) * pow(b_delta, 2) + return sqrt(r_term + g_term + b_term) + + +def alexa_color_name_to_rgb(color_name: str) -> tuple[int, int, int]: + """Convert an alexa color name into RGB.""" + return color_name_to_rgb(color_name.replace("_", "")) + + +def rgb_to_alexa_color( + rgb: tuple[int, int, int], +) -> tuple[Optional[tuple[float, float]], Optional[str]]: + """Convert a given RGB value into the closest Alexa color.""" + name, alexa_rgb = min( + ALEXA_COLORS.items(), + key=lambda alexa_color: red_mean(alexa_color[1], rgb), + ) + red, green, blue = alexa_rgb + return color_RGB_to_hs(red, green, blue), name + + +def hs_to_alexa_color( + hs_color: Optional[tuple[float, float]], +) -> tuple[Optional[tuple[float, float]], Optional[str]]: + """Convert a given hue/saturation value into the closest Alexa color.""" + if hs_color is None: + return None, None + hue, saturation = hs_color + return rgb_to_alexa_color(color_hs_to_RGB(hue, saturation)) + + +def hsb_to_alexa_color( + hsb: Optional[tuple[float, float, float]], +) -> tuple[Optional[tuple[float, float]], Optional[str]]: + """Convert a given hue/saturation/brightness value into the closest Alexa color.""" + if hsb is None: + return None, None + hue, saturation, brightness = hsb + return rgb_to_alexa_color(color_hsb_to_RGB(hue, saturation, brightness)) diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json new file mode 100644 index 0000000..0a32955 --- /dev/null +++ b/custom_components/alexa_media/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "alexa_media", + "name": "Alexa Media Player", + "codeowners": ["@alandtse", "@keatontaylor"], + "config_flow": true, + "dependencies": ["persistent_notification", "http"], + "documentation": "https://github.com/alandtse/alexa_media_player/wiki", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/alandtse/alexa_media_player/issues", + "loggers": ["alexapy", "authcaptureproxy"], + "requirements": [ + "alexapy==1.29.25", + "packaging>=20.3", + "wrapt>=1.14.0", + "dictor>=0.1.12,<0.2" + ], + "version": "5.15.6" +} diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py new file mode 100644 index 0000000..30efb55 --- /dev/null +++ b/custom_components/alexa_media/media_player.py @@ -0,0 +1,2133 @@ +""" +Support to interface with Alexa Devices. + +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 logging +import os +import re +import subprocess +from typing import Any, Optional +import urllib.request + +from homeassistant import util +from homeassistant.components import media_source +from homeassistant.components.media_player import MediaPlayerEntity as MediaPlayerDevice +from homeassistant.components.media_player.browse_media import ( + async_process_play_media_url, +) +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ANNOUNCE, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, + RepeatMode, +) +from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.event import async_call_later +from homeassistant.util import slugify + +from . import ( + CONF_PUBLIC_URL, + CONF_QUEUE_DELAY, + DATA_ALEXAMEDIA, + DEFAULT_PUBLIC_URL, + DEFAULT_QUEUE_DELAY, + DOMAIN as ALEXA_DOMAIN, + hide_email, + hide_serial, +) +from .alexa_media import AlexaMedia +from .const import ( + DEPENDENT_ALEXA_COMPONENTS, + MIN_TIME_BETWEEN_FORCED_SCANS, + MIN_TIME_BETWEEN_SCANS, + MODEL_IDS, + PLAY_SCAN_INTERVAL, + PUBLIC_URL_ERROR_MESSAGE, + STREAMING_ERROR_MESSAGE, + UPLOAD_PATH, +) +from .exceptions import TimeoutException +from .helpers import _catch_login_errors, add_devices, is_http2_enabled, safe_get + +SUPPORT_ALEXA = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET +) + +TRANSPORT_FEATURES: dict[str, MediaPlayerEntityFeature] = { + "next": MediaPlayerEntityFeature.NEXT_TRACK, + "previous": MediaPlayerEntityFeature.PREVIOUS_TRACK, + "shuffle": MediaPlayerEntityFeature.SHUFFLE_SET, + "repeat": MediaPlayerEntityFeature.REPEAT_SET, + "seekForward": MediaPlayerEntityFeature.SEEK, + "seekBackward": MediaPlayerEntityFeature.SEEK, +} + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = [ALEXA_DOMAIN] + + +async def create_www_directory(hass: HomeAssistant): + """Create www directory.""" + paths = [ + hass.config.path("www"), # http://homeassistant.local:8123/local + hass.config.path( + UPLOAD_PATH + ), # http://homeassistant.local:8123/local/alexa_tts + ] + + def mkdir() -> None: + """Create a directory.""" + for path in paths: + if not os.path.exists(path): + _LOGGER.debug("Creating directory: %s", path) + os.makedirs(path, exist_ok=True) + + await hass.async_add_executor_job(mkdir) + + +# @retry_async(limit=5, delay=2, catch_exceptions=True) +async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Set up the Alexa media player platform.""" + await create_www_directory(hass) + + devices = [] # type: List[AlexaClient] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = safe_get(discovery_info, ["config", CONF_EMAIL]) + if account is None: + raise ConfigEntryNotReady + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + entry_setup = len(account_dict["entities"]["media_player"]) + media_players = account_dict["devices"]["media_player"] + alexa_client = None + # Make clusterMembers list from parentClusters + for key, device in media_players.items(): + if parent_clusters := device.get("parentClusters"): + for parent_id in parent_clusters: + if media_players.get(parent_id): + if media_players[parent_id].get("clusterMembers") is None: + media_players[parent_id]["clusterMembers"] = [] + if key not in media_players[parent_id]["clusterMembers"]: + media_players[parent_id]["clusterMembers"].append(key) + for key, device in media_players.items(): + if key not in account_dict["entities"]["media_player"]: + alexa_client = AlexaClient( + device, + account_dict["login_obj"], + hass.data[DATA_ALEXAMEDIA]["accounts"][account]["second_account_index"], + ) + await alexa_client.init(device) + devices.append(alexa_client) + ( + hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][ + "media_player" + ][key] + ) = alexa_client + else: + _LOGGER.debug( + "%s: Skipping already added device: %s:%s", + hide_email(account), + hide_serial(key), + alexa_client, + ) + result = await add_devices(hide_email(account), devices, add_devices_callback) + if result and entry_setup: + _LOGGER.debug("Detected config entry already setup, using load platform") + for component in DEPENDENT_ALEXA_COMPONENTS: + hass.async_create_task( + async_load_platform( + hass, + component, + ALEXA_DOMAIN, + {CONF_NAME: ALEXA_DOMAIN, "config": config}, + config, + ) + ) + return result + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alexa media player platform by config_entry.""" + if await async_setup_platform( + hass, config_entry.data, async_add_devices, discovery_info=None + ): + account = config_entry.data[CONF_EMAIL] + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + for component in DEPENDENT_ALEXA_COMPONENTS: + try: + entry_setup = len(account_dict["entities"][component]) + except (TypeError, KeyError): + entry_setup = 1 + if entry_setup or component == "notify": + _LOGGER.debug("%s: Loading %s", hide_email(account), component) + cleaned_config = config_entry.data.copy() + cleaned_config.pop(CONF_PASSWORD, None) + # CONF_PASSWORD contains sensitive info which is no longer needed + hass.async_create_task( + async_load_platform( + hass, + component, + ALEXA_DOMAIN, + {CONF_NAME: ALEXA_DOMAIN, "config": cleaned_config}, + cleaned_config, + ) + ) + else: + _LOGGER.debug( + "%s: Loading config entry for %s", hide_email(account), component + ) + try: + await hass.config_entries.async_forward_entry_setups( + config_entry, [component] + ) + except (asyncio.TimeoutError, TimeoutException) as ex: + raise ConfigEntryNotReady( + f"Timeout while loading config entry for {component}" + ) from ex + return True + raise ConfigEntryNotReady + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry.""" + account = entry.data[CONF_EMAIL] + _LOGGER.debug("%s: Attempting to unload media players", hide_email(account)) + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + for device in account_dict["entities"]["media_player"].values(): + _LOGGER.debug("%s: Removing %s", hide_email(account), device) + await device.async_remove() + return True + + +class AlexaClient(MediaPlayerDevice, AlexaMedia): + """Representation of a Alexa device.""" + + def __init__(self, device, login, second_account_index=0): + """Initialize the Alexa device.""" + super().__init__(self, login) + + # Logged in info + self._authenticated = None + self._can_access_prime_music = None + self._customer_email = None + self._customer_id = None + self._customer_name = None + + # Device info + self._device = device + self._device_name = None + self._device_serial_number = None + self._device_type = None + self._device_family = None + self._device_owner_customer_id = None + self._software_version = None + self._available = None + self._assumed_state = False + self._capabilities = [] + self._cluster_members = [] + self._locale = None + # Media + self._session = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_pos = None + self._media_album_name = None + self._media_artist = None + self._media_player_state = None + self._media_is_muted = False + self._media_vol_level = None + self._previous_volume = None + self._saved_volume = None + self._source = None + self._source_list = [] + self._connected_bluetooth = None + self._bluetooth_list = [] + self._history_records = [] + self._shuffle = None + self._repeat = None + self._playing_parent = None + self._player_info = None + self._waiting_media_id = None + # Last Device + self._last_called = None + self._last_called_timestamp = None + self._last_called_summary = None + self._last_called_response = None + # Do not Disturb state + self._dnd = None + # Polling state + self._should_poll = True + self._last_update = util.utcnow() + self._listener = None + self._bluetooth_state = None + self._app_device_list = None + self._parent_clusters = None + self._timezone = None + self._second_account_index = second_account_index + + self._prev_state = None + self._state_call_later_cancel = None + + self._attr_supported_features = SUPPORT_ALEXA + + async def init(self, device): + """Initialize.""" + await self.refresh(device, skip_api=True) + + async def async_added_to_hass(self): + """Perform tasks after loading.""" + # Register event handler on bus + await self.refresh(self._device) + self._listener = async_dispatcher_connect( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], + self._handle_event, + ) + # Register to coordinator: + email = self._login.email + coordinator = self.hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "coordinator" + ) + if coordinator: + coordinator.async_add_listener(self.update) + + async def async_will_remove_from_hass(self): + """Prepare to remove entity.""" + # Register event handler on bus + self._listener() + email = self._login.email + coordinator = self.hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "coordinator" + ) + if coordinator: + try: + coordinator.async_remove_listener(self.update) + except AttributeError: + pass # ignore missing listener + + async def _handle_event(self, event): + # pylint: disable=too-many-branches,too-many-statements + """Handle events. + + This will update last_called and player_state events. + Each MediaClient reports if it's the last_called MediaClient and will + listen for HA events to determine it is the last_called. + When polling instead of websockets, all devices on same account will + update to handle starting music with other devices. If websocket is on + only the updated alexa will update. + Last_called events are only sent if it's a new device or timestamp. + Without polling, we must schedule the HA update manually. + https://developers.home-assistant.io/docs/en/entity_index.html#subscribing-to-updates + The difference between self.update and self.schedule_update_ha_state + is self.update will pull data from Amazon, while schedule_update + assumes the MediaClient state is already updated. + """ + + async def _refresh_if_no_audiopush(already_refreshed=False): + email = self._login.email + seen_commands = ( + self.hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "websocket_commands" + ].keys() + if "websocket_commands" + in (self.hass.data[DATA_ALEXAMEDIA]["accounts"][email]) + else None + ) + if ( + not already_refreshed + and seen_commands + and not ( + "PUSH_AUDIO_PLAYER_STATE" in seen_commands + or "PUSH_MEDIA_CHANGE" in seen_commands + or "PUSH_MEDIA_PROGRESS_CHANGE" in seen_commands + ) + ): + # force refresh if player_state update not found, see #397 + _LOGGER.debug( + "%s: No PUSH_AUDIO_PLAYER_STATE/" + "PUSH_MEDIA_CHANGE/PUSH_MEDIA_PROGRESS_CHANGE in %s;" + "forcing refresh", + hide_email(email), + seen_commands, + ) + await self.async_update() + + async def _wait_player_info(media_id, timeout=3): + self._player_info = None + start = util.dt.as_timestamp(util.utcnow()) + while ( + self._player_info is None + and media_id == self._waiting_media_id + and (start + timeout >= util.dt.as_timestamp(util.utcnow())) + ): + await asyncio.sleep(0.1) + + try: + if not self.enabled: + return + except AttributeError: + pass + already_refreshed = False + event_serial = None + info_changed = False + if "last_called_change" in event: + event_serial = ( + event["last_called_change"]["serialNumber"] + if event["last_called_change"] + else None + ) + elif "bluetooth_change" in event: + event_serial = ( + event["bluetooth_change"]["deviceSerialNumber"] + if event["bluetooth_change"] + else None + ) + elif "player_state" in event: + event_serial = ( + event["player_state"]["dopplerId"]["deviceSerialNumber"] + if event["player_state"] + else None + ) + _LOGGER.debug("player_state event_serial: %s", hide_serial(event_serial)) + elif "queue_state" in event: + event_serial = ( + event["queue_state"]["dopplerId"]["deviceSerialNumber"] + if event["queue_state"] + else None + ) + elif "push_activity" in event: + event_serial = safe_get(event, ["push_activity", "key", "serialNumber"]) + elif "now_playing" in event: + player_info = safe_get( + event, ["now_playing", "update", "update", "nowPlayingData"], {} + ) + media_id = player_info.get("mediaId") + if self._waiting_media_id and media_id in self._waiting_media_id: + if player_info.get("playerState"): + player_info["state"] = player_info["playerState"] + media_length = safe_get(player_info, ["progress", "mediaLength"]) + if media_length is not None: + player_info["progress"]["mediaLength"] = int(media_length / 1000) + # Get and set mediaProgress only when mediaLength is obtained. + # Fixed an issue where mediaLength was sometimes acquired as 0 on Spotify etc., + # causing the progress bar to disappear. + media_progress = safe_get( + player_info, ["progress", "mediaProgress"] + ) + if media_progress is not None: + player_info["progress"]["mediaProgress"] = int( + media_progress / 1000 + ) + if safe_get(player_info, ["mainArt", "url"]) is None: + if not player_info.get("mainArt"): + player_info["mainArt"] = {} + player_info["mainArt"]["url"] = player_info["mainArt"].get( + "fullUrl" + ) + player_info["last_update"] = util.utcnow() + event_serial = self.device_serial_number + _LOGGER.debug( + f"Match media_id: {media_id} in waiting_media_id:{self._waiting_media_id} , player_info: {player_info}" + ) + self._player_info = player_info + info_changed = True + elif "parent_state" in event: + event_serial = safe_get( + event, ["parent_state", "dopplerId", "deviceSerialNumber"] + ) + if event_serial == self.device_serial_number: + _LOGGER.debug( + "DeviceID(%s) receive event form parent: %s", + hide_serial(event_serial), + hide_serial(event), + ) + parent_state = event.get("parent_state", {}) + if parent_state.get("volume") is None: + parent_state["volume"] = { + "muted": self._media_is_muted, + "volume": self._media_vol_level, + } + self._set_attrs(parent_state) + self._player_info = parent_state + if parent_state.get("state") == "PLAYING" and ( + parentSerial := ( + safe_get( + event, ["parent_state", "dopplerId", "parentSerialNumber"] + ) + ) + ): + self._playing_parent = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"].get(parentSerial) + else: + self._playing_parent = None + info_changed = True + if not event_serial: + return + if event_serial == self.device_serial_number: + self._available = True + self.schedule_update_ha_state() + if "last_called_change" in event: + if ( + event_serial == self.device_serial_number + or any( + item["serialNumber"] == event_serial + for item in self._app_device_list + ) + and self._last_called_timestamp + != event["last_called_change"]["timestamp"] + ): + _LOGGER.debug( + "%s: last_called is %s (%s)", + hide_email(self._login.email), + self, + hide_serial(self.device_serial_number), + ) + self._last_called = True + self._last_called_timestamp = event["last_called_change"]["timestamp"] + self._last_called_summary = event["last_called_change"].get("summary") + self._last_called_response = event["last_called_change"].get("response") + if self.hass and self.schedule_update_ha_state: + self.schedule_update_ha_state() + _LOGGER.debug("[handle event] Updating notify targets") + await self._update_notify_targets() + else: + self._last_called = False + if self.hass and self.async_schedule_update_ha_state: + force_refresh = not is_http2_enabled(self.hass, self._login.email) + _LOGGER.debug( + "%s: scheduling ha_state update(force_refresh: %s)", + hide_email(self._login.email), + not is_http2_enabled(self.hass, self._login.email), + ) + self.async_schedule_update_ha_state(force_refresh=force_refresh) + elif "bluetooth_change" in event: + if event_serial == self.device_serial_number: + self._bluetooth_state = event["bluetooth_change"] + self._source = self._get_source() + self._source_list = self._get_source_list() + self._connected_bluetooth = self._get_connected_bluetooth() + self._bluetooth_list = self._get_bluetooth_list() + streaming_state = self._bluetooth_state.get("streamingState") + _LOGGER.debug( + "%s: Updating '%s' Bluetooth_state", + hide_email(self._login.email), + self.name, + ) + if self._connected_bluetooth: + if ( + not self._session + or self._session.get("mediaId") != "BluetoothMediaId" + ): + # Synthesize a Bluetooth media session when /api/np/player + # does not expose active Bluetooth playback state. + _LOGGER.debug("Creating synthesized Bluetooth media session") + self._session = { + "mediaId": "BluetoothMediaId", + "state": None, + "infoText": {}, + "miniInfoText": {}, + "mainArt": {}, + "miniArt": {}, + "progress": {}, + "transport": { + "playPause": "ENABLED", + "next": "ENABLED", + "previous": "ENABLED", + "repeat": "HIDDEN", + "shuffle": "HIDDEN", + }, + "volume": { + "muted": self._media_is_muted, + "volume": int((self._media_vol_level or 0) * 100), + }, + } + + # Defensive structure checks for nested dictionaries + for key in [ + "infoText", + "miniInfoText", + "mainArt", + "miniArt", + "progress", + ]: + if key not in self._session or not isinstance( + self._session[key], dict + ): + self._session[key] = {} + + # Handle playback states + if streaming_state == "MUSIC": + self._media_player_state = self._session["state"] = "PLAYING" + elif streaming_state in (None, "NONE"): + self._media_player_state = self._session["state"] = "PAUSED" + + # Mutate track details + self._session["infoText"]["title"] = self._session["miniInfoText"][ + "title" + ] = "Bluetooth" + self._session["infoText"]["subText1"] = self._session[ + "miniInfoText" + ]["subText1"] = f"Streaming from {self._source}" + self._session["infoText"]["subText2"] = self._session[ + "miniInfoText" + ]["subText2"] = "" + + # Clear tracking progress lines + self._session["progress"]["mediaProgress"] = None + self._session["progress"]["mediaLength"] = None + self._media_pos = self._media_duration = None + + # Prevent stale transport capabilities from previous media leaking into HA. + self._attr_supported_features = ( + SUPPORT_ALEXA + & ~MediaPlayerEntityFeature.SEEK + & ~MediaPlayerEntityFeature.SHUFFLE_SET + & ~MediaPlayerEntityFeature.REPEAT_SET + ) + + # Use Bluetooth icon metadata for the synthesized Bluetooth session + self._session["mainArt"]["artType"] = "IconArtSource" + self._session["mainArt"]["iconId"] = "bluetooth-art" + + self._set_attrs(self._session) + + else: + # This executes when self._connected_bluetooth evaluates to None/False + _LOGGER.debug( + "%s: Cleaning up Bluetooth state and session for %s", + hide_email(self._login.email), + self.name, + ) + + # Clear out media detail instance variables completely + self._clear_media_details() + + # explicit resets to prevent stale data leakages + self._media_artist = None + self._media_album_name = None + self._media_title = None + self._media_pos = None + self._media_duration = None + + # Teardown session tracking and push state back to IDLE + self._session = None + self._connected_bluetooth = None + self._media_player_state = "IDLE" + + if self.hass and self.schedule_update_ha_state: + self.schedule_update_ha_state() + elif "player_state" in event: + player_state = event["player_state"] + _LOGGER.debug("player_state: %s", hide_serial(player_state)) + if event_serial == self.device_serial_number: + if "audioPlayerState" in player_state: + _LOGGER.debug( + "%s: %s state update: %s", + hide_email(self._login.email), + self.name, + player_state["audioPlayerState"], + ) + if player_state["audioPlayerState"] == "PLAYING": + self._media_player_state = "PLAYING" + elif player_state["audioPlayerState"] == "INTERRUPTED": + self._clear_media_details() + media_id = player_state.get("mediaReferenceId") + if media_id: + self._waiting_media_id = media_id + await _wait_player_info(media_id) + if self._waiting_media_id != media_id: + return + if not media_id and self._player_info is None: + # allow delay before trying to refresh to avoid http 400 errors + await asyncio.sleep(2) + await self.async_update() + already_refreshed = True + elif "mediaReferenceId" in player_state: + _LOGGER.debug( + "%s: %s media update: %s", + hide_email(self._login.email), + self.name, + player_state["mediaReferenceId"], + ) + await self.async_update() + already_refreshed = True + elif "volumeSetting" in player_state: + _LOGGER.debug( + "%s: %s volume updated: %s", + hide_email(self._login.email), + self.name, + player_state["volumeSetting"], + ) + if self._session: + if not self._session.get("volume"): + self._session["volume"] = {} + self._session["volume"]["volume"] = player_state[ + "volumeSetting" + ] + self._session["volume"]["muted"] = player_state.get( + "isMuted", False + ) + self._media_is_muted = self._session["volume"]["muted"] + self._media_vol_level = player_state["volumeSetting"] / 100 + if self.hass and self.schedule_update_ha_state: + self.schedule_update_ha_state() + elif "dopplerConnectionState" in player_state: + self.available = player_state["dopplerConnectionState"] == "ONLINE" + if self.hass and self.schedule_update_ha_state: + self.schedule_update_ha_state() + await _refresh_if_no_audiopush(already_refreshed) + elif "push_activity" in event: + if self.state in { + MediaPlayerState.IDLE, + MediaPlayerState.PAUSED, + MediaPlayerState.PLAYING, + }: + _LOGGER.debug( + "%s: %s checking for potential state update due to push activity on %s", + hide_email(self._login.email), + self.name, + hide_serial(event_serial), + ) + # allow delay before trying to refresh to avoid http 400 errors + await asyncio.sleep(2) + await self.async_update() + already_refreshed = True + if info_changed and self._player_info and self._cluster_members: + # This is Speaker Group or Speaker pair so throw event data + if self.hass: + for device_id in self._cluster_members: + json_payload = self._make_dispatcher_data( + self._player_info, device_id + ) + _LOGGER.debug( + "Updating player info by parent (http2): %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], + {"parent_state": json_payload}, + ) + + if "queue_state" in event: + queue_state = event["queue_state"] + if event_serial == self.device_serial_number: + if ( + "trackOrderChanged" in queue_state + and not queue_state["trackOrderChanged"] + and "loopMode" in queue_state + ): + self._attr_supported_features |= MediaPlayerEntityFeature.REPEAT_SET + self._repeat = queue_state["loopMode"] == "LOOP_QUEUE" + self._attr_repeat = ( + RepeatMode.ALL if self._repeat else RepeatMode.OFF + ) + _LOGGER.debug( + "%s: %s repeat updated to: %s %s", + hide_email(self._login.email), + self.name, + self._repeat, + queue_state["loopMode"], + ) + elif "playBackOrder" in queue_state: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SHUFFLE_SET + ) + self._shuffle = queue_state["playBackOrder"] == "SHUFFLE_ALL" + _LOGGER.debug( + "%s: %s shuffle updated to: %s %s", + hide_email(self._login.email), + self.name, + self._shuffle, + queue_state["playBackOrder"], + ) + await _refresh_if_no_audiopush(already_refreshed) + + def _make_dispatcher_data( + self, player_info: dict[str, Any], device_id: str + ) -> dict[str, Any]: + """Rewrite data that propagates downstream""" + json_payload = player_info.copy() + json_payload["dopplerId"] = { + "deviceSerialNumber": device_id, + "parentSerialNumber": self._device_serial_number, + } + json_payload["isPlayingInLemur"] = False + json_payload["lemurVolume"] = None + json_payload["volume"] = None + return json_payload + + def _clear_media_details(self): + """Set all Media Items to None.""" + # General + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_pos = None + self._media_album_name = None + self._media_artist = None + self._media_player_state = "IDLE" + self._media_is_muted = False + # volume is also used for announce/tts so state should remain + # self._media_vol_level = None + self._attr_supported_features = SUPPORT_ALEXA + self._player_info = None + + def _set_authentication_details(self, auth): + """Set Authentication based off auth.""" + self._authenticated = auth["authenticated"] + self._can_access_prime_music = auth["canAccessPrimeMusicContent"] + self._customer_email = auth["customerEmail"] + self._customer_id = auth["customerId"] + self._customer_name = auth["customerName"] + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + async def _api_get_state(self): + return await self.alexa_api.get_state() + + @_catch_login_errors + async def refresh(self, device=None, skip_api: bool = False, no_throttle=False): + # pylint: disable=too-many-branches,too-many-statements + """Refresh device data. + + This is a per device refresh and for many Alexa devices can result in + many refreshes from each individual device. This will call the + AlexaAPI directly. + + Args: + device (json): A refreshed device json from Amazon. For efficiency, + an individual device does not refresh if it's reported + as offline. + skip_api (bool): Whether to only due a device json update and not hit the API + + """ + if device is not None: + self._device_name = device["accountName"] + self._device_family = device["deviceFamily"] + self._device_type = device["deviceType"] + self._device_serial_number = device["serialNumber"] + self._app_device_list = device["appDeviceList"] + self._device_owner_customer_id = device["deviceOwnerCustomerId"] + self._software_version = device["softwareVersion"] + self._available = device["online"] + self._capabilities = device["capabilities"] + self._cluster_members = device["clusterMembers"] + self._parent_clusters = device["parentClusters"] + self._bluetooth_state = device.get("bluetooth_state", {}) + self._locale = device["locale"] if "locale" in device else "en-US" + self._timezone = device["timeZoneId"] if "timeZoneId" in device else "UTC" + self._dnd = device["dnd"] if "dnd" in device else None + self._set_authentication_details(device["auth_info"]) + session = None + api_call = False + if self.available: + _LOGGER.debug( + "%s: Refreshing %s", + self.account, + self if device is None else self._device_name, + ) + self._assumed_state = False + if "PAIR_BT_SOURCE" in self._capabilities: + self._source = self._get_source() + self._source_list = self._get_source_list() + self._connected_bluetooth = self._get_connected_bluetooth() + self._bluetooth_list = self._get_bluetooth_list() + new_last_called = self._get_last_called() + if new_last_called and self._last_called != new_last_called: + self._last_called = new_last_called + self._last_called_timestamp = self.hass.data[DATA_ALEXAMEDIA][ + "accounts" + ][self._login.email]["last_called"]["timestamp"] + self._last_called_summary = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["last_called"].get("summary") + self._last_called_response = self.hass.data[DATA_ALEXAMEDIA][ + "accounts" + ][self._login.email]["last_called"].get("response") + _LOGGER.debug("[refresh] Updating notify targets") + await self._update_notify_targets() + if skip_api and self.hass: + self.schedule_update_ha_state() + return + if "MUSIC_SKILL" in self._capabilities: + if self._parent_clusters and self.hass: + playing_parents = list( + filter( + lambda x: ( + self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"].get(x) + and self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"][x].state + == MediaPlayerState.PLAYING + ), + self._parent_clusters, + ) + ) + else: + playing_parents = [] + parent_session = {} + if playing_parents: + if len(playing_parents) > 1: + _LOGGER.warning( + "Found multiple playing parents please file an issue" + ) + parent = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"][playing_parents[0]] + self._playing_parent = parent + parent_session = parent.session + if parent_session: + session = parent_session.copy() + session["isPlayingInLemur"] = False + session["lemurVolume"] = None + if parent_session.get("lemurVolume") and self.device_serial_number: + member_volume = safe_get( + parent_session, + ["lemurVolume", "memberVolume", self.device_serial_number], + ) + if member_volume is not None: + session["volume"] = member_volume + session = {"playerInfo": session} + else: + self._playing_parent = None + if self._player_info: + _player_info = self._player_info.copy() + if self._session: + _player_info["volume"] = self._session.get("volume", {}) + session = {"playerInfo": _player_info} + else: + session = await self._api_get_state(no_throttle=no_throttle) + _LOGGER.debug("Returned data of _api_get_state(): %s", session) + api_call = True + if safe_get(session, ["playerInfo", "state"]) is None: + # _LOGGER.warning( + # "%s: Can't get session state by alexa_api.get_state() of %s. Probably a re-login occurred, so ignore it this time.", + # self.account, + # self if device is None else self._device_name, + # ) + return + self._clear_media_details() + # update the session if it exists + self._session = session.get("playerInfo") if session else None + if self._session: + if self._session.get("isPlayingInLemur"): + if menbers_volume := safe_get( + self._session, ["lemurVolume", "memberVolume"], {} + ): + if self.hass: + for device_id in self._cluster_members: + json_payload = self._make_dispatcher_data( + self._session, device_id + ) + json_payload["volume"] = menbers_volume.get(device_id) + _LOGGER.debug( + "Updating player info by parent (API Call): %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], + {"parent_state": json_payload}, + ) + + if _transport := self._session.get("transport"): + if not api_call: + # API calls do not return correct values for "shuffle" and "repeat" + self._shuffle = ( + _transport["shuffle"] == "SELECTED" + if ( + "shuffle" in _transport + and _transport["shuffle"] not in ("DISABLED", "HIDDEN") + ) + else None + ) + self._repeat = ( + _transport["repeat"] == "SELECTED" + if ( + "repeat" in _transport + and _transport["repeat"] not in ("DISABLED", "HIDDEN") + ) + else None + ) + self._attr_repeat = ( + RepeatMode.ALL if self._repeat else RepeatMode.OFF + ) + self._attr_supported_features = SUPPORT_ALEXA + for transport_key, feature in TRANSPORT_FEATURES.items(): + if api_call and transport_key in ("shuffle", "repeat"): + # API calls do not return correct values for "shuffle" and "repeat" + continue + if _transport.get(transport_key) in ( + "DISABLED", + "HIDDEN", + None, + ) and self._attr_supported_features == ( + self._attr_supported_features | feature + ): + self._attr_supported_features ^= feature + + if self._session.get("state"): + self._set_attrs(self._session) + # Safely access 'http2' setting + push_disabled = not is_http2_enabled(self.hass, self._login.email) + if ( + push_disabled + and self.hass + and self._session.get("isPlayingInLemur") + ): + asyncio.gather( + *map( + lambda x: ( + self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"][x].async_update() + ), + filter( + lambda x: ( + self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"].get(x) + and self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self._login.email + ]["entities"]["media_player"][x].available + ), + self._cluster_members, + ), + ) + ) + if self.hass: + self.schedule_update_ha_state() + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @_catch_login_errors + async def async_select_source(self, source): + """Select input source.""" + if source == "Local Speaker": + if self.hass: + self.hass.async_create_task(self.alexa_api.disconnect_bluetooth()) + else: + await self.alexa_api.disconnect_bluetooth() + self._source = "Local Speaker" + elif self._bluetooth_state.get("pairedDeviceList"): + for devices in self._bluetooth_state["pairedDeviceList"]: + if devices["friendlyName"] == source: + if self.hass: + self.hass.async_create_task( + self.alexa_api.set_bluetooth(devices["address"]) + ) + else: + await self.alexa_api.set_bluetooth(devices["address"]) + self._source = source + # Safely access 'http2' setting + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + def _get_source(self): + source = "Local Speaker" + if self._bluetooth_state.get("pairedDeviceList"): + for device in self._bluetooth_state["pairedDeviceList"]: + if ( + device["connected"] is True + and device["friendlyName"] in self.source_list + ): + return device["friendlyName"] + return source + + def _get_source_list(self): + sources = [] + if self._bluetooth_state.get("pairedDeviceList"): + for devices in self._bluetooth_state["pairedDeviceList"]: + if devices["profiles"] and "A2DP-SOURCE" in devices["profiles"]: + sources.append(devices["friendlyName"]) + return ["Local Speaker"] + sources + + def _get_connected_bluetooth(self): + source = None + if self._bluetooth_state.get("pairedDeviceList"): + for device in self._bluetooth_state["pairedDeviceList"]: + if device["connected"] is True: + return device["friendlyName"] + return source + + def _get_bluetooth_list(self): + sources = [] + if self._bluetooth_state.get("pairedDeviceList"): + for devices in self._bluetooth_state["pairedDeviceList"]: + sources.append(devices["friendlyName"]) + return sources + + def _get_last_called(self): + try: + last_called_serial = ( + None + if self.hass is None + else ( + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email][ + "last_called" + ]["serialNumber"] + ) + ) + except (TypeError, KeyError): + last_called_serial = None + _LOGGER.debug( + "%s: %s: Last_called check: self: %s; reported: %s", + hide_email(self._login.email), + self._device_name, + hide_serial(self._device_serial_number), + hide_serial(last_called_serial), + ) + return last_called_serial is not None and ( + self._device_serial_number == last_called_serial + or any( + item["serialNumber"] == last_called_serial + for item in self._app_device_list + ) + ) + + def _set_attrs(self, player_info): + """Set player attributes by player info dict.""" + self._media_player_state = player_info.get("state") + self._media_title = safe_get(player_info, ["infoText", "title"]) + self._media_artist = safe_get(player_info, ["infoText", "subText1"]) + self._media_album_name = safe_get(player_info, ["infoText", "subText2"]) + self._media_image_url = safe_get(player_info, ["mainArt", "url"]) + self._media_pos = safe_get(player_info, ["progress", "mediaProgress"]) + self._media_duration = safe_get(player_info, ["progress", "mediaLength"]) + muted = volume = None + if not player_info.get("lemurVolume"): + if player_info.get("volume") is not None: + volume_info = player_info.get("volume", {}) + muted = volume_info.get("muted") + volume = volume_info.get("volume") + else: + if composite := safe_get( + player_info, ["lemurVolume", "compositeVolume"], {} + ): + muted = safe_get(composite, ["muted"]) + volume = safe_get(composite, ["volume"]) + if muted is not None: + self._media_is_muted = muted + if volume is not None and isinstance(volume, (int, float)): + if isinstance(volume, int) or volume > 1: + self._media_vol_level = volume / 100 + else: + self._media_vol_level = float(volume) + + @property + def available(self): + """Return the availability of the client.""" + return self._available + + @available.setter + def available(self, state): + """Set the availability state.""" + self._available = state + + @property + def assumed_state(self): + """Return whether the state is an assumed_state.""" + return self._assumed_state + + @property + def hidden(self): + """Return whether the sensor should be hidden.""" + return "MUSIC_SKILL" not in self._capabilities + + @property + def unique_id(self): + """Return the id of this Alexa client.""" + email = self._login.email + return ( + slugify(f"{self.device_serial_number}_{email}") + if self._second_account_index + else self.device_serial_number + ) + + @property + def name(self): + """Return the name of the device.""" + return self._device_name + + @property + def device_serial_number(self): + """Return the machine identifier of the device.""" + return self._device_serial_number + + @property + def session(self): + """Return the session, if any.""" + return self._session + + @property + def state(self): + """Return the state of the device.""" + if not self.available: + return STATE_UNAVAILABLE + + # Fix: Intercept and explicitly return standard Home Assistant player states + # based on Amazon's streaming state string profile + if self._connected_bluetooth and self._bluetooth_state: + streaming_state = self._bluetooth_state.get("streamingState") + if streaming_state == "MUSIC": + return MediaPlayerState.PLAYING + if streaming_state in (None, "NONE", "PAUSED"): + return MediaPlayerState.PAUSED + + if self._media_player_state == "PLAYING": + return MediaPlayerState.PLAYING + if self._media_player_state == "PAUSED": + return MediaPlayerState.PAUSED + if self._media_player_state == "IDLE": + return MediaPlayerState.IDLE + return MediaPlayerState.IDLE + + def update(self): + """Get the latest details on a media player synchronously.""" + return + # return self.hass.add_job(async_update) + + @_catch_login_errors + async def async_update(self): + """Get the latest details on a media player. + + Because media players spend the majority of time idle, an adaptive + update should be used to avoid flooding Amazon focusing on known + play states. An initial version included an update_devices call on + every update. However, this quickly floods the network for every new + device added. This should only call refresh() to call the AlexaAPI. + """ + try: + if not self.enabled: + return + except AttributeError: + pass + email = self._login.email + + # Check if DATA_ALEXAMEDIA and 'accounts' exist + accounts_data = safe_get(self.hass.data, [DATA_ALEXAMEDIA, "accounts"], {}) + if ( + self.entity_id is None # Device has not initialized yet + or email not in accounts_data + or self._login.session.closed + ): + self._assumed_state = True + self.available = False + return + + # Safely access the device + device = None + if self.device_serial_number: + device = safe_get( + accounts_data, + [email, "devices", "media_player", self.device_serial_number], + ) + if not device: + _LOGGER.warning( + "Device serial number %s not found for account %s. Skipping update.", + self.device_serial_number, + hide_email(email), + ) + self.available = False + return + + # Safely access websocket_commands + seen_commands = ( + accounts_data[email]["websocket_commands"].keys() + if "websocket_commands" in accounts_data[email] + else None + ) + + await self.refresh(device, no_throttle=True) + + # Safely access 'http2' setting + push_enabled = is_http2_enabled(self.hass, self._login.email) + + _LOGGER.debug("is push_enabled? %s", push_enabled) + + if not push_enabled: + if ( + self.state in [MediaPlayerState.PLAYING] + and + # Only enable polling if websocket not connected + ( + not push_enabled + or not seen_commands + or not ( + "PUSH_AUDIO_PLAYER_STATE" in seen_commands + or "PUSH_MEDIA_CHANGE" in seen_commands + or "PUSH_MEDIA_PROGRESS_CHANGE" in seen_commands + ) + ) + ): + self._should_poll = False # disable polling since manual update + if ( + self._last_update == 0 + or util.dt.as_timestamp(util.utcnow()) + - util.dt.as_timestamp(self._last_update) + > PLAY_SCAN_INTERVAL + ): + _LOGGER.debug( + "%s: %s playing; scheduling update in %s seconds", + hide_email(email), + self.name, + PLAY_SCAN_INTERVAL, + ) + async_call_later( + self.hass, + PLAY_SCAN_INTERVAL, + self.async_schedule_update_ha_state, + ) + elif self._should_poll: # Not playing, one last poll + self._should_poll = False + if not push_enabled: + _LOGGER.debug( + "%s: Disabling polling and scheduling last update in 300 seconds for %s", + hide_email(email), + self.name, + ) + async_call_later( + self.hass, + 300, + self.async_schedule_update_ha_state, + ) + else: + _LOGGER.debug( + "%s: Disabling polling for %s", + hide_email(email), + self.name, + ) + else: + self._should_poll = False + self._last_update = util.utcnow() + self.schedule_update_ha_state() + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]: + return MediaType.MUSIC + return MediaPlayerState.IDLE + + @property + def media_artist(self): + """Return the artist of current playing media, music track only.""" + if self._connected_bluetooth: + source_name = self._source or self._connected_bluetooth + # Intercept if it's empty, None, or explicitly stuck on Amazon's generic boot string + if not self._media_artist or self._media_artist == "Streaming": + return f"Streaming from {source_name}" + return self._media_artist + + @property + def media_album_name(self): + """Return the album name of current playing media, music track only.""" + if self._connected_bluetooth and self._source: + return None + return self._media_album_name + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + # Fix: Force None to stop Home Assistant core from falling back to zero-bound tracking + if self._connected_bluetooth: + return None + return self._media_duration + + @property + def media_position(self): + """Return the position of current playing media in seconds.""" + if self._connected_bluetooth: + return None + return self._media_pos + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + # Fix: Prevent timestamp leaking when a simulated session has no linear timeline + if self._session and self._session.get("mediaId") == "BluetoothMediaId": + return None + + return ( + self._player_info["last_update"] + if self._player_info and self._player_info.get("last_update") + else self._last_update + ) + + @property + def media_image_url(self) -> Optional[str]: + """Return the image URL of current playing media.""" + # Force None during Bluetooth so Home Assistant stops looking for artwork files + if ( + self._connected_bluetooth + and self._session + and self._session.get("mediaId") == "BluetoothMediaId" + ): + return None + + if self._media_image_url: + return re.sub("\\(", "%28", re.sub("\\)", "%29", self._media_image_url)) + # fix failure of HA media player ui to quote "(" or ")" + return None + + @property + def icon(self) -> str: + """Return the icon to use in the frontend.""" + # Dynamically inject the exact MDI bluetooth music icon during active push streams + if ( + self._connected_bluetooth + and self._session + and self._session.get("mediaId") == "BluetoothMediaId" + ): + return "mdi:music-note-bluetooth" + + # Fall back to the default Alexa/Echo device icons defined elsewhere in the integration + return super().icon + + @property + def media_image_remotely_accessible(self) -> bool: + """Return whether image is accessible outside of the home network.""" + return bool(self._media_image_url) + + @property + def media_title(self): + """Return the title of current playing media.""" + return self._media_title + + @property + def device_family(self): + """Return the make of the device (ex. Echo, Other).""" + return self._device_family + + @property + def dnd_state(self): + """Return the Do Not Disturb state.""" + return self._dnd + + @dnd_state.setter + def dnd_state(self, state): + """Set the Do Not Disturb state.""" + self._dnd = state + + @_catch_login_errors + async def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + if self.hass: + self.hass.async_create_task(self.alexa_api.shuffle(shuffle)) + else: + await self.alexa_api.shuffle(shuffle) + self._shuffle = shuffle + + @property + def shuffle(self): + """Return the Shuffle state.""" + return self._shuffle + + @shuffle.setter + def shuffle(self, state): + """Set the Shuffle state.""" + self._shuffle = state + self.schedule_update_ha_state() + + @_catch_login_errors + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + repeat_state = repeat == RepeatMode.ALL + if self.hass: + self.hass.async_create_task(self.alexa_api.repeat(repeat_state)) + else: + await self.alexa_api.repeat(repeat_state) + self._repeat = repeat_state + self._attr_repeat = RepeatMode.ALL if self._repeat else RepeatMode.OFF + + @property + def repeat_state(self): + """Return the Repeat state.""" + return self._repeat + + @repeat_state.setter + def repeat_state(self, state): + """Set the Repeat state.""" + self._repeat = state + self.schedule_update_ha_state() + + @_catch_login_errors + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + if not self.available: + return + + # Save the current volume level before we change it + _LOGGER.debug("Saving previous volume level: %s", self.volume_level) + self._previous_volume = self.volume_level + + # Change the volume level on the device + if self.hass: + self.hass.async_create_task(self.alexa_api.set_volume(volume)) + else: + await self.alexa_api.set_volume(volume) + self._media_vol_level = volume + + # Let http2push update the new volume level + if not is_http2_enabled(self.hass, self._login.email): + # Otherwise we do it ourselves + await self.async_update() + + @property + def volume_level(self): + """Return the volume level of the client (0..1).""" + return self._media_vol_level + + @property + def is_volume_muted(self): + """Return boolean if volume is currently muted.""" + return self._media_is_muted + + @_catch_login_errors + async def async_mute_volume(self, mute): + """Mute the volume. + + Since we can't actually mute, we'll: + - On mute, store volume and set volume to 0 + - On unmute, set volume to previously stored volume + """ + if not self.available: + return + + self._media_is_muted = mute + if mute: + self._saved_volume = self.volume_level + if self.hass: + self.hass.async_create_task(self.alexa_api.set_volume(0)) + else: + await self.alexa_api.set_volume(0) + else: + if self._saved_volume is not None: + if self.hass: + self.hass.async_create_task( + self.alexa_api.set_volume(self._saved_volume) + ) + else: + await self.alexa_api.set_volume(self._saved_volume) + else: + if self.hass: + self.hass.async_create_task(self.alexa_api.set_volume(50)) + else: + await self.alexa_api.set_volume(50) + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + @_catch_login_errors + async def async_media_play(self): + """Send play command.""" + if not ( + self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED] + and self.available + ): + return + _LOGGER.debug( + "%s: %s sending PLAY command; state=%s media_id=%s", + hide_email(self._login.email), + self.name, + self.state, + self._session.get("mediaId") if self._session else None, + ) + if self._playing_parent: + await self._playing_parent.async_media_play() + else: + is_bt = self._session and self._session.get("mediaId") == "BluetoothMediaId" + _LOGGER.debug( + "%s: %s PLAY precheck: is_bt=%s state=%s media_id=%s transport=%s", + hide_email(self._login.email), + self.name, + is_bt, + self.state, + self._session.get("mediaId") if self._session else None, + self._session.get("transport") if self._session else None, + ) + result = await self.alexa_api.play() + _LOGGER.debug( + "%s: %s PLAY result: %s", + hide_email(self._login.email), + self.name, + result, + ) + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + @_catch_login_errors + async def async_media_pause(self): + """Send pause command.""" + if not ( + self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED] + and self.available + ): + return + if self._playing_parent: + await self._playing_parent.async_media_pause() + else: + if self.hass: + self.hass.async_create_task(self.alexa_api.pause()) + else: + await self.alexa_api.pause() + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + @_catch_login_errors + async def async_media_stop(self): + """Send stop command.""" + if not self.available: + return + if self._playing_parent: + await self._playing_parent.async_media_stop() + else: + if self.hass: + self.hass.async_create_task( + self.alexa_api.stop( + customer_id=self._customer_id, + queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self.email + ]["options"][CONF_QUEUE_DELAY], + ) + ) + else: + await self.alexa_api.stop( + customer_id=self._customer_id, + queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][ + "options" + ][CONF_QUEUE_DELAY], + ) + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + @_catch_login_errors + async def async_turn_off(self): + """Turn the client off. + + While Alexa's do not have on/off capability, we can use this as another + trigger to do updates. For turning off, we can clear media_details. + """ + self._should_poll = False + await self.async_media_pause() + self._clear_media_details() + + @_catch_login_errors + async def async_turn_on(self): + """Turn the client on. + + While Alexa's do not have on/off capability, we can use this as another + trigger to do updates. + """ + self._should_poll = True + await self.async_media_pause() + + @_catch_login_errors + async def async_media_next_track(self): + """Send next track command.""" + if not ( + self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED] + and self.available + ): + return + if self._playing_parent: + await self._playing_parent.async_media_next_track() + else: + if self.hass: + self.hass.async_create_task(self.alexa_api.next()) + else: + await self.alexa_api.next() + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + @_catch_login_errors + async def async_media_previous_track(self): + """Send previous track command.""" + if not ( + self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED] + and self.available + ): + return + if self._playing_parent: + await self._playing_parent.async_media_previous_track() + else: + if self.hass: + self.hass.async_create_task(self.alexa_api.previous()) + else: + await self.alexa_api.previous() + if not is_http2_enabled(self.hass, self._login.email): + await self.async_update() + + @_catch_login_errors + async def async_send_tts(self, message, **kwargs): + """Send TTS to Device. + + NOTE: Does not work on WHA Groups. + """ + if self.hass: + self.hass.async_create_task( + self.alexa_api.send_tts( + message, customer_id=self._customer_id, **kwargs + ) + ) + else: + await self.alexa_api.send_tts( + message, customer_id=self._customer_id, **kwargs + ) + + @_catch_login_errors + async def async_send_announcement(self, message, **kwargs): + """Send announcement to the media player.""" + if self.hass: + self.hass.async_create_task( + self.alexa_api.send_announcement( + message, customer_id=self._customer_id, **kwargs + ) + ) + else: + await self.alexa_api.send_announcement( + message, customer_id=self._customer_id, **kwargs + ) + + @_catch_login_errors + async def async_send_mobilepush(self, message, **kwargs): + """Send push to the media player's associated mobile devices.""" + if self.hass: + self.hass.async_create_task( + self.alexa_api.send_mobilepush( + message, customer_id=self._customer_id, **kwargs + ) + ) + else: + await self.alexa_api.send_mobilepush( + message, customer_id=self._customer_id, **kwargs + ) + + @_catch_login_errors + async def async_send_dropin_notification(self, message, **kwargs): + """Send notification dropin to the media player's associated mobile devices.""" + if self.hass: + self.hass.async_create_task( + self.alexa_api.send_dropin_notification( + message, customer_id=self._customer_id, **kwargs + ) + ) + else: + await self.alexa_api.send_dropin_notification( + message, customer_id=self._customer_id, **kwargs + ) + + @_catch_login_errors + async def async_play_tts_cloud_say(self, public_url, media_id, **kwargs): + file_name = media_id + if media_source.is_media_source_id(media_id): + media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + file_name = media.url[media.url.rindex("/") : media.url.rindex(".")] + media_id = async_process_play_media_url(self.hass, media.url) + + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + input_file_path = self.hass.config.path( + f"{UPLOAD_PATH}{file_name}_input.mp3" + ) + output_file_name = f"{file_name}_output.mp3" + output_file_path = self.hass.config.path(f"{UPLOAD_PATH}{output_file_name}") + + # file might already exist -> the same tts is cached from previous calls + if not os.path.exists(output_file_path): + await self.hass.async_add_executor_job( + urllib.request.urlretrieve, media_id, input_file_path + ) + command = [ + "ffmpeg", + "-i", + input_file_path, + "-ac", + "2", + "-codec:a", + "libmp3lame", + "-b:a", + "48k", + "-ar", + "24000", + "-write_xing", + "0", + output_file_path, + ] + if subprocess.run(command, check=True).returncode != 0: + _LOGGER.error( + "%s: %s:ffmpeg command FAILED converting %s to %s", + hide_email(self._login.email), + self, + input_file_path, + output_file_path, + ) + + _LOGGER.debug( + "%s: %s:Playing %slocal/alexa_tts%s", + hide_email(self._login.email), + self, + public_url, + output_file_name, + ) + await self.async_send_tts( + f"