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:
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user