/
/
/
1"""Bluesound Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING
8
9from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
10from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature
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_DEFAULT_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 PLAYBACK_POLL_INTERVAL,
27 PLAYBACK_STATE_MAP,
28 PLAYBACK_STATE_POLL_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, ConfigValueType
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._listen_task: asyncio.Task | None = None
65 # Set base player attributes
66 self._attr_supported_features = PLAYER_FEATURES_BASE.copy()
67 self._attr_name = name
68 self._attr_device_info = DeviceInfo(
69 model=discovery_info.get("model", "BluOS Device"),
70 manufacturer="BluOS",
71 )
72 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
73 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
74 if mac_address := discovery_info.get("mac"):
75 if is_valid_mac_address(mac_address):
76 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
77 self._attr_available = True
78 self._attr_source_list = []
79 self._attr_needs_poll = True
80 self._attr_poll_interval = IDLE_POLL_INTERVAL
81 self._attr_can_group_with = {provider.instance_id}
82
83 @property
84 def requires_flow_mode(self) -> bool:
85 """Return if the player requires flow mode."""
86 return True
87
88 async def setup(self) -> None:
89 """Set up the player."""
90 # Add volume support if available
91 await self.update_attributes()
92 if self.discovery_info.get("zs"):
93 self._attr_supported_features.add(PlayerFeature.VOLUME_SET)
94 await self.mass.players.register_or_update(self)
95
96 async def get_config_entries(
97 self,
98 action: str | None = None,
99 values: dict[str, ConfigValueType] | None = None,
100 ) -> list[ConfigEntry]:
101 """Return all (provider/player specific) Config Entries for the player."""
102 return [
103 CONF_ENTRY_HTTP_PROFILE_DEFAULT_3,
104 create_sample_rates_config_entry(
105 max_sample_rate=192000,
106 safe_max_sample_rate=192000,
107 max_bit_depth=24,
108 safe_max_bit_depth=24,
109 ),
110 CONF_ENTRY_ICY_METADATA_DEFAULT_FULL,
111 ]
112
113 async def disconnect(self) -> None:
114 """Disconnect the BluOS client and cleanup."""
115 if self._listen_task and not self._listen_task.done():
116 self._listen_task.cancel()
117 if self.client:
118 await self.client.close()
119 self.connected = False
120 self.logger.debug("Disconnected from player API")
121
122 async def stop(self) -> None:
123 """Send STOP command to BluOS player."""
124 play_state = await self.client.stop(timeout=1)
125 if play_state == "stop":
126 self._set_polling_dynamic()
127 self._attr_playback_state = PlaybackState.IDLE
128 self._attr_current_media = None
129 self.update_state()
130
131 async def play(self) -> None:
132 """Send PLAY command to BluOS player."""
133 play_state = await self.client.play(timeout=1)
134 if play_state == "stream":
135 self._set_polling_dynamic()
136 self._attr_playback_state = PlaybackState.PLAYING
137 self.update_state()
138
139 async def pause(self) -> None:
140 """Send PAUSE command to BluOS player."""
141 play_state = await self.client.pause(timeout=1)
142 if play_state == "pause":
143 self._set_polling_dynamic()
144 self.logger.debug("Set BluOS state to %s", play_state)
145 self._attr_playback_state = PlaybackState.PAUSED
146 self.update_state()
147
148 async def volume_set(self, volume_level: int) -> None:
149 """Send VOLUME_SET command to BluOS player."""
150 await self.client.volume(level=volume_level, timeout=1)
151 self.logger.debug("Set BluOS speaker volume to %s", volume_level)
152 self._attr_volume_level = volume_level
153 self.update_state()
154
155 async def volume_mute(self, muted: bool) -> None:
156 """Send VOLUME MUTE command to BluOS player."""
157 await self.client.volume(mute=muted)
158 self._attr_volume_muted = muted
159 self.update_state()
160
161 async def next_track(self):
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):
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) -> 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 = self.mass.players.get_player(player_id, raise_unavailable=True)
219 return PairedPlayer(client.ip_address, client.port)
220
221 if player_ids_to_remove:
222 for player_id in player_ids_to_remove:
223 paired_player = player_id_to_paired_player(player_id)
224 try:
225 self.sync_status = await self.client.remove_follower(
226 paired_player.ip, paired_player.port, timeout=3
227 )
228 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
229 self.logger.debug(f"Could not remove players: {err!s}")
230 continue
231 removed_player = self.mass.players.get_player(player_id)
232 if removed_player:
233 removed_player._set_polling_dynamic()
234 removed_player._attr_current_media = None
235 removed_player.update_state()
236
237 if player_ids_to_add:
238 for player_id in player_ids_to_add:
239 paired_player = player_id_to_paired_player(player_id)
240 try:
241 await self.client.add_follower(paired_player.ip, paired_player.port, timeout=5)
242 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
243 self.logger.debug(f"Could not add player {paired_player}: {err!s}")
244 continue
245 self._attr_group_members.append(player_id)
246 added_player = self.mass.players.get_player(player_id)
247 if added_player:
248 added_player._set_polling_dynamic()
249 added_player.update_state()
250
251 self._set_polling_dynamic()
252 self.update_state()
253
254 async def ungroup(self) -> None:
255 """Handle UNGROUP command for BluOS player."""
256 leader = self.client.leader
257 leader_player_id = self.client.provider.player_map((leader.ip, leader.port))
258 await self.mass.players.get_player(leader_player_id).set_members(None, [self.player_id])
259
260 async def poll(self) -> None:
261 """Poll player for state updates."""
262 await self.update_attributes()
263
264 def _resolve_source(self) -> None:
265 """Check PLAYER_SOURCE_MAP for known sources, otherwise create a new source."""
266
267 def resolve_analog_digital_source(source_name) -> PlayerMedia:
268 """Resolve Analog/Digital Source here, avoid duplicate entries in PLAYER_SOURCE_MAP."""
269 return PlayerSource(
270 id=source_name,
271 name=source_name,
272 passive=True,
273 can_play_pause=False,
274 can_next_previous=False,
275 can_seek=False,
276 )
277
278 self.logger.debug(self.status)
279 mass_url = self.mass.streams.base_url
280 if self.status.stream_url and mass_url in self.status.stream_url:
281 self._attr_active_source = None
282 elif player_source := PLAYER_SOURCE_MAP.get(self.status.input_id):
283 self._attr_active_source = self.status.input_id
284 self._attr_source_list.append(player_source)
285 elif player_source := PLAYER_SOURCE_MAP.get(self.status.service):
286 self._attr_active_source = self.status.service
287 self._attr_source_list.append(player_source)
288 elif player_source := PLAYER_SOURCE_MAP.get(self.status.name):
289 self._attr_active_source = self.status.name
290 self._attr_source_list.append(player_source)
291 elif (name := self.status.name) and ("Analog Input" in name or "Digital Input" in name):
292 player_source = resolve_analog_digital_source(name)
293 self._attr_active_source = name
294 self._attr_source_list.append(player_source)
295 else:
296 self._attr_active_source = self.status.input_id
297 self.logger.debug("Appending new PlayerSource")
298 self._attr_source_list.append(
299 PlayerSource(
300 id=self.status.input_id,
301 name=self.status.input_id,
302 passive=True,
303 can_play_pause=True,
304 can_seek=self.status.can_seek,
305 can_next_previous=True,
306 )
307 )
308
309 def _resolve_media(self) -> None:
310 """Resolve currently playing media dependent on available status attributes."""
311 image = self.status.image
312 if image:
313 image_url = image if image.startswith("http") else self.client.base_url + image
314 else:
315 image_url = None
316
317 self._attr_current_media = PlayerMedia(
318 uri=self.status.stream_url if self.status.stream_url else self.status.name,
319 title=self.status.name,
320 artist=self.status.artist,
321 album=self.status.album,
322 image_url=image_url,
323 duration=self.status.total_seconds if self.status.total_seconds else None,
324 )
325
326 async def update_attributes(self) -> None:
327 """Update the BluOS player attributes."""
328 self.logger.debug(f"updating {self.player_id} attributes")
329 if self.dynamic_poll_count > 0:
330 self.dynamic_poll_count -= 1
331
332 try:
333 self.status = await self.client.status()
334 self._attr_available = True
335 except (PlayerUnreachableError, PlayerUnexpectedResponseError) as err:
336 self.logger.debug(f"Player {self.name} status check failed: {err}")
337 self._attr_available = False
338 self._attr_poll_interval = IDLE_POLL_INTERVAL
339 self.update_state()
340 return
341
342 if (
343 self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0
344 ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]:
345 self.logger.debug(f"Changing bluos poll state from {self.poll_state} to static")
346 self.poll_state = POLL_STATE_STATIC
347
348 self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state]
349
350 # Update polling interval
351 if self.poll_state != POLL_STATE_DYNAMIC:
352 if self._attr_playback_state == PlaybackState.PLAYING:
353 self.logger.debug("Setting playback poll interval")
354 self._attr_poll_interval = PLAYBACK_POLL_INTERVAL
355 else:
356 self.logger.debug("Setting idle poll interval")
357 self._attr_poll_interval = IDLE_POLL_INTERVAL
358
359 self.sync_status = await self.client.sync_status()
360 self._attr_source_list = await self._get_bluesound_sources()
361
362 self._attr_name = self.sync_status.name
363
364 # Update timing
365 self._attr_elapsed_time = self.status.seconds
366 self._attr_elapsed_time_last_updated = time.time()
367
368 if self.sync_status.volume == -1:
369 # -1 is fixed volume
370 self._attr_volume_level = 100
371 else:
372 self._attr_volume_level = self.sync_status.volume
373 self._attr_volume_muted = self.status.mute
374
375 if not self.sync_status.leader:
376 # Player not grouped or player is group leader
377 if self.sync_status.followers:
378 self._attr_group_members = [
379 self.provider.player_map[f.ip, f.port]
380 for f in self.sync_status.followers
381 if (f.ip, f.port) in self.provider.player_map
382 ]
383 else:
384 self._attr_group_members.clear()
385
386 self._resolve_source()
387 self._resolve_media()
388 else:
389 # Player has group leader
390 self._attr_group_members.clear()
391 leader = self.sync_status.leader
392 leader_player_id = self.provider.player_map.get((leader.ip, leader.port), None)
393 self._attr_active_source = leader_player_id
394
395 self.update_state()
396
397 async def select_source(self, source: str) -> None:
398 """
399 Handle SELECT SOURCE command on the player.
400
401 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
402
403 :param source: The source(id) to select, as defined in the source_list.
404 """
405 source_type, source_id = source.split("-", 1)
406 if source_type == "preset":
407 await self.client.load_preset(preset_id=source_id)
408 elif source_type == "input":
409 await self.client.play_url(source_id)
410 self._set_polling_dynamic()
411 self.update_state()
412
413 async def _get_bluesound_sources(self, timeout: float | None = None) -> None:
414 """Resolve Bluesound presets and inputs to MA PlayerSource.
415
416 :param timeout: The timeout for getting inputs and presets.
417 """
418
419 def _preset_to_ma_source(preset: Preset):
420 return PlayerSource(
421 id=f"preset-{preset.id}",
422 name=f"Preset {preset.id:02d}: {preset.name}",
423 passive=False,
424 can_play_pause=True,
425 can_seek=False,
426 can_next_previous=True,
427 )
428
429 def _input_to_ma_source(bluos_input: Input):
430 return PlayerSource(
431 id=f"input-{bluos_input.url}",
432 name=f"Input: {bluos_input.text}",
433 passive=False,
434 can_play_pause=False,
435 can_seek=False,
436 can_next_previous=False,
437 )
438
439 presets = await self.client.presets(timeout=timeout)
440 inputs = await self.client.inputs(timeout=timeout)
441 inputs_as_sources = [_input_to_ma_source(bluos_input) for bluos_input in inputs]
442 return [_preset_to_ma_source(preset) for preset in presets] + inputs_as_sources
443
444 def _set_polling_dynamic(self, poll_count: int = 6, poll_interval: float = 0.5):
445 self.poll_state = POLL_STATE_DYNAMIC
446 self.dynamic_poll_count = poll_count
447 self._attr_poll_interval = poll_interval
448
449 @property
450 def synced_to(self) -> str | None:
451 """
452 Return the id of the player this player is synced to (sync leader).
453
454 If this player is not synced to another player (or is the sync leader itself),
455 this should return None.
456 If it is part of a (permanent) group, this should also return None.
457 """
458 if self.sync_status.leader:
459 leader = self.sync_status.leader
460 return self.provider.player_map.get((leader.ip, leader.port), None)
461 return None
462