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