/
/
/
1"""Bluesound Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, cast
8
9from music_assistant_models.config_entries import ConfigEntry
10from music_assistant_models.enums import IdentifierType, PlaybackState
11from music_assistant_models.errors import PlayerCommandFailed
12from pyblu import Player as BluosPlayer
13from pyblu import Status, SyncStatus
14from pyblu.entities import Input, PairedPlayer, Preset
15from pyblu.errors import PlayerUnexpectedResponseError, PlayerUnreachableError
16
17from music_assistant.constants import (
18 CONF_ENTRY_HTTP_PROFILE_FORCED_3,
19 CONF_ENTRY_ICY_METADATA_DEFAULT_FULL,
20 create_sample_rates_config_entry,
21)
22from music_assistant.helpers.util import is_valid_mac_address
23from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
24from music_assistant.providers.bluesound.const import (
25 IDLE_POLL_INTERVAL,
26 MAX_CONNECTING_POLLS,
27 PLAYBACK_POLL_INTERVAL,
28 PLAYBACK_STATE_MAP,
29 PLAYER_FEATURES_BASE,
30 PLAYER_SOURCE_MAP,
31 POLL_STATE_DYNAMIC,
32 POLL_STATE_STATIC,
33)
34
35if TYPE_CHECKING:
36 from music_assistant_models.config_entries import ConfigEntry
37
38 from .provider import BluesoundDiscoveryInfo, BluesoundPlayerProvider
39
40
41class BluesoundPlayer(Player):
42 """Holds the details of the (discovered) BluOS player."""
43
44 def __init__(
45 self,
46 provider: BluesoundPlayerProvider,
47 player_id: str,
48 discovery_info: BluesoundDiscoveryInfo,
49 name: str,
50 ip_address: str,
51 port: int,
52 ) -> None:
53 """Initialize the BluOS Player."""
54 super().__init__(provider, player_id)
55 self.port = port
56 self.discovery_info = discovery_info
57 self.ip_address = ip_address
58 self.connected: bool = True
59 self.client = BluosPlayer(self.ip_address, self.port, self.mass.http_session)
60 self.sync_status: SyncStatus
61 self.status: Status
62 self.poll_state = POLL_STATE_STATIC
63 self.dynamic_poll_count: int = 0
64 self._connecting_polls: int = 0
65 self._listen_task: asyncio.Task[None] | None = None
66 # Set base player attributes
67 self._attr_supported_features = PLAYER_FEATURES_BASE.copy()
68 self._attr_name = name
69 self._attr_device_info = DeviceInfo(
70 model=discovery_info.get("model", "BluOS Device"),
71 manufacturer="BluOS",
72 )
73 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
74 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
75 if mac_address := discovery_info.get("mac"):
76 if is_valid_mac_address(mac_address):
77 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
78 self._attr_available = True
79 self._attr_source_list = []
80 self._attr_needs_poll = True
81 self._attr_poll_interval = IDLE_POLL_INTERVAL
82 self._attr_can_group_with = {provider.instance_id}
83
84 @property
85 def requires_flow_mode(self) -> bool:
86 """Return if the player requires flow mode."""
87 return True
88
89 async def setup(self) -> None:
90 """Set up the player."""
91 await self.update_attributes()
92 await self.mass.players.register_or_update(self)
93
94 async def get_config_entries(self) -> list[ConfigEntry]:
95 """Return all (provider/player specific) Config Entries for the player."""
96 return [
97 # BluOS keeps looping the audio on the other HTTP profiles,
98 # so this is not a choice we can leave to the user.
99 CONF_ENTRY_HTTP_PROFILE_FORCED_3,
100 create_sample_rates_config_entry(
101 max_sample_rate=192000,
102 safe_max_sample_rate=192000,
103 max_bit_depth=24,
104 safe_max_bit_depth=24,
105 ),
106 CONF_ENTRY_ICY_METADATA_DEFAULT_FULL,
107 ]
108
109 async def disconnect(self) -> None:
110 """Disconnect the BluOS client and cleanup."""
111 if self._listen_task and not self._listen_task.done():
112 self._listen_task.cancel()
113 if self.client:
114 await self.client.close()
115 self.connected = False
116 self.logger.debug("Disconnected from player API")
117
118 async def stop(self) -> None:
119 """Send STOP command to BluOS player."""
120 play_state = await self.client.stop(timeout=1)
121 if play_state == "stop":
122 self._set_polling_dynamic()
123 self._attr_playback_state = PlaybackState.IDLE
124 self._attr_current_media = None
125 self.update_state()
126
127 async def play(self) -> None:
128 """Send PLAY command to BluOS player."""
129 play_state = await self.client.play(timeout=1)
130 if play_state == "stream":
131 self._set_polling_dynamic()
132 self._attr_playback_state = PlaybackState.PLAYING
133 self.update_state()
134
135 async def pause(self) -> None:
136 """Send PAUSE command to BluOS player."""
137 play_state = await self.client.pause(timeout=1)
138 if play_state == "pause":
139 self._set_polling_dynamic()
140 self.logger.debug("Set BluOS state to %s", play_state)
141 self._attr_playback_state = PlaybackState.PAUSED
142 self.update_state()
143
144 async def volume_set(self, volume_level: int) -> None:
145 """Send VOLUME_SET command to BluOS player."""
146 volume_response = await self.client.volume(level=volume_level, timeout=1)
147 self.logger.debug(
148 "Set BluOS speaker volume to %s, response: %s", volume_level, volume_response
149 )
150 self._attr_volume_level = volume_level
151 self._set_polling_dynamic()
152 self.update_state()
153
154 async def volume_mute(self, muted: bool) -> None:
155 """Send VOLUME MUTE command to BluOS player."""
156 await self.client.volume(mute=muted)
157 self._attr_volume_muted = muted
158 self._set_polling_dynamic()
159 self.update_state()
160
161 async def next_track(self) -> None:
162 """Send NEXT TRACK command to BluOS player."""
163 await self.client.skip()
164 self._set_polling_dynamic()
165 self.update_state()
166
167 async def previous_track(self) -> None:
168 """Send PREVIOUS TRACK command to BluOS player."""
169 await self.client.back()
170 self._set_polling_dynamic()
171 self.update_state()
172
173 async def seek(self, position: int) -> None:
174 """Send PLAY command to BluOS player."""
175 play_state = await self.client.play(seek=position, timeout=1)
176 if play_state in ("stream", "play"):
177 self._set_polling_dynamic()
178 self._attr_elapsed_time = position
179 self._attr_elapsed_time_last_updated = time.time()
180 self._attr_playback_state = PlaybackState.PLAYING
181 self.update_state()
182
183 async def play_media(self, media: PlayerMedia) -> None:
184 """Handle PLAY MEDIA for BluOS player using the provided URL."""
185 self.logger.debug("Play_media called")
186 self.logger.debug(media)
187 url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
188 play_state = await self.client.play_url(url, timeout=1)
189
190 # Enable dynamic polling
191 if play_state == "stream":
192 self._set_polling_dynamic()
193 self._attr_playback_state = PlaybackState.PLAYING
194
195 self.logger.debug("Set BluOS state to %s", play_state)
196
197 # Optionally, handle the playback_state or additional logic here
198 if play_state in ("PlayerUnexpectedResponseError", "PlayerUnreachableError"):
199 raise PlayerCommandFailed("Failed to start playback.")
200
201 # Optimistically update state
202 self._attr_current_media = media
203 self._attr_elapsed_time = 0
204 self._attr_elapsed_time_last_updated = time.time()
205 self.update_state()
206
207 async def set_members(
208 self,
209 player_ids_to_add: list[str] | None = None,
210 player_ids_to_remove: list[str] | None = None,
211 ) -> None:
212 """Handle GROUP command for BluOS player."""
213 if not player_ids_to_add and not player_ids_to_remove:
214 # nothing to do
215 return
216
217 def player_id_to_paired_player(player_id: str) -> PairedPlayer:
218 client = cast(
219 "BluesoundPlayer",
220 self.mass.players.get_player(player_id, raise_unavailable=True),
221 )
222 return PairedPlayer(client.ip_address, client.port)
223
224 if player_ids_to_remove:
225 for player_id in player_ids_to_remove:
226 paired_player = player_id_to_paired_player(player_id)
227 try:
228 self.sync_status = await self.client.remove_follower(
229 paired_player.ip, paired_player.port, timeout=3
230 )
231 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
232 self.logger.debug(f"Could not remove players: {err!s}")
233 continue
234 removed_player = self.mass.players.get_player(player_id)
235 if removed_player:
236 cast("BluesoundPlayer", removed_player)._set_polling_dynamic()
237 removed_player._attr_current_media = None
238 removed_player.update_state()
239
240 if player_ids_to_add:
241 for player_id in player_ids_to_add:
242 paired_player = player_id_to_paired_player(player_id)
243 try:
244 await self.client.add_follower(paired_player.ip, paired_player.port, timeout=5)
245 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
246 self.logger.debug(f"Could not add player {paired_player}: {err!s}")
247 continue
248 self._attr_group_members.append(player_id)
249 added_player = self.mass.players.get_player(player_id)
250 if added_player:
251 cast("BluesoundPlayer", added_player)._set_polling_dynamic()
252 added_player.update_state()
253
254 self._set_polling_dynamic()
255 self.update_state()
256
257 async def ungroup(self) -> None:
258 """Handle UNGROUP command for BluOS player."""
259 if not (leader := self.sync_status.leader):
260 return
261 provider = cast("BluesoundPlayerProvider", self.provider)
262 if (leader_player_id := provider.player_map.get((leader.ip, leader.port))) and (
263 leader_player := self.mass.players.get_player(leader_player_id)
264 ):
265 await leader_player.set_members(None, [self.player_id])
266
267 async def poll(self) -> None:
268 """Poll player for state updates."""
269 await self.update_attributes()
270
271 def _resolve_source(self) -> None:
272 """Check PLAYER_SOURCE_MAP for known sources, otherwise create a new source."""
273
274 def resolve_analog_digital_source(source_name: str) -> PlayerSource:
275 """Resolve Analog/Digital Source here, avoid duplicate entries in PLAYER_SOURCE_MAP."""
276 return PlayerSource(
277 id=source_name,
278 name=source_name,
279 passive=True,
280 can_play_pause=False,
281 can_next_previous=False,
282 can_seek=False,
283 )
284
285 self.logger.debug(self.status)
286 mass_url = self.mass.streams.base_url
287 if self.status.stream_url and mass_url in self.status.stream_url:
288 self._attr_active_source = None
289 elif player_source := PLAYER_SOURCE_MAP.get(cast("str", self.status.input_id)):
290 self._attr_active_source = self.status.input_id
291 self._attr_source_list.append(player_source)
292 elif player_source := PLAYER_SOURCE_MAP.get(cast("str", self.status.service)):
293 self._attr_active_source = self.status.service
294 self._attr_source_list.append(player_source)
295 elif player_source := PLAYER_SOURCE_MAP.get(cast("str", self.status.name)):
296 self._attr_active_source = self.status.name
297 self._attr_source_list.append(player_source)
298 elif (name := self.status.name) and ("Analog Input" in name or "Digital Input" in name):
299 player_source = resolve_analog_digital_source(name)
300 self._attr_active_source = name
301 self._attr_source_list.append(player_source)
302 else:
303 self._attr_active_source = self.status.input_id
304 self.logger.debug("Appending new PlayerSource")
305 self._attr_source_list.append(
306 PlayerSource(
307 id=cast("str", self.status.input_id),
308 name=cast("str", self.status.input_id),
309 passive=True,
310 can_play_pause=True,
311 can_seek=self.status.can_seek,
312 can_next_previous=True,
313 )
314 )
315
316 def _resolve_media(self) -> None:
317 """Resolve currently playing media dependent on available status attributes."""
318 image = self.status.image
319 if image:
320 image_url = image if image.startswith("http") else self.client.base_url + image
321 else:
322 image_url = None
323
324 self._attr_current_media = PlayerMedia(
325 uri=cast("str", self.status.stream_url or self.status.name),
326 title=self.status.name,
327 artist=self.status.artist,
328 album=self.status.album,
329 image_url=image_url,
330 # keep float as-is; int() here would change the stored precision
331 duration=cast("int | None", self.status.total_seconds or None),
332 )
333
334 def _resolve_playback_state(self) -> PlaybackState:
335 """Resolve the playback state from the reported BluOS transport state."""
336 # BluOS reports 'connecting' whenever it is (re)filling its buffer, including
337 # while it plays out the tail of a stream that stopped sending. Taking that as
338 # idle would end the queue while the player is still making sound, so we hold on
339 # to the playing state for a few polls. Beyond that the player is not buffering
340 # but stuck, and reporting it idle is what lets playback recover.
341 if self.status.state == "connecting" and self._attr_playback_state == PlaybackState.PLAYING:
342 self._connecting_polls += 1
343 if self._connecting_polls <= MAX_CONNECTING_POLLS:
344 return PlaybackState.PLAYING
345 else:
346 self._connecting_polls = 0
347 return PLAYBACK_STATE_MAP[self.status.state]
348
349 async def update_attributes(self) -> None:
350 """Update the BluOS player attributes."""
351 self.logger.debug(f"updating {self.player_id} attributes")
352 if self.dynamic_poll_count > 0:
353 self.dynamic_poll_count -= 1
354
355 try:
356 self.status = await self.client.status()
357 self._attr_available = True
358 except (PlayerUnreachableError, PlayerUnexpectedResponseError) as err:
359 self.logger.debug(f"Player {self.name} status check failed: {err}")
360 self._attr_available = False
361 self._attr_poll_interval = IDLE_POLL_INTERVAL
362 self.update_state()
363 return
364
365 if self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0:
366 self.logger.debug(f"Changing bluos poll state from {self.poll_state} to static")
367 self.poll_state = POLL_STATE_STATIC
368
369 self._attr_playback_state = self._resolve_playback_state()
370
371 # Update polling interval
372 if self.poll_state != POLL_STATE_DYNAMIC:
373 if self._attr_playback_state == PlaybackState.PLAYING:
374 self.logger.debug("Setting playback poll interval")
375 self._attr_poll_interval = PLAYBACK_POLL_INTERVAL
376 else:
377 self.logger.debug("Setting idle poll interval")
378 self._attr_poll_interval = IDLE_POLL_INTERVAL
379
380 self.sync_status = await self.client.sync_status()
381 self._attr_source_list = await self._get_bluesound_sources()
382
383 self._attr_name = self.sync_status.name
384
385 # Update timing
386 self._attr_elapsed_time = self.status.seconds
387 self._attr_elapsed_time_last_updated = time.time()
388
389 # Only allow volume reports when playing Bluesound as native input
390 # E.g. Airplay reports are on a different scale, causing volume jumps
391 if self.status.input_id is None:
392 if self.sync_status.volume == -1:
393 # -1 is fixed volume
394 self._attr_volume_level = 100
395 else:
396 self._attr_volume_level = self.sync_status.volume
397 self._attr_volume_muted = self.status.mute
398
399 self.logger.debug(
400 f"Volume from sync_status: {self.sync_status.volume}, from status: {self.status.volume}"
401 )
402
403 provider = cast("BluesoundPlayerProvider", self.provider)
404 if not self.sync_status.leader:
405 # Player not grouped or player is group leader
406 if self.sync_status.followers:
407 self._attr_group_members = [
408 provider.player_map[f.ip, f.port]
409 for f in self.sync_status.followers
410 if (f.ip, f.port) in provider.player_map
411 ]
412 else:
413 self._attr_group_members.clear()
414
415 self._resolve_source()
416 self._resolve_media()
417 else:
418 # Player has group leader
419 self._attr_group_members.clear()
420 leader = self.sync_status.leader
421 leader_player_id = provider.player_map.get((leader.ip, leader.port), None)
422 self._attr_active_source = leader_player_id
423
424 self.update_state()
425
426 async def select_source(self, source: str) -> None:
427 """
428 Handle SELECT SOURCE command on the player.
429
430 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
431
432 :param source: The source(id) to select, as defined in the source_list.
433 """
434 source_type, source_id = source.split("-", 1)
435 if source_type == "preset":
436 await self.client.load_preset(preset_id=int(source_id))
437 elif source_type == "input":
438 await self.client.play_url(source_id)
439 self._set_polling_dynamic()
440 self.update_state()
441
442 async def _get_bluesound_sources(self, timeout: float | None = None) -> list[PlayerSource]:
443 """
444 Resolve Bluesound presets and inputs to MA PlayerSource.
445
446 :param timeout: The timeout for getting inputs and presets.
447 """
448
449 def _preset_to_ma_source(preset: Preset) -> PlayerSource:
450 return PlayerSource(
451 id=f"preset-{preset.id}",
452 name=f"Preset {preset.id:02d}: {preset.name}",
453 passive=False,
454 can_play_pause=True,
455 can_seek=False,
456 can_next_previous=True,
457 )
458
459 def _input_to_ma_source(bluos_input: Input) -> PlayerSource:
460 return PlayerSource(
461 id=f"input-{bluos_input.url}",
462 name=f"Input: {bluos_input.text}",
463 passive=False,
464 can_play_pause=False,
465 can_seek=False,
466 can_next_previous=False,
467 )
468
469 presets = await self.client.presets(timeout=timeout)
470 inputs = await self.client.inputs(timeout=timeout)
471 inputs_as_sources = [_input_to_ma_source(bluos_input) for bluos_input in inputs]
472 return [_preset_to_ma_source(preset) for preset in presets] + inputs_as_sources
473
474 def _set_polling_dynamic(self, poll_count: int = 6, poll_interval: float = 0.5) -> None:
475 self.poll_state = POLL_STATE_DYNAMIC
476 self.dynamic_poll_count = poll_count
477 # sub-second interval for dynamic polling; base attr is typed int
478 self._attr_poll_interval = poll_interval # type: ignore[assignment]
479
480 @property
481 def synced_to(self) -> str | None:
482 """
483 Return the id of the player this player is synced to (sync leader).
484
485 If this player is not synced to another player (or is the sync leader itself),
486 this should return None.
487 If it is part of a (permanent) group, this should also return None.
488 """
489 if self.sync_status.leader:
490 leader = self.sync_status.leader
491 return cast("BluesoundPlayerProvider", self.provider).player_map.get(
492 (leader.ip, leader.port), None
493 )
494 return None
495