diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 3dcc5d15c52..0fc021b1065 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -53,6 +53,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SWITCH, Platform.TEXT, ] diff --git a/homeassistant/components/music_assistant/select.py b/homeassistant/components/music_assistant/select.py new file mode 100644 index 00000000000..bc47b7b006a --- /dev/null +++ b/homeassistant/components/music_assistant/select.py @@ -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 + ) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 7b403a4ff96..299e7d8caa6 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -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" diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 996e13cdbac..2ddf5a71855 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -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" } ] } diff --git a/tests/components/music_assistant/snapshots/test_select.ambr b/tests/components/music_assistant/snapshots/test_select.ambr new file mode 100644 index 00000000000..f34c9edaa0e --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_select.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_player_1_link_audio_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'select.test_player_1_link_audio_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'lip_sync_translation', + }) +# --- diff --git a/tests/components/music_assistant/test_select.py b/tests/components/music_assistant/test_select.py new file mode 100644 index 00000000000..c9bed9eb77e --- /dev/null +++ b/tests/components/music_assistant/test_select.py @@ -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" + )