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"
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
# 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)
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,
)
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,
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
"""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 = (
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(
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
# 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
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)
"""
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(
"""
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(
)
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)
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(
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)
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()
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:
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]
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
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):
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
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
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
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,
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:
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",
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
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
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:
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",
+ ),
+ )
# 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,
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:
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,
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)
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)
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",
},
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)
),
player=Player(
player_id=player_id,
- provider=self.instance_id,
+ provider=self.lookup_key,
type=player_type,
name=cast_info.friendly_name,
available=False,
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
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",
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"],
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
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
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, ...]:
# 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,
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)
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()
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)
--- /dev/null
+"""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")
import asyncio
import time
-from enum import IntFlag
from typing import TYPE_CHECKING, Any
from hass_client.exceptions import FailedCommand
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
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,
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={
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)
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)
ConfigValueType,
PlayerConfig,
)
+from music_assistant_models.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
ConfigEntryType,
ContentType,
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,
)
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
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(
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(
# 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
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)
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
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):
)
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):
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)
player = Player(
player_id=group_player_id,
- provider=self.instance_id,
+ provider=self.lookup_key,
type=PlayerType.GROUP,
name=name,
available=True,
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
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] = []
# 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,
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)
)
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"),
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
PLAYER_FEATURES_BASE = {
PlayerFeature.SET_MEMBERS,
- PlayerFeature.VOLUME_MUTE,
PlayerFeature.PAUSE,
PlayerFeature.ENQUEUE,
}
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,
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)
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)
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 = (
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
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."""
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.")
# 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
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"],
),
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,
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