/
/
1"""Wiim Player implementation."""
2
3from __future__ import annotations
4
5import time
6import typing
7from typing import TYPE_CHECKING, cast
8
9from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType
10from music_assistant_models.player import DeviceInfo
11from wiim import PlayingStatus, WiimDevice
12from wiim.exceptions import WiimException
13from wiim.models import WiimGroupRole
14
15from music_assistant.constants import (
16 EXTERNAL_PAUSE_IDLE_TIMEOUT,
17 create_sample_rates_config_entry,
18)
19from music_assistant.helpers.upnp import create_didl_metadata
20from music_assistant.models.player import Player, PlayerMedia
21
22from .constants import (
23 INPUT_MODE_SOURCES,
24 PASSIVE_SOURCES,
25 PLAYER_ID_PREFIX,
26 SOURCE_AIRPLAY,
27 SOURCE_ID_TO_INPUT_MODE,
28 SOURCE_NETWORK,
29 SOURCE_SPOTIFY,
30 SOURCE_UNKNOWN,
31)
32
33if TYPE_CHECKING:
34 from async_upnp_client.client import UpnpService, UpnpStateVariable
35 from music_assistant_models.config_entries import ConfigEntry
36
37 from .provider import WiimProvider
38
39SDK_TO_MA_STATE: dict[PlayingStatus, PlaybackState] = {
40 PlayingStatus.PLAYING: PlaybackState.PLAYING,
41 PlayingStatus.PAUSED: PlaybackState.PAUSED,
42 PlayingStatus.STOPPED: PlaybackState.IDLE,
43 PlayingStatus.LOADING: PlaybackState.PLAYING,
44}
45
46
47class WiimPlayer(Player):
48 """Wiim Player in Music Assistant."""
49
50 # the device reports a source the app released as plain 'paused', indistinguishable
51 # from a real pause, so only the grace period can tell us the session is over.
52 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
53
54 def __init__(
55 self,
56 provider: WiimProvider,
57 player_id: str,
58 device: WiimDevice,
59 mac_address: str | None = None,
60 ) -> None:
61 """Initialize the Player."""
62 super().__init__(provider, player_id)
63
64 self._attr_name = device.name
65 self._attr_type = PlayerType.PLAYER
66 self._attr_supported_features = {
67 PlayerFeature.PLAY_MEDIA,
68 PlayerFeature.ENQUEUE,
69 PlayerFeature.GAPLESS_PLAYBACK,
70 PlayerFeature.SEEK,
71 PlayerFeature.VOLUME_SET,
72 PlayerFeature.VOLUME_MUTE,
73 PlayerFeature.PAUSE,
74 PlayerFeature.SET_MEMBERS,
75 PlayerFeature.SELECT_SOURCE,
76 }
77 self._attr_can_group_with = {provider.instance_id}
78 self.device = device
79 self._wiim_controller = provider.wiim_controller
80
81 self._attr_device_info = DeviceInfo(
82 model=device.model_name,
83 manufacturer=device.manufacturer or "WiiM",
84 software_version=device.firmware_version,
85 )
86 self._attr_device_info.add_identifier(IdentifierType.UUID, device.udn.removeprefix("uuid:"))
87 if device.ip_address:
88 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, device.ip_address)
89 if mac_address:
90 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
91
92 device.general_event_callback = self._handle_sdk_general_device_update
93 device.rendering_control_event_callback = self._handle_sdk_rendering_control_event
94 device.av_transport_event_callback = self._handle_sdk_av_transport_event
95 device.play_queue_event_callback = self._handle_sdk_play_queue_event
96
97 self._last_logged_sdk_uri: str | None = None
98 self._last_logged_sdk_status: PlayingStatus | None = None
99 self._ma_stream_uri: str | None = None
100
101 # --- Lifecycle ---
102
103 async def setup(self) -> None:
104 """Handle logic when the player is set up in the Player controller."""
105 for mode_name in self.device.supported_input_modes:
106 if mode_name in INPUT_MODE_SOURCES and mode_name != SOURCE_NETWORK:
107 self._attr_source_list.append(INPUT_MODE_SOURCES[mode_name])
108 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_AIRPLAY])
109 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_SPOTIFY])
110 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_UNKNOWN])
111 self._attr_needs_poll = True
112 self._attr_poll_interval = 5
113
114 async def poll(self) -> None:
115 """Poll player for transport state and position updates."""
116 await self._sync_position()
117 await self._refresh_device_status()
118 self._attr_poll_interval = 5 if self._attr_playback_state == PlaybackState.PLAYING else 30
119
120 async def on_unload(self) -> None:
121 """Handle logic when the player is unloaded from the Player controller."""
122 await super().on_unload()
123 self.device.general_event_callback = None
124 self.device.av_transport_event_callback = None
125 self.device.rendering_control_event_callback = None
126 self.device.play_queue_event_callback = None
127 try:
128 await self._wiim_controller.remove_device(self.device.udn)
129 await self.device.disconnect()
130 except Exception:
131 self.logger.exception("Error tearing down WiiM device %s", self.name)
132 self.logger.debug("Player %s unloaded, SDK resources released", self.name)
133
134 async def get_config_entries(self) -> list[ConfigEntry]:
135 """Return player-specific config entries."""
136 return [
137 create_sample_rates_config_entry(
138 max_sample_rate=192000,
139 safe_max_sample_rate=192000,
140 max_bit_depth=24,
141 safe_max_bit_depth=24,
142 ),
143 ]
144
145 # --- Player commands ---
146
147 async def play_media(self, media: PlayerMedia) -> None:
148 """Play media command."""
149 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
150 didl_metadata = create_didl_metadata(media, url=stream_url)
151 self.set_current_media(
152 uri=stream_url,
153 title=media.title,
154 artist=media.artist,
155 album=media.album,
156 image_url=media.image_url,
157 duration=media.duration,
158 source_id=media.source_id,
159 clear_all=True,
160 )
161 self._attr_elapsed_time = 0
162 self._attr_elapsed_time_last_updated = time.time()
163 self._ma_stream_uri = stream_url
164 try:
165 await self.device.async_play(uri=stream_url, metadata=didl_metadata)
166 except WiimException as err:
167 # The device never took our stream, so the guard must not outlive the attempt.
168 self._ma_stream_uri = None
169 self._handle_command_error("play_media", err)
170 return
171 self._update_ma_state_from_sdk_cache()
172
173 async def enqueue_next_media(self, media: PlayerMedia) -> None:
174 """Handle enqueuing of the next queue item on the player."""
175 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
176 didl_metadata = create_didl_metadata(media, url=stream_url)
177 self.logger.debug(
178 "enqueue_next_media on %s: queue_item_id=%s, uri=%s",
179 self._attr_name,
180 media.queue_item_id,
181 stream_url,
182 )
183 try:
184 await self.device._invoke_upnp_action(
185 "AVTransport",
186 "SetNextAVTransportURI",
187 NextURI=stream_url,
188 NextURIMetaData=didl_metadata,
189 )
190 except WiimException as err:
191 self.logger.warning("Enqueue failed on %s: %s", self._attr_name, err)
192
193 async def play(self) -> None:
194 """Play command."""
195 try:
196 await self.device.async_play()
197 except WiimException as err:
198 self._handle_command_error("play", err)
199 return
200 await self._sync_position()
201
202 async def pause(self) -> None:
203 """Pause command."""
204 try:
205 await self.device.async_pause()
206 except WiimException as err:
207 self._handle_command_error("pause", err)
208 return
209 await self._sync_position()
210
211 async def stop(self) -> None:
212 """Stop command."""
213 self._attr_active_source = None
214 self._attr_current_media = None
215 self._ma_stream_uri = None
216 try:
217 await self.device.async_stop()
218 except WiimException as err:
219 self._handle_command_error("stop", err)
220 return
221 self._update_ma_state_from_sdk_cache()
222
223 async def seek(self, position: int) -> None:
224 """Seek to position in seconds."""
225 try:
226 await self.device.async_seek(position)
227 except WiimException as err:
228 self._handle_command_error("seek", err)
229
230 async def volume_set(self, volume_level: int) -> None:
231 """Handle VOLUME_SET command on the player."""
232 try:
233 await self.device.async_set_volume(volume_level)
234 except WiimException as err:
235 self._handle_command_error("volume_set", err)
236 return
237 self._update_ma_state_from_sdk_cache()
238
239 async def volume_mute(self, muted: bool) -> None:
240 """Handle VOLUME MUTE command on the player."""
241 try:
242 await self.device.async_set_mute(muted)
243 except WiimException as err:
244 self._handle_command_error("volume_mute", err)
245 return
246 self._update_ma_state_from_sdk_cache()
247
248 async def select_source(self, source: str) -> None:
249 """
250 Handle SELECT SOURCE command on the player.
251
252 :param source: The source(id) to select, as defined in the source_list.
253 """
254 sdk_mode = SOURCE_ID_TO_INPUT_MODE.get(source)
255 if not sdk_mode:
256 self.logger.warning("Unknown source '%s' for %s", source, self.display_name)
257 return
258 try:
259 await self.device.async_set_play_mode(sdk_mode)
260 except WiimException as err:
261 self._handle_command_error("select_source", err)
262 return
263 self._update_ma_state_from_sdk_cache()
264
265 async def set_members(
266 self,
267 player_ids_to_add: list[str] | None = None,
268 player_ids_to_remove: list[str] | None = None,
269 ) -> None:
270 """Handle SET_MEMBERS command on the player."""
271 queue = self.mass.player_queues.get(self.player_id)
272 pq_data = self.mass.player_queues.queue_data_or_none(self.player_id) if queue else None
273 entry_sdk_uri = self.device.current_media.uri if self.device.current_media else None
274 self.logger.debug(
275 "set_members entry on %s: add=%s, remove=%s | "
276 "queue.current_item_id=%s, queue.next_item_id=%s, "
277 "queue.next_item_id_enqueued=%s | "
278 "sdk.current_uri=%s, sdk.playing_status=%s",
279 self._attr_name,
280 player_ids_to_add,
281 player_ids_to_remove,
282 queue.current_item.queue_item_id if queue and queue.current_item else None,
283 queue.next_item.queue_item_id if queue and queue.next_item else None,
284 pq_data.next_item_id_enqueued if pq_data else None,
285 entry_sdk_uri,
286 self.device.playing_status,
287 )
288 try:
289 if player_ids_to_add:
290 follower_udns = [
291 cast("WiimPlayer", member).device.udn
292 for member_id in player_ids_to_add
293 if (member := self.mass.players.get_player(member_id))
294 ]
295 if follower_udns:
296 await self._wiim_controller.async_join_group(self.device.udn, follower_udns)
297
298 if player_ids_to_remove:
299 for member_id in player_ids_to_remove:
300 if member := self.mass.players.get_player(member_id):
301 await self._wiim_controller.async_ungroup_device(
302 cast("WiimPlayer", member).device.udn
303 )
304 except WiimException as err:
305 self._handle_command_error("set_members", err)
306 finally:
307 exit_sdk_uri = self.device.current_media.uri if self.device.current_media else None
308 self.logger.debug(
309 "set_members exit on %s: sdk.current_uri=%s, sdk.playing_status=%s, "
310 "queue.current_item_id=%s",
311 self._attr_name,
312 exit_sdk_uri,
313 self.device.playing_status,
314 queue.current_item.queue_item_id if queue and queue.current_item else None,
315 )
316
317 # --- SDK event handlers ---
318
319 def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
320 """Handle general updates from the SDK (availability changes)."""
321 if not device.available:
322 self.logger.debug("Device %s became unavailable", self._attr_name)
323 self._update_ma_state_from_sdk_cache()
324 return
325 if device.supports_http_api:
326 self.logger.debug("Device %s available, ensuring subscriptions", self._attr_name)
327 self.mass.create_task(self._ensure_subscriptions_and_update())
328 else:
329 self._update_ma_state_from_sdk_cache()
330
331 def _handle_sdk_av_transport_event(
332 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
333 ) -> None:
334 """Handle AVTransport events from the SDK."""
335 event_data = self.device.event_data
336 if transport_state := event_data.get("TransportState"):
337 try:
338 sdk_status = PlayingStatus(transport_state)
339 except ValueError:
340 pass
341 else:
342 if sdk_status in (
343 PlayingStatus.PLAYING,
344 PlayingStatus.PAUSED,
345 PlayingStatus.LOADING,
346 ):
347 self.mass.create_task(self._sync_position())
348 self._update_ma_state_from_sdk_cache()
349
350 def _handle_sdk_rendering_control_event(
351 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
352 ) -> None:
353 """Handle RenderingControl events from the SDK."""
354 self._update_ma_state_from_sdk_cache()
355 for sv in state_variables:
356 if sv.name == "LastChange" and sv.value and "Slave" in str(sv.value):
357 self.mass.create_task(self._refresh_multiroom())
358 break
359
360 def _handle_sdk_play_queue_event(
361 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
362 ) -> None:
363 """Handle PlayQueue events from the SDK."""
364 self._update_ma_state_from_sdk_cache()
365
366 # --- Private helpers ---
367
368 def _update_ma_state_from_sdk_cache(self) -> None:
369 """Update MA state from SDK's cache/HTTP poll attributes."""
370 self._attr_available = self.device.available
371 if self.device.name != self._attr_name:
372 self._attr_name = self.device.name
373
374 if not self._attr_available:
375 self._attr_current_media = None
376 self._attr_active_source = None
377 self.update_state()
378 return
379
380 self._attr_volume_level = self.device.volume
381 self._attr_volume_muted = self.device.is_muted
382
383 # Followers have their state derived by MA from the leader.
384 snapshot = self._wiim_controller.get_group_snapshot(self.device.udn)
385 if snapshot.role == WiimGroupRole.FOLLOWER:
386 self.update_state()
387 return
388
389 media = self.device.current_media
390 device_uri = media.uri if media and media.uri else ""
391 play_mode = self.device.play_mode
392
393 # Playback state
394 if (new_state := self._resolve_playback_state(device_uri, play_mode)) is not None:
395 self._attr_playback_state = new_state
396
397 # Group members
398 managed_member_ids = [
399 f"{PLAYER_ID_PREFIX}{member_udn}"
400 for member_udn in snapshot.member_udns
401 if member_udn != self.device.udn
402 and self.mass.players.get_player(f"{PLAYER_ID_PREFIX}{member_udn}")
403 ]
404 self._attr_group_members = (
405 [self.player_id, *managed_member_ids] if managed_member_ids else []
406 )
407
408 if play_mode and play_mode != SOURCE_NETWORK and play_mode in INPUT_MODE_SOURCES:
409 self._attr_active_source = INPUT_MODE_SOURCES[play_mode].id
410 elif play_mode == SOURCE_NETWORK:
411 ma_queue = self.mass.player_queues.get(self.player_id)
412 assert ma_queue is not None
413 if device_uri == "wiimu_airplay":
414 self._attr_active_source = SOURCE_AIRPLAY
415 elif device_uri.startswith("spotify:"):
416 self._attr_active_source = SOURCE_SPOTIFY
417 elif ma_queue.current_item:
418 self._attr_active_source = self.player_id
419 else:
420 self._attr_active_source = SOURCE_UNKNOWN
421 else:
422 self._attr_active_source = None
423
424 source_display_name: str | None = None
425 if play_mode and play_mode in INPUT_MODE_SOURCES:
426 source_display_name = INPUT_MODE_SOURCES[play_mode].name
427 elif self._attr_active_source in PASSIVE_SOURCES:
428 source_display_name = PASSIVE_SOURCES[self._attr_active_source].name
429
430 is_ma_source = self._attr_active_source == self.player_id
431 sdk_has_metadata = bool(media and (media.title or media.artist or media.album))
432
433 if self._attr_active_source is None:
434 self._attr_current_media = None
435 elif is_ma_source and not sdk_has_metadata:
436 # Keep the metadata play_media set until the SDK catches up.
437 pass
438 else:
439 self._attr_current_media = PlayerMedia(
440 uri=(media.uri if media else None) or "",
441 title=(media.title if media else None) or source_display_name,
442 artist=media.artist if media else None,
443 album=media.album if media else None,
444 image_url=media.image_url if media else None,
445 duration=media.duration if media else None,
446 source_id=self._attr_active_source,
447 )
448
449 self._log_sdk_state_change()
450 self.update_state()
451
452 def _resolve_playback_state(
453 self, device_uri: str, play_mode: str | None
454 ) -> PlaybackState | None:
455 """
456 Map the SDK playing status to a MA playback state.
457
458 Returns ``None`` when the current state should be kept: either no status
459 is known yet, or the report is implausible and should not propagate.
460
461 :param device_uri: The media URI currently loaded on the device ("" if none).
462 :param play_mode: The device's current play mode (input source).
463 """
464 # widened annotation: the SDK types playing_status as non-optional, but
465 # we stay tolerant of a None from mocks/edge paths (as the code always has)
466 sdk_status: PlayingStatus | None = self.device.playing_status
467 if sdk_status is None:
468 return None
469 new_state = SDK_TO_MA_STATE.get(sdk_status, PlaybackState.IDLE)
470 # The device acks transport commands with a transient (false) PLAYING
471 # report while no media is loaded yet (observed during group session
472 # setup). Nothing can actually play in network mode without a URI, so
473 # keep the previous state instead of propagating the false start and
474 # the PLAYING->IDLE->PLAYING flicker it causes downstream. External
475 # inputs (line-in, Bluetooth, ...) legitimately play without a URI and
476 # are not affected.
477 if new_state == PlaybackState.PLAYING and play_mode == SOURCE_NETWORK and not device_uri:
478 return None
479 return new_state
480
481 def _log_sdk_state_change(self) -> None:
482 """Log a debug line whenever the SDK-reported URI or playing_status changes."""
483 new_sdk_uri = self.device.current_media.uri if self.device.current_media else None
484 new_sdk_status = self.device.playing_status
485 if (
486 new_sdk_uri == self._last_logged_sdk_uri
487 and new_sdk_status == self._last_logged_sdk_status
488 ):
489 return
490 ma_queue = self.mass.player_queues.get(self.player_id)
491 self.logger.debug(
492 "WiiM state changed on %s: uri=%r->%r, status=%s->%s | queue.current_item_id=%s",
493 self._attr_name,
494 self._last_logged_sdk_uri,
495 new_sdk_uri,
496 self._last_logged_sdk_status,
497 new_sdk_status,
498 ma_queue.current_item.queue_item_id if ma_queue and ma_queue.current_item else None,
499 )
500 self._last_logged_sdk_uri = new_sdk_uri
501 self._last_logged_sdk_status = new_sdk_status
502
503 async def _ensure_subscriptions_and_update(self) -> None:
504 """Re-subscribe to UPnP events and update state."""
505 try:
506 await self.device.ensure_subscriptions()
507 except WiimException as err:
508 self.logger.warning("Failed to re-subscribe for %s: %s", self._attr_name, err)
509 self._update_ma_state_from_sdk_cache()
510
511 async def _refresh_device_status(self) -> None:
512 """Fetch fresh transport state, play mode and volume from the device."""
513 if not self.device.available:
514 # the SDK owns availability detection and recovery; querying a device it
515 # already gave up on only holds up the poll of every other player.
516 return
517 try:
518 await self.device.async_update_http_status()
519 except WiimException as err:
520 self.logger.debug("Failed to refresh status for %s: %s", self._attr_name, err)
521 return
522 self._update_ma_state_from_sdk_cache()
523
524 async def _sync_position(self) -> None:
525 """Fetch fresh position from the device and update state."""
526 try:
527 await self.device.sync_device_duration_and_position()
528 except WiimException as err:
529 self.logger.debug("Failed to sync position for %s: %s", self._attr_name, err)
530 return
531 media = self.device.current_media
532 device_uri = media.uri if media else None
533 if device_uri and device_uri == self._ma_stream_uri:
534 self._ma_stream_uri = None
535 # The device reports its previous content's position until it loads our stream URI.
536 if self._ma_stream_uri is None and media is not None and media.position is not None:
537 self._attr_elapsed_time = media.position
538 self._attr_elapsed_time_last_updated = time.time()
539 self._update_ma_state_from_sdk_cache()
540
541 async def _refresh_multiroom(self) -> None:
542 """Refresh multiroom status from devices, then update all WiiM players."""
543 try:
544 await self._wiim_controller.async_update_all_multiroom_status()
545 except WiimException as err:
546 self.logger.debug("Failed to refresh multiroom status: %s", err)
547 for player in self.provider.players:
548 if isinstance(player, WiimPlayer):
549 player._update_ma_state_from_sdk_cache()
550
551 def _handle_command_error(self, action: str, err: WiimException) -> None:
552 """Handle a command error by logging and refreshing state."""
553 self.logger.warning("Command '%s' failed on %s: %s", action, self._attr_name, err)
554 self._update_ma_state_from_sdk_cache()
555