From b9588e57deeea7f7b38e1cd4695303be88ac83a2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 28 Sep 2026 04:55:52 -0400 Subject: [PATCH] Match ESPHome media player features to native API commands (#183397) Co-authored-by: Claude --- .../components/esphome/media_player.py | 98 +++++- homeassistant/components/esphome/strings.json | 6 + tests/components/esphome/test_media_player.py | 330 ++++++++++++++++++ 3 files changed, 423 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 2a9c3cbdd2e2..5e827cce8601 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -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: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 5ec3fb35e1a9..e88f9c9f2ccf 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -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": { diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index dc2936d32b2d..d84c8287fd0e 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -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,