Add support for sound modes to Music Assistant. (#167838)

This commit is contained in:
Fabian Munkes
2026-04-10 00:11:06 +02:00
committed by GitHub
parent cf4d8f0974
commit 0764e3e239
6 changed files with 159 additions and 6 deletions

View File

@@ -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."

View File

@@ -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.<id> 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

View File

@@ -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"

View File

@@ -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"
}
]
},
{

View File

@@ -32,7 +32,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 8320959>,
'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': <MediaPlayerEntityFeature: 8320959>,
'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': <MediaPlayerEntityFeature: 8323007>,
'translation_key': None,
'supported_features': <MediaPlayerEntityFeature: 8388543>,
'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': <MediaPlayerState.OFF: 'off'>,
'mass_player_type': 'player',
'sound_mode_list': list([
'munich',
'vienna',
]),
'source_list': list([
'Music Assistant Queue',
'Line-In',
]),
'supported_features': <MediaPlayerEntityFeature: 8323007>,
'supported_features': <MediaPlayerEntityFeature: 8388543>,
}),
'context': <ANY>,
'entity_id': 'media_player.test_player_1',

View File

@@ -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