Match ESPHome media player features to native API commands (#183397)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-28 04:55:52 -04:00
committed by GitHub
co-authored by Claude
parent ab60cff328
commit b9588e57de
3 changed files with 423 additions and 11 deletions
@@ -19,17 +19,22 @@ from aioesphomeapi import (
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
async_process_play_media_url,
)
from homeassistant.core import callback
from homeassistant.exceptions import ServiceValidationError
from .const import DOMAIN
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
@@ -54,28 +59,30 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM
}
)
# The native API has no commands for these flags
_UNSUPPORTED_FEATURES = (
EspMediaPlayerEntityFeature.SEEK
| EspMediaPlayerEntityFeature.PREVIOUS_TRACK
| EspMediaPlayerEntityFeature.NEXT_TRACK
| EspMediaPlayerEntityFeature.SELECT_SOURCE
| EspMediaPlayerEntityFeature.SELECT_SOUND_MODE
| EspMediaPlayerEntityFeature.SHUFFLE_SET
| EspMediaPlayerEntityFeature.GROUPING
)
_FEATURES = {
EspMediaPlayerEntityFeature.PAUSE: MediaPlayerEntityFeature.PAUSE,
EspMediaPlayerEntityFeature.SEEK: MediaPlayerEntityFeature.SEEK,
EspMediaPlayerEntityFeature.VOLUME_SET: MediaPlayerEntityFeature.VOLUME_SET,
EspMediaPlayerEntityFeature.VOLUME_MUTE: MediaPlayerEntityFeature.VOLUME_MUTE,
EspMediaPlayerEntityFeature.PREVIOUS_TRACK: MediaPlayerEntityFeature.PREVIOUS_TRACK,
EspMediaPlayerEntityFeature.NEXT_TRACK: MediaPlayerEntityFeature.NEXT_TRACK,
EspMediaPlayerEntityFeature.TURN_ON: MediaPlayerEntityFeature.TURN_ON,
EspMediaPlayerEntityFeature.TURN_OFF: MediaPlayerEntityFeature.TURN_OFF,
EspMediaPlayerEntityFeature.PLAY_MEDIA: MediaPlayerEntityFeature.PLAY_MEDIA,
EspMediaPlayerEntityFeature.VOLUME_STEP: MediaPlayerEntityFeature.VOLUME_STEP,
EspMediaPlayerEntityFeature.SELECT_SOURCE: MediaPlayerEntityFeature.SELECT_SOURCE,
EspMediaPlayerEntityFeature.STOP: MediaPlayerEntityFeature.STOP,
EspMediaPlayerEntityFeature.CLEAR_PLAYLIST: MediaPlayerEntityFeature.CLEAR_PLAYLIST,
EspMediaPlayerEntityFeature.PLAY: MediaPlayerEntityFeature.PLAY,
EspMediaPlayerEntityFeature.SHUFFLE_SET: MediaPlayerEntityFeature.SHUFFLE_SET,
EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: (
MediaPlayerEntityFeature.SELECT_SOUND_MODE
),
EspMediaPlayerEntityFeature.BROWSE_MEDIA: MediaPlayerEntityFeature.BROWSE_MEDIA,
EspMediaPlayerEntityFeature.REPEAT_SET: MediaPlayerEntityFeature.REPEAT_SET,
EspMediaPlayerEntityFeature.GROUPING: MediaPlayerEntityFeature.GROUPING,
EspMediaPlayerEntityFeature.MEDIA_ANNOUNCE: MediaPlayerEntityFeature.MEDIA_ANNOUNCE,
EspMediaPlayerEntityFeature.MEDIA_ENQUEUE: MediaPlayerEntityFeature.MEDIA_ENQUEUE,
EspMediaPlayerEntityFeature.SEARCH_MEDIA: MediaPlayerEntityFeature.SEARCH_MEDIA,
@@ -96,8 +103,11 @@ class EsphomeMediaPlayer(
def _on_static_info_update(self, static_info: EntityInfo) -> None:
"""Set attrs from static info."""
super()._on_static_info_update(static_info)
esp_flags = EspMediaPlayerEntityFeature(
self._static_info.feature_flags_compat(self._api_version)
esp_flags = (
EspMediaPlayerEntityFeature(
self._static_info.feature_flags_compat(self._api_version)
)
& ~_UNSUPPORTED_FEATURES
)
flags = MediaPlayerEntityFeature(0)
for espflag in esp_flags:
@@ -134,6 +144,14 @@ class EsphomeMediaPlayer(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Send the play command with media url to the media player."""
enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE)
# The device can only append to its playlist or replace it
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="enqueue_mode_not_supported",
translation_placeholders={"enqueue": enqueue},
)
if media_source.is_media_source_id(media_id):
sourced_media = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
@@ -160,6 +178,11 @@ class EsphomeMediaPlayer(
self._client.media_player_command(
self._key,
command=(
MediaPlayerCommand.ENQUEUE
if enqueue == MediaPlayerEnqueue.ADD
else None
),
media_url=media_id,
announcement=announcement,
device_id=self._static_info.device_id,
@@ -251,6 +274,26 @@ class EsphomeMediaPlayer(
self._key, volume=volume, device_id=self._static_info.device_id
)
@convert_api_error_ha_error
@override
async def async_volume_up(self) -> None:
"""Turn volume up."""
self._client.media_player_command(
self._key,
command=MediaPlayerCommand.VOLUME_UP,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
@override
async def async_volume_down(self) -> None:
"""Turn volume down."""
self._client.media_player_command(
self._key,
command=MediaPlayerCommand.VOLUME_DOWN,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
@override
async def async_media_pause(self) -> None:
@@ -281,6 +324,39 @@ class EsphomeMediaPlayer(
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
@override
async def async_clear_playlist(self) -> None:
"""Clear the playlist."""
self._client.media_player_command(
self._key,
command=MediaPlayerCommand.CLEAR_PLAYLIST,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
@override
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set the repeat mode."""
if repeat == RepeatMode.ALL:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="repeat_mode_not_supported",
translation_placeholders={"repeat_mode": repeat},
)
self._client.media_player_command(
self._key,
command=(
MediaPlayerCommand.REPEAT_ONE
if repeat == RepeatMode.ONE
else MediaPlayerCommand.REPEAT_OFF
),
device_id=self._static_info.device_id,
)
# The device does not report its repeat mode
self._attr_repeat = repeat
self.async_write_ha_state()
@convert_api_error_ha_error
@override
async def async_mute_volume(self, mute: bool) -> None:
@@ -142,6 +142,9 @@
"action_call_timeout": {
"message": "Timeout waiting for response from action call {call_name} on {device_name}"
},
"enqueue_mode_not_supported": {
"message": "Enqueue mode {enqueue} is not supported."
},
"error_communicating_with_device": {
"message": "Error communicating with the device {device_name}: {error}"
},
@@ -153,6 +156,9 @@
},
"ota_in_progress": {
"message": "An OTA (Over-The-Air) update is already in progress for {configuration}."
},
"repeat_mode_not_supported": {
"message": "Repeat mode {repeat_mode} is not supported."
}
},
"issues": {
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, call, patch
from aioesphomeapi import (
APIClient,
MediaPlayerCommand,
MediaPlayerEntityFeature as EspMediaPlayerEntityFeature,
MediaPlayerEntityState,
MediaPlayerFormatPurpose,
MediaPlayerInfo,
@@ -20,25 +21,35 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
ATTR_MEDIA_REPEAT,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_CLEAR_PLAYLIST,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_STOP,
SERVICE_PLAY_MEDIA,
SERVICE_REPEAT_SET,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
STATE_PLAYING,
BrowseMedia,
MediaClass,
MediaPlayerEnqueue,
MediaPlayerEntityFeature,
MediaType,
RepeatMode,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
@@ -279,6 +290,323 @@ async def test_media_player_entity_with_undefined_flags(
)
async def test_media_player_entity_ignores_flags_without_command(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Test that device flags without a native API command are not exposed."""
entity_info = [
MediaPlayerInfo(
object_id="mymedia_player",
key=1,
name="my media_player",
feature_flags=(
EspMediaPlayerEntityFeature.PAUSE
| EspMediaPlayerEntityFeature.PLAY
| EspMediaPlayerEntityFeature.VOLUME_STEP
| EspMediaPlayerEntityFeature.CLEAR_PLAYLIST
| EspMediaPlayerEntityFeature.REPEAT_SET
| EspMediaPlayerEntityFeature.MEDIA_ENQUEUE
| EspMediaPlayerEntityFeature.SEEK
| EspMediaPlayerEntityFeature.PREVIOUS_TRACK
| EspMediaPlayerEntityFeature.NEXT_TRACK
| EspMediaPlayerEntityFeature.SELECT_SOURCE
| EspMediaPlayerEntityFeature.SELECT_SOUND_MODE
| EspMediaPlayerEntityFeature.SHUFFLE_SET
| EspMediaPlayerEntityFeature.GROUPING
),
)
]
states = [
MediaPlayerEntityState(
key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get("media_player.test_my_media_player")
assert state is not None
assert state.attributes["supported_features"] == (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
)
@pytest.mark.parametrize(
("service", "service_data", "command"),
[
pytest.param(
SERVICE_CLEAR_PLAYLIST,
{},
MediaPlayerCommand.CLEAR_PLAYLIST,
id="clear_playlist",
),
pytest.param(
SERVICE_REPEAT_SET,
{ATTR_MEDIA_REPEAT: RepeatMode.ONE},
MediaPlayerCommand.REPEAT_ONE,
id="repeat_one",
),
pytest.param(
SERVICE_REPEAT_SET,
{ATTR_MEDIA_REPEAT: RepeatMode.OFF},
MediaPlayerCommand.REPEAT_OFF,
id="repeat_off",
),
pytest.param(
SERVICE_VOLUME_UP,
{},
MediaPlayerCommand.VOLUME_UP,
id="volume_up",
),
pytest.param(
SERVICE_VOLUME_DOWN,
{},
MediaPlayerCommand.VOLUME_DOWN,
id="volume_down",
),
],
)
async def test_media_player_playlist_commands(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
service: str,
service_data: dict[str, str],
command: MediaPlayerCommand,
) -> None:
"""Test the clear playlist, repeat and volume step commands."""
entity_info = [
MediaPlayerInfo(
object_id="mymedia_player",
key=1,
name="my media_player",
feature_flags=(
EspMediaPlayerEntityFeature.CLEAR_PLAYLIST
| EspMediaPlayerEntityFeature.REPEAT_SET
| EspMediaPlayerEntityFeature.VOLUME_STEP
),
)
]
states = [
MediaPlayerEntityState(
key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.test_my_media_player", **service_data},
blocking=True,
)
mock_client.media_player_command.assert_called_once_with(
1, command=command, device_id=0
)
@pytest.mark.parametrize("repeat", [RepeatMode.ONE, RepeatMode.OFF])
async def test_media_player_repeat_state(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
repeat: RepeatMode,
) -> None:
"""Test that the repeat mode is kept after it is set."""
entity_info = [
MediaPlayerInfo(
object_id="mymedia_player",
key=1,
name="my media_player",
feature_flags=EspMediaPlayerEntityFeature.REPEAT_SET,
)
]
states = [
MediaPlayerEntityState(
key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_REPEAT_SET,
{
ATTR_ENTITY_ID: "media_player.test_my_media_player",
ATTR_MEDIA_REPEAT: repeat,
},
blocking=True,
)
state = hass.states.get("media_player.test_my_media_player")
assert state is not None
assert state.attributes[ATTR_MEDIA_REPEAT] == repeat
async def test_media_player_repeat_all_not_supported(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Test that repeat mode all raises an error."""
entity_info = [
MediaPlayerInfo(
object_id="mymedia_player",
key=1,
name="my media_player",
feature_flags=EspMediaPlayerEntityFeature.REPEAT_SET,
)
]
states = [
MediaPlayerEntityState(
key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_REPEAT_SET,
{
ATTR_ENTITY_ID: "media_player.test_my_media_player",
ATTR_MEDIA_REPEAT: RepeatMode.ALL,
},
blocking=True,
)
assert exc_info.value.translation_key == "repeat_mode_not_supported"
mock_client.media_player_command.assert_not_called()
@pytest.mark.parametrize(
("enqueue", "command"),
[
pytest.param(MediaPlayerEnqueue.ADD, MediaPlayerCommand.ENQUEUE, id="add"),
pytest.param(MediaPlayerEnqueue.REPLACE, None, id="replace"),
],
)
async def test_media_player_play_media_enqueue(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
enqueue: MediaPlayerEnqueue,
command: MediaPlayerCommand | None,
) -> None:
"""Test that only the add enqueue mode sends the enqueue command."""
entity_info = [
MediaPlayerInfo(
object_id="mymedia_player",
key=1,
name="my media_player",
feature_flags=(
EspMediaPlayerEntityFeature.PLAY_MEDIA
| EspMediaPlayerEntityFeature.MEDIA_ENQUEUE
),
)
]
states = [
MediaPlayerEntityState(
key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_my_media_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: "http://www.example.com/xy.mp3",
ATTR_MEDIA_ENQUEUE: enqueue,
},
blocking=True,
)
mock_client.media_player_command.assert_called_once_with(
1,
command=command,
media_url="http://www.example.com/xy.mp3",
announcement=None,
device_id=0,
)
@pytest.mark.parametrize(
"enqueue",
[
pytest.param(MediaPlayerEnqueue.NEXT, id="next"),
pytest.param(MediaPlayerEnqueue.PLAY, id="play"),
],
)
async def test_media_player_play_media_enqueue_not_supported(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
enqueue: MediaPlayerEnqueue,
) -> None:
"""Test that enqueue modes the device cannot follow raise an error."""
entity_info = [
MediaPlayerInfo(
object_id="mymedia_player",
key=1,
name="my media_player",
feature_flags=(
EspMediaPlayerEntityFeature.PLAY_MEDIA
| EspMediaPlayerEntityFeature.MEDIA_ENQUEUE
),
)
]
states = [
MediaPlayerEntityState(
key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_my_media_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: "http://www.example.com/xy.mp3",
ATTR_MEDIA_ENQUEUE: enqueue,
},
blocking=True,
)
assert exc_info.value.translation_key == "enqueue_mode_not_supported"
mock_client.media_player_command.assert_not_called()
async def test_media_player_entity_with_source(
hass: HomeAssistant,
mock_client: APIClient,
@@ -384,6 +712,7 @@ async def test_media_player_entity_with_source(
[
call(
1,
command=None,
media_url="http://www.example.com/xy.mp3",
announcement=None,
device_id=0,
@@ -418,6 +747,7 @@ async def test_media_player_entity_with_source(
[
call(
1,
command=None,
media_url="media-source://tts?message=hello",
announcement=True,
device_id=0,