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