/
/
/
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 WiimDeviceException, WiimRequestException
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 only pushes state changes, so a source it keeps reported as paused after
51 # the app released it is never corrected on its own.
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 state updates to prevent position drift."""
116 await self._sync_position()
117 self._attr_poll_interval = 5 if self._attr_playback_state == PlaybackState.PLAYING else 30
118
119 async def on_unload(self) -> None:
120 """Handle logic when the player is unloaded from the Player controller."""
121 await super().on_unload()
122 self.device.general_event_callback = None
123 self.device.av_transport_event_callback = None
124 self.device.rendering_control_event_callback = None
125 self.device.play_queue_event_callback = None
126 try:
127 await self._wiim_controller.remove_device(self.device.udn)
128 await self.device.disconnect()
129 except Exception:
130 self.logger.exception("Error tearing down WiiM device %s", self.name)
131 self.logger.debug("Player %s unloaded, SDK resources released", self.name)
132
133 async def get_config_entries(self) -> list[ConfigEntry]:
134 """Return player-specific config entries."""
135 return [
136 create_sample_rates_config_entry(
137 max_sample_rate=192000,
138 safe_max_sample_rate=192000,
139 max_bit_depth=24,
140 safe_max_bit_depth=24,
141 ),
142 ]
143
144 # --- Player commands ---
145
146 async def play_media(self, media: PlayerMedia) -> None:
147 """Play media command."""
148 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
149 didl_metadata = create_didl_metadata(media, url=stream_url)
150 self.set_current_media(
151 uri=stream_url,
152 title=media.title,
153 artist=media.artist,
154 album=media.album,
155 image_url=media.image_url,
156 duration=media.duration,
157 source_id=media.source_id,
158 clear_all=True,
159 )
160 self._attr_elapsed_time = 0
161 self._attr_elapsed_time_last_updated = time.time()
162 self._ma_stream_uri = stream_url
163 try:
164 await self.device.async_play(uri=stream_url, metadata=didl_metadata)
165 except (WiimDeviceException, WiimRequestException) as err:
166 # The device never took our stream, so the guard must not outlive the attempt.
167 self._ma_stream_uri = None
168 self._handle_command_error("play_media", err)
169 return
170 self._update_ma_state_from_sdk_cache()
171
172 async def enqueue_next_media(self, media: PlayerMedia) -> None:
173 """Handle enqueuing of the next queue item on the player."""
174 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
175 didl_metadata = create_didl_metadata(media, url=stream_url)
176 self.logger.debug(
177 "enqueue_next_media on %s: queue_item_id=%s, uri=%s",
178 self._attr_name,
179 media.queue_item_id,
180 stream_url,
181 )
182 try:
183 await self.device._invoke_upnp_action(
184 "AVTransport",
185 "SetNextAVTransportURI",
186 NextURI=stream_url,
187 NextURIMetaData=didl_metadata,
188 )
189 except (WiimDeviceException, WiimRequestException) as err:
190 self.logger.warning("Enqueue failed on %s: %s", self._attr_name, err)
191
192 async def play(self) -> None:
193 """Play command."""
194 try:
195 await self.device.async_play()
196 except (WiimDeviceException, WiimRequestException) as err:
197 self._handle_command_error("play", err)
198 return
199 await self._sync_position()
200
201 async def pause(self) -> None:
202 """Pause command."""
203 try:
204 await self.device.async_pause()
205 except (WiimDeviceException, WiimRequestException) as err:
206 self._handle_command_error("pause", err)
207 return
208 await self._sync_position()
209
210 async def stop(self) -> None:
211 """Stop command."""
212 self._attr_active_source = None
213 self._attr_current_media = None
214 self._ma_stream_uri = None
215 try:
216 await self.device.async_stop()
217 except (WiimDeviceException, WiimRequestException) as err:
218 self._handle_command_error("stop", err)
219 return
220 self._update_ma_state_from_sdk_cache()
221
222 async def seek(self, position: int) -> None:
223 """Seek to position in seconds."""
224 try:
225 await self.device.async_seek(position)
226 except (WiimDeviceException, WiimRequestException) as err:
227 self._handle_command_error("seek", err)
228
229 async def volume_set(self, volume_level: int) -> None:
230 """Handle VOLUME_SET command on the player."""
231 try:
232 # Remove and uncomment when wiim-sdk 0.1.5+ has been released
233 # await self.device.async_set_volume(volume_level)
234 # Inlined fix from https://github.com/Linkplay2020/wiim/pull/18:
235 # async_set_volume uses a UPnP action that is unreliable; the HTTP
236 # command works. Replicates the SDK method body until 0.1.5 ships.
237 volume_level = max(0, min(100, volume_level))
238 await self.device._http_command_ok("setPlayerCmd:vol:{}", str(volume_level))
239 self.device.volume = volume_level
240 except (WiimDeviceException, WiimRequestException) as err:
241 self._handle_command_error("volume_set", err)
242 return
243 self._update_ma_state_from_sdk_cache()
244
245 async def volume_mute(self, muted: bool) -> None:
246 """Handle VOLUME MUTE command on the player."""
247 try:
248 await self.device.async_set_mute(muted)
249 except (WiimDeviceException, WiimRequestException) as err:
250 self._handle_command_error("volume_mute", err)
251 return
252 self._update_ma_state_from_sdk_cache()
253
254 async def select_source(self, source: str) -> None:
255 """
256 Handle SELECT SOURCE command on the player.
257
258 :param source: The source(id) to select, as defined in the source_list.
259 """
260 sdk_mode = SOURCE_ID_TO_INPUT_MODE.get(source)
261 if not sdk_mode:
262 self.logger.warning("Unknown source '%s' for %s", source, self.display_name)
263 return
264 try:
265 await self.device.async_set_play_mode(sdk_mode)
266 except (WiimDeviceException, WiimRequestException) as err:
267 self._handle_command_error("select_source", err)
268 return
269 self._update_ma_state_from_sdk_cache()
270
271 async def set_members(
272 self,
273 player_ids_to_add: list[str] | None = None,
274 player_ids_to_remove: list[str] | None = None,
275 ) -> None:
276 """Handle SET_MEMBERS command on the player."""
277 queue = self.mass.player_queues.get(self.player_id)
278 pq_data = self.mass.player_queues.queue_data_or_none(self.player_id) if queue else None
279 entry_sdk_uri = self.device.current_media.uri if self.device.current_media else None
280 self.logger.debug(
281 "set_members entry on %s: add=%s, remove=%s | "
282 "queue.current_item_id=%s, queue.next_item_id=%s, "
283 "queue.next_item_id_enqueued=%s | "
284 "sdk.current_uri=%s, sdk.playing_status=%s",
285 self._attr_name,
286 player_ids_to_add,
287 player_ids_to_remove,
288 queue.current_item.queue_item_id if queue and queue.current_item else None,
289 queue.next_item.queue_item_id if queue and queue.next_item else None,
290 pq_data.next_item_id_enqueued if pq_data else None,
291 entry_sdk_uri,
292 self.device.playing_status,
293 )
294 try:
295 if player_ids_to_add:
296 follower_udns = [
297 cast("WiimPlayer", member).device.udn
298 for member_id in player_ids_to_add
299 if (member := self.mass.players.get_player(member_id))
300 ]
301 if follower_udns:
302 await self._wiim_controller.async_join_group(self.device.udn, follower_udns)
303
304 if player_ids_to_remove:
305 for member_id in player_ids_to_remove:
306 if member := self.mass.players.get_player(member_id):
307 await self._wiim_controller.async_ungroup_device(
308 cast("WiimPlayer", member).device.udn
309 )
310 except (WiimDeviceException, WiimRequestException) as err:
311 self._handle_command_error("set_members", err)
312 finally:
313 exit_sdk_uri = self.device.current_media.uri if self.device.current_media else None
314 self.logger.debug(
315 "set_members exit on %s: sdk.current_uri=%s, sdk.playing_status=%s, "
316 "queue.current_item_id=%s",
317 self._attr_name,
318 exit_sdk_uri,
319 self.device.playing_status,
320 queue.current_item.queue_item_id if queue and queue.current_item else None,
321 )
322
323 # --- SDK event handlers ---
324
325 def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
326 """Handle general updates from the SDK (availability changes)."""
327 if not device.available:
328 self.logger.debug("Device %s became unavailable", self._attr_name)
329 self._update_ma_state_from_sdk_cache()
330 return
331 if device.supports_http_api:
332 self.logger.debug("Device %s available, ensuring subscriptions", self._attr_name)
333 self.mass.create_task(self._ensure_subscriptions_and_update())
334 else:
335 self._update_ma_state_from_sdk_cache()
336
337 def _handle_sdk_av_transport_event(
338 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
339 ) -> None:
340 """Handle AVTransport events from the SDK."""
341 event_data = self.device.event_data
342 if transport_state := event_data.get("TransportState"):
343 try:
344 sdk_status = PlayingStatus(transport_state)
345 except ValueError:
346 pass
347 else:
348 if sdk_status in (
349 PlayingStatus.PLAYING,
350 PlayingStatus.PAUSED,
351 PlayingStatus.LOADING,
352 ):
353 self.mass.create_task(self._sync_position())
354 self._update_ma_state_from_sdk_cache()
355
356 def _handle_sdk_rendering_control_event(
357 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
358 ) -> None:
359 """Handle RenderingControl events from the SDK."""
360 self._update_ma_state_from_sdk_cache()
361 for sv in state_variables:
362 if sv.name == "LastChange" and sv.value and "Slave" in str(sv.value):
363 self.mass.create_task(self._refresh_multiroom())
364 break
365
366 def _handle_sdk_play_queue_event(
367 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
368 ) -> None:
369 """Handle PlayQueue events from the SDK."""
370 self._update_ma_state_from_sdk_cache()
371
372 # --- Private helpers ---
373
374 def _update_ma_state_from_sdk_cache(self) -> None:
375 """Update MA state from SDK's cache/HTTP poll attributes."""
376 self._attr_available = self.device.available
377 if self.device.name != self._attr_name:
378 self._attr_name = self.device.name
379
380 if not self._attr_available:
381 self._attr_current_media = None
382 self._attr_active_source = None
383 self.update_state()
384 return
385
386 self._attr_volume_level = self.device.volume
387 self._attr_volume_muted = self.device.is_muted
388
389 # Followers have their state derived by MA from the leader.
390 snapshot = self._wiim_controller.get_group_snapshot(self.device.udn)
391 if snapshot.role == WiimGroupRole.FOLLOWER:
392 self.update_state()
393 return
394
395 media = self.device.current_media
396 device_uri = media.uri if media and media.uri else ""
397 play_mode = self.device.play_mode
398
399 # Playback state
400 if (new_state := self._resolve_playback_state(device_uri, play_mode)) is not None:
401 self._attr_playback_state = new_state
402
403 # Group members
404 managed_member_ids = [
405 f"{PLAYER_ID_PREFIX}{member_udn}"
406 for member_udn in snapshot.member_udns
407 if member_udn != self.device.udn
408 and self.mass.players.get_player(f"{PLAYER_ID_PREFIX}{member_udn}")
409 ]
410 self._attr_group_members = (
411 [self.player_id, *managed_member_ids] if managed_member_ids else []
412 )
413
414 if play_mode and play_mode != SOURCE_NETWORK and play_mode in INPUT_MODE_SOURCES:
415 self._attr_active_source = INPUT_MODE_SOURCES[play_mode].id
416 elif play_mode == SOURCE_NETWORK:
417 ma_queue = self.mass.player_queues.get(self.player_id)
418 assert ma_queue is not None
419 if device_uri == "wiimu_airplay":
420 self._attr_active_source = SOURCE_AIRPLAY
421 elif device_uri.startswith("spotify:"):
422 self._attr_active_source = SOURCE_SPOTIFY
423 elif ma_queue.current_item:
424 self._attr_active_source = self.player_id
425 else:
426 self._attr_active_source = SOURCE_UNKNOWN
427 else:
428 self._attr_active_source = None
429
430 source_display_name: str | None = None
431 if play_mode and play_mode in INPUT_MODE_SOURCES:
432 source_display_name = INPUT_MODE_SOURCES[play_mode].name
433 elif self._attr_active_source in PASSIVE_SOURCES:
434 source_display_name = PASSIVE_SOURCES[self._attr_active_source].name
435
436 is_ma_source = self._attr_active_source == self.player_id
437 sdk_has_metadata = bool(media and (media.title or media.artist or media.album))
438
439 if self._attr_active_source is None:
440 self._attr_current_media = None
441 elif is_ma_source and not sdk_has_metadata:
442 # Keep the metadata play_media set until the SDK catches up.
443 pass
444 else:
445 self._attr_current_media = PlayerMedia(
446 uri=(media.uri if media else None) or "",
447 title=(media.title if media else None) or source_display_name,
448 artist=media.artist if media else None,
449 album=media.album if media else None,
450 image_url=media.image_url if media else None,
451 duration=media.duration if media else None,
452 source_id=self._attr_active_source,
453 )
454
455 self._log_sdk_state_change()
456 self.update_state()
457
458 def _resolve_playback_state(
459 self, device_uri: str, play_mode: str | None
460 ) -> PlaybackState | None:
461 """
462 Map the SDK playing status to a MA playback state.
463
464 Returns ``None`` when the current state should be kept: either no status
465 is known yet, or the report is implausible and should not propagate.
466
467 :param device_uri: The media URI currently loaded on the device ("" if none).
468 :param play_mode: The device's current play mode (input source).
469 """
470 # widened annotation: the SDK types playing_status as non-optional, but
471 # we stay tolerant of a None from mocks/edge paths (as the code always has)
472 sdk_status: PlayingStatus | None = self.device.playing_status
473 if sdk_status is None:
474 return None
475 new_state = SDK_TO_MA_STATE.get(sdk_status, PlaybackState.IDLE)
476 # The device acks transport commands with a transient (false) PLAYING
477 # report while no media is loaded yet (observed during group session
478 # setup). Nothing can actually play in network mode without a URI, so
479 # keep the previous state instead of propagating the false start and
480 # the PLAYING->IDLE->PLAYING flicker it causes downstream. External
481 # inputs (line-in, Bluetooth, ...) legitimately play without a URI and
482 # are not affected.
483 if new_state == PlaybackState.PLAYING and play_mode == SOURCE_NETWORK and not device_uri:
484 return None
485 return new_state
486
487 def _log_sdk_state_change(self) -> None:
488 """Log a debug line whenever the SDK-reported URI or playing_status changes."""
489 new_sdk_uri = self.device.current_media.uri if self.device.current_media else None
490 new_sdk_status = self.device.playing_status
491 if (
492 new_sdk_uri == self._last_logged_sdk_uri
493 and new_sdk_status == self._last_logged_sdk_status
494 ):
495 return
496 ma_queue = self.mass.player_queues.get(self.player_id)
497 self.logger.debug(
498 "WiiM state changed on %s: uri=%r->%r, status=%s->%s | queue.current_item_id=%s",
499 self._attr_name,
500 self._last_logged_sdk_uri,
501 new_sdk_uri,
502 self._last_logged_sdk_status,
503 new_sdk_status,
504 ma_queue.current_item.queue_item_id if ma_queue and ma_queue.current_item else None,
505 )
506 self._last_logged_sdk_uri = new_sdk_uri
507 self._last_logged_sdk_status = new_sdk_status
508
509 async def _ensure_subscriptions_and_update(self) -> None:
510 """Re-subscribe to UPnP events and update state."""
511 try:
512 await self.device.ensure_subscriptions()
513 except (WiimDeviceException, WiimRequestException) as err:
514 self.logger.warning("Failed to re-subscribe for %s: %s", self._attr_name, err)
515 self._update_ma_state_from_sdk_cache()
516
517 async def _sync_position(self) -> None:
518 """Fetch fresh position from the device and update state."""
519 try:
520 await self.device.sync_device_duration_and_position()
521 except (WiimDeviceException, WiimRequestException) as err:
522 self.logger.debug("Failed to sync position for %s: %s", self._attr_name, err)
523 return
524 media = self.device.current_media
525 device_uri = media.uri if media else None
526 if device_uri and device_uri == self._ma_stream_uri:
527 self._ma_stream_uri = None
528 # The device reports its previous content's position until it loads our stream URI.
529 if self._ma_stream_uri is None and media is not None and media.position is not None:
530 self._attr_elapsed_time = media.position
531 self._attr_elapsed_time_last_updated = time.time()
532 self._update_ma_state_from_sdk_cache()
533
534 async def _refresh_multiroom(self) -> None:
535 """Refresh multiroom status from devices, then update all WiiM players."""
536 try:
537 await self._wiim_controller.async_update_all_multiroom_status()
538 except (WiimDeviceException, WiimRequestException) as err:
539 self.logger.debug("Failed to refresh multiroom status: %s", err)
540 for player in self.provider.players:
541 if isinstance(player, WiimPlayer):
542 player._update_ma_state_from_sdk_cache()
543
544 def _handle_command_error(
545 self, action: str, err: WiimDeviceException | WiimRequestException
546 ) -> None:
547 """Handle a command error by logging and refreshing state."""
548 self.logger.warning("Command '%s' failed on %s: %s", action, self._attr_name, err)
549 self._update_ma_state_from_sdk_cache()
550