Add Player Controls feature (#1925)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 31 Jan 2025 11:53:38 +0000 (12:53 +0100)
committerGitHub <noreply@github.com>
Fri, 31 Jan 2025 11:53:38 +0000 (12:53 +0100)
21 files changed:
music_assistant/constants.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players.py
music_assistant/models/player_provider.py
music_assistant/providers/_template_player_provider/__init__.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/bluesound/__init__.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/dlna/__init__.py
music_assistant/providers/fully_kiosk/__init__.py
music_assistant/providers/hass/__init__.py
music_assistant/providers/hass/constants.py [new file with mode: 0644]
music_assistant/providers/hass_players/__init__.py
music_assistant/providers/player_group/__init__.py
music_assistant/providers/slimproto/__init__.py
music_assistant/providers/snapcast/__init__.py
music_assistant/providers/sonos/const.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/__init__.py
music_assistant/providers/sonos_s1/player.py

index de32b3ffc910b659611855d26befad10872a20ab..820c29e7957cae6519e3fdb3635d887f30c26124 100644 (file)
@@ -75,6 +75,9 @@ CONF_VOLUME_NORMALIZATION_RADIO: Final[str] = "volume_normalization_radio"
 CONF_VOLUME_NORMALIZATION_TRACKS: Final[str] = "volume_normalization_tracks"
 CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO: Final[str] = "volume_normalization_fixed_gain_radio"
 CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS: Final[str] = "volume_normalization_fixed_gain_tracks"
+CONF_POWER_CONTROL: Final[str] = "power_control"
+CONF_VOLUME_CONTROL: Final[str] = "volume_control"
+CONF_MUTE_CONTROL: Final[str] = "mute_control"
 
 # config default values
 DEFAULT_HOST: Final[str] = "0.0.0.0"
index 045d106f5098b10ff30ce1a6a5fd09bf2672af69..70dc2d87f7ed6fdf4fa88830cd69fef60c6859b1 100644 (file)
@@ -740,7 +740,7 @@ class PlayerQueuesController(CoreController):
         if resume_item is not None:
             resume_pos = resume_pos if resume_pos > 10 else 0
             queue_player = self.mass.players.get(queue_id)
-            if fade_in is None and not queue_player.powered:
+            if fade_in is None and queue_player.state == PlayerState.IDLE:
                 fade_in = resume_pos > 0
             if resume_item.media_type == MediaType.RADIO:
                 # we're not able to skip in online radio so this is pointless
@@ -827,8 +827,8 @@ class PlayerQueuesController(CoreController):
             # edge case: the user wants to move playback from the group as a whole, to a single
             # player in the group or it is grouped and the command targeted at the single player.
             # We need to dissolve the group first.
-            await self.mass.players.cmd_power(
-                target_player.active_group or target_player.synced_to, False
+            await self.mass.players.cmd_ungroup(
+                target_player.active_group or target_player.synced_to
             )
             await asyncio.sleep(3)
 
index 43773d7b313ed5cb0c794ea61341517087541484..f0fec78cd0dd3c569ed5dce51102fb9e45f985dc 100644 (file)
@@ -14,6 +14,11 @@ import time
 from contextlib import suppress
 from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast
 
+from music_assistant_models.constants import (
+    PLAYER_CONTROL_FAKE,
+    PLAYER_CONTROL_NATIVE,
+    PLAYER_CONTROL_NONE,
+)
 from music_assistant_models.enums import (
     EventType,
     MediaType,
@@ -30,6 +35,7 @@ from music_assistant_models.errors import (
 )
 from music_assistant_models.media_items import UniqueList
 from music_assistant_models.player import Player, PlayerMedia
+from music_assistant_models.player_control import PlayerControl  # noqa: TC002
 
 from music_assistant.constants import (
     CONF_AUTO_PLAY,
@@ -38,10 +44,12 @@ from music_assistant.constants import (
     CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
     CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
     CONF_ENTRY_PLAYER_ICON,
-    CONF_ENTRY_PLAYER_ICON_GROUP,
     CONF_HIDE_PLAYER,
+    CONF_MUTE_CONTROL,
     CONF_PLAYERS,
+    CONF_POWER_CONTROL,
     CONF_TTS_PRE_ANNOUNCE,
+    CONF_VOLUME_CONTROL,
 )
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.tags import async_parse_tags
@@ -102,6 +110,7 @@ class PlayerController(CoreController):
         """Initialize core controller."""
         super().__init__(*args, **kwargs)
         self._players: dict[str, Player] = {}
+        self._controls: dict[str, PlayerControl] = {}
         self._prev_states: dict[str, dict] = {}
         self.manifest.name = "Players controller"
         self.manifest.description = (
@@ -141,13 +150,20 @@ class PlayerController(CoreController):
         self,
         return_unavailable: bool = True,
         return_disabled: bool = False,
-    ) -> tuple[Player, ...]:
+    ) -> list[Player]:
         """Return all registered players."""
-        return tuple(
+        return [
             player
             for player in self._players.values()
             if (player.available or return_unavailable) and (player.enabled or return_disabled)
-        )
+        ]
+
+    @api_command("players/player_controls")
+    def player_controls(
+        self,
+    ) -> list[PlayerControl]:
+        """Return all registered playercontrols."""
+        return list(self._controls.values())
 
     @api_command("players/get")
     def get(
@@ -347,17 +363,37 @@ class PlayerController(CoreController):
         elif not powered and player.type == PlayerType.PLAYER and player.group_childs:
             async with TaskManager(self.mass) as tg:
                 for member in self.iter_group_members(player, True):
+                    if member.power_control == PLAYER_CONTROL_NONE:
+                        continue
                     tg.create_task(self.cmd_power(member.player_id, False))
 
         # handle actual power command
-        if PlayerFeature.POWER in player.supported_features:
-            # player supports power command: forward to player provider
+        if player.power_control == PLAYER_CONTROL_NONE:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support power control"
+            )
+        if player.power_control == PLAYER_CONTROL_NATIVE:
+            # player supports power command natively: forward to player provider
             player_provider = self.get_player_provider(player_id)
             async with self._player_throttlers[player_id]:
                 await player_provider.cmd_power(player_id, powered)
-        else:
-            # allow the stop command to process and prevent race conditions
+        elif player.power_control == PLAYER_CONTROL_FAKE:
+            # user wants to use fake power control - so we only (optimistically) update the state
+            # short sleep: allow the stop command to process and prevent race conditions
             await asyncio.sleep(0.2)
+        else:
+            # handle external player control
+            player_control = self._controls.get(player.power_control)
+            control_name = player_control.name if player_control else player.power_control
+            self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
+            if not player_control or not player_control.supports_power:
+                raise UnsupportedFeaturedException(
+                    f"Player control {control_name} is not available"
+                )
+            if powered:
+                await player_control.power_on()
+            else:
+                await player_control.power_off()
 
         # always optimistically set the power state to update the UI
         # as fast as possible and prevent race conditions
@@ -392,12 +428,27 @@ class PlayerController(CoreController):
             # redirect to group volume control
             await self.cmd_group_volume(player_id, volume_level)
             return
-        if PlayerFeature.VOLUME_SET not in player.supported_features:
-            msg = f"Player {player.display_name} does not support volume_set"
-            raise UnsupportedFeaturedException(msg)
-        player_provider = self.get_player_provider(player_id)
-        async with self._player_throttlers[player_id]:
-            await player_provider.cmd_volume_set(player_id, volume_level)
+
+        if player.volume_control == PLAYER_CONTROL_NONE:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support volume control"
+            )
+        if player.volume_control == PLAYER_CONTROL_NATIVE:
+            # player supports volume command natively: forward to player provider
+            player_provider = self.get_player_provider(player_id)
+            async with self._player_throttlers[player_id]:
+                await player_provider.cmd_volume_set(player_id, volume_level)
+        else:
+            # handle external player control
+            player_control = self._controls.get(player.volume_control)
+            control_name = player_control.name if player_control else player.volume_control
+            self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
+            if not player_control or not player_control.supports_volume:
+                raise UnsupportedFeaturedException(
+                    f"Player control {control_name} is not available"
+                )
+            async with self._player_throttlers[player_id]:
+                await player_control.volume_set(volume_level)
 
     @api_command("players/cmd/volume_up")
     @handle_player_command
@@ -454,7 +505,7 @@ class PlayerController(CoreController):
         for child_player in self.iter_group_members(
             group_player, only_powered=True, exclude_self=False
         ):
-            if PlayerFeature.VOLUME_SET not in child_player.supported_features:
+            if child_player.volume_control == PLAYER_CONTROL_NONE:
                 continue
             cur_child_volume = child_player.volume_level
             new_child_volume = int(cur_child_volume + volume_dif)
@@ -511,22 +562,39 @@ class PlayerController(CoreController):
         """
         player = self.get(player_id, True)
         assert player
-        if PlayerFeature.VOLUME_MUTE not in player.supported_features:
-            self.logger.info(
-                "Player %s does not support muting, using volume instead",
+        if player.mute_control == PLAYER_CONTROL_NONE:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support muting"
+            )
+        if player.mute_control == PLAYER_CONTROL_NATIVE:
+            # player supports mute command natively: forward to player provider
+            player_provider = self.get_player_provider(player_id)
+            async with self._player_throttlers[player_id]:
+                await player_provider.cmd_volume_mute(player_id, muted)
+        elif player.power_control == PLAYER_CONTROL_FAKE:
+            # user wants to use fake mute control - so we use volume instead
+            self.logger.debug(
+                "Using volume for muting for player %s",
                 player.display_name,
             )
             if muted:
-                player._prev_volume_level = player.volume_level
+                player.previous_volume_level = player.volume_level
                 player.volume_muted = True
                 await self.cmd_volume_set(player_id, 0)
             else:
                 player.volume_muted = False
-                await self.cmd_volume_set(player_id, player._prev_volume_level)
-            return
-        player_provider = self.get_player_provider(player_id)
-        async with self._player_throttlers[player_id]:
-            await player_provider.cmd_volume_mute(player_id, muted)
+                await self.cmd_volume_set(player_id, player.previous_volume_level)
+        else:
+            # handle external player control
+            player_control = self._controls.get(player.mute_control)
+            control_name = player_control.name if player_control else player.mute_control
+            self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
+            if not player_control or not player_control.supports_mute:
+                raise UnsupportedFeaturedException(
+                    f"Player control {control_name} is not available"
+                )
+            async with self._player_throttlers[player_id]:
+                await player_control.mute_set(muted)
 
     @api_command("players/cmd/play_announcement")
     async def play_announcement(
@@ -613,7 +681,7 @@ class PlayerController(CoreController):
         """
         player = self._get_player_with_redirect(player_id)
         # power on the player if needed
-        if not player.powered:
+        if not player.powered and player.power_control != PLAYER_CONTROL_NONE:
             await self.cmd_power(player.player_id, True)
         player_prov = self.get_player_provider(player.player_id)
         await player_prov.play_media(
@@ -718,7 +786,7 @@ class PlayerController(CoreController):
                 )
                 await self.cmd_ungroup(child_player.player_id)
             # power on the player if needed
-            if not child_player.powered:
+            if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE:
                 await self.cmd_power(child_player.player_id, True, skip_update=True)
             # if we reach here, all checks passed
             final_player_ids.append(child_player_id)
@@ -823,11 +891,10 @@ class PlayerController(CoreController):
             msg = f"Player {player_id} is already registered"
             raise AlreadyRegisteredError(msg)
 
-        # make sure that the player's provider is set to the instance id
-        if prov := self.mass.get_provider(player.provider):
-            player.provider = prov.instance_id
-        else:
-            raise RuntimeError("Invalid provider ID given: %s", player.provider)
+        # make sure that the player's provider is set to the lookup key (=instance id)
+        prov = self.mass.get_provider(player.provider)
+        if not prov or prov.lookup_key != player.provider:
+            raise RuntimeError(f"Invalid provider ID given: {player.provider}")
 
         # make sure a default config exists
         self.mass.config.create_default_player_config(
@@ -848,11 +915,16 @@ class PlayerController(CoreController):
         if not player.enabled:
             return
 
+        # ensure initial player state gets populated with values from config
+        player_config = await self.mass.config.get_player_config(player_id)
+        self._set_player_state_from_config(player, player_config)
+
         self.logger.info(
             "Player registered: %s/%s",
             player_id,
-            player.name,
+            player.display_name,
         )
+
         self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
         # always call update to fix special attributes like display name, group volume etc.
         self.update(player.player_id)
@@ -892,7 +964,31 @@ class PlayerController(CoreController):
         player = self._players[player_id]
         prev_state = self._prev_states.get(player_id, {})
         player.active_source = self._get_active_source(player)
-        player.volume_level = player.volume_level or 0  # guard for None volume
+        # prefer any overridden name from config
+        player.display_name = (
+            self.mass.config.get_raw_player_config_value(player.player_id, "name")
+            or player.name
+            # use prev value (e.g. some fallback)
+            or player.display_name
+        )
+        # handle player controls
+        if player.power_control == PLAYER_CONTROL_NONE:
+            player.powered = None
+        elif player.power_control == PLAYER_CONTROL_FAKE:
+            player.powered = True if player.powered is None else player.powered
+        elif player.power_control != PLAYER_CONTROL_NATIVE:
+            if player_control := self._controls.get(player.power_control):
+                player.powered = player_control.power_state
+        if player.volume_control == PLAYER_CONTROL_NONE:
+            player.volume_level = None
+        elif player.volume_control != PLAYER_CONTROL_NATIVE:
+            if player_control := self._controls.get(player.volume_control):
+                player.volume_level = player_control.volume_level
+        if player.mute_control == PLAYER_CONTROL_NONE:
+            player.volume_muted = None
+        elif player.mute_control not in (PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_FAKE):
+            if player_control := self._controls.get(player.mute_control):
+                player.volume_muted = player_control.volume_muted
         # correct group_members if needed
         if player.group_childs == [player.player_id]:
             player.group_childs.clear()
@@ -915,22 +1011,6 @@ class PlayerController(CoreController):
         player.group_volume = self._get_group_volume_level(player)
         if player.type == PlayerType.GROUP:
             player.volume_level = player.group_volume
-        # prefer any overridden name from config
-        player.display_name = (
-            self.mass.config.get_raw_player_config_value(player.player_id, "name")
-            or player.name
-            or player.player_id
-        )
-        player.hidden = self.mass.config.get_raw_player_config_value(
-            player.player_id, CONF_HIDE_PLAYER, False
-        )
-        player.icon = self.mass.config.get_raw_player_config_value(
-            player.player_id,
-            CONF_ENTRY_PLAYER_ICON.key,
-            CONF_ENTRY_PLAYER_ICON_GROUP.default_value
-            if player.type == PlayerType.GROUP
-            else CONF_ENTRY_PLAYER_ICON.default_value,
-        )
 
         # correct available state if needed
         if not player.enabled:
@@ -1023,6 +1103,59 @@ class PlayerController(CoreController):
             if player_prov := self.mass.get_provider(group_player.provider):
                 self.mass.create_task(player_prov.poll_player(group_player.player_id))
 
+    async def register_player_control(self, player_control: PlayerControl) -> None:
+        """Register a new PlayerControl on the controller."""
+        if self.mass.closing:
+            return
+        control_id = player_control.id
+
+        if control_id in self._controls:
+            msg = f"PlayerControl {control_id} is already registered"
+            raise AlreadyRegisteredError(msg)
+
+        # make sure that the playercontrol's provider is set to the lookup_key
+        prov = self.mass.get_provider(player_control.provider)
+        if not prov or prov.lookup_key != player_control.provider:
+            raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
+
+        self._controls[control_id] = player_control
+
+        self.logger.info(
+            "PlayerControl registered: %s/%s",
+            control_id,
+            player_control.name,
+        )
+
+        # always call update to update any attached players etc.
+        self.update_player_control(player_control.id)
+
+    async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
+        """Register a new playercontrol on the controller or update existing one."""
+        if self.mass.closing:
+            return
+        if player_control.id in self._controls:
+            self._controls[player_control.id] = player_control
+            self.update_player_control(player_control.id)
+            return
+        await self.register_player_control(player_control)
+
+    def update_player_control(self, control_id: str) -> None:
+        """Update playercontrol state."""
+        if self.mass.closing:
+            return
+        # update all players that are using this control
+        for player in self._players.values():
+            if control_id in (player.power_control, player.volume_control, player.mute_control):
+                self.update(player.player_id)
+
+    def remove_player_control(self, control_id: str) -> None:
+        """Remove a player_control from the player manager."""
+        control = self._controls.pop(control_id, None)
+        if control is None:
+            return
+        self._controls.pop(control_id, None)
+        self.logger.info("PlayerControl removed: %s", control.name)
+
     def get_player_provider(self, player_id: str) -> PlayerProvider:
         """Return PlayerProvider for given player."""
         player = self._players[player_id]
@@ -1080,15 +1213,15 @@ class PlayerController(CoreController):
             if child_player := self.get(child_id, False):
                 if not child_player.available or not child_player.enabled:
                     continue
-                if not (not only_powered or child_player.powered):
+                if only_powered and child_player.powered is False:
                     continue
-                if not (not active_only or child_player.active_group == group_player.player_id):
+                if active_only and child_player.active_group != group_player.player_id:
                     continue
                 if exclude_self and child_player.player_id == group_player.player_id:
                     continue
-                if not (
-                    not only_playing
-                    or child_player.state in (PlayerState.PLAYING, PlayerState.PAUSED)
+                if only_playing and child_player.state not in (
+                    PlayerState.PLAYING,
+                    PlayerState.PAUSED,
                 ):
                     continue
                 yield child_player
@@ -1140,6 +1273,8 @@ class PlayerController(CoreController):
         player_disabled = "enabled" in changed_keys and not config.enabled
         if not (player := self.get(config.player_id)):
             return
+        # ensure player state gets updated with any updated config
+        self._set_player_state_from_config(player, config)
         resume_queue: PlayerQueue | None = self.mass.player_queues.get(player.active_source)
         # signal player provider that the config changed
         if player_provider := self.mass.get_provider(config.provider):
@@ -1147,7 +1282,10 @@ class PlayerController(CoreController):
                 await player_provider.on_player_config_change(config, changed_keys)
         if player_disabled:
             # edge case: ensure that the player is powered off if the player gets disabled
-            await self.cmd_power(config.player_id, False)
+            if player.power_control != PLAYER_CONTROL_NONE:
+                await self.cmd_power(config.player_id, False)
+            elif player.state != PlayerState.IDLE:
+                await self.cmd_stop(config.player_id)
             player.available = False
         # if the PlayerQueue was playing, restart playback
         # TODO: add property to ConfigEntry if it requires a restart of playback on change
@@ -1207,7 +1345,7 @@ class PlayerController(CoreController):
                 continue
             if available_only and not _player.available:
                 continue
-            if powered_only and not _player.powered:
+            if powered_only and _player.powered is False:
                 continue
             if player.player_id in _player.group_childs:
                 yield _player
@@ -1232,7 +1370,7 @@ class PlayerController(CoreController):
         group_volume = 0
         active_players = 0
         for child_player in self.iter_group_members(player, only_powered=True, exclude_self=False):
-            if PlayerFeature.VOLUME_SET not in child_player.supported_features:
+            if child_player.volume_control == PLAYER_CONTROL_NONE:
                 continue
             group_volume += child_player.volume_level or 0
             active_players += 1
@@ -1261,6 +1399,15 @@ class PlayerController(CoreController):
             if player.player_id in group_player.group_childs:
                 group_player.group_childs.remove(player.player_id)
 
+    def _set_player_state_from_config(self, player: Player, config: PlayerConfig) -> None:
+        """Set player state from config."""
+        player.display_name = config.name or player.name or config.default_name or player.player_id
+        player.hidden = config.get_value(CONF_HIDE_PLAYER)
+        player.icon = config.get_value(CONF_ENTRY_PLAYER_ICON.key)
+        player.power_control = config.get_value(CONF_POWER_CONTROL)
+        player.volume_control = config.get_value(CONF_VOLUME_CONTROL)
+        player.mute_control = config.get_value(CONF_MUTE_CONTROL)
+
     async def _play_announcement(
         self,
         player: Player,
@@ -1371,7 +1518,7 @@ class PlayerController(CoreController):
         await asyncio.sleep(0.2)
         player.current_item_id = prev_item_id
         # either power off the player or resume playing
-        if not prev_power:
+        if not prev_power and player.power_control != PLAYER_CONTROL_NONE:
             await self.cmd_power(player.player_id, False)
             return
         elif prev_synced_to:
@@ -1405,7 +1552,7 @@ class PlayerController(CoreController):
                     except PlayerUnavailableError:
                         player.available = False
                         player.state = PlayerState.IDLE
-                        player.powered = False
+                        player.powered = None
                     except Exception as err:
                         self.logger.warning(
                             "Error while requesting latest state from player %s: %s",
index a737da174dc51159b7733bee46525c5ed5a63cbf..08d0b6118cab72d49ac1fd487ec00bd815345558 100644 (file)
@@ -5,6 +5,13 @@ from __future__ import annotations
 from abc import abstractmethod
 from typing import TYPE_CHECKING
 
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
+from music_assistant_models.constants import (
+    PLAYER_CONTROL_FAKE,
+    PLAYER_CONTROL_NATIVE,
+    PLAYER_CONTROL_NONE,
+)
+from music_assistant_models.enums import ConfigEntryType, PlayerFeature
 from music_assistant_models.errors import UnsupportedFeaturedException
 from zeroconf import ServiceStateChange
 from zeroconf.asyncio import AsyncServiceInfo
@@ -15,12 +22,15 @@ from music_assistant.constants import (
     CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
     CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
     CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
+    CONF_MUTE_CONTROL,
+    CONF_POWER_CONTROL,
+    CONF_VOLUME_CONTROL,
 )
 
 from .provider import Provider
 
 if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ConfigEntry, PlayerConfig
+    from music_assistant_models.config_entries import PlayerConfig
     from music_assistant_models.player import Player, PlayerMedia
 
 # ruff: noqa: ARG001, ARG002
@@ -45,6 +55,8 @@ class PlayerProvider(Provider):
             CONF_ENTRY_ANNOUNCE_VOLUME,
             CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
             CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
+            # add player control entries
+            *self._create_player_control_config_entries(self.mass.players.get(player_id)),
         )
 
     async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
@@ -257,3 +269,82 @@ class PlayerProvider(Provider):
             for player in self.mass.players
             if player.provider in (self.instance_id, self.domain)
         ]
+
+    def _create_player_control_config_entries(
+        self, player: Player | None
+    ) -> tuple[ConfigEntry, ...]:
+        """Create config entries for player controls."""
+        all_controls = self.mass.players.player_controls()
+        power_controls = [x for x in all_controls if x.supports_power]
+        volume_controls = [x for x in all_controls if x.supports_volume]
+        mute_controls = [x for x in all_controls if x.supports_mute]
+        # work out player supported features
+        supports_power = PlayerFeature.POWER in player.supported_features if player else False
+        supports_volume = PlayerFeature.VOLUME_SET in player.supported_features if player else False
+        supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features if player else False
+        # create base options per control type (and add defaults like native and fake)
+        base_power_options: list[ConfigValueOption] = [
+            ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
+            ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
+        ]
+        if supports_power:
+            base_power_options.append(
+                ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
+            )
+        base_volume_options: list[ConfigValueOption] = [
+            ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
+        ]
+        if supports_volume:
+            base_volume_options.append(
+                ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
+            )
+        base_mute_options: list[ConfigValueOption] = [
+            ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
+            ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE),
+        ]
+        if supports_mute:
+            base_mute_options.append(
+                ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
+            )
+        # return final config entries for all options
+        return (
+            # Power control config entry
+            ConfigEntry(
+                key=CONF_POWER_CONTROL,
+                type=ConfigEntryType.STRING,
+                label="Power Control",
+                default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE,
+                required=True,
+                options=(
+                    *base_power_options,
+                    *(ConfigValueOption(x.name, x.id) for x in power_controls),
+                ),
+                category="player_controls",
+            ),
+            # Volume control config entry
+            ConfigEntry(
+                key=CONF_VOLUME_CONTROL,
+                type=ConfigEntryType.STRING,
+                label="Volume Control",
+                default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE,
+                required=True,
+                options=(
+                    *base_volume_options,
+                    *(ConfigValueOption(x.name, x.id) for x in volume_controls),
+                ),
+                category="player_controls",
+            ),
+            # Mute control config entry
+            ConfigEntry(
+                key=CONF_MUTE_CONTROL,
+                type=ConfigEntryType.STRING,
+                label="Mute Control",
+                default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE,
+                required=True,
+                options=(
+                    *base_mute_options,
+                    *(ConfigValueOption(x.name, x.id) for x in mute_controls),
+                ),
+                category="player_controls",
+            ),
+        )
index e82ffddfef161f8d2986a8f272b534665723e1a2..9e5aa66ad450133e5de97f0b4695dbd3abb6828d 100644 (file)
@@ -210,7 +210,7 @@ class MyDemoPlayerprovider(PlayerProvider):
         # Instantiate the MA Player object and register it with the player manager
         mass_player = Player(
             player_id=player_id,
-            provider=self.instance_id,
+            provider=self.lookup_key,
             type=PlayerType.PLAYER,
             name=name,
             available=True,
index 16067b26c0e046ddde0b52b979c70a00f4de1f6a..52b12403394266875d6cfec8fa2899fcf4c65011 100644 (file)
@@ -380,9 +380,6 @@ class AirplayProvider(PlayerProvider):
         parent_player.group_childs.append(parent_player.player_id)
         parent_player.group_childs.append(child_player.player_id)
         child_player.synced_to = parent_player.player_id
-        # mark players as powered
-        parent_player.powered = True
-        child_player.powered = True
         # check if we should (re)start or join a stream session
         active_queue = self.mass.player_queues.get_active_queue(parent_player.player_id)
         if active_queue.state == PlayerState.PLAYING:
@@ -482,11 +479,10 @@ class AirplayProvider(PlayerProvider):
             volume = FALLBACK_VOLUME
         mass_player = Player(
             player_id=player_id,
-            provider=self.instance_id,
+            provider=self.lookup_key,
             type=PlayerType.PLAYER,
             name=display_name,
             available=True,
-            powered=False,
             device_info=DeviceInfo(
                 model=model,
                 manufacturer=manufacturer,
@@ -499,7 +495,7 @@ class AirplayProvider(PlayerProvider):
                 PlayerFeature.VOLUME_SET,
             },
             volume_level=volume,
-            can_group_with={self.instance_id},
+            can_group_with={self.lookup_key},
             enabled_by_default=not is_broken_raop_model(manufacturer, model),
         )
         await self.mass.players.register_or_update(mass_player)
@@ -655,7 +651,8 @@ class AirplayProvider(PlayerProvider):
                 return
             await asyncio.sleep(0.5)
 
-        airplay_player.logger.info(
-            "Player has been in prevent playback mode for too long, powering off.",
-        )
-        await self.mass.players.cmd_power(airplay_player.player_id, False)
+        if airplay_player.raop_stream and airplay_player.raop_stream.session:
+            airplay_player.logger.info(
+                "Player has been in prevent playback mode for too long, aborting playback.",
+            )
+            await airplay_player.raop_stream.session.remove_client(airplay_player)
index 746f741ef6a55f4627fc2562949cbeda697a0ae4..08f03eb19b4092e0ccfb8baba3dcda83cc9c109d 100644 (file)
@@ -277,11 +277,10 @@ class BluesoundPlayerProvider(PlayerProvider):
 
         bluos_player.mass_player = mass_player = Player(
             player_id=self.player_id,
-            provider=self.instance_id,
+            provider=self.lookup_key,
             type=PlayerType.PLAYER,
             name=name,
             available=True,
-            powered=True,
             device_info=DeviceInfo(
                 model="BluOS speaker",
                 manufacturer="Bluesound",
@@ -295,7 +294,7 @@ class BluesoundPlayerProvider(PlayerProvider):
             },
             needs_poll=True,
             poll_interval=30,
-            can_group_with={self.instance_id},
+            can_group_with={self.lookup_key},
         )
         await self.mass.players.register(mass_player)
 
index fc9758a0a21fcb28ba0f66ef77bd7bd577f8e957..56ce3ea4720f1e4de51859bda0ffe840522dd262 100644 (file)
@@ -379,7 +379,7 @@ class ChromecastProvider(PlayerProvider):
                 ),
                 player=Player(
                     player_id=player_id,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     type=player_type,
                     name=cast_info.friendly_name,
                     available=False,
index 028ab3265f91d5e882993880f54362c758b03e1b..22836391745a130fa3718b0e80e0449260f5d3b0 100644 (file)
@@ -23,12 +23,7 @@ from async_upnp_client.exceptions import UpnpError, UpnpResponseError
 from async_upnp_client.profiles.dlna import DmrDevice, TransportState
 from async_upnp_client.search import async_search
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import (
-    ConfigEntryType,
-    PlayerFeature,
-    PlayerState,
-    PlayerType,
-)
+from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType
 from music_assistant_models.errors import PlayerUnavailableError
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
@@ -490,11 +485,10 @@ class DLNAPlayerProvider(PlayerProvider):
                     udn=udn,
                     player=Player(
                         player_id=udn,
-                        provider=self.instance_id,
+                        provider=self.lookup_key,
                         type=PlayerType.PLAYER,
                         name=udn,
                         available=False,
-                        powered=False,
                         # device info will be discovered later after connect
                         device_info=DeviceInfo(
                             model="unknown",
index 6f7b2c2967cd34ccdaa125e7eff7a186a1f6d0a4..b096db65cec4a513d3f654538d9dfd800fdb9505 100644 (file)
@@ -117,11 +117,10 @@ class FullyKioskProvider(PlayerProvider):
         if not player:
             player = Player(
                 player_id=player_id,
-                provider=self.instance_id,
+                provider=self.lookup_key,
                 type=PlayerType.PLAYER,
                 name=self._fully.deviceInfo["deviceName"],
                 available=True,
-                powered=False,
                 device_info=DeviceInfo(
                     model=self._fully.deviceInfo["deviceModel"],
                     manufacturer=self._fully.deviceInfo["deviceManufacturer"],
index 34ed0c6f1f29737f0e1c34e50cd2a832b4f0963a..fd9e11fc01487f8c6902eca5f838c2f9764e68cb 100644 (file)
@@ -11,7 +11,8 @@ from __future__ import annotations
 
 import asyncio
 import logging
-from typing import TYPE_CHECKING
+from functools import partial
+from typing import TYPE_CHECKING, cast
 
 import shortuuid
 from hass_client import HomeAssistantClient
@@ -23,15 +24,20 @@ from hass_client.utils import (
     get_token,
     get_websocket_url,
 )
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import ConfigEntryType
 from music_assistant_models.errors import LoginFailed, SetupFailedError
+from music_assistant_models.player_control import PlayerControl
 
 from music_assistant.constants import MASS_LOGO_ONLINE
 from music_assistant.helpers.auth import AuthenticationHelper
+from music_assistant.helpers.util import try_parse_int
 from music_assistant.models.plugin import PluginProvider
 
+from .constants import OFF_STATES, MediaPlayerEntityFeature
+
 if TYPE_CHECKING:
+    from hass_client.models import CompressedState, EntityStateEvent
     from music_assistant_models.config_entries import ProviderConfig
     from music_assistant_models.provider import ProviderManifest
 
@@ -43,18 +49,21 @@ CONF_URL = "url"
 CONF_AUTH_TOKEN = "token"
 CONF_ACTION_AUTH = "auth"
 CONF_VERIFY_SSL = "verify_ssl"
+CONF_POWER_CONTROLS = "power_controls"
+CONF_MUTE_CONTROLS = "mute_controls"
+CONF_VOLUME_CONTROLS = "volume_controls"
 
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return HomeAssistant(mass, manifest, config)
+    return HomeAssistantProvider(mass, manifest, config)
 
 
 async def get_config_entries(
     mass: MusicAssistant,
-    instance_id: str | None = None,  # noqa: ARG001
+    instance_id: str | None = None,
     action: str | None = None,
     values: dict[str, ConfigValueType] | None = None,
 ) -> tuple[ConfigEntry, ...]:
@@ -93,10 +102,11 @@ async def get_config_entries(
         # set the retrieved token on the values object to pass along
         values[CONF_AUTH_TOKEN] = long_lived_token
 
+    base_entries: tuple[ConfigEntry, ...]
     if mass.running_as_hass_addon:
         # on supervisor, we use the internal url
         # token set to None for auto retrieval
-        return (
+        base_entries = (
             ConfigEntry(
                 key=CONF_URL,
                 type=ConfigEntryType.STRING,
@@ -124,56 +134,171 @@ async def get_config_entries(
                 hidden=True,
             ),
         )
-    # manual configuration
+    else:
+        # manual configuration
+        base_entries = (
+            ConfigEntry(
+                key=CONF_URL,
+                type=ConfigEntryType.STRING,
+                label="URL",
+                required=True,
+                description="URL to your Home Assistant instance (e.g. http://192.168.1.1:8123)",
+                value=values.get(CONF_URL) if values else None,
+            ),
+            ConfigEntry(
+                key=CONF_ACTION_AUTH,
+                type=ConfigEntryType.ACTION,
+                label="(re)Authenticate Home Assistant",
+                description="Authenticate to your home assistant "
+                "instance and generate the long lived token.",
+                action=CONF_ACTION_AUTH,
+                depends_on=CONF_URL,
+                required=False,
+            ),
+            ConfigEntry(
+                key=CONF_AUTH_TOKEN,
+                type=ConfigEntryType.SECURE_STRING,
+                label="Authentication token for HomeAssistant",
+                description="You can either paste a Long Lived Token here manually or use the "
+                "'authenticate' button to generate a token for you with logging in.",
+                depends_on=CONF_URL,
+                value=values.get(CONF_AUTH_TOKEN) if values else None,
+                category="advanced",
+            ),
+            ConfigEntry(
+                key=CONF_VERIFY_SSL,
+                type=ConfigEntryType.BOOLEAN,
+                label="Verify SSL",
+                required=False,
+                description="Whether or not to verify the certificate of SSL/TLS connections.",
+                category="advanced",
+                default_value=True,
+            ),
+        )
+
+    # append player controls entries (if we have an active instance)
+    if instance_id and (hass_prov := mass.get_provider(instance_id)):
+        hass_prov = cast(HomeAssistantProvider, hass_prov)
+        return (*base_entries, *(await _get_player_control_config_entries(hass_prov.hass)))
+
     return (
+        *base_entries,
         ConfigEntry(
-            key=CONF_URL,
+            key=CONF_POWER_CONTROLS,
             type=ConfigEntryType.STRING,
-            label="URL",
-            required=True,
-            description="URL to your Home Assistant instance (e.g. http://192.168.1.1:8123)",
-            value=values.get(CONF_URL) if values else None,
+            label=CONF_POWER_CONTROLS,
+            default_value=[],
+            multi_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_VOLUME_CONTROLS,
+            type=ConfigEntryType.STRING,
+            label=CONF_VOLUME_CONTROLS,
+            default_value=[],
+            multi_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_MUTE_CONTROLS,
+            type=ConfigEntryType.STRING,
+            label=CONF_MUTE_CONTROLS,
+            default_value=[],
+            multi_value=True,
         ),
+    )
+
+
+async def _get_player_control_config_entries(hass: HomeAssistantClient) -> tuple[ConfigEntry, ...]:
+    """Return all HA state objects for (valid) media_player entities."""
+    all_power_entities: list[ConfigValueOption] = []
+    all_mute_entities: list[ConfigValueOption] = []
+    all_volume_entities: list[ConfigValueOption] = []
+    # collect all entities that are usable for player controls
+    for state in await hass.get_states():
+        if "friendly_name" not in state["attributes"]:
+            # filter out invalid/unavailable players
+            continue
+        entity_platform = state["entity_id"].split(".")[0]
+        name = f"{state['attributes']['friendly_name']} ({state['entity_id']})"
+
+        if entity_platform in ("switch", "input_boolean"):
+            # simple on/off controls are suitable as power and mute controls
+            all_power_entities.append(ConfigValueOption(name, state["entity_id"]))
+            all_mute_entities.append(ConfigValueOption(name, state["entity_id"]))
+            continue
+        if entity_platform == "input_number":
+            # input_number is suitable for volume control
+            all_volume_entities.append(ConfigValueOption(name, state["entity_id"]))
+            continue
+
+        # media player can be used as control, depending on features
+        if entity_platform != "media_player":
+            continue
+        if "mass_player_type" in state["attributes"]:
+            # filter out mass players
+            continue
+        supported_features = MediaPlayerEntityFeature(state["attributes"]["supported_features"])
+        if MediaPlayerEntityFeature.VOLUME_MUTE in supported_features:
+            all_mute_entities.append(ConfigValueOption(name, state["entity_id"]))
+        if MediaPlayerEntityFeature.VOLUME_SET in supported_features:
+            all_volume_entities.append(ConfigValueOption(name, state["entity_id"]))
+        if (
+            MediaPlayerEntityFeature.TURN_ON in supported_features
+            and MediaPlayerEntityFeature.TURN_OFF in supported_features
+        ):
+            all_power_entities.append(ConfigValueOption(name, state["entity_id"]))
+    all_power_entities.sort(key=lambda x: x.title)
+    all_mute_entities.sort(key=lambda x: x.title)
+    all_volume_entities.sort(key=lambda x: x.title)
+    return (
         ConfigEntry(
-            key=CONF_ACTION_AUTH,
-            type=ConfigEntryType.ACTION,
-            label="(re)Authenticate Home Assistant",
-            description="Authenticate to your home assistant "
-            "instance and generate the long lived token.",
-            action=CONF_ACTION_AUTH,
-            depends_on=CONF_URL,
-            required=False,
+            key=CONF_POWER_CONTROLS,
+            type=ConfigEntryType.STRING,
+            label="Player Power Control entities",
+            required=True,
+            options=tuple(all_power_entities),
+            multi_value=True,
+            default_value=[],
+            description="Specify which Home Assistant entities you "
+            "like to import as player Power controls in Music Assistant.",
+            category="player_controls",
         ),
         ConfigEntry(
-            key=CONF_AUTH_TOKEN,
-            type=ConfigEntryType.SECURE_STRING,
-            label="Authentication token for HomeAssistant",
-            description="You can either paste a Long Lived Token here manually or use the "
-            "'authenticate' button to generate a token for you with logging in.",
-            depends_on=CONF_URL,
-            value=values.get(CONF_AUTH_TOKEN) if values else None,
-            category="advanced",
+            key=CONF_VOLUME_CONTROLS,
+            type=ConfigEntryType.STRING,
+            label="Player Volume Control entities",
+            required=True,
+            options=tuple(all_volume_entities),
+            multi_value=True,
+            default_value=[],
+            description="Specify which Home Assistant entities you "
+            "like to import as player Volume controls in Music Assistant.",
+            category="player_controls",
         ),
         ConfigEntry(
-            key=CONF_VERIFY_SSL,
-            type=ConfigEntryType.BOOLEAN,
-            label="Verify SSL",
-            required=False,
-            description="Whether or not to verify the certificate of SSL/TLS connections.",
-            category="advanced",
-            default_value=True,
+            key=CONF_MUTE_CONTROLS,
+            type=ConfigEntryType.STRING,
+            label="Player Mute Control entities",
+            required=True,
+            options=tuple(all_mute_entities),
+            multi_value=True,
+            default_value=[],
+            description="Specify which Home Assistant entities you "
+            "like to import as player Mute controls in Music Assistant.",
+            category="player_controls",
         ),
     )
 
 
-class HomeAssistant(PluginProvider):
+class HomeAssistantProvider(PluginProvider):
     """Home Assistant Plugin for Music Assistant."""
 
     hass: HomeAssistantClient
     _listen_task: asyncio.Task[None] | None = None
+    _player_controls: dict[str, PlayerControl] | None = None
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the plugin."""
+        self._player_controls = {}
         url = get_websocket_url(self.config.get_value(CONF_URL))
         token = self.config.get_value(CONF_AUTH_TOKEN)
         logging.getLogger("hass_client").setLevel(self.logger.level + 10)
@@ -185,12 +310,20 @@ class HomeAssistant(PluginProvider):
             raise SetupFailedError(err_msg) from err
         self._listen_task = self.mass.create_task(self._hass_listener())
 
+    async def loaded_in_mass(self) -> None:
+        """Call after the provider has been loaded."""
+        await self._register_player_controls()
+
     async def unload(self, is_removed: bool = False) -> None:
         """
         Handle unload/close of the provider.
 
         Called when provider is deregistered (e.g. MA exiting or config reloading).
         """
+        # unregister all player controls
+        if self._player_controls:
+            for entity_id in self._player_controls:
+                self.mass.players.remove_player_control(entity_id)
         if self._listen_task and not self._listen_task.done():
             self._listen_task.cancel()
         await self.hass.disconnect()
@@ -205,3 +338,142 @@ class HomeAssistant(PluginProvider):
         self.logger.info("Connection to HA lost. Connection will be automatically retried later.")
         # schedule a reload of the provider
         self.mass.call_later(5, self.mass.load_provider, self.instance_id, allow_retry=True)
+
+    def _on_entity_state_update(self, event: EntityStateEvent) -> None:
+        """Handle Entity State event."""
+        if entity_additions := event.get("a"):
+            for entity_id, state in entity_additions.items():
+                self._update_control_from_state_msg(entity_id, state)
+        if entity_changes := event.get("c"):
+            for entity_id, state_diff in entity_changes.items():
+                if "+" not in state_diff:
+                    continue
+                self._update_control_from_state_msg(entity_id, state_diff["+"])
+
+    async def _register_player_controls(self) -> None:
+        """Register all player controls."""
+        power_controls = cast(list[str], self.config.get_value(CONF_POWER_CONTROLS))
+        mute_controls = cast(list[str], self.config.get_value(CONF_MUTE_CONTROLS))
+        volume_controls = cast(list[str], self.config.get_value(CONF_VOLUME_CONTROLS))
+        control_entity_ids: set[str] = {
+            *power_controls,
+            *mute_controls,
+            *volume_controls,
+        }
+        hass_states = {
+            state["entity_id"]: state
+            for state in await self.hass.get_states()
+            if state["entity_id"] in control_entity_ids
+        }
+        assert self._player_controls is not None  # for type checking
+        for entity_id in control_entity_ids:
+            entity_platform = entity_id.split(".")[0]
+            hass_state = hass_states.get(entity_id)
+            if hass_state:
+                name = f"{hass_state['attributes']['friendly_name']} ({entity_id})"
+            else:
+                name = entity_id
+            control = PlayerControl(
+                id=entity_id,
+                provider=self.lookup_key,
+                name=name,
+            )
+            if entity_id in power_controls:
+                control.supports_power = True
+                control.power_state = hass_state["state"] not in OFF_STATES if hass_state else False
+                control.power_on = partial(self._handle_player_control_power_on, entity_id)
+                control.power_off = partial(self._handle_player_control_power_off, entity_id)
+            if entity_id in volume_controls:
+                control.supports_volume = True
+                if entity_platform == "media_player":
+                    control.volume_level = hass_state["attributes"].get("volume_level", 0) * 100
+                else:
+                    control.volume_level = try_parse_int(hass_state["state"]) or 0
+                control.volume_set = partial(self._handle_player_control_volume_set, entity_id)
+            if entity_id in mute_controls:
+                control.supports_mute = True
+                if entity_platform == "media_player":
+                    control.volume_muted = hass_state["attributes"].get("volume_muted")
+                elif hass_state:
+                    control.volume_muted = hass_state["state"] not in OFF_STATES
+                else:
+                    control.volume_muted = False
+                control.mute_set = partial(self._handle_player_control_mute_set, entity_id)
+            self._player_controls[entity_id] = control
+            await self.mass.players.register_player_control(control)
+        # register for entity state updates
+        await self.hass.subscribe_entities(self._on_entity_state_update, list(control_entity_ids))
+
+    async def _handle_player_control_power_on(self, entity_id: str) -> None:
+        """Handle powering on the playercontrol."""
+        await self.hass.call_service(
+            domain="homeassistant",
+            service="turn_on",
+            target={"entity_id": entity_id},
+        )
+
+    async def _handle_player_control_power_off(self, entity_id: str) -> None:
+        """Handle powering off the playercontrol."""
+        await self.hass.call_service(
+            domain="homeassistant",
+            service="turn_off",
+            target={"entity_id": entity_id},
+        )
+
+    async def _handle_player_control_mute_set(self, entity_id: str, muted: bool) -> None:
+        """Handle muting the playercontrol."""
+        if entity_id.startswith("media_player."):
+            await self.hass.call_service(
+                domain="media_player",
+                service="volume_mute",
+                service_data={"is_volume_muted": muted},
+                target={"entity_id": entity_id},
+            )
+        else:
+            await self.hass.call_service(
+                domain="homeassistant",
+                service="turn_off" if muted else "turn_on",
+                target={"entity_id": entity_id},
+            )
+
+    async def _handle_player_control_volume_set(self, entity_id: str, volume_level: int) -> None:
+        """Handle setting volume on the playercontrol."""
+        if entity_id.startswith("media_player."):
+            await self.hass.call_service(
+                domain="media_player",
+                service="volume_set",
+                service_data={"volume_level": volume_level / 100},
+                target={"entity_id": entity_id},
+            )
+        else:
+            await self.hass.call_service(
+                domain="input_number",
+                service="set_value",
+                target={"entity_id": entity_id},
+                service_data={"value": volume_level},
+            )
+
+    def _update_control_from_state_msg(self, entity_id: str, state: CompressedState) -> None:
+        """Update PlayerControl from state(update) message."""
+        if self._player_controls is None:
+            return
+        if not (player_control := self._player_controls.get(entity_id)):
+            return
+        entity_platform = entity_id.split(".")[0]
+        if "s" in state:
+            # state changed
+            if player_control.supports_power:
+                player_control.power_state = state["s"] not in OFF_STATES
+            if player_control.supports_mute and entity_platform != "media_player":
+                player_control.volume_muted = state["s"] not in OFF_STATES
+            if player_control.supports_volume and entity_platform != "media_player":
+                player_control.volume_level = try_parse_int(state["s"]) or 0
+        if "a" in state and (attributes := state["a"]):
+            if player_control.supports_volume:
+                if entity_platform == "media_player":
+                    player_control.volume_level = attributes.get("volume_level", 0) * 100
+                else:
+                    player_control.volume_level = try_parse_int(attributes.get("value")) or 0
+            if player_control.supports_mute and entity_platform == "media_player":
+                player_control.volume_muted = attributes.get("volume_muted")
+        self.mass.players.update_player_control(entity_id)
diff --git a/music_assistant/providers/hass/constants.py b/music_assistant/providers/hass/constants.py
new file mode 100644 (file)
index 0000000..7e81837
--- /dev/null
@@ -0,0 +1,49 @@
+"""Constants for the Home Assistant provider."""
+
+from __future__ import annotations
+
+from enum import IntFlag
+
+from music_assistant_models.enums import PlayerState
+
+
+class MediaPlayerEntityFeature(IntFlag):
+    """Supported features of the media player entity."""
+
+    PAUSE = 1
+    SEEK = 2
+    VOLUME_SET = 4
+    VOLUME_MUTE = 8
+    PREVIOUS_TRACK = 16
+    NEXT_TRACK = 32
+
+    TURN_ON = 128
+    TURN_OFF = 256
+    PLAY_MEDIA = 512
+    VOLUME_STEP = 1024
+    SELECT_SOURCE = 2048
+    STOP = 4096
+    CLEAR_PLAYLIST = 8192
+    PLAY = 16384
+    SHUFFLE_SET = 32768
+    SELECT_SOUND_MODE = 65536
+    BROWSE_MEDIA = 131072
+    REPEAT_SET = 262144
+    GROUPING = 524288
+    MEDIA_ANNOUNCE = 1048576
+    MEDIA_ENQUEUE = 2097152
+
+
+StateMap = {
+    "playing": PlayerState.PLAYING,
+    "paused": PlayerState.PAUSED,
+    "buffering": PlayerState.PLAYING,
+    "idle": PlayerState.IDLE,
+    "off": PlayerState.IDLE,
+    "standby": PlayerState.IDLE,
+    "unknown": PlayerState.IDLE,
+    "unavailable": PlayerState.IDLE,
+}
+
+# HA states that we consider as "powered off"
+OFF_STATES = ("unavailable", "unknown", "standby", "off")
index c2a321358b78288d761e418c6f8f30acc045288a..d75ee45bc32a9b4364db8b13db4a2b6154604321 100644 (file)
@@ -9,7 +9,6 @@ from __future__ import annotations
 
 import asyncio
 import time
-from enum import IntFlag
 from typing import TYPE_CHECKING, Any
 
 from hass_client.exceptions import FailedCommand
@@ -34,6 +33,7 @@ from music_assistant.helpers.datetime import from_iso_string
 from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.models.player_provider import PlayerProvider
 from music_assistant.providers.hass import DOMAIN as HASS_DOMAIN
+from music_assistant.providers.hass.constants import OFF_STATES, MediaPlayerEntityFeature, StateMap
 
 if TYPE_CHECKING:
     from collections.abc import AsyncGenerator
@@ -47,48 +47,10 @@ if TYPE_CHECKING:
 
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
-    from music_assistant.providers.hass import HomeAssistant as HomeAssistantProvider
+    from music_assistant.providers.hass import HomeAssistantProvider
 
 CONF_PLAYERS = "players"
 
-StateMap = {
-    "playing": PlayerState.PLAYING,
-    "paused": PlayerState.PAUSED,
-    "buffering": PlayerState.PLAYING,
-    "idle": PlayerState.IDLE,
-    "off": PlayerState.IDLE,
-    "standby": PlayerState.IDLE,
-    "unknown": PlayerState.IDLE,
-    "unavailable": PlayerState.IDLE,
-}
-
-
-class MediaPlayerEntityFeature(IntFlag):
-    """Supported features of the media player entity."""
-
-    PAUSE = 1
-    SEEK = 2
-    VOLUME_SET = 4
-    VOLUME_MUTE = 8
-    PREVIOUS_TRACK = 16
-    NEXT_TRACK = 32
-
-    TURN_ON = 128
-    TURN_OFF = 256
-    PLAY_MEDIA = 512
-    VOLUME_STEP = 1024
-    SELECT_SOURCE = 2048
-    STOP = 4096
-    CLEAR_PLAYLIST = 8192
-    PLAY = 16384
-    SHUFFLE_SET = 32768
-    SELECT_SOUND_MODE = 65536
-    BROWSE_MEDIA = 131072
-    REPEAT_SET = 262144
-    GROUPING = 524288
-    MEDIA_ANNOUNCE = 1048576
-    MEDIA_ENQUEUE = 2097152
-
 
 DEFAULT_PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_CROSSFADE,
@@ -438,11 +400,10 @@ class HomeAssistantPlayers(PlayerProvider):
 
         player = Player(
             player_id=state["entity_id"],
-            provider=self.instance_id,
+            provider=self.lookup_key,
             type=PlayerType.PLAYER,
             name=state["attributes"]["friendly_name"],
             available=state["state"] not in ("unavailable", "unknown"),
-            powered=state["state"] not in ("unavailable", "unknown", "standby", "off"),
             device_info=DeviceInfo.from_dict(dev_info),
             state=StateMap.get(state["state"], PlayerState.IDLE),
             extra_data={
@@ -474,6 +435,7 @@ class HomeAssistantPlayers(PlayerProvider):
             and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features
         ):
             player.supported_features.add(PlayerFeature.POWER)
+            player.powered = state["state"] not in ("unavailable", "unknown", "standby", "off")
 
         self._update_player_attributes(player, state["attributes"])
         await self.mass.players.register_or_update(player)
@@ -494,12 +456,8 @@ class HomeAssistantPlayers(PlayerProvider):
                 return
             if "s" in state:
                 player.state = StateMap.get(state["s"], PlayerState.IDLE)
-                player.powered = state["s"] not in (
-                    "unavailable",
-                    "unknown",
-                    "standby",
-                    "off",
-                )
+                if PlayerFeature.POWER in player.supported_features:
+                    player.powered = state["s"] not in OFF_STATES
             if "a" in state:
                 self._update_player_attributes(player, state["a"])
             self.mass.players.update(entity_id)
index d2bafb61b28c188cc5c7f26da7de8856081fb6a2..6082612288f5201254c6b08f3e5d072be00faf25 100644 (file)
@@ -21,6 +21,7 @@ from music_assistant_models.config_entries import (
     ConfigValueType,
     PlayerConfig,
 )
+from music_assistant_models.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE
 from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
@@ -53,7 +54,10 @@ from music_assistant.constants import (
     CONF_FLOW_MODE,
     CONF_GROUP_MEMBERS,
     CONF_HTTP_PROFILE,
+    CONF_MUTE_CONTROL,
+    CONF_POWER_CONTROL,
     CONF_SAMPLE_RATES,
+    CONF_VOLUME_CONTROL,
     DEFAULT_PCM_FORMAT,
     create_sample_rates_config_entry,
 )
@@ -175,8 +179,8 @@ class PlayerGroupProvider(PlayerProvider):
         await super().loaded_in_mass()
         # temp: migrate old config entries
         # remove this after MA 2.4 release
-        for player_config in await self.mass.config.get_player_configs():
-            if player_config.provider == self.instance_id:
+        for player_config in await self.mass.config.get_player_configs(include_values=True):
+            if player_config.values.get(CONF_GROUP_TYPE) is not None:
                 # already migrated
                 continue
             # migrate old syncgroup players to this provider
@@ -227,6 +231,30 @@ class PlayerGroupProvider(PlayerProvider):
             CONF_ENTRY_GROUP_TYPE,
             CONF_ENTRY_GROUP_MEMBERS,
             CONFIG_ENTRY_DYNAMIC_MEMBERS,
+            # add player control entries as hidden entries
+            ConfigEntry(
+                key=CONF_POWER_CONTROL,
+                type=ConfigEntryType.STRING,
+                label=CONF_POWER_CONTROL,
+                default_value=PLAYER_CONTROL_NATIVE,
+                hidden=True,
+            ),
+            ConfigEntry(
+                key=CONF_VOLUME_CONTROL,
+                type=ConfigEntryType.STRING,
+                label=CONF_VOLUME_CONTROL,
+                default_value=PLAYER_CONTROL_NATIVE,
+                hidden=True,
+            ),
+            ConfigEntry(
+                key=CONF_MUTE_CONTROL,
+                type=ConfigEntryType.STRING,
+                label=CONF_MUTE_CONTROL,
+                # disable mute control for group players for now
+                # TODO: work out if all child players support mute control
+                default_value=PLAYER_CONTROL_NONE,
+                hidden=True,
+            ),
         )
         # group type is static and can not be changed. we just grab the existing, stored value
         group_type: str = self.mass.config.get_raw_player_config_value(
@@ -356,6 +384,9 @@ class PlayerGroupProvider(PlayerProvider):
         if not powered and group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
             await self.cmd_stop(group_player.player_id)
 
+        if powered and player_id.startswith(SYNCGROUP_PREFIX):
+            await self._form_syncgroup(group_player)
+
         if powered:
             # handle TURN_ON of the group player by turning on all members
             for member in self.mass.players.iter_group_members(
@@ -379,7 +410,7 @@ class PlayerGroupProvider(PlayerProvider):
                     # solve this by powering off the other group
                     await self.mass.players.cmd_power(member.active_group, False)
                     await asyncio.sleep(1)
-                if not member.powered:
+                if not member.powered and member.power_control != PLAYER_CONTROL_NONE:
                     member.active_group = None  # needed to prevent race conditions
                     await self.mass.players.cmd_power(member.player_id, True)
                 # set active source to group player if the group (is going to be) powered
@@ -397,11 +428,9 @@ class PlayerGroupProvider(PlayerProvider):
                 member.active_group = None
                 member.active_source = None
                 # handle TURN_OFF of the group player by turning off all members
-                if member.powered:
+                if member.powered and member.power_control != PLAYER_CONTROL_NONE:
                     await self.mass.players.cmd_power(member.player_id, False)
 
-        if powered and player_id.startswith(SYNCGROUP_PREFIX):
-            await self._form_syncgroup(group_player)
         # optimistically set the group state
         group_player.powered = powered
         self.mass.players.update(group_player.player_id)
@@ -536,7 +565,7 @@ class PlayerGroupProvider(PlayerProvider):
             if ProviderFeature.SYNC_PLAYERS not in player_prov.supported_features:
                 msg = f"Provider {player_prov.name} does not support creating groups"
                 raise UnsupportedFeaturedException(msg)
-            group_type = player_prov.instance_id  # just in case only domain was sent
+            group_type = player_prov.lookup_key  # just in case only domain was sent
 
         new_group_id = f"{prefix}{shortuuid.random(8).lower()}"
         # cleanup list, just in case the frontend sends some garbage
@@ -677,7 +706,7 @@ class PlayerGroupProvider(PlayerProvider):
     async def _register_all_players(self) -> None:
         """Register all (virtual/fake) group players in the Player controller."""
         player_configs = await self.mass.config.get_player_configs(
-            self.instance_id, include_values=True
+            self.lookup_key, include_values=True
         )
         for player_config in player_configs:
             if self.mass.players.get(player_config.player_id):
@@ -714,9 +743,9 @@ class PlayerGroupProvider(PlayerProvider):
             )
             can_group_with = {
                 # allow grouping with all providers, except the playergroup provider itself
-                x.instance_id
+                x.lookup_key
                 for x in self.mass.players.providers
-                if x.instance_id != self.instance_id
+                if x.lookup_key != self.lookup_key
             }
             player_features.add(PlayerFeature.MULTI_DEVICE_DSP)
         elif player_provider := self.mass.get_provider(group_type):
@@ -725,7 +754,7 @@ class PlayerGroupProvider(PlayerProvider):
                 player_provider = cast(PlayerProvider, player_provider)
             model_name = "Sync Group"
             manufacturer = self.mass.get_provider(group_type).name
-            can_group_with = {player_provider.instance_id}
+            can_group_with = {player_provider.lookup_key}
             for feature in (PlayerFeature.PAUSE, PlayerFeature.VOLUME_MUTE, PlayerFeature.ENQUEUE):
                 if all(feature in x.supported_features for x in player_provider.players):
                     player_features.add(feature)
@@ -741,7 +770,7 @@ class PlayerGroupProvider(PlayerProvider):
 
         player = Player(
             player_id=group_player_id,
-            provider=self.instance_id,
+            provider=self.lookup_key,
             type=PlayerType.GROUP,
             name=name,
             available=True,
@@ -845,12 +874,12 @@ class PlayerGroupProvider(PlayerProvider):
         if group_type == GROUP_TYPE_UNIVERSAL:
             can_group_with = {
                 # allow grouping with all providers, except the playergroup provider itself
-                x.instance_id
+                x.lookup_key
                 for x in self.mass.players.providers
-                if x.instance_id != self.instance_id
+                if x.lookup_key != self.lookup_key
             }
         elif sync_player_provider := self.mass.get_provider(group_type):
-            can_group_with = {sync_player_provider.instance_id}
+            can_group_with = {sync_player_provider.lookup_key}
         else:
             can_group_with = {}
         player.can_group_with = can_group_with
@@ -927,7 +956,7 @@ class PlayerGroupProvider(PlayerProvider):
                 x
                 for x in members
                 if (player := self.mass.players.get(x))
-                and player.provider in (player_provider.instance_id, self.instance_id)
+                and player.provider == player_provider.lookup_key
             ]
         # cleanup members - filter out impossible choices
         syncgroup_childs: list[str] = []
index 17c28bc67f34be5f18e07fb96f309bbaa2aa1e39..64c5e24810a000960f800fdfdf2683ed2be6054c 100644 (file)
@@ -633,7 +633,7 @@ class SlimprotoProvider(PlayerProvider):
             # player does not yet exist, create it
             player = Player(
                 player_id=player_id,
-                provider=self.instance_id,
+                provider=self.lookup_key,
                 type=PlayerType.PLAYER,
                 name=slimplayer.name,
                 available=True,
@@ -652,7 +652,7 @@ class SlimprotoProvider(PlayerProvider):
                     PlayerFeature.VOLUME_MUTE,
                     PlayerFeature.ENQUEUE,
                 },
-                can_group_with={self.instance_id},
+                can_group_with={self.lookup_key},
             )
             await self.mass.players.register_or_update(player)
 
index 93a8f5449b12e89ceffd94fb0fe8434c717cecc9..580ff530af944168e84172e53ad3f9ee8b9a88f9 100644 (file)
@@ -367,11 +367,10 @@ class SnapCastProvider(PlayerProvider):
             )
             player = Player(
                 player_id=player_id,
-                provider=self.instance_id,
+                provider=self.lookup_key,
                 type=PlayerType.PLAYER,
                 name=snap_client.friendly_name,
-                available=True,
-                powered=snap_client.connected,
+                available=snap_client.connected,
                 device_info=DeviceInfo(
                     model=snap_client._client.get("host").get("os"),
                     ip_address=snap_client._client.get("host").get("ip"),
@@ -383,7 +382,7 @@ class SnapCastProvider(PlayerProvider):
                     PlayerFeature.VOLUME_MUTE,
                 },
                 synced_to=self._synced_to(player_id),
-                can_group_with={self.instance_id},
+                can_group_with={self.lookup_key},
             )
         asyncio.run_coroutine_threadsafe(
             self.mass.players.register_or_update(player), loop=self.mass.loop
index 5e02456635444a63e0e4d57f12b7e62e133e2510..b541cd16e36ec00125f8664552bdeadec339bad6 100644 (file)
@@ -14,7 +14,6 @@ PLAYBACK_STATE_MAP = {
 
 PLAYER_FEATURES_BASE = {
     PlayerFeature.SET_MEMBERS,
-    PlayerFeature.VOLUME_MUTE,
     PlayerFeature.PAUSE,
     PlayerFeature.ENQUEUE,
 }
index c6c009f9441cd6c39fc495d416d8cd980777764f..568a623101bb1454844e0f69c9367d9278105173 100644 (file)
@@ -118,19 +118,18 @@ class SonosPlayer:
             supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
         if not self.client.player.has_fixed_volume:
             supported_features.add(PlayerFeature.VOLUME_SET)
+            supported_features.add(PlayerFeature.VOLUME_MUTE)
         if not self.get_linked_airplay_player(False):
             supported_features.add(PlayerFeature.NEXT_PREVIOUS)
 
         # instantiate the MA player
         self.mass_player = mass_player = Player(
             player_id=self.player_id,
-            provider=self.prov.instance_id,
+            provider=self.prov.lookup_key,
             type=PlayerType.PLAYER,
             name=self.discovery_info["device"]["name"]
             or self.discovery_info["device"]["modelDisplayName"],
             available=True,
-            # sonos has no power support so we always assume its powered
-            powered=True,
             device_info=DeviceInfo(
                 model=self.discovery_info["device"]["modelDisplayName"],
                 manufacturer=self.prov.manifest.name,
@@ -139,7 +138,7 @@ class SonosPlayer:
             supported_features=supported_features,
             # NOTE: strictly taken we can have multiple sonos households
             # but for now we assume we only have one
-            can_group_with={self.prov.instance_id},
+            can_group_with={self.prov.lookup_key},
         )
         self.update_attributes()
         await self.mass.players.register_or_update(mass_player)
@@ -281,7 +280,7 @@ class SonosPlayer:
                     if x.player_id != airplay_player.player_id
                 )
             else:
-                self.mass_player.can_group_with = {self.prov.instance_id}
+                self.mass_player.can_group_with = {self.prov.lookup_key}
             self.mass_player.synced_to = None
         else:
             # player is group child (synced to another player)
@@ -311,7 +310,6 @@ class SonosPlayer:
                 PlayerState.PAUSED,
             ):
                 self.mass_player.state = airplay_player.state
-                self.mass_player.powered = True
                 self.mass_player.active_source = airplay_player.active_source
                 self.mass_player.elapsed_time = airplay_player.elapsed_time
                 self.mass_player.elapsed_time_last_updated = (
index ec937025ed93c841001d62a04cf94a17163b369b..435c2375db22af9fc81d8f6bc2da431530fb041d 100644 (file)
@@ -17,7 +17,7 @@ from aiohttp.client_exceptions import ClientError
 from aiosonos.api.models import SonosCapability
 from aiosonos.utils import get_discovery_info
 from music_assistant_models.config_entries import ConfigEntry, PlayerConfig
-from music_assistant_models.enums import ConfigEntryType, ContentType, ProviderFeature
+from music_assistant_models.enums import ConfigEntryType, ContentType, PlayerState, ProviderFeature
 from music_assistant_models.errors import PlayerCommandFailed
 from music_assistant_models.player import DeviceInfo, PlayerMedia
 from zeroconf import ServiceStateChange
@@ -208,7 +208,7 @@ class SonosPlayerProvider(PlayerProvider):
                 await airplay_prov.cmd_stop(airplay_player.player_id)
                 airplay_player.active_source = None
             if not sonos_player.airplay_mode_enabled:
-                await self.mass.players.cmd_power(config.player_id, False)
+                await self.mass.players.cmd_stop(config.player_id)
 
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player."""
@@ -251,8 +251,9 @@ class SonosPlayerProvider(PlayerProvider):
         if airplay_player := sonos_player.get_linked_airplay_player(False):
             # if airplay mode is enabled, we could possibly receive child player id's that are
             # not Sonos players, but Airplay players. We redirect those.
-            if sonos_player.mass_player.active_source and not self.mass.player_queues.get(
-                sonos_player.mass_player.active_source
+            if (
+                airplay_player.active_source == sonos_player.mass_player.active_source
+                and airplay_player.state == PlayerState.PLAYING
             ):
                 # edge case player is not playing a MA queue - fail this request
                 raise PlayerCommandFailed("Player is not playing a Music Assistant queue.")
index 28cc44c74625a8fc875a3b479b71c5f5e8471a74..23c0474b4c66e9819d916c28858bb53b7f99f9a8 100644 (file)
@@ -351,10 +351,8 @@ class SonosPlayerProvider(PlayerProvider):
         # dynamically change the poll interval
         if sonos_player.mass_player.state == PlayerState.PLAYING:
             sonos_player.mass_player.poll_interval = 5
-        elif sonos_player.mass_player.powered:
-            sonos_player.mass_player.poll_interval = 20
         else:
-            sonos_player.mass_player.poll_interval = 60
+            sonos_player.mass_player.poll_interval = 30
         try:
             # the check_poll logic will work out what endpoints need polling now
             # based on when we last received info from the device
@@ -431,16 +429,13 @@ class SonosPlayerProvider(PlayerProvider):
         if soco.uid not in self.boot_counts:
             self.boot_counts[soco.uid] = soco.boot_seqnum
         self.logger.debug("Adding new player: %s", speaker_info)
-        transport_info = soco.get_current_transport_info()
-        play_state = transport_info["current_transport_state"]
         if not (mass_player := self.mass.players.get(soco.uid)):
             mass_player = Player(
                 player_id=soco.uid,
-                provider=self.instance_id,
+                provider=self.lookup_key,
                 type=PlayerType.PLAYER,
                 name=soco.player_name,
                 available=True,
-                powered=play_state in ("PLAYING", "TRANSITIONING"),
                 supported_features=PLAYER_FEATURES,
                 device_info=DeviceInfo(
                     model=speaker_info["model_name"],
@@ -449,7 +444,7 @@ class SonosPlayerProvider(PlayerProvider):
                 ),
                 needs_poll=True,
                 poll_interval=30,
-                can_group_with={self.instance_id},
+                can_group_with={self.lookup_key},
             )
         self.sonosplayers[player_id] = sonos_player = SonosPlayer(
             self,
index bb91bb53005db478d3f02c11b3a83fe564a4fefa..db73e2511ecdc0eba3fab69d6387179206162878 100644 (file)
@@ -622,24 +622,13 @@ class SonosPlayer:
         self.mass_player.available = self.available
 
         if not self.available:
-            self.mass_player.powered = False
             self.mass_player.state = PlayerState.IDLE
             self.mass_player.synced_to = None
             self.mass_player.group_childs.clear()
             return
 
         # transport info (playback state)
-        self.mass_player.state = current_state = _convert_state(self.playback_status)
-
-        # power 'on' player if we detect its playing
-        if not self.mass_player.powered and (
-            current_state == PlayerState.PLAYING
-            or (
-                self.sync_coordinator
-                and self.sync_coordinator.mass_player.state == PlayerState.PLAYING
-            )
-        ):
-            self.mass_player.powered = True
+        self.mass_player.state = _convert_state(self.playback_status)
 
         # media info (track info)
         self.mass_player.current_item_id = self.uri