Add PlayerOptions to backend and MusicCast (#3064)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Mon, 9 Feb 2026 08:43:50 +0000 (09:43 +0100)
committerGitHub <noreply@github.com>
Mon, 9 Feb 2026 08:43:50 +0000 (09:43 +0100)
music_assistant/controllers/players/player_controller.py
music_assistant/models/player.py
music_assistant/providers/musiccast/constants.py
music_assistant/providers/musiccast/musiccast.py
music_assistant/providers/musiccast/player.py

index 5ab1ba999aea957d600fedf092ae138214be5261..f8583993f771eac995692df88fabb408e1c4b1b8 100644 (file)
@@ -47,6 +47,7 @@ from music_assistant_models.errors import (
     ProviderUnavailableError,
     UnsupportedFeaturedException,
 )
+from music_assistant_models.player import PlayerOptionValueType  # noqa: TC002
 from music_assistant_models.player_control import PlayerControl  # noqa: TC002
 
 from music_assistant.constants import (
@@ -1025,6 +1026,70 @@ class PlayerController(CoreController):
             player.set_active_mass_source(media.source_id)
         await player.play_media(media)
 
+    @api_command("players/cmd/select_sound_mode")
+    @handle_player_command
+    async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
+        """
+        Handle SELECT SOUND MODE command on given player.
+
+        - player_id: player_id of the player to handle the command
+        - sound_mode: The ID of the sound mode that needs to be activated/selected.
+        """
+        player = self.get(player_id, True)
+        assert player is not None  # for type checking
+
+        if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support sound mode selection"
+            )
+
+        prev_sound_mode = player.active_sound_mode
+        if sound_mode == prev_sound_mode:
+            return
+
+        # basic check if sound mode is valid for player
+        if not any(x for x in player.sound_mode_list if x.id == sound_mode):
+            raise PlayerCommandFailed(
+                f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
+            )
+
+        # forward to player
+        await player.select_sound_mode(sound_mode)
+
+    @api_command("players/cmd/set_option")
+    @handle_player_command
+    async def set_option(
+        self, player_id: str, option_key: str, option_value: PlayerOptionValueType
+    ) -> None:
+        """
+        Handle SET_OPTION command on given player.
+
+        - player_id: player_id of the player to handle the command
+        - option_key: The key of the player option that needs to be activated/selected.
+        - option_value: The new value of the player option.
+        """
+        player = self.get(player_id, True)
+        assert player is not None  # for type checking
+
+        if PlayerFeature.OPTIONS not in player.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support set_option"
+            )
+
+        prev_player_option = next((x for x in player.options if x.key == option_key), None)
+        if not prev_player_option:
+            return
+        if prev_player_option.value == option_value:
+            return
+
+        if prev_player_option.read_only:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} option {option_key} is read-only"
+            )
+
+        # forward to player
+        await player.set_option(option_key=option_key, option_value=option_value)
+
     @api_command("players/cmd/select_source")
     @handle_player_command
     async def select_source(self, player_id: str, source: str | None) -> None:
@@ -1625,6 +1690,12 @@ class PlayerController(CoreController):
         # signal player update on the eventbus
         self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
 
+        # signal a separate PlayerOptionsUpdated event
+        if options := changed_values.get("options"):
+            self.mass.signal_event(
+                EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
+            )
+
         if skip_forward and not force_update:
             return
 
index c4f4d2f043a29d966f964e09af77fc3ddd388815..da5808e816e5702183ddfe680082047ccd158bce 100644 (file)
@@ -21,9 +21,21 @@ from music_assistant_models.constants import (
     PLAYER_CONTROL_NATIVE,
     PLAYER_CONTROL_NONE,
 )
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import (
+    MediaType,
+    PlaybackState,
+    PlayerFeature,
+    PlayerType,
+)
 from music_assistant_models.errors import UnsupportedFeaturedException
-from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
+from music_assistant_models.player import (
+    DeviceInfo,
+    PlayerMedia,
+    PlayerOption,
+    PlayerOptionValueType,
+    PlayerSoundMode,
+    PlayerSource,
+)
 from music_assistant_models.player import Player as PlayerState
 from music_assistant_models.unique_list import UniqueList
 from propcache import under_cached_property as cached_property
@@ -64,6 +76,8 @@ class Player(ABC):
     _attr_device_info: DeviceInfo
     _attr_can_group_with: set[str]
     _attr_source_list: list[PlayerSource]
+    _attr_sound_mode_list: list[PlayerSoundMode]
+    _attr_options: list[PlayerOption]
     _attr_available: bool = True
     _attr_name: str | None = None
     _attr_powered: bool | None = None
@@ -73,6 +87,7 @@ class Player(ABC):
     _attr_elapsed_time: float | None = None
     _attr_elapsed_time_last_updated: float | None = None
     _attr_active_source: str | None = None
+    _attr_active_sound_mode: str | None = None
     _attr_current_media: PlayerMedia | None = None
     _attr_needs_poll: bool = False
     _attr_poll_interval: int = 30
@@ -92,6 +107,8 @@ class Player(ABC):
         self._attr_device_info = DeviceInfo()
         self._attr_can_group_with = set()
         self._attr_source_list = []
+        self._attr_sound_mode_list = []
+        self._attr_options = []
         # do not override/overwrite these private attributes below!
         self._cache: dict[str, Any] = {}  # storage dict for cached properties
         self._player_id = player_id
@@ -494,6 +511,31 @@ class Player(ABC):
             "select_source needs to be implemented when PlayerFeature.SELECT_SOURCE is set"
         )
 
+    async def select_sound_mode(self, sound_mode: str) -> None:
+        """
+        Handle SELECT SOUND MODE command on the player.
+
+        Will only be called if the PlayerFeature.SELECT_SOUND_MODE is supported.
+
+        :param source: The sound_mode(id) to select, as defined in the sound_mode_list.
+        """
+        raise NotImplementedError(
+            "select_sound_mode needs to be implemented when PlayerFeature.SELECT_SOUND_MODE is set"
+        )
+
+    async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
+        """
+        Handle SET_OPTION command on the player.
+
+        Will only be called if the PlayerFeature.OPTIONS is supported.
+
+        :param option_key: The option_key of the PlayerOption
+        :param option_value: The new value of the PlayerOption
+        """
+        raise NotImplementedError(
+            "set_option needs to be implemented when PlayerFeature.Option is set"
+        )
+
     async def set_members(
         self,
         player_ids_to_add: list[str] | None = None,
@@ -615,6 +657,21 @@ class Player(ABC):
                 return player.player_id
         return None
 
+    @property
+    def active_sound_mode(self) -> str | None:
+        """Return active sound mode of this player."""
+        return self._attr_active_sound_mode
+
+    @cached_property
+    def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
+        """Return available PlayerSoundModes for Player."""
+        return UniqueList(self._attr_sound_mode_list)
+
+    @cached_property
+    def options(self) -> UniqueList[PlayerOption]:
+        """Return all PlayerOptions for Player."""
+        return UniqueList(self._attr_options)
+
     def _on_player_media_updated(self) -> None:  # noqa: B027
         """Handle callback when the current media of the player is updated."""
         # optional callback for players that want to be informed when the final
@@ -1096,6 +1153,9 @@ class Player(ABC):
             synced_to=self.synced_to,
             active_source=self.active_source,
             source_list=self.source_list,
+            active_sound_mode=self.active_sound_mode,
+            sound_mode_list=self.sound_mode_list,
+            options=self.options,
             active_group=self.active_group,
             current_media=self.current_media,
             name=self.display_name,
index 02d4121df97492ccecd428106cc6e6ccac417c7e..40fc52d36448991b4c6f8eb63cf6e61b2e8ad3df 100644 (file)
@@ -1,5 +1,12 @@
 """Constants for the MusicCast provider."""
 
+from aiomusiccast.capabilities import BinarySensor as MCBinarySensor
+from aiomusiccast.capabilities import BinarySetter as MCBinarySetter
+from aiomusiccast.capabilities import NumberSensor as MCNumberSensor
+from aiomusiccast.capabilities import NumberSetter as MCNumberSetter
+from aiomusiccast.capabilities import OptionSetter as MCOptionSetter
+from aiomusiccast.capabilities import TextSensor as MCTextSensor
+
 from music_assistant.constants import (
     CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
     CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED,
@@ -69,3 +76,21 @@ MC_CONTROL_SOURCE_IDS.append(
     # tuner can be controlled, will change the station
     "tuner",
 )
+
+# for most sound modes we can just split at '_' and capitalize
+# here are some exceptions:
+MC_SOUND_MODE_FRIENDLY_NAMES = {
+    "2ch_stereo": "2 Channel Stereo",
+    "all_ch_stereo": "All Channels Stereo",
+    "surr_decoder": "Surround Decoder",
+}
+
+# We translate aiomusiccast's capabilities to PlayerOptions
+MC_CAPABILITIES = (
+    MCBinarySensor
+    | MCBinarySetter
+    | MCNumberSensor
+    | MCNumberSetter
+    | MCTextSensor
+    | MCOptionSetter
+)
index c7da26d1f4f4f5e92849edcdce9c7072d57e0921..45bf0654fe2956602c7b7ed242fed81708cf8a9e 100644 (file)
@@ -73,6 +73,22 @@ class MusicCastZoneDevice:
             if self.device.group_reduce_by_source:
                 await entity._check_client_list()
 
+    @property
+    def sound_mode_id(self) -> str | None:
+        """ID of current sound mode."""
+        zone = self.device.data.zones.get(self.zone_name)
+        assert zone is not None  # for type checking
+        assert isinstance(zone.sound_program, str | None)  # for type checking
+        return zone.sound_program
+
+    @property
+    def sound_mode_list(self) -> list[str]:
+        """Return a list of available sound modes."""
+        zone = self.device.data.zones.get(self.zone_name)
+        assert zone is not None  # for type checking
+        assert isinstance(zone.sound_program_list, list)  # for type checking
+        return zone.sound_program_list
+
     @property
     def source_id(self) -> str:
         """ID of the current input source.
@@ -328,6 +344,10 @@ class MusicCastZoneDevice:
         """Select input source. Internal source name."""
         await self.device.select_source(self.zone_name, source_id)
 
+    async def select_sound_mode(self, sound_mode_id: str) -> None:
+        """Select sound mode. Internal sound_mode name."""
+        await self.device.select_sound_mode(self.zone_name, sound_mode_id)
+
     def is_part_of_group(self, group_server: "MusicCastZoneDevice") -> bool:
         """Return True if the given server is the server of self's group."""
         return group_server != self and (
index 27a9e22c068570b06be1fa8042b13c579f917065..ed95ba180879ba79ed3661db20201c7479aba707 100644 (file)
@@ -3,15 +3,32 @@
 import asyncio
 import time
 from collections.abc import Callable, Coroutine
+from contextlib import suppress
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, cast
 
 from aiohttp.client_exceptions import ClientError
+from aiomusiccast.capabilities import BinarySensor as MCBinarySensor
+from aiomusiccast.capabilities import BinarySetter as MCBinarySetter
+from aiomusiccast.capabilities import NumberSensor as MCNumberSensor
+from aiomusiccast.capabilities import NumberSetter as MCNumberSetter
+from aiomusiccast.capabilities import OptionSetter as MCOptionSetter
+from aiomusiccast.capabilities import TextSensor as MCTextSensor
 from aiomusiccast.exceptions import MusicCastGroupException
 from aiomusiccast.pyamaha import MusicCastConnectionException
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
-from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
+from music_assistant_models.player import (
+    DeviceInfo,
+    PlayerMedia,
+    PlayerOption,
+    PlayerOptionEntry,
+    PlayerOptionType,
+    PlayerOptionValueType,
+    PlayerSoundMode,
+    PlayerSource,
+)
+from music_assistant_models.unique_list import UniqueList
 from propcache import under_cached_property as cached_property
 
 from music_assistant.models.player import Player
@@ -28,10 +45,12 @@ from music_assistant.providers.musiccast.constants import (
     CONF_PLAYER_HANDLE_SOURCE_DISABLED,
     CONF_PLAYER_SWITCH_SOURCE_NON_NET,
     CONF_PLAYER_TURN_OFF_ON_LEAVE,
+    MC_CAPABILITIES,
     MC_CONTROL_SOURCE_IDS,
     MC_NETUSB_SOURCE_IDS,
     MC_PASSIVE_SOURCE_IDS,
     MC_POLL_INTERVAL,
+    MC_SOUND_MODE_FRIENDLY_NAMES,
     MC_SOURCE_MAIN_SYNC,
     MC_SOURCE_MC_LINK,
     PLAYER_CONFIG_ENTRIES,
@@ -98,6 +117,8 @@ class MusicCastPlayer(Player):
             PlayerFeature.NEXT_PREVIOUS,
             PlayerFeature.ENQUEUE,
             PlayerFeature.GAPLESS_PLAYBACK,
+            PlayerFeature.SELECT_SOUND_MODE,
+            PlayerFeature.OPTIONS,
         }
 
         self._attr_device_info = DeviceInfo(
@@ -134,6 +155,15 @@ class MusicCastPlayer(Player):
                 )
             )
 
+        # SOUND MODES
+        for source_id in self.zone_device.sound_mode_list:
+            friendly_name = MC_SOUND_MODE_FRIENDLY_NAMES.get(source_id) or " ".join(
+                [x.capitalize() for x in source_id.split("_")]
+            )
+            self._attr_sound_mode_list.append(
+                PlayerSoundMode(id=source_id, name=friendly_name, passive=False)
+            )
+
     async def set_dynamic_attributes(self) -> None:
         """Update Player attributes."""
         # ruff: noqa: PLR0915
@@ -270,6 +300,9 @@ class MusicCastPlayer(Player):
                     else None
                 )
 
+        # SOUND MODE
+        self._attr_active_sound_mode = self.zone_device.sound_mode_id
+
         # GROUPING
         # A zone cannot be synced to another zone or main of the same device.
         # Additionally, a zone can only be synced, if main is currently not using any netusb
@@ -293,6 +326,91 @@ class MusicCastPlayer(Player):
                 self._get_player_id_from_zone_device(x) for x in self.zone_device.musiccast_group
             ]
 
+        # PLAYER OPTIONS
+        # see https://github.com/vigonotion/aiomusiccast/blob/main/aiomusiccast/capabilities.py
+        # capability can be any instance of OptionSetter, BinarySetter, NumberSetter, NumberSensor,
+        # BinarySensor, TextSensor
+        # the type hint of the lib's zone_data.capabilities is wrong (_not_ list[str])
+        self._attr_options = []
+        for capability in cast(
+            "list[MC_CAPABILITIES]",
+            zone_data.capabilities,
+        ):
+            if isinstance(capability, MCBinarySensor):
+                self._attr_options.append(
+                    PlayerOption(
+                        key=capability.id,
+                        name=capability.name,
+                        type=PlayerOptionType.BOOLEAN,
+                        read_only=True,
+                        value=capability.current,
+                    )
+                )
+            elif isinstance(capability, MCBinarySetter):
+                self._attr_options.append(
+                    PlayerOption(
+                        key=capability.id,
+                        name=capability.name,
+                        type=PlayerOptionType.BOOLEAN,
+                        value=capability.current,
+                        read_only=False,
+                    )
+                )
+            elif isinstance(capability, MCNumberSensor):
+                self._attr_options.append(
+                    PlayerOption(
+                        key=capability.id,
+                        name=capability.name,
+                        type=PlayerOptionType.INTEGER,
+                        value=capability.current,
+                        read_only=True,
+                    )
+                )
+            elif isinstance(capability, MCNumberSetter):
+                self._attr_options.append(
+                    PlayerOption(
+                        key=capability.id,
+                        name=capability.name,
+                        type=PlayerOptionType.INTEGER,
+                        value=capability.current,
+                        read_only=False,
+                        min_value=capability.value_range.minimum,
+                        max_value=capability.value_range.maximum,
+                        step=capability.value_range.step,
+                    )
+                )
+            elif isinstance(capability, MCTextSensor):
+                self._attr_options.append(
+                    PlayerOption(
+                        key=capability.id,
+                        name=capability.name,
+                        type=PlayerOptionType.STRING,
+                        value=capability.current,
+                        read_only=True,
+                    )
+                )
+            elif isinstance(capability, MCOptionSetter):
+                options = []
+                for option_key, option_name in capability.options.items():
+                    options.append(
+                        PlayerOptionEntry(
+                            key=str(option_key),  # aiomusiccast allows str and int.
+                            name=option_name,
+                            value=str(option_key),
+                            type=PlayerOptionType.STRING,
+                        )
+                    )
+                self._attr_options.append(
+                    PlayerOption(
+                        key=capability.id,
+                        name=capability.name,
+                        type=PlayerOptionType.STRING,
+                        value=str(capability.current),
+                        read_only=False,
+                        options=UniqueList(options),
+                    )
+                )
+
         self.update_state()
 
     @cached_property
@@ -528,6 +646,46 @@ class MusicCastPlayer(Player):
         """Select source command."""
         await self._cmd_run(self.zone_device.select_source, source)
 
+    async def select_sound_mode(self, sound_mode: str) -> None:
+        """Select sound Mode Command."""
+        await self._cmd_run(self.zone_device.select_sound_mode, sound_mode)
+
+    async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
+        """Set player option."""
+        if self.zone_device.zone_data is None:
+            return
+        for capability in cast(
+            "list[MC_CAPABILITIES]",
+            self.zone_device.zone_data.capabilities,
+        ):
+            if str(capability.id) != option_key:
+                continue
+            if not isinstance(capability, MCBinarySetter | MCNumberSetter | MCOptionSetter):
+                self.logger.error(f"Option {capability.name} is read only!")
+                return
+            if isinstance(capability, MCBinarySetter):
+                await capability.set(bool(option_value))
+            elif isinstance(capability, MCNumberSetter):
+                min_value = capability.value_range.minimum
+                max_value = capability.value_range.maximum
+                if not min_value <= int(option_value) <= max_value:
+                    self.logger.error(
+                        f"Option {capability.name} has numeric range of"
+                        f"{min_value} <= value <= {max_value}"
+                    )
+                    return
+                await capability.set(int(option_value))
+            elif isinstance(capability, MCOptionSetter):
+                assert isinstance(option_value, str | int)  # for type checking
+                _option_value = option_value  # we may have an int in aiomusiccast as key
+                with suppress(ValueError):
+                    _option_value = int(_option_value)
+                if _option_value not in capability.options:
+                    self.logger.error(f"Option {_option_value} is not allowed for {option_key}")
+                    return
+                await capability.set(_option_value)
+            break
+
     async def ungroup(self) -> None:
         """Ungroup command."""
         if self.zone_device.zone_name.startswith("zone"):