1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-16 21:41:44 +01:00

Add initial support for PlayerOptions: Select entities to Music Assistant (#167974)

Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
This commit is contained in:
Fabian Munkes
2026-04-20 05:14:21 +02:00
committed by GitHub
parent 894b3bd6a4
commit 0af4dfb7fd
6 changed files with 483 additions and 4 deletions
@@ -53,6 +53,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SELECT,
Platform.SWITCH,
Platform.TEXT,
]
@@ -0,0 +1,123 @@
"""Music Assistant select platform."""
from __future__ import annotations
from typing import Final
from music_assistant_client.client import MusicAssistantClient
from music_assistant_models.player import PlayerOption, PlayerOptionType
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MusicAssistantConfigEntry
from .entity import MusicAssistantPlayerOptionEntity
from .helpers import catch_musicassistant_error
PLAYER_OPTIONS_SELECT: Final[dict[str, bool]] = {
# translation_key: enabled_by_default
"dimmer": False,
"equalizer_mode": False,
"link_audio_delay": True,
"link_audio_quality": False,
"link_control": False,
"sleep": False,
"surround_decoder_type": False,
"tone_control_mode": True,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant Select Entities (Player Options) from Config Entry."""
mass = entry.runtime_data.mass
def add_player(player_id: str) -> None:
"""Handle add player."""
player = mass.players.get(player_id)
if player is None:
return
entities: list[MusicAssistantPlayerConfigSelect] = []
for player_option in player.options:
if (
not player_option.read_only
and player_option.type
!= PlayerOptionType.BOOLEAN # these always go to switch
and player_option.options
):
# We ignore entities with unknown translation key for the base name.
# However, we accept a non-available translation_key in strings.json for the entity's state,
# as these are oftentimes dynamically created, dependent on a specific player and might not be known to the provider
# developer. In that case, the frontend falls back to showing the state's bare translation key.
if player_option.translation_key not in PLAYER_OPTIONS_SELECT:
continue
entities.append(
MusicAssistantPlayerConfigSelect(
mass,
player_id,
player_option=player_option,
entity_description=SelectEntityDescription(
key=player_option.key,
translation_key=player_option.translation_key,
entity_registry_enabled_default=PLAYER_OPTIONS_SELECT[
player_option.translation_key
],
),
)
)
async_add_entities(entities)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.SELECT, add_player)
class MusicAssistantPlayerConfigSelect(MusicAssistantPlayerOptionEntity, SelectEntity):
"""Representation of a select entity to control player provider dependent settings."""
def __init__(
self,
mass: MusicAssistantClient,
player_id: str,
player_option: PlayerOption,
entity_description: SelectEntityDescription,
) -> None:
"""Initialize MusicAssistantPlayerConfigSelect."""
# this was verified already in the entry callback
assert player_option.options is not None
# we have to define the dicts before initializing the parent, as this
# then calls self.on_player_option_update
self._option_translation_key_to_key_mapping = {
option.translation_key: option.key for option in player_option.options
}
self._option_key_to_translation_key_mapping = {
option.key: option.translation_key for option in player_option.options
}
super().__init__(mass, player_id, player_option)
self.entity_description = entity_description
self._attr_options = list(self._option_translation_key_to_key_mapping.keys())
@catch_musicassistant_error
async def async_select_option(self, option: str) -> None:
"""Select an option."""
await self.mass.players.set_option(
self.player_id,
self.mass_option_key,
self._option_translation_key_to_key_mapping[option],
)
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Update on player option update."""
self._attr_current_option = (
self._option_key_to_translation_key_mapping.get(player_option.value)
if isinstance(player_option.value, str)
else None
)
@@ -147,6 +147,80 @@
"name": "Treble"
}
},
"select": {
"dimmer": {
"name": "Dimmer",
"state": {
"auto": "[%key:common::state::auto%]"
}
},
"equalizer_mode": {
"name": "Equalizer mode",
"state": {
"auto": "[%key:common::state::auto%]",
"bypass": "Bypass",
"manual": "[%key:common::state::manual%]"
}
},
"link_audio_delay": {
"name": "Link audio delay",
"state": {
"audio_sync": "Audio synchronization",
"audio_sync_off": "Audio synchronization off",
"audio_sync_on": "Audio synchronization on",
"balanced": "Balanced",
"lip_sync": "Lip synchronization"
}
},
"link_audio_quality": {
"name": "Link audio quality",
"state": {
"compressed": "Compressed",
"uncompressed": "Uncompressed"
}
},
"link_control": {
"name": "Link control",
"state": {
"speed": "Speed",
"stability": "Stability",
"standard": "Standard"
}
},
"sleep": {
"name": "Sleep timer",
"state": {
"0": "[%key:common::state::off%]",
"30": "30 minutes",
"60": "60 minutes",
"90": "90 minutes",
"120": "120 minutes"
}
},
"surround_decoder_type": {
"name": "Surround decoder type",
"state": {
"auto": "[%key:common::state::auto%]",
"dolby_pl": "Dolby ProLogic",
"dolby_pl2x_game": "Dolby ProLogic 2x Game",
"dolby_pl2x_movie": "Dolby ProLogic 2x Movie",
"dolby_pl2x_music": "Dolby ProLogic 2x Music",
"dolby_surround": "Dolby Surround",
"dts_neo6_cinema": "DTS Neo:6 Cinema",
"dts_neo6_music": "DTS Neo:6 Music",
"dts_neural_x": "DTS Neural:X",
"toggle": "[%key:common::action::toggle%]"
}
},
"tone_control_mode": {
"name": "Tone control mode",
"state": {
"auto": "[%key:common::state::auto%]",
"bypass": "Bypass",
"manual": "[%key:common::state::manual%]"
}
}
},
"switch": {
"adaptive_drc": {
"name": "Adaptive DRC"
@@ -123,7 +123,7 @@
"key": "link_audio_delay",
"name": "Link Audio Delay",
"type": "string",
"translation_key": "player_options.link_audio_delay",
"translation_key": "link_audio_delay",
"translation_params": null,
"value": "lip_sync",
"read_only": false,
@@ -135,19 +135,85 @@
"key": "audio_sync",
"name": "audio_sync",
"type": "string",
"value": "audio_sync"
"value": "audio_sync",
"translation_key": "audio_sync_translation"
},
{
"key": "balanced",
"name": "balanced",
"type": "string",
"value": "balanced"
"value": "balanced",
"translation_key": "balanced_translation"
},
{
"key": "lip_sync",
"name": "lip_sync",
"type": "string",
"value": "lip_sync"
"value": "lip_sync",
"translation_key": "lip_sync_translation"
}
]
},
{
"key": "link_audio_delay_ro",
"name": "Link Audio Delay",
"type": "string",
"translation_key": "link_audio_delay",
"translation_params": null,
"value": "lip_sync",
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": [
{
"key": "audio_sync",
"name": "audio_sync",
"type": "string",
"value": "audio_sync",
"translation_key": "audio_sync_translation"
},
{
"key": "balanced",
"name": "balanced",
"type": "string",
"value": "balanced",
"translation_key": "balanced_translation"
},
{
"key": "lip_sync",
"name": "lip_sync",
"type": "string",
"value": "lip_sync",
"translation_key": "lip_sync_translation"
}
]
},
{
"key": "boolean_with_options",
"name": "Boolean with options",
"type": "boolean",
"translation_key": "link_audio_delay",
"translation_params": null,
"value": true,
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": [
{
"key": "option_a",
"name": "Option A",
"type": "boolean",
"value": false,
"translation_key": "option_a"
},
{
"key": "option_b",
"name": "Option B",
"type": "boolean",
"value": true,
"translation_key": "option_b"
}
]
}
@@ -0,0 +1,62 @@
# serializer version: 1
# name: test_select_entities[select.test_player_1_link_audio_delay-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'audio_sync_translation',
'balanced_translation',
'lip_sync_translation',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.test_player_1_link_audio_delay',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Link audio delay',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Link audio delay',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'link_audio_delay',
'unique_id': '00:00:00:00:00:01_link_audio_delay',
'unit_of_measurement': None,
})
# ---
# name: test_select_entities[select.test_player_1_link_audio_delay-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Player 1 Link audio delay',
'options': list([
'audio_sync_translation',
'balanced_translation',
'lip_sync_translation',
]),
}),
'context': <ANY>,
'entity_id': 'select.test_player_1_link_audio_delay',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'lip_sync_translation',
})
# ---
@@ -0,0 +1,153 @@
"""Test Music Assistant select entities."""
from unittest.mock import MagicMock, call
from music_assistant_models.enums import EventType
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.music_assistant.const import DOMAIN
from homeassistant.components.music_assistant.select import PLAYER_OPTIONS_SELECT
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.translation import LOCALE_EN, async_get_translations
from .common import (
setup_integration_from_fixtures,
snapshot_music_assistant_entities,
trigger_subscription_callback,
)
async def test_select_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
music_assistant_client: MagicMock,
) -> None:
"""Test select entities."""
await setup_integration_from_fixtures(hass, music_assistant_client)
snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.SELECT)
async def test_select_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test select set action."""
mass_player_id = "00:00:00:00:00:01"
mass_option_key = "link_audio_delay"
entity_id = "select.test_player_1_link_audio_delay"
option_sub_translation_key = "balanced_translation"
option_sub_key = "balanced"
await setup_integration_from_fixtures(hass, music_assistant_client)
state = hass.states.get(entity_id)
assert state
assert state.state != option_sub_translation_key
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: entity_id,
ATTR_OPTION: option_sub_translation_key,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"players/cmd/set_option",
player_id=mass_player_id,
option_key=mass_option_key,
option_value=option_sub_key,
)
# test invalid option
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: entity_id,
ATTR_OPTION: "non_existing_key",
},
blocking=True,
)
async def test_external_update(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test external value update."""
mass_player_id = "00:00:00:00:00:01"
mass_option_key = "link_audio_delay"
entity_id = "select.test_player_1_link_audio_delay"
await setup_integration_from_fixtures(hass, music_assistant_client)
# get current option and remove it
select_option = next(
option
for option in music_assistant_client.players._players[mass_player_id].options
if option.key == mass_option_key
)
music_assistant_client.players._players[mass_player_id].options.remove(
select_option
)
# set new value different from previous one
previous_value = select_option.value
new_value = "audio_sync"
new_value_translation = "audio_sync_translation"
select_option.value = new_value
assert previous_value != select_option.value
music_assistant_client.players._players[mass_player_id].options.append(
select_option
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_OPTIONS_UPDATED, mass_player_id
)
state = hass.states.get(entity_id)
assert state
assert state.state == new_value_translation
async def test_ignored(
hass: HomeAssistant,
music_assistant_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that non-compatible player options are ignored."""
config_entry = await setup_integration_from_fixtures(hass, music_assistant_client)
registry_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
# we only have a single non read-only and non-boolean player option
assert sum(1 for entry in registry_entries if entry.domain == SELECT_DOMAIN) == 1
async def test_name_translation_availability(
hass: HomeAssistant,
) -> None:
"""Verify, that the list of available translation keys is reflected in strings.json."""
# verify, that PLAYER_OPTIONS_SELECT matches strings.json
translations = await async_get_translations(
hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN]
)
prefix = f"component.{DOMAIN}.entity.{Platform.SELECT.value}."
for translation_key in PLAYER_OPTIONS_SELECT:
assert translations.get(f"{prefix}{translation_key}.name") is not None, (
f"{translation_key} is missing in strings.json for platform select"
)