2134 lines
84 KiB
Python
2134 lines
84 KiB
Python
"""
|
|
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"<audio src='{public_url}local/alexa_tts{output_file_name}' />"
|
|
)
|
|
else:
|
|
await self.async_send_tts(STREAMING_ERROR_MESSAGE)
|
|
_LOGGER.warning(STREAMING_ERROR_MESSAGE)
|
|
|
|
@_catch_login_errors
|
|
async def async_play_media(self, media_type, media_id, enqueue=None, **kwargs):
|
|
# pylint: disable=unused-argument,too-many-branches
|
|
"""Send the play_media command to the media player."""
|
|
queue_delay = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][
|
|
"options"
|
|
].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY)
|
|
public_url = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][
|
|
"options"
|
|
].get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL)
|
|
if media_type == "music":
|
|
if public_url:
|
|
# Handle TTS playback
|
|
await self.async_play_tts_cloud_say(public_url, media_id, **kwargs)
|
|
else:
|
|
# Log and notify for missing public URL
|
|
_LOGGER.warning(PUBLIC_URL_ERROR_MESSAGE)
|
|
await self.async_send_tts(PUBLIC_URL_ERROR_MESSAGE)
|
|
elif media_type == "sequence":
|
|
_LOGGER.debug(
|
|
"%s: %s:Running sequence %s with queue_delay %s",
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
queue_delay,
|
|
)
|
|
if self.hass:
|
|
self.hass.async_create_task(
|
|
self.alexa_api.send_sequence(
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
**kwargs,
|
|
)
|
|
)
|
|
else:
|
|
await self.alexa_api.send_sequence(
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
**kwargs,
|
|
)
|
|
elif media_type == "routine":
|
|
_LOGGER.debug(
|
|
"%s: %s:Running routine %s with queue_delay %s",
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
queue_delay,
|
|
)
|
|
if self.hass:
|
|
self.hass.async_create_task(
|
|
self.alexa_api.run_routine(
|
|
media_id,
|
|
queue_delay=queue_delay,
|
|
)
|
|
)
|
|
else:
|
|
await self.alexa_api.run_routine(
|
|
media_id,
|
|
queue_delay=queue_delay,
|
|
)
|
|
elif media_type == "sound":
|
|
_LOGGER.debug(
|
|
"%s: %s:Playing sound %s with queue_delay %s",
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
queue_delay,
|
|
)
|
|
if self.hass:
|
|
self.hass.async_create_task(
|
|
self.alexa_api.play_sound(
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
**kwargs,
|
|
)
|
|
)
|
|
else:
|
|
await self.alexa_api.play_sound(
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
**kwargs,
|
|
)
|
|
elif media_type == "skill":
|
|
_LOGGER.debug(
|
|
"%s: %s:Running skill %s with queue_delay %s",
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
queue_delay,
|
|
)
|
|
if self.hass:
|
|
self.hass.async_create_task(
|
|
self.alexa_api.run_skill(
|
|
media_id,
|
|
queue_delay=queue_delay,
|
|
)
|
|
)
|
|
else:
|
|
await self.alexa_api.run_skill(
|
|
media_id,
|
|
queue_delay=queue_delay,
|
|
)
|
|
elif media_type == "image":
|
|
_LOGGER.debug(
|
|
"%s: %s:Setting background to %s",
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
)
|
|
if self.hass:
|
|
self.hass.async_create_task(self.alexa_api.set_background(media_id))
|
|
else:
|
|
await self.alexa_api.set_background(media_id)
|
|
elif media_type == "custom":
|
|
_LOGGER.debug(
|
|
'%s: %s:Running custom command: "%s" with queue_delay %s',
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
queue_delay,
|
|
)
|
|
if self.hass:
|
|
self.hass.async_create_task(
|
|
self.alexa_api.run_custom(
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
**kwargs,
|
|
)
|
|
)
|
|
else:
|
|
await self.alexa_api.run_custom(
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
**kwargs,
|
|
)
|
|
else:
|
|
_LOGGER.debug(
|
|
"%s: %s:Playing music %s on %s with queue_delay %s",
|
|
hide_email(self._login.email),
|
|
self,
|
|
media_id,
|
|
media_type,
|
|
queue_delay,
|
|
)
|
|
timer = safe_get(kwargs, ["extra", "timer"])
|
|
if isinstance(timer, str):
|
|
try:
|
|
timer = int(timer)
|
|
except ValueError:
|
|
timer = None
|
|
elif not isinstance(timer, int):
|
|
timer = None
|
|
if self.hass:
|
|
self.hass.async_create_task(
|
|
self.alexa_api.play_music(
|
|
media_type,
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
timer=timer,
|
|
**kwargs,
|
|
)
|
|
)
|
|
else:
|
|
await self.alexa_api.play_music(
|
|
media_type,
|
|
media_id,
|
|
customer_id=self._customer_id,
|
|
queue_delay=queue_delay,
|
|
timer=timer,
|
|
**kwargs,
|
|
)
|
|
if not is_http2_enabled(self.hass, self._login.email):
|
|
await self.async_update()
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
"""Return the state attributes."""
|
|
attr = {
|
|
"available": self.available,
|
|
"last_called": self._last_called,
|
|
"last_called_timestamp": self._last_called_timestamp,
|
|
"last_called_summary": self._last_called_summary,
|
|
"last_called_response": self._last_called_response,
|
|
"connected_bluetooth": self._connected_bluetooth,
|
|
"bluetooth_list": self._bluetooth_list,
|
|
"history_records": self._history_records,
|
|
"previous_volume": self._previous_volume,
|
|
}
|
|
return attr
|
|
|
|
@property
|
|
def should_poll(self):
|
|
"""Return the polling state."""
|
|
return self._should_poll
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""
|
|
Provide the device information mapping used by Home Assistant for this entity.
|
|
|
|
Returns:
|
|
dict: A mapping containing the device's identification and metadata with keys:
|
|
- `identifiers`: set containing a (domain, unique_id) tuple
|
|
- `name`: device display name
|
|
- `manufacturer`: device manufacturer (always "Amazon")
|
|
- `model`: device model string
|
|
- `serial_number`: device serial number (unique_id)
|
|
- `sw_version`: software version
|
|
"""
|
|
return {
|
|
"identifiers": {(ALEXA_DOMAIN, self.unique_id)},
|
|
"name": self.name,
|
|
"manufacturer": "Amazon",
|
|
"model": MODEL_IDS.get(
|
|
self._device_type, f"{self._device_family} {self._device_type}"
|
|
),
|
|
"serial_number": self.device_serial_number,
|
|
"sw_version": self._software_version,
|
|
}
|
|
|
|
async def _update_notify_targets(self) -> None:
|
|
"""Update notification service targets."""
|
|
notify = self.hass.data[DATA_ALEXAMEDIA].get("notify_service")
|
|
if not notify:
|
|
return
|
|
|
|
if not hasattr(notify, "registered_targets"):
|
|
_LOGGER.debug(
|
|
"%s: Unable to refresh notify targets; notify not ready",
|
|
hide_email(self._login.email),
|
|
)
|
|
return
|
|
|
|
email = hide_email(self._login.email)
|
|
|
|
_LOGGER.debug("%s: Refreshing notify targets", email)
|
|
|
|
# Evaluate once for logging (HA legacy registration may evaluate again internally)
|
|
targets = notify.targets
|
|
|
|
entity_name = (self.entity_id or "").split(".", 1)[-1]
|
|
suffix = (
|
|
f"_{self._login.email}" if entity_name and entity_name[-1].isdigit() else ""
|
|
)
|
|
last_called_key = f"last_called{suffix}"
|
|
last_called_uid = targets.get(last_called_key)
|
|
|
|
_LOGGER.debug(
|
|
"%s: Computed %d notify targets; %s -> %s",
|
|
email,
|
|
len(targets),
|
|
last_called_key,
|
|
hide_serial(last_called_uid),
|
|
)
|
|
|
|
await notify.async_register_services()
|
|
|
|
prefix = getattr(notify, "_target_service_name_prefix", ALEXA_DOMAIN)
|
|
service_key = slugify(f"{prefix}_{last_called_key}")
|
|
mapped = notify.registered_targets.get(service_key)
|
|
|
|
if notify.last_called and mapped != self.unique_id:
|
|
_LOGGER.debug(
|
|
"%s: notify last_called mapping mismatch: %s=%s; %s -> %s (expected %s)",
|
|
email,
|
|
last_called_key,
|
|
hide_serial(last_called_uid),
|
|
service_key,
|
|
mapped,
|
|
self.unique_id,
|
|
)
|
|
# Reset stale last_called target mapping and re-register notify services.
|
|
previous_last_called = notify.last_called
|
|
try:
|
|
notify.last_called = False
|
|
await notify.async_register_services()
|
|
notify.last_called = True
|
|
await notify.async_register_services()
|
|
_LOGGER.debug(
|
|
"notify.last_called toggled for %s; re-registered notify services",
|
|
last_called_key,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
_LOGGER.exception(
|
|
"Failed to reset notify.last_called for %s", last_called_key
|
|
)
|
|
finally:
|
|
notify.last_called = previous_last_called
|
|
|
|
def _fire_last_called_event(_now) -> None:
|
|
"""Fire after yielding once so entity/service state has settled."""
|
|
event_data = {
|
|
"last_called": self.device_serial_number,
|
|
"name": self._device_name,
|
|
"timestamp": self._last_called_timestamp,
|
|
"summary": self._last_called_summary,
|
|
"response": self._last_called_response,
|
|
}
|
|
|
|
_LOGGER.debug("Firing alexa_media_last_called_event")
|
|
self.hass.bus.fire("alexa_media_last_called_event", event_data)
|
|
|
|
_LOGGER.debug("Scheduling alexa_media_last_called_event")
|
|
# Defer to the next loop iteration so downstream consumers see updated state.
|
|
async_call_later(self.hass, 0, _fire_last_called_event)
|