Add media_player platform to Alexa Devices (#165825)

Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
This commit is contained in:
jameson_uk
2026-05-27 00:43:04 +02:00
committed by GitHub
co-authored by Simone Chemelli
parent e19601f991
commit a2551647b8
6 changed files with 1096 additions and 2 deletions
@@ -17,6 +17,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
@@ -40,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
await coordinator.sync_history_state()
await coordinator.sync_media_state()
async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass)
@@ -8,7 +8,12 @@ from aioamazondevices.exceptions import (
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
from aioamazondevices.structures import (
AmazonDevice,
AmazonMediaState,
AmazonVocalRecord,
AmazonVolumeState,
)
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
@@ -74,10 +79,17 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
}
self._vocal_records: dict[str, AmazonVocalRecord] = {}
self.api.on_history_event.append(self.history_state_event_handler)
self.api.on_history_event.freeze()
self._volume_states: dict[str, AmazonVolumeState] = {}
self.api.on_volume_state_event.append(self.volume_state_event_handler)
self.api.on_volume_state_event.freeze()
self._media_states: dict[str, AmazonMediaState] = {}
self.api.on_media_state_event.append(self.media_state_event_handler)
self.api.on_media_state_event.freeze()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
@@ -189,3 +201,31 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
"""Vocal records of devices."""
return self._vocal_records
async def sync_media_state(self) -> None:
"""Sync media state."""
await self.api.sync_media_state()
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
) -> None:
"""Handle pushed media state changed events."""
self._media_states = media_state
self.async_update_listeners()
@property
def media_states(self) -> dict[str, AmazonMediaState]:
"""Media state of devices."""
return self._media_states
async def volume_state_event_handler(
self, volume_states: dict[str, AmazonVolumeState]
) -> None:
"""Handle pushed volume change events."""
self._volume_states = volume_states
self.async_update_listeners()
@property
def volume_states(self) -> dict[str, AmazonVolumeState]:
"""Volumes of devices."""
return self._volume_states
@@ -0,0 +1,294 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from aioamazondevices.structures import (
AmazonMediaControls,
AmazonMediaState,
AmazonVolumeState,
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
STANDARD_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices media player entities from a config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
def _check_device() -> None:
"""Add entities for newly discovered devices."""
new_entities: list[AlexaDevicesMediaPlayer] = []
for serial_num, device in coordinator.data.items():
if serial_num in known_devices or not device.media_player_supported:
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
)
if new_entities:
async_add_entities(new_entities)
remove_listener = coordinator.async_add_listener(_check_device)
entry.async_on_unload(remove_listener)
_check_device()
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
super().__init__(coordinator, serial_num, description)
@property
def media_state(self) -> AmazonMediaState | None:
"""Return the media state relating to device."""
if not self.coordinator or not self.coordinator.media_states:
return None
return self.coordinator.media_states.get(self._serial_num)
@property
def volume_state(self) -> AmazonVolumeState | None:
"""Volume settings for device."""
if not self.coordinator or not self.coordinator.volume_states:
return None
return self.coordinator.volume_states.get(self._serial_num)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Return dynamically supported features based on current media."""
features = STANDARD_SUPPORTED_FEATURES
if self.media_state is None:
return features
if self.media_state.pause_enabled:
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
if self.media_state.next_enabled:
features |= MediaPlayerEntityFeature.NEXT_TRACK
if self.media_state.previous_enabled:
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
return features
@property
def state(self) -> MediaPlayerState | None:
"""Return the current state of the player."""
if not self.media_state:
return MediaPlayerState.IDLE
if self.media_state.player_state == "PLAYING":
return MediaPlayerState.PLAYING
if self.media_state.player_state == "PAUSED":
return MediaPlayerState.PAUSED
return MediaPlayerState.IDLE
@property
def volume_level(self) -> float | None:
"""Return the volume level (0.0 to 1.0)."""
if not self.volume_state or self.volume_state.volume is None:
return None
return self.volume_state.volume / 100
@property
def is_volume_muted(self) -> bool | None:
"""Return True if the volume is muted."""
if not self.volume_state:
return None
return self.volume_state.volume == 0
@property
def media_title(self) -> str | None:
"""Track title."""
if not self.media_state:
return None
return self.media_state.now_playing_title
@property
def media_artist(self) -> str | None:
"""Artist name."""
if not self.media_state:
return None
return self.media_state.now_playing_line1
@property
def media_album_name(self) -> str | None:
"""Album name."""
if not self.media_state:
return None
return self.media_state.now_playing_line2
@property
def media_image_url(self) -> str | None:
"""Album art URL."""
if not self.media_state:
return None
return self.media_state.now_playing_url
@property
def media_duration(self) -> int | None:
"""Duration in seconds."""
if not self.media_state:
return None
return self.media_state.media_length
@property
def media_position(self) -> int | None:
"""Current playback position in seconds."""
if not self.media_state:
return None
return self.media_state.media_position
@property
def media_position_updated_at(self) -> datetime | None:
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
if not self.media_state:
return None
return self.media_state.media_position_updated_at
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
return MediaType.MUSIC
return None
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
announce: bool | None = None,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
"Setting volume for %s to %s%%",
self.device.serial_number,
volume,
)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
device_volume = round(volume * 100)
await self.async_set_device_volume(device_volume)
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or un-mute the volume."""
# Whilst you can mute a device by asking it there appears to be
# no way to do this programmatically so set volume to 0
if not self.volume_state or self.volume_state.volume is None:
return
if mute:
self._prev_volume = self.volume_state.volume
target_volume = 0
else:
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(target_volume / 100)
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._send_media_command(AmazonMediaControls.Stop)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_media_command(AmazonMediaControls.Pause)
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_media_command(AmazonMediaControls.Play)
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_media_command(AmazonMediaControls.Next)
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_media_command(AmazonMediaControls.Previous)
@@ -63,6 +63,8 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]:
return_value={TEST_DEVICE_1_SN: TEST_VOCAL_RECORD_INITIAL}
)
client.on_history_event = MagicMock()
client.on_volume_state_event = MagicMock()
client.on_media_state_event = MagicMock()
http2_task = asyncio.Future()
http2_task.set_result(None)
client.start_http2_processing = AsyncMock(return_value=http2_task)
@@ -0,0 +1,54 @@
# serializer version: 1
# name: test_all_entities[media_player.echo_test-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.echo_test',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'original_icon': None,
'original_name': None,
'platform': 'alexa_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 5644>,
'translation_key': None,
'unique_id': 'echo_test_serial_number-media',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[media_player.echo_test-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Echo Test',
'supported_features': <MediaPlayerEntityFeature: 5644>,
}),
'context': <ANY>,
'entity_id': 'media_player.echo_test',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'idle',
})
# ---
@@ -0,0 +1,702 @@
"""Tests for the Alexa Devices media player platform."""
from copy import deepcopy
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import (
AmazonMediaControls,
AmazonMediaState,
AmazonVolumeState,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.media_player import (
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN as MP_DOMAIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_STOP,
SERVICE_PLAY_MEDIA,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .const import TEST_DEVICE_1_SN
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "media_player.echo_test"
def _make_media_state(
player_state: str = "PLAYING",
pause_enabled: bool = True,
next_enabled: bool = True,
previous_enabled: bool = True,
now_playing_title: str = "Test Title",
now_playing_line1: str = "Test Artist",
now_playing_line2: str = "Test Album",
now_playing_url: str = "https://example.com/art.jpg",
media_length: int = 300,
media_position: int = 42,
media_position_updated_at: datetime | None = None,
) -> AmazonMediaState:
"""Return a populated AmazonMediaState for use in tests."""
return AmazonMediaState(
player_state=player_state,
pause_enabled=pause_enabled,
next_enabled=next_enabled,
previous_enabled=previous_enabled,
now_playing_title=now_playing_title,
now_playing_line1=now_playing_line1,
now_playing_line2=now_playing_line2,
now_playing_url=now_playing_url,
media_length=media_length,
media_position=media_position,
media_position_updated_at=media_position_updated_at
or datetime(2024, 1, 1, tzinfo=UTC),
seek_back_enabled=False,
seek_forward_enabled=False,
shuffle_enabled=False,
repeat_enabled=False,
media_provider="Test Provider",
media_provider_url=None,
)
def _get_registered_event_handler(
mock_amazon_devices_client: AsyncMock,
event_attr: str,
) -> AsyncMock:
"""Return the callback registered on the mocked library event."""
event = getattr(mock_amazon_devices_client, event_attr)
event.append.assert_called_once()
return event.append.call_args.args[0]
def _make_volume_state(volume: int = 50) -> AmazonVolumeState:
"""Return an AmazonVolumeState for use in tests."""
return AmazonVolumeState(volume=volume, is_muted=False)
def _get_media_state_event_callback(
mock_amazon_devices_client: AsyncMock,
) -> AsyncMock:
"""Return the registered media state event callback."""
return _get_registered_event_handler(
mock_amazon_devices_client, "on_media_state_event"
)
def _get_volume_state_event_callback(
mock_amazon_devices_client: AsyncMock,
) -> AsyncMock:
"""Return the registered volume state event callback."""
return _get_registered_event_handler(
mock_amazon_devices_client, "on_volume_state_event"
)
async def _push_media_state(
mock_amazon_devices_client: AsyncMock,
media_state: AmazonMediaState,
) -> None:
"""Update coordinator media state via the registered event handler."""
event_handler = _get_media_state_event_callback(mock_amazon_devices_client)
await event_handler({TEST_DEVICE_1_SN: media_state})
async def _push_volume_state(
mock_amazon_devices_client: AsyncMock,
volume_state: AmazonVolumeState,
) -> None:
"""Update coordinator volume state via the registered event handler."""
event_handler = _get_volume_state_event_callback(mock_amazon_devices_client)
await event_handler({TEST_DEVICE_1_SN: volume_state})
async def _clear_volume_state(
mock_amazon_devices_client: AsyncMock,
) -> None:
"""Clear coordinator volume state via the registered event handler."""
event_handler = _get_volume_state_event_callback(mock_amazon_devices_client)
await event_handler({})
async def _setup_media_player_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Set up integration with only the media player platform enabled."""
with patch(
"homeassistant.components.alexa_devices.PLATFORMS", [Platform.MEDIA_PLAYER]
):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_amazon_devices_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities are registered correctly (snapshot)."""
await _setup_media_player_platform(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_media_player_not_created_for_unsupported_device(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""No entity is created for a device that does not support media playback."""
from .const import TEST_DEVICE_1 # noqa: PLC0415
device = deepcopy(TEST_DEVICE_1)
device.media_player_supported = False
mock_amazon_devices_client.get_devices_data.return_value = {
TEST_DEVICE_1_SN: device
}
await _setup_media_player_platform(hass, mock_config_entry)
assert hass.states.get(ENTITY_ID) is None
@pytest.mark.parametrize(
"side_effect",
[
CannotConnect,
CannotRetrieveData,
CannotAuthenticate,
],
)
async def test_coordinator_data_update_fails(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
) -> None:
"""Entity becomes unavailable when the coordinator poll raises an exception."""
await _setup_media_player_platform(hass, mock_config_entry)
assert hass.states.get(ENTITY_ID) is not None
mock_amazon_devices_client.get_devices_data.side_effect = side_effect
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
async def test_offline_device_is_unavailable(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""An offline device is reported as unavailable on initial setup."""
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].online = False
await _setup_media_player_platform(hass, mock_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
async def test_offline_device_recovers(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A device that comes back online leaves the unavailable state."""
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].online = False
await _setup_media_player_platform(hass, mock_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].online = True
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.state != STATE_UNAVAILABLE
@pytest.mark.parametrize(
("player_state", "expected_ha_state"),
[
("PLAYING", MediaPlayerState.PLAYING),
("PAUSED", MediaPlayerState.PAUSED),
("BUFFERING", MediaPlayerState.IDLE),
("STOPPED", MediaPlayerState.IDLE),
],
)
async def test_player_state(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
player_state: str,
expected_ha_state: MediaPlayerState,
) -> None:
"""Player state is mapped correctly from the API value."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_media_state(
mock_amazon_devices_client,
media_state=_make_media_state(player_state=player_state),
)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=_make_volume_state(),
)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.state == expected_ha_state
async def test_idle_when_no_media_state(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""State is idle when the coordinator has no media state for the device."""
await _setup_media_player_platform(hass, mock_config_entry)
assert (state := hass.states.get(ENTITY_ID))
assert state.state == MediaPlayerState.IDLE
@pytest.mark.parametrize(
("raw_volume", "expected_level"),
[
(0, 0.0),
(50, 0.5),
(100, 1.0),
],
)
async def test_volume_level(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
raw_volume: int,
expected_level: float,
) -> None:
"""Volume level is converted from 0-100 integer to 0.0-1.0 float."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=_make_volume_state(raw_volume),
)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes.get("volume_level") == pytest.approx(expected_level)
@pytest.mark.parametrize(
("raw_volume", "expected_muted"),
[
(0, True),
(1, False),
(50, False),
],
)
async def test_is_volume_muted(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
raw_volume: int,
expected_muted: bool,
) -> None:
"""Volume is considered muted only when the raw level is 0."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=_make_volume_state(raw_volume),
)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes.get("is_volume_muted") == expected_muted
async def test_media_metadata_attributes(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Media title, artist, album and image URL are forwarded from the API."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_media_state(
mock_amazon_devices_client,
media_state=_make_media_state(
now_playing_title="Bohemian Rhapsody",
now_playing_line1="Queen",
now_playing_line2="A Night at the Opera",
now_playing_url="https://example.com/queen.jpg",
media_length=354,
media_position=120,
),
)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes.get("media_title") == "Bohemian Rhapsody"
assert state.attributes.get("media_artist") == "Queen"
assert state.attributes.get("media_album_name") == "A Night at the Opera"
assert state.attributes.get("entity_picture")
assert state.attributes["entity_picture"].startswith(
f"/api/media_player_proxy/{ENTITY_ID}"
)
assert state.attributes.get("media_duration") == 354
assert state.attributes.get("media_position") == 120
async def test_media_metadata_none_when_no_state(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Metadata attributes are absent when there is no media state."""
await _setup_media_player_platform(hass, mock_config_entry)
assert (state := hass.states.get(ENTITY_ID))
for attr in ("media_title", "media_artist", "media_album_name", "media_duration"):
assert state.attributes.get(attr) is None
async def test_service_set_volume_level(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""SERVICE_VOLUME_SET converts the 0.0-1.0 value and calls the API."""
await _setup_media_player_platform(hass, mock_config_entry)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_SET,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.75},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.assert_awaited_once()
assert mock_amazon_devices_client.set_device_volume.call_args.args[1] == 75
async def test_service_mute_volume(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Muting sets the device volume to 0 and stores the previous level."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=_make_volume_state(60),
)
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.assert_awaited_once()
assert mock_amazon_devices_client.set_device_volume.call_args.args[1] == 0
async def test_service_unmute_volume_restores_level(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Un-muting restores the volume level saved before muting."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=_make_volume_state(80),
)
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.reset_mock()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.assert_awaited_once()
assert mock_amazon_devices_client.set_device_volume.call_args.args[1] == 80
@pytest.mark.parametrize(
("service", "media_controls_attr", "media_state"),
[
(SERVICE_MEDIA_STOP, "Stop", _make_media_state()),
(
SERVICE_MEDIA_PAUSE,
"Pause",
_make_media_state(player_state="PLAYING", pause_enabled=True),
),
(
SERVICE_MEDIA_PLAY,
"Play",
_make_media_state(player_state="PAUSED", pause_enabled=True),
),
(SERVICE_MEDIA_NEXT_TRACK, "Next", _make_media_state(next_enabled=True)),
(
SERVICE_MEDIA_PREVIOUS_TRACK,
"Previous",
_make_media_state(previous_enabled=True),
),
],
)
async def test_media_transport_commands(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
service: str,
media_controls_attr: str,
media_state: AmazonMediaState,
) -> None:
"""Each transport service sends the correct AmazonMediaControls command."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_media_state(
mock_amazon_devices_client,
media_state=media_state,
)
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
service,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
expected_command = getattr(AmazonMediaControls, media_controls_attr)
mock_amazon_devices_client.send_media_command.assert_awaited_once()
assert (
mock_amazon_devices_client.send_media_command.call_args.args[1]
== expected_command
)
async def test_service_play_media(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""SERVICE_PLAY_MEDIA forwards the search term and provider to the API."""
await _setup_media_player_platform(hass, mock_config_entry)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
"media_content_id": "Abbey Road",
"media_content_type": MediaType.MUSIC,
},
blocking=True,
)
mock_amazon_devices_client.call_alexa_music.assert_awaited_once_with(
mock_amazon_devices_client.get_devices_data.return_value[TEST_DEVICE_1_SN],
"Abbey Road",
MediaType.MUSIC,
)
@pytest.mark.parametrize(
("pause_enabled", "next_enabled", "previous_enabled", "expected", "absent"),
[
(
True,
True,
True,
[
MediaPlayerEntityFeature.PLAY,
MediaPlayerEntityFeature.PAUSE,
MediaPlayerEntityFeature.NEXT_TRACK,
MediaPlayerEntityFeature.PREVIOUS_TRACK,
],
[],
),
(
False,
True,
False,
[MediaPlayerEntityFeature.NEXT_TRACK],
[
MediaPlayerEntityFeature.PLAY,
MediaPlayerEntityFeature.PAUSE,
MediaPlayerEntityFeature.PREVIOUS_TRACK,
],
),
(
False,
False,
False,
[],
[
MediaPlayerEntityFeature.PLAY,
MediaPlayerEntityFeature.PAUSE,
MediaPlayerEntityFeature.NEXT_TRACK,
MediaPlayerEntityFeature.PREVIOUS_TRACK,
],
),
],
)
async def test_supported_features_are_dynamic(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
pause_enabled: bool,
next_enabled: bool,
previous_enabled: bool,
expected: list[MediaPlayerEntityFeature],
absent: list[MediaPlayerEntityFeature],
) -> None:
"""Optional feature flags appear only when the API reports them as enabled."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_media_state(
mock_amazon_devices_client,
media_state=_make_media_state(
pause_enabled=pause_enabled,
next_enabled=next_enabled,
previous_enabled=previous_enabled,
),
)
await hass.async_block_till_done()
assert (state := hass.states.get(ENTITY_ID))
raw = state.attributes.get("supported_features", 0)
for feature in expected:
assert raw & feature, f"Expected feature {feature!r} to be set"
for feature in absent:
assert not (raw & feature), f"Did not expect feature {feature!r} to be set"
async def test_standard_features_always_present(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""VOLUME_SET, VOLUME_STEP, VOLUME_MUTE, STOP, and PLAY_MEDIA are always supported."""
await _setup_media_player_platform(hass, mock_config_entry)
assert (state := hass.states.get(ENTITY_ID))
raw = state.attributes.get("supported_features", 0)
for feature in (
MediaPlayerEntityFeature.VOLUME_SET,
MediaPlayerEntityFeature.VOLUME_STEP,
MediaPlayerEntityFeature.VOLUME_MUTE,
MediaPlayerEntityFeature.STOP,
MediaPlayerEntityFeature.PLAY_MEDIA,
):
assert raw & feature, f"Expected standard feature {feature!r} to always be set"
async def test_mute_volume_no_volume_state_returns_early(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Mute returns early when there is no volume state."""
await _setup_media_player_platform(hass, mock_config_entry)
await _clear_volume_state(mock_amazon_devices_client)
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.assert_not_awaited()
async def test_unmute_volume_without_prev_volume_returns_early(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Unmute returns early when there is no previous volume stored."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=_make_volume_state(50),
)
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.assert_not_awaited()