From 0764e3e2399b3e7c1099cb7494e5603754b3039a Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:11:06 +0200 Subject: [PATCH] Add support for sound modes to Music Assistant. (#167838) --- .../components/music_assistant/const.py | 1 + .../music_assistant/media_player.py | 38 +++++++++++ .../components/music_assistant/strings.json | 63 +++++++++++++++++++ .../music_assistant/fixtures/players.json | 17 ++++- .../snapshots/test_media_player.ambr | 18 ++++-- .../music_assistant/test_media_player.py | 28 +++++++++ 6 files changed, 159 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 5a89510471fa..2a823c48cf57 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -82,3 +82,4 @@ ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha" LOGGER = logging.getLogger(__package__) PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options." +SOUND_MODES_TRANSLATION_KEY_PREFIX = "player_sound_mode." diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8eb13002fd9c..2c1d8f5fec36 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -60,6 +60,7 @@ from .const import ( ATTR_REPEAT_MODE, ATTR_SHUFFLE_ENABLED, DOMAIN, + SOUND_MODES_TRANSLATION_KEY_PREFIX, ) from .entity import MusicAssistantEntity from .helpers import catch_musicassistant_error @@ -131,6 +132,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): _attr_name = None _attr_media_image_remotely_accessible = True _attr_media_content_type = HAMediaType.MUSIC + _attr_translation_key = "ma_media_player" def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: """Initialize MediaPlayer entity.""" @@ -140,6 +142,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 self._source_list_mapping: dict[str, str] = {} + self._sound_mode_list_mapping: dict[str, str] = {} async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -218,6 +221,29 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._source_list_mapping = source_mappings self._attr_source = active_source_name + # same for sound modes + sound_mode_mappings: dict[str, str] = {} + for sound_mode in player.sound_mode_list: + if sound_mode.passive: + # ignore passive sound_mode because HA does not differentiate between + # active and passive sound mode + continue + if ( + sound_mode.translation_key is None + or SOUND_MODES_TRANSLATION_KEY_PREFIX not in sound_mode.translation_key + ): + # MA's data class initializes the translation_key to + # player_sound_mode. automatically if it is not given, so we should + # always have a non None value + continue + translation_key = sound_mode.translation_key[ + len(SOUND_MODES_TRANSLATION_KEY_PREFIX) : + ] + sound_mode_mappings[translation_key] = sound_mode.id + self._attr_sound_mode_list = list(sound_mode_mappings.keys()) + self._sound_mode_list_mapping = sound_mode_mappings + self._attr_sound_mode = player.active_sound_mode + group_members: list[str] = [] if player.group_members: group_members = player.group_members @@ -397,6 +423,16 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) await self.mass.players.player_command_select_source(self.player_id, source_id) + @catch_musicassistant_error + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + sound_mode_id = self._sound_mode_list_mapping.get(sound_mode) + if sound_mode_id is None: + raise ServiceValidationError( + f"Sound mode '{sound_mode}' not found for player {self.name}" + ) + await self.mass.players.select_sound_mode(self.player_id, sound_mode_id) + @catch_musicassistant_error async def _async_handle_play_media( self, @@ -682,4 +718,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): supported_features |= MediaPlayerEntityFeature.TURN_OFF if PlayerFeature.SELECT_SOURCE in self.player.supported_features: supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE + if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._attr_supported_features = supported_features diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 65f8c730da8e..7ee7669087ba 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -54,6 +54,69 @@ "name": "Favorite current song" } }, + "media_player": { + "ma_media_player": { + "state_attributes": { + "sound_mode": { + "state": { + "2ch_stereo": "2ch stereo", + "5ch_stereo": "5ch stereo", + "7ch_stereo": "7ch stereo", + "9ch_stereo": "9ch stereo", + "11ch_stereo": "11ch stereo", + "action_game": "Action game", + "adventure": "Adventure", + "all_ch_stereo": "All ch stereo", + "amsterdam": "Hall in Amsterdam", + "arena": "Arena", + "bottom_line": "The Bottom Line", + "cellar_club": "Cellar club", + "chamber": "Chamber", + "concert": "Live concert", + "disco": "Disco", + "drama": "Drama", + "enhanced": "Enhanced", + "frankfurt": "Hall in Frankfurt", + "freiburg": "Church in Freiburg", + "game": "Game", + "jazz_club": "Jazz club", + "mono_movie": "Mono movie", + "movie": "Movie", + "munich": "Hall in Munich", + "munich_a": "Hall in Munich A", + "munich_b": "Hall in Munich B", + "music": "Music", + "music_video": "Music video", + "my_surround": "My surround", + "off": "[%key:common::state::off%]", + "pavilion": "Pavilion", + "recital_opera": "Recital/opera", + "roleplaying_game": "Roleplaying game", + "roxy_theatre": "The Roxy Theatre", + "royaumont": "Church in Royaumont", + "sci-fi": "Sci-fi", + "spectacle": "Spectacle", + "sports": "Sports", + "standard": "Standard", + "stereo": "Stereo", + "straight": "Straight", + "stuttgart": "Hall in Stuttgart", + "surr_decoder": "Surround decoder", + "talk_show": "Talk show", + "target": "Target", + "tokyo": "Church in Tokyo", + "tv_program": "TV program", + "usa_a": "Hall in USA A", + "usa_b": "Hall in USA B", + "vienna": "Hall in Vienna", + "village_gate": "Village Gate", + "village_vanguard": "Village Vanguard", + "warehouse_loft": "Warehouse loft" + } + } + } + } + }, "number": { "bass": { "name": "Bass" diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 5306b17d1bc9..cb078259dfbc 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -18,7 +18,8 @@ "set_members", "power", "enqueue", - "select_source" + "select_source", + "select_sound_mode" ], "elapsed_time": null, "elapsed_time_last_updated": 0, @@ -193,6 +194,20 @@ "can_seek": false, "can_next_previous": false } + ], + "sound_mode_list": [ + { + "id": "munich", + "name": "Munich", + "passive": false, + "translation_key": "player_sound_mode.munich" + }, + { + "id": "vienna", + "name": "Vienna", + "passive": false, + "translation_key": "player_sound_mode.vienna" + } ] }, { diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index dc9e7603570b..f4cff652b0b9 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -32,7 +32,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'ma_media_player', 'unique_id': '00:00:00:00:00:02', 'unit_of_measurement': None, }) @@ -103,7 +103,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'ma_media_player', 'unique_id': 'test_group_player_1', 'unit_of_measurement': None, }) @@ -152,6 +152,10 @@ ]), 'area_id': None, 'capabilities': dict({ + 'sound_mode_list': list([ + 'munich', + 'vienna', + ]), 'source_list': list([ 'Music Assistant Queue', 'Line-In', @@ -181,8 +185,8 @@ 'platform': 'music_assistant', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'ma_media_player', 'unique_id': '00:00:00:00:00:01', 'unit_of_measurement': None, }) @@ -198,11 +202,15 @@ 'icon': 'mdi:speaker', 'last_non_buffering_state': , 'mass_player_type': 'player', + 'sound_mode_list': list([ + 'munich', + 'vienna', + ]), 'source_list': list([ 'Music Assistant Queue', 'Line-In', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'media_player.test_player_1', diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index f6fcce103b31..57107c933ffa 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -24,9 +24,11 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, MediaPlayerEntityFeature, @@ -653,6 +655,31 @@ async def test_media_player_select_source_action( ) +async def test_media_player_select_sound_mode_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity select sound mode action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOUND_MODE: "munich", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/select_sound_mode", player_id=mass_player_id, sound_mode="munich" + ) + + async def test_media_player_supported_features( hass: HomeAssistant, music_assistant_client: MagicMock, @@ -686,6 +713,7 @@ async def test_media_player_supported_features( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) assert state.attributes["supported_features"] == expected_features # remove power control capability from player, trigger subscription callback