From: Marcel van der Veldt Date: Fri, 31 Jan 2025 11:53:38 +0000 (+0100) Subject: Add Player Controls feature (#1925) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=06cec6a04d7e80b36f7a7dcfb1e27122d03cbe63;p=music-assistant-server.git Add Player Controls feature (#1925) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index de32b3ff..820c29e7 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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" diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 045d106f..70dc2d87 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -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) diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py index 43773d7b..f0fec78c 100644 --- a/music_assistant/controllers/players.py +++ b/music_assistant/controllers/players.py @@ -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", diff --git a/music_assistant/models/player_provider.py b/music_assistant/models/player_provider.py index a737da17..08d0b611 100644 --- a/music_assistant/models/player_provider.py +++ b/music_assistant/models/player_provider.py @@ -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", + ), + ) diff --git a/music_assistant/providers/_template_player_provider/__init__.py b/music_assistant/providers/_template_player_provider/__init__.py index e82ffddf..9e5aa66a 100644 --- a/music_assistant/providers/_template_player_provider/__init__.py +++ b/music_assistant/providers/_template_player_provider/__init__.py @@ -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, diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 16067b26..52b12403 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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) diff --git a/music_assistant/providers/bluesound/__init__.py b/music_assistant/providers/bluesound/__init__.py index 746f741e..08f03eb1 100644 --- a/music_assistant/providers/bluesound/__init__.py +++ b/music_assistant/providers/bluesound/__init__.py @@ -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) diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index fc9758a0..56ce3ea4 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -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, diff --git a/music_assistant/providers/dlna/__init__.py b/music_assistant/providers/dlna/__init__.py index 028ab326..22836391 100644 --- a/music_assistant/providers/dlna/__init__.py +++ b/music_assistant/providers/dlna/__init__.py @@ -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", diff --git a/music_assistant/providers/fully_kiosk/__init__.py b/music_assistant/providers/fully_kiosk/__init__.py index 6f7b2c29..b096db65 100644 --- a/music_assistant/providers/fully_kiosk/__init__.py +++ b/music_assistant/providers/fully_kiosk/__init__.py @@ -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"], diff --git a/music_assistant/providers/hass/__init__.py b/music_assistant/providers/hass/__init__.py index 34ed0c6f..fd9e11fc 100644 --- a/music_assistant/providers/hass/__init__.py +++ b/music_assistant/providers/hass/__init__.py @@ -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 index 00000000..7e81837d --- /dev/null +++ b/music_assistant/providers/hass/constants.py @@ -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") diff --git a/music_assistant/providers/hass_players/__init__.py b/music_assistant/providers/hass_players/__init__.py index c2a32135..d75ee45b 100644 --- a/music_assistant/providers/hass_players/__init__.py +++ b/music_assistant/providers/hass_players/__init__.py @@ -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) diff --git a/music_assistant/providers/player_group/__init__.py b/music_assistant/providers/player_group/__init__.py index d2bafb61..60826122 100644 --- a/music_assistant/providers/player_group/__init__.py +++ b/music_assistant/providers/player_group/__init__.py @@ -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] = [] diff --git a/music_assistant/providers/slimproto/__init__.py b/music_assistant/providers/slimproto/__init__.py index 17c28bc6..64c5e248 100644 --- a/music_assistant/providers/slimproto/__init__.py +++ b/music_assistant/providers/slimproto/__init__.py @@ -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) diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index 93a8f544..580ff530 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -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 diff --git a/music_assistant/providers/sonos/const.py b/music_assistant/providers/sonos/const.py index 5e024566..b541cd16 100644 --- a/music_assistant/providers/sonos/const.py +++ b/music_assistant/providers/sonos/const.py @@ -14,7 +14,6 @@ PLAYBACK_STATE_MAP = { PLAYER_FEATURES_BASE = { PlayerFeature.SET_MEMBERS, - PlayerFeature.VOLUME_MUTE, PlayerFeature.PAUSE, PlayerFeature.ENQUEUE, } diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index c6c009f9..568a6231 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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 = ( diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index ec937025..435c2375 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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.") diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 28cc44c7..23c0474b 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -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, diff --git a/music_assistant/providers/sonos_s1/player.py b/music_assistant/providers/sonos_s1/player.py index bb91bb53..db73e251 100644 --- a/music_assistant/providers/sonos_s1/player.py +++ b/music_assistant/providers/sonos_s1/player.py @@ -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