/
/
/
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._set_polling_dynamic()
154 self.update_state()
155
156 async def volume_mute(self, muted: bool) -> None:
157 """Send VOLUME MUTE command to BluOS player."""
158 await self.client.volume(mute=muted)
159 self._attr_volume_muted = muted
160 self._set_polling_dynamic()
161 self.update_state()
162
163 async def next_track(self):
164 """Send NEXT TRACK command to BluOS player."""
165 await self.client.skip()
166 self._set_polling_dynamic()
167 self.update_state()
168
169 async def previous_track(self):
170 """Send PREVIOUS TRACK command to BluOS player."""
171 await self.client.back()
172 self._set_polling_dynamic()
173 self.update_state()
174
175 async def seek(self, position) -> None:
176 """Send PLAY command to BluOS player."""
177 play_state = await self.client.play(seek=position, timeout=1)
178 if play_state in ("stream", "play"):
179 self._set_polling_dynamic()
180 self._attr_elapsed_time = position
181 self._attr_elapsed_time_last_updated = time.time()
182 self._attr_playback_state = PlaybackState.PLAYING
183 self.update_state()
184
185 async def play_media(self, media: PlayerMedia) -> None:
186 """Handle PLAY MEDIA for BluOS player using the provided URL."""
187 self.logger.debug("Play_media called")
188 self.logger.debug(media)
189 url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
190 play_state = await self.client.play_url(url, timeout=1)
191
192 # Enable dynamic polling
193 if play_state == "stream":
194 self._set_polling_dynamic()
195 self._attr_playback_state = PlaybackState.PLAYING
196
197 self.logger.debug("Set BluOS state to %s", play_state)
198
199 # Optionally, handle the playback_state or additional logic here
200 if play_state in ("PlayerUnexpectedResponseError", "PlayerUnreachableError"):
201 raise PlayerCommandFailed("Failed to start playback.")
202
203 # Optimistically update state
204 self._attr_current_media = media
205 self._attr_elapsed_time = 0
206 self._attr_elapsed_time_last_updated = time.time()
207 self.update_state()
208
209 async def set_members(
210 self,
211 player_ids_to_add: list[str] | None = None,
212 player_ids_to_remove: list[str] | None = None,
213 ) -> None:
214 """Handle GROUP command for BluOS player."""
215 if not player_ids_to_add and not player_ids_to_remove:
216 # nothing to do
217 return
218
219 def player_id_to_paired_player(player_id: str) -> PairedPlayer:
220 client = self.mass.players.get_player(player_id, raise_unavailable=True)
221 return PairedPlayer(client.ip_address, client.port)
222
223 if player_ids_to_remove:
224 for player_id in player_ids_to_remove:
225 paired_player = player_id_to_paired_player(player_id)
226 try:
227 self.sync_status = await self.client.remove_follower(
228 paired_player.ip, paired_player.port, timeout=3
229 )
230 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
231 self.logger.debug(f"Could not remove players: {err!s}")
232 continue
233 removed_player = self.mass.players.get_player(player_id)
234 if removed_player:
235 removed_player._set_polling_dynamic()
236 removed_player._attr_current_media = None
237 removed_player.update_state()
238
239 if player_ids_to_add:
240 for player_id in player_ids_to_add:
241 paired_player = player_id_to_paired_player(player_id)
242 try:
243 await self.client.add_follower(paired_player.ip, paired_player.port, timeout=5)
244 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
245 self.logger.debug(f"Could not add player {paired_player}: {err!s}")
246 continue
247 self._attr_group_members.append(player_id)
248 added_player = self.mass.players.get_player(player_id)
249 if added_player:
250 added_player._set_polling_dynamic()
251 added_player.update_state()
252
253 self._set_polling_dynamic()
254 self.update_state()
255
256 async def ungroup(self) -> None:
257 """Handle UNGROUP command for BluOS player."""
258 leader = self.client.leader
259 leader_player_id = self.client.provider.player_map((leader.ip, leader.port))
260 await self.mass.players.get_player(leader_player_id).set_members(None, [self.player_id])
261
262 async def poll(self) -> None:
263 """Poll player for state updates."""
264 await self.update_attributes()
265
266 def _resolve_source(self) -> None:
267 """Check PLAYER_SOURCE_MAP for known sources, otherwise create a new source."""
268
269 def resolve_analog_digital_source(source_name) -> PlayerMedia:
270 """Resolve Analog/Digital Source here, avoid duplicate entries in PLAYER_SOURCE_MAP."""
271 return PlayerSource(
272 id=source_name,
273 name=source_name,
274 passive=True,
275 can_play_pause=False,
276 can_next_previous=False,
277 can_seek=False,
278 )
279
280 self.logger.debug(self.status)
281 mass_url = self.mass.streams.base_url
282 if self.status.stream_url and mass_url in self.status.stream_url:
283 self._attr_active_source = None
284 elif player_source := PLAYER_SOURCE_MAP.get(self.status.input_id):
285 self._attr_active_source = self.status.input_id
286 self._attr_source_list.append(player_source)
287 elif player_source := PLAYER_SOURCE_MAP.get(self.status.service):
288 self._attr_active_source = self.status.service
289 self._attr_source_list.append(player_source)
290 elif player_source := PLAYER_SOURCE_MAP.get(self.status.name):
291 self._attr_active_source = self.status.name
292 self._attr_source_list.append(player_source)
293 elif (name := self.status.name) and ("Analog Input" in name or "Digital Input" in name):
294 player_source = resolve_analog_digital_source(name)
295 self._attr_active_source = name
296 self._attr_source_list.append(player_source)
297 else:
298 self._attr_active_source = self.status.input_id
299 self.logger.debug("Appending new PlayerSource")
300 self._attr_source_list.append(
301 PlayerSource(
302 id=self.status.input_id,
303 name=self.status.input_id,
304 passive=True,
305 can_play_pause=True,
306 can_seek=self.status.can_seek,
307 can_next_previous=True,
308 )
309 )
310
311 def _resolve_media(self) -> None:
312 """Resolve currently playing media dependent on available status attributes."""
313 image = self.status.image
314 if image:
315 image_url = image if image.startswith("http") else self.client.base_url + image
316 else:
317 image_url = None
318
319 self._attr_current_media = PlayerMedia(
320 uri=self.status.stream_url if self.status.stream_url else self.status.name,
321 title=self.status.name,
322 artist=self.status.artist,
323 album=self.status.album,
324 image_url=image_url,
325 duration=self.status.total_seconds if self.status.total_seconds else None,
326 )
327
328 async def update_attributes(self) -> None:
329 """Update the BluOS player attributes."""
330 self.logger.debug(f"updating {self.player_id} attributes")
331 if self.dynamic_poll_count > 0:
332 self.dynamic_poll_count -= 1
333
334 try:
335 self.status = await self.client.status()
336 self._attr_available = True
337 except (PlayerUnreachableError, PlayerUnexpectedResponseError) as err:
338 self.logger.debug(f"Player {self.name} status check failed: {err}")
339 self._attr_available = False
340 self._attr_poll_interval = IDLE_POLL_INTERVAL
341 self.update_state()
342 return
343
344 if (
345 self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0
346 ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]:
347 self.logger.debug(f"Changing bluos poll state from {self.poll_state} to static")
348 self.poll_state = POLL_STATE_STATIC
349
350 self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state]
351
352 # Update polling interval
353 if self.poll_state != POLL_STATE_DYNAMIC:
354 if self._attr_playback_state == PlaybackState.PLAYING:
355 self.logger.debug("Setting playback poll interval")
356 self._attr_poll_interval = PLAYBACK_POLL_INTERVAL
357 else:
358 self.logger.debug("Setting idle poll interval")
359 self._attr_poll_interval = IDLE_POLL_INTERVAL
360
361 self.sync_status = await self.client.sync_status()
362 self._attr_source_list = await self._get_bluesound_sources()
363
364 self._attr_name = self.sync_status.name
365
366 # Update timing
367 self._attr_elapsed_time = self.status.seconds
368 self._attr_elapsed_time_last_updated = time.time()
369
370 if self.sync_status.volume == -1:
371 # -1 is fixed volume
372 self._attr_volume_level = 100
373 else:
374 self._attr_volume_level = self.sync_status.volume
375 self._attr_volume_muted = self.status.mute
376
377 if not self.sync_status.leader:
378 # Player not grouped or player is group leader
379 if self.sync_status.followers:
380 self._attr_group_members = [
381 self.provider.player_map[f.ip, f.port]
382 for f in self.sync_status.followers
383 if (f.ip, f.port) in self.provider.player_map
384 ]
385 else:
386 self._attr_group_members.clear()
387
388 self._resolve_source()
389 self._resolve_media()
390 else:
391 # Player has group leader
392 self._attr_group_members.clear()
393 leader = self.sync_status.leader
394 leader_player_id = self.provider.player_map.get((leader.ip, leader.port), None)
395 self._attr_active_source = leader_player_id
396
397 self.update_state()
398
399 async def select_source(self, source: str) -> None:
400 """
401 Handle SELECT SOURCE command on the player.
402
403 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
404
405 :param source: The source(id) to select, as defined in the source_list.
406 """
407 source_type, source_id = source.split("-", 1)
408 if source_type == "preset":
409 await self.client.load_preset(preset_id=source_id)
410 elif source_type == "input":
411 await self.client.play_url(source_id)
412 self._set_polling_dynamic()
413 self.update_state()
414
415 async def _get_bluesound_sources(self, timeout: float | None = None) -> None:
416 """Resolve Bluesound presets and inputs to MA PlayerSource.
417
418 :param timeout: The timeout for getting inputs and presets.
419 """
420
421 def _preset_to_ma_source(preset: Preset):
422 return PlayerSource(
423 id=f"preset-{preset.id}",
424 name=f"Preset {preset.id:02d}: {preset.name}",
425 passive=False,
426 can_play_pause=True,
427 can_seek=False,
428 can_next_previous=True,
429 )
430
431 def _input_to_ma_source(bluos_input: Input):
432 return PlayerSource(
433 id=f"input-{bluos_input.url}",
434 name=f"Input: {bluos_input.text}",
435 passive=False,
436 can_play_pause=False,
437 can_seek=False,
438 can_next_previous=False,
439 )
440
441 presets = await self.client.presets(timeout=timeout)
442 inputs = await self.client.inputs(timeout=timeout)
443 inputs_as_sources = [_input_to_ma_source(bluos_input) for bluos_input in inputs]
444 return [_preset_to_ma_source(preset) for preset in presets] + inputs_as_sources
445
446 def _set_polling_dynamic(self, poll_count: int = 6, poll_interval: float = 0.5):
447 self.poll_state = POLL_STATE_DYNAMIC
448 self.dynamic_poll_count = poll_count
449 self._attr_poll_interval = poll_interval
450
451 @property
452 def synced_to(self) -> str | None:
453 """
454 Return the id of the player this player is synced to (sync leader).
455
456 If this player is not synced to another player (or is the sync leader itself),
457 this should return None.
458 If it is part of a (permanent) group, this should also return None.
459 """
460 if self.sync_status.leader:
461 leader = self.sync_status.leader
462 return self.provider.player_map.get((leader.ip, leader.port), None)
463 return None
464