mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Add media_player platform to Alexa Devices (#165825)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
This commit is contained in:
co-authored by
Simone Chemelli
parent
e19601f991
commit
a2551647b8
@@ -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()
|
||||
Reference in New Issue
Block a user