Bluesound native grouping and control of external sources (#2359)
authorJoProbst <95jonas.p@gmail.com>
Tue, 9 Sep 2025 09:33:56 +0000 (11:33 +0200)
committerGitHub <noreply@github.com>
Tue, 9 Sep 2025 09:33:56 +0000 (11:33 +0200)
music_assistant/providers/bluesound/const.py [new file with mode: 0644]
music_assistant/providers/bluesound/manifest.json
music_assistant/providers/bluesound/player.py
music_assistant/providers/bluesound/provider.py
requirements_all.txt

diff --git a/music_assistant/providers/bluesound/const.py b/music_assistant/providers/bluesound/const.py
new file mode 100644 (file)
index 0000000..6d20421
--- /dev/null
@@ -0,0 +1,114 @@
+"""Constants for the Bluesound provider."""
+
+from __future__ import annotations
+
+from music_assistant_models.enums import PlaybackState, PlayerFeature
+
+from music_assistant.models.player import PlayerSource
+
+IDLE_POLL_INTERVAL = 30
+PLAYBACK_POLL_INTERVAL = 10
+
+PLAYER_FEATURES_BASE = {
+    PlayerFeature.SET_MEMBERS,
+    PlayerFeature.VOLUME_MUTE,
+    PlayerFeature.PAUSE,
+    PlayerFeature.SELECT_SOURCE,
+    PlayerFeature.NEXT_PREVIOUS,
+    PlayerFeature.SEEK,
+}
+
+PLAYBACK_STATE_MAP = {
+    "play": PlaybackState.PLAYING,
+    "stream": PlaybackState.PLAYING,
+    "stop": PlaybackState.IDLE,
+    "pause": PlaybackState.PAUSED,
+    "connecting": PlaybackState.IDLE,
+}
+
+PLAYBACK_STATE_POLL_MAP = {
+    "play": PlaybackState.PLAYING,
+    "stream": PlaybackState.PLAYING,
+    "stop": PlaybackState.IDLE,
+    "pause": PlaybackState.PAUSED,
+    "connecting": "CONNECTING",
+}
+
+SOURCE_TIDAL = "Tidal"
+SOURCE_AIRPLAY = "AirPlay"
+SOURCE_SPOTIFY = "Spotify"
+SOURCE_RADIOPARADISE = "RadioParadise"
+SOURCE_TUNEIN = "TuneIn"
+SOURCE_HTTP = "http"
+SOURCE_BLUETOOTH = "Bluetooth"
+SOURCE_TV = "HDMI ARC"
+
+PLAYER_SOURCE_MAP = {
+    SOURCE_HTTP: PlayerSource(
+        id=SOURCE_HTTP,
+        name="HTTP Stream",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+    SOURCE_BLUETOOTH: PlayerSource(
+        id=SOURCE_BLUETOOTH,
+        name="Bluetooth",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+    SOURCE_TV: PlayerSource(
+        id=SOURCE_TV,
+        name="HDMI ARC",
+        passive=True,
+        can_play_pause=False,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+    SOURCE_AIRPLAY: PlayerSource(
+        id=SOURCE_AIRPLAY,
+        name="AirPlay",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+    SOURCE_SPOTIFY: PlayerSource(
+        id=SOURCE_SPOTIFY,
+        name="Spotify",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=True,
+        can_seek=True,
+    ),
+    SOURCE_TIDAL: PlayerSource(
+        id=SOURCE_TIDAL,
+        name="Tidal",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=True,
+        can_seek=True,
+    ),
+    SOURCE_RADIOPARADISE: PlayerSource(
+        id=SOURCE_RADIOPARADISE,
+        name="Radio Paradise",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=True,
+        can_seek=False,
+    ),
+    SOURCE_TUNEIN: PlayerSource(
+        id=SOURCE_TUNEIN,
+        name="TuneIn",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+}
+
+POLL_STATE_STATIC = "static"
+POLL_STATE_DYNAMIC = "dynamic"
index a8eb94ee9d512115da3f49c17dda35936f00d6f3..ab7f33492fc82bf6446ed130e90086d8d5e9a7cc 100644 (file)
@@ -5,7 +5,7 @@
   "name": "Bluesound",
   "description": "BluOS Player provider for Music Assistant.",
   "codeowners": ["@cyanogenbot"],
-  "requirements": ["pyblu==2.0.1"],
+  "requirements": ["pyblu==2.0.4"],
   "documentation": "https://music-assistant.io/player-support/bluesound/",
   "mdns_discovery": ["_musc._tcp.local.","_musp._tcp.local."]
 }
index b874c2fd22bccc56868b46d2b9f9ebc71f9efef1..29596a0f6f648f71b30123d6b90084c684133b4a 100644 (file)
@@ -4,59 +4,38 @@ from __future__ import annotations
 
 import asyncio
 import time
-from typing import TYPE_CHECKING, cast
+from typing import TYPE_CHECKING
 
+from music_assistant_models.config_entries import ConfigEntry
 from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
 from music_assistant_models.errors import PlayerCommandFailed
 from pyblu import Player as BluosPlayer
 from pyblu import Status, SyncStatus
+from pyblu.entities import Input, PairedPlayer, Preset
+from pyblu.errors import PlayerUnexpectedResponseError, PlayerUnreachableError
 
 from music_assistant.constants import (
     CONF_ENTRY_ENABLE_ICY_METADATA,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_HTTP_PROFILE_FORCED_2,
     CONF_ENTRY_OUTPUT_CODEC,
-    VERBOSE_LOG_LEVEL,
 )
-from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
+from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
+from music_assistant.providers.bluesound.const import (
+    IDLE_POLL_INTERVAL,
+    PLAYBACK_POLL_INTERVAL,
+    PLAYBACK_STATE_MAP,
+    PLAYBACK_STATE_POLL_MAP,
+    PLAYER_FEATURES_BASE,
+    PLAYER_SOURCE_MAP,
+    POLL_STATE_DYNAMIC,
+    POLL_STATE_STATIC,
+)
 
 if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ConfigEntry
-
     from .provider import BluesoundDiscoveryInfo, BluesoundPlayerProvider
 
 
-PLAYER_FEATURES_BASE = {
-    PlayerFeature.SET_MEMBERS,
-    PlayerFeature.VOLUME_MUTE,
-    PlayerFeature.PAUSE,
-}
-
-PLAYBACK_STATE_MAP = {
-    "play": PlaybackState.PLAYING,
-    "stream": PlaybackState.PLAYING,
-    "stop": PlaybackState.IDLE,
-    "pause": PlaybackState.PAUSED,
-    "connecting": PlaybackState.IDLE,
-}
-
-PLAYBACK_STATE_POLL_MAP = {
-    "play": PlaybackState.PLAYING,
-    "stream": PlaybackState.PLAYING,
-    "stop": PlaybackState.IDLE,
-    "pause": PlaybackState.PAUSED,
-    "connecting": "CONNECTING",
-}
-
-SOURCE_LINE_IN = "line_in"
-SOURCE_AIRPLAY = "airplay"
-SOURCE_SPOTIFY = "spotify"
-SOURCE_UNKNOWN = "unknown"
-SOURCE_RADIO = "radio"
-POLL_STATE_STATIC = "static"
-POLL_STATE_DYNAMIC = "dynamic"
-
-
 class BluesoundPlayer(Player):
     """Holds the details of the (discovered) BluOS player."""
 
@@ -91,13 +70,15 @@ class BluesoundPlayer(Player):
             ip_address=ip_address,
         )
         self._attr_available = True
+        self._attr_source_list = []
         self._attr_needs_poll = True
-        self._attr_poll_interval = 30
+        self._attr_poll_interval = IDLE_POLL_INTERVAL
         self._attr_can_group_with = {provider.lookup_key}
 
     async def setup(self) -> None:
         """Set up the player."""
         # Add volume support if available
+        await self.update_attributes()
         if self.discovery_info.get("zs"):
             self._attr_supported_features.add(PlayerFeature.VOLUME_SET)
         await self.mass.players.register_or_update(self)
@@ -109,7 +90,9 @@ class BluesoundPlayer(Player):
             CONF_ENTRY_HTTP_PROFILE_FORCED_2,
             CONF_ENTRY_OUTPUT_CODEC,
             CONF_ENTRY_FLOW_MODE_ENFORCED,
-            CONF_ENTRY_ENABLE_ICY_METADATA,
+            ConfigEntry.from_dict(
+                {**CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), "default_value": "full"}
+            ),
         ]
 
     async def disconnect(self) -> None:
@@ -125,9 +108,7 @@ class BluesoundPlayer(Player):
         """Send STOP command to BluOS player."""
         play_state = await self.client.stop(timeout=1)
         if play_state == "stop":
-            self.poll_state = POLL_STATE_DYNAMIC
-            self.dynamic_poll_count = 6
-            self._attr_poll_interval = 0.5
+            self._set_polling_dynamic()
         self._attr_playback_state = PlaybackState.IDLE
         self.update_state()
 
@@ -135,9 +116,7 @@ class BluesoundPlayer(Player):
         """Send PLAY command to BluOS player."""
         play_state = await self.client.play(timeout=1)
         if play_state == "stream":
-            self.poll_state = POLL_STATE_DYNAMIC
-            self.dynamic_poll_count = 6
-            self._attr_poll_interval = 0.5
+            self._set_polling_dynamic()
         self._attr_playback_state = PlaybackState.PLAYING
         self.update_state()
 
@@ -145,9 +124,7 @@ class BluesoundPlayer(Player):
         """Send PAUSE command to BluOS player."""
         play_state = await self.client.pause(timeout=1)
         if play_state == "pause":
-            self.poll_state = POLL_STATE_DYNAMIC
-            self.dynamic_poll_count = 6
-            self._attr_poll_interval = 0.5
+            self._set_polling_dynamic()
         self.logger.debug("Set BluOS state to %s", play_state)
         self._attr_playback_state = PlaybackState.PAUSED
         self.update_state()
@@ -165,16 +142,37 @@ class BluesoundPlayer(Player):
         self._attr_volume_muted = muted
         self.update_state()
 
+    async def next_track(self):
+        """Send NEXT TRACK command to BluOS player."""
+        await self.client.skip()
+        self._set_polling_dynamic()
+        self.update_state()
+
+    async def previous_track(self):
+        """Send PREVIOUS TRACK command to BluOS player."""
+        await self.client.back()
+        self._set_polling_dynamic()
+        self.update_state()
+
+    async def seek(self, position) -> None:
+        """Send PLAY command to BluOS player."""
+        play_state = await self.client.play(seek=position, timeout=1)
+        if play_state in ("stream", "play"):
+            self._set_polling_dynamic()
+        self._attr_elapsed_time = position
+        self._attr_elapsed_time_last_updated = time.time()
+        self._attr_playback_state = PlaybackState.PLAYING
+        self.update_state()
+
     async def play_media(self, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA for BluOS player using the provided URL."""
         self.logger.debug("Play_media called")
+        self.logger.debug(media)
         play_state = await self.client.play_url(media.uri, timeout=1)
 
         # Enable dynamic polling
         if play_state == "stream":
-            self.poll_state = POLL_STATE_DYNAMIC
-            self.dynamic_poll_count = 6
-            self._attr_poll_interval = 0.5
+            self._set_polling_dynamic()
             self._attr_playback_state = PlaybackState.PLAYING
 
         self.logger.debug("Set BluOS state to %s", play_state)
@@ -183,6 +181,11 @@ class BluesoundPlayer(Player):
         if play_state in ("PlayerUnexpectedResponseError", "PlayerUnreachableError"):
             raise PlayerCommandFailed("Failed to start playback.")
 
+        # Optimistically update state
+        self._attr_current_media = media
+        self._attr_active_source = media.queue_id
+        self._attr_elapsed_time = 0
+        self._attr_elapsed_time_last_updated = time.time()
         self.update_state()
 
     async def set_members(
@@ -191,102 +194,243 @@ class BluesoundPlayer(Player):
         player_ids_to_remove: list[str] | None = None,
     ) -> None:
         """Handle GROUP command for BluOS player."""
-        # TODO: Implement grouping logic
+        if not player_ids_to_add and not player_ids_to_remove:
+            # nothing to do
+            return
+
+        def player_id_to_paired_player(player_id: str) -> PairedPlayer:
+            client = self.mass.players.get(player_id, raise_unavailable=True)
+            return PairedPlayer(client.ip_address, client.port)
+
+        if player_ids_to_remove:
+            for player_id in player_ids_to_remove:
+                paired_player = player_id_to_paired_player(player_id)
+                try:
+                    self.sync_status = await self.client.remove_follower(
+                        paired_player.ip, paired_player.port, timeout=3
+                    )
+                except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
+                    self.logger.debug(f"Could not remove players: {err!s}")
+                    continue
+                removed_player = self.mass.players.get(player_id)
+                if removed_player:
+                    removed_player._set_polling_dynamic()
+                    removed_player._attr_current_media = None
+                    removed_player._attr_active_source = None
+                    removed_player.update_state()
+
+        if player_ids_to_add:
+            for player_id in player_ids_to_add:
+                paired_player = player_id_to_paired_player(player_id)
+                try:
+                    await self.client.add_follower(paired_player.ip, paired_player.port, timeout=5)
+                except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
+                    self.logger.debug(f"Could not add player {paired_player}: {err!s}")
+                    continue
+                self._attr_group_members.append(player_id)
+                added_player = self.mass.players.get(player_id)
+                if added_player:
+                    added_player._set_polling_dynamic()
+                    added_player.update_state()
+
+        self._set_polling_dynamic()
+        self.update_state()
 
     async def ungroup(self) -> None:
         """Handle UNGROUP command for BluOS player."""
-        await self.client.player.leave_group()
+        leader = self.client.leader
+        leader_player_id = self.client.provider.player_map((leader.ip, leader.port))
+        await self.mass.player.get(leader_player_id).set_members(None, [self.player_id])
 
     async def poll(self) -> None:
         """Poll player for state updates."""
         await self.update_attributes()
 
+    def _resolve_source(self) -> None:
+        """Check  PLAYER_SOURCE_MAP for known sources, otherwise create a new source."""
+
+        def resolve_analog_digital_source(source_name) -> PlayerMedia:
+            """Resolve Analog/Digital Source here, avoid duplicate entries in PLAYER_SOURCE_MAP."""
+            return PlayerSource(
+                id=source_name,
+                name=source_name,
+                passive=True,
+                can_play_pause=False,
+                can_next_previous=False,
+                can_seek=False,
+            )
+
+        self.logger.debug(self.status)
+        mass_active = self.mass.streams.base_url
+        if self.status.stream_url and mass_active in self.status.stream_url:
+            self._attr_active_source = self.player_id
+        elif player_source := PLAYER_SOURCE_MAP.get(self.status.input_id):
+            self._attr_active_source = self.status.input_id
+            self._attr_source_list.append(player_source)
+        elif player_source := PLAYER_SOURCE_MAP.get(self.status.service):
+            self._attr_active_source = self.status.service
+            self._attr_source_list.append(player_source)
+        elif player_source := PLAYER_SOURCE_MAP.get(self.status.name):
+            self._attr_active_source = self.status.name
+            self._attr_source_list.append(player_source)
+        elif (name := self.status.name) and ("Analog Input" in name or "Digital Input" in name):
+            player_source = resolve_analog_digital_source(name)
+            self._attr_active_source = name
+            self._attr_source_list.append(player_source)
+        else:
+            self._attr_active_source = self.status.input_id
+            self.logger.debug("Appending new PlayerSource")
+            self._attr_source_list.append(
+                PlayerSource(
+                    id=self.status.input_id,
+                    name=self.status.input_id,
+                    passive=True,
+                    can_play_pause=True,
+                    can_seek=self.status.can_seek,
+                    can_next_previous=True,
+                )
+            )
+
+    def _resolve_media(self) -> None:
+        """Resolve currently playing media dependent on available status attributes."""
+        image = self.status.image
+        if image:
+            image_url = image if image.startswith("http") else self.client.base_url + image
+        else:
+            image_url = None
+
+        self._attr_current_media = PlayerMedia(
+            uri=self.status.stream_url if self.status.stream_url else self.status.name,
+            title=self.status.name,
+            artist=self.status.artist,
+            album=self.status.album,
+            image_url=image_url,
+            duration=self.status.total_seconds if self.status.total_seconds else None,
+        )
+
     async def update_attributes(self) -> None:
         """Update the BluOS player attributes."""
-        self.logger.debug("updating %s attributes", self.player_id)
+        self.logger.debug(f"updating {self.player_id} attributes")
         if self.dynamic_poll_count > 0:
             self.dynamic_poll_count -= 1
 
+        try:
+            self.status = await self.client.status()
+            self._attr_available = True
+        except (PlayerUnreachableError, PlayerUnexpectedResponseError) as err:
+            self.logger.debug(f"Player {self.name} status check failed: {err}")
+            self._attr_available = False
+            self._attr_poll_interval = IDLE_POLL_INTERVAL
+            self.update_state()
+            return
+
+        if (
+            self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0
+        ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]:
+            self.logger.debug(f"Changing bluos poll state from {self.poll_state} to static")
+            self.poll_state = POLL_STATE_STATIC
+
+        self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state]
+
+        # Update polling interval
+        if self.poll_state != POLL_STATE_DYNAMIC:
+            if self._attr_playback_state == PlaybackState.PLAYING:
+                self.logger.debug("Setting playback poll interval")
+                self._attr_poll_interval = PLAYBACK_POLL_INTERVAL
+            else:
+                self.logger.debug("Setting idle poll interval")
+                self._attr_poll_interval = IDLE_POLL_INTERVAL
+
         self.sync_status = await self.client.sync_status()
-        self.status = await self.client.status()
+        self._attr_source_list = await self._get_bluesound_sources()
+
+        self._attr_name = self.sync_status.name
 
         # Update timing
         self._attr_elapsed_time = self.status.seconds
         self._attr_elapsed_time_last_updated = time.time()
 
         if self.sync_status.volume == -1:
+            # -1 is fixed volume
             self._attr_volume_level = 100
         else:
             self._attr_volume_level = self.sync_status.volume
         self._attr_volume_muted = self.status.mute
 
-        self.logger.log(
-            VERBOSE_LOG_LEVEL,
-            "Speaker state: %s vs reported state: %s",
-            PLAYBACK_STATE_POLL_MAP[self.status.state],
-            self._attr_playback_state,
-        )
-
-        if (
-            self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0
-        ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]:
-            self.logger.debug("Changing bluos poll state from %s to static", self.poll_state)
-            self.poll_state = POLL_STATE_STATIC
-            self._attr_poll_interval = 30
-
-        if self.status.state == "stream":
-            mass_active = self.mass.streams.base_url
-        elif self.status.state == "stream" and self.status.input_id == "input0":
-            self._attr_active_source = SOURCE_LINE_IN
-        elif self.status.state == "stream" and self.status.input_id == "Airplay":
-            self._attr_active_source = SOURCE_AIRPLAY
-        elif self.status.state == "stream" and self.status.input_id == "Spotify":
-            self._attr_active_source = SOURCE_SPOTIFY
-        elif self.status.state == "stream" and self.status.input_id == "RadioParadise":
-            self._attr_active_source = SOURCE_RADIO
-        elif self.status.state == "stream" and (mass_active not in self.status.stream_url):
-            self._attr_active_source = SOURCE_UNKNOWN
-
-        # TODO check pair status
-
-        # TODO fix pairing
-
-        # Create a lookup map of (ip, port) -> player_id for all known players.
-        player_map = {
-            (player.ip_address, player.port): player.player_id
-            for player in cast("list[BluesoundPlayer]", self.provider.players)
-        }
-
-        if self.sync_status.leader is None:
+        if not self.sync_status.leader:
+            # Player not grouped or player is group leader
             if self.sync_status.followers:
-                if len(self.sync_status.followers) > 1:
-                    self._attr_group_members = [
-                        player_map[f.ip, f.port]
-                        for f in self.sync_status.followers
-                        if (f.ip, f.port) in player_map
-                    ]
-                else:
-                    self._attr_group_members.clear()
-
-            if self.status.state == "stream":
-                self._attr_current_media = PlayerMedia(
-                    uri=self.status.stream_url,
-                    title=self.status.name,
-                    artist=self.status.artist,
-                    album=self.status.album,
-                    image_url=self.status.image,
-                )
+                self._attr_group_members = [
+                    self.provider.player_map[f.ip, f.port]
+                    for f in self.sync_status.followers
+                    if (f.ip, f.port) in self.provider.player_map
+                ]
             else:
-                self._attr_current_media = None
+                self._attr_group_members.clear()
 
+            self._resolve_source()
+            self._resolve_media()
         else:
+            # Player has group leader
             self._attr_group_members.clear()
             leader = self.sync_status.leader
-            self._attr_active_source = player_map[leader.ip, leader.port]
+            leader_player_id = self.provider.player_map.get((leader.ip, leader.port), None)
+            self._attr_active_source = leader_player_id
 
-        self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state]
         self.update_state()
 
+    async def select_source(self, source: str) -> None:
+        """
+        Handle SELECT SOURCE command on the player.
+
+        Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
+
+        :param source: The source(id) to select, as defined in the source_list.
+        """
+        source_type, source_id = source.split("-", 1)
+        if source_type == "preset":
+            await self.client.load_preset(preset_id=source_id)
+        elif source_type == "input":
+            await self.client.play_url(source_id)
+        self._set_polling_dynamic()
+        self.update_state()
+
+    async def _get_bluesound_sources(self, timeout: float | None = None) -> None:
+        """Resolve Bluesound presets and inputs to MA PlayerSource.
+
+        :param timeout: The timeout for getting inputs and presets.
+        """
+
+        def _preset_to_ma_source(preset: Preset):
+            return PlayerSource(
+                id=f"preset-{preset.id}",
+                name=f"Preset {preset.id:02d}: {preset.name}",
+                passive=False,
+                can_play_pause=True,
+                can_seek=False,
+                can_next_previous=True,
+            )
+
+        def _input_to_ma_source(bluos_input: Input):
+            return PlayerSource(
+                id=f"input-{bluos_input.url}",
+                name=f"Input: {bluos_input.text}",
+                passive=False,
+                can_play_pause=False,
+                can_seek=False,
+                can_next_previous=False,
+            )
+
+        presets = await self.client.presets(timeout=timeout)
+        inputs = await self.client.inputs(timeout=timeout)
+        inputs_as_sources = [_input_to_ma_source(bluos_input) for bluos_input in inputs]
+        return [_preset_to_ma_source(preset) for preset in presets] + inputs_as_sources
+
+    def _set_polling_dynamic(self, poll_count: int = 6, poll_interval: float = 0.5):
+        self.poll_state = POLL_STATE_DYNAMIC
+        self.dynamic_poll_count = poll_count
+        self._attr_poll_interval = poll_interval
+
     @property
     def synced_to(self) -> str | None:
         """
@@ -296,5 +440,6 @@ class BluesoundPlayer(Player):
         this should return None.
         """
         if self.sync_status.leader:
-            return self.sync_status.leader
+            leader = self.sync_status.leader
+            return self.provider.player_map.get((leader.ip, leader.port), None)
         return None
index 16fea47b97a5151c511e8acb0a1a0bdd56477830..026b8aa5e96f9e7724604933b90144a841b27b9d 100644 (file)
@@ -33,12 +33,16 @@ class BluesoundDiscoveryInfo(TypedDict):
 class BluesoundPlayerProvider(PlayerProvider):
     """Bluos compatible player provider, providing support for bluesound speakers."""
 
-    bluos_players: dict[str, BluesoundPlayer] = {}
+    player_map: dict[(str, str), str] = {}
 
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return {ProviderFeature.SYNC_PLAYERS}
+        return {
+            ProviderFeature.SYNC_PLAYERS,
+            ProviderFeature.CREATE_GROUP_PLAYER,
+            ProviderFeature.REMOVE_GROUP_PLAYER,
+        }
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -47,21 +51,14 @@ class BluesoundPlayerProvider(PlayerProvider):
         self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
     ) -> None:
         """Handle MDNS service state callback for BluOS."""
+        if state_change == ServiceStateChange.Removed:
+            # Wait for connection to fail, same as sonos.
+            return
         name = name.split(".", 1)[0]
         assert info is not None
         player_id = info.decoded_properties["mac"]
         assert player_id is not None
 
-        # Handle removed player
-        if state_change == ServiceStateChange.Removed:
-            # Check if the player manager has an existing entry for this player
-            if mass_player := self.mass.players.get(player_id):
-                # The player has become unavailable
-                self.logger.debug("Player offline: %s", mass_player.display_name)
-                mass_player._attr_available = False
-                mass_player.update_state()
-            return
-
         ip_address = get_primary_ip_address_from_zeroconf(info)
         port = get_port_from_zeroconf(info)
 
@@ -69,20 +66,17 @@ class BluesoundPlayerProvider(PlayerProvider):
         assert port is not None
 
         # Handle update of existing player
-        if bluos_player := self.bluos_players.get(player_id):
-            ip_changed = False
+        if bluos_player := self.mass.players.get(player_id):
             # Check if the IP address has changed
             if ip_address and ip_address != bluos_player.ip_address:
                 self.logger.debug(
                     "IP address for player %s updated to %s", bluos_player.name, ip_address
                 )
-                ip_changed = True  # Always recreate the player on ip changes
-
-            # Mark player as available if it was previously unavailable
-            if not bluos_player.available and not ip_changed:
+            else:
+                # IP address not changed
                 self.logger.debug("Player back online: %s", bluos_player.name)
                 bluos_player._attr_available = True
-                bluos_player.update_state()
+                await bluos_player.update_attributes()
                 return
 
         # New player discovered
@@ -99,7 +93,7 @@ class BluesoundPlayerProvider(PlayerProvider):
 
         # Create BluOS player
         bluos_player = BluesoundPlayer(self, player_id, discovery_info, name, ip_address, port)
-        self.bluos_players[player_id] = bluos_player
+        self.player_map[(ip_address, port)] = player_id
 
         # Register with Music Assistant
         await bluos_player.setup()
index 1b714931f42c8b2d4798e4754233fd4ad199b04e..4258101268962f2f09c35ec43fa5030df6c52352 100644 (file)
@@ -39,7 +39,7 @@ plexapi==4.17.1
 podcastparser==0.6.10
 propcache>=0.2.1
 py-opensonic==7.0.2
-pyblu==2.0.1
+pyblu==2.0.4
 PyChromecast==14.0.7
 pycryptodome==3.23.0
 pylast==5.5.0