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