/
/
/
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 # Remove and uncomment when wiim-sdk 0.1.5+ has been released
234 # await self.device.async_set_volume(volume_level)
235 # Inlined fix from https://github.com/Linkplay2020/wiim/pull/18:
236 # async_set_volume uses a UPnP action that is unreliable; the HTTP
237 # command works. Replicates the SDK method body until 0.1.5 ships.
238 volume_level = max(0, min(100, volume_level))
239 await self.device._http_command_ok("setPlayerCmd:vol:{}", str(volume_level))
240 self.device.volume = volume_level
241 except WiimException as err:
242 self._handle_command_error("volume_set", err)
243 return
244 self._update_ma_state_from_sdk_cache()
245
246 async def volume_mute(self, muted: bool) -> None:
247 """Handle VOLUME MUTE command on the player."""
248 try:
249 await self.device.async_set_mute(muted)
250 except WiimException as err:
251 self._handle_command_error("volume_mute", err)
252 return
253 self._update_ma_state_from_sdk_cache()
254
255 async def select_source(self, source: str) -> None:
256 """
257 Handle SELECT SOURCE command on the player.
258
259 :param source: The source(id) to select, as defined in the source_list.
260 """
261 sdk_mode = SOURCE_ID_TO_INPUT_MODE.get(source)
262 if not sdk_mode:
263 self.logger.warning("Unknown source '%s' for %s", source, self.display_name)
264 return
265 try:
266 await self.device.async_set_play_mode(sdk_mode)
267 except WiimException as err:
268 self._handle_command_error("select_source", err)
269 return
270 self._update_ma_state_from_sdk_cache()
271
272 async def set_members(
273 self,
274 player_ids_to_add: list[str] | None = None,
275 player_ids_to_remove: list[str] | None = None,
276 ) -> None:
277 """Handle SET_MEMBERS command on the player."""
278 queue = self.mass.player_queues.get(self.player_id)
279 pq_data = self.mass.player_queues.queue_data_or_none(self.player_id) if queue else None
280 entry_sdk_uri = self.device.current_media.uri if self.device.current_media else None
281 self.logger.debug(
282 "set_members entry on %s: add=%s, remove=%s | "
283 "queue.current_item_id=%s, queue.next_item_id=%s, "
284 "queue.next_item_id_enqueued=%s | "
285 "sdk.current_uri=%s, sdk.playing_status=%s",
286 self._attr_name,
287 player_ids_to_add,
288 player_ids_to_remove,
289 queue.current_item.queue_item_id if queue and queue.current_item else None,
290 queue.next_item.queue_item_id if queue and queue.next_item else None,
291 pq_data.next_item_id_enqueued if pq_data else None,
292 entry_sdk_uri,
293 self.device.playing_status,
294 )
295 try:
296 if player_ids_to_add:
297 follower_udns = [
298 cast("WiimPlayer", member).device.udn
299 for member_id in player_ids_to_add
300 if (member := self.mass.players.get_player(member_id))
301 ]
302 if follower_udns:
303 await self._wiim_controller.async_join_group(self.device.udn, follower_udns)
304
305 if player_ids_to_remove:
306 for member_id in player_ids_to_remove:
307 if member := self.mass.players.get_player(member_id):
308 await self._wiim_controller.async_ungroup_device(
309 cast("WiimPlayer", member).device.udn
310 )
311 except WiimException as err:
312 self._handle_command_error("set_members", err)
313 finally:
314 exit_sdk_uri = self.device.current_media.uri if self.device.current_media else None
315 self.logger.debug(
316 "set_members exit on %s: sdk.current_uri=%s, sdk.playing_status=%s, "
317 "queue.current_item_id=%s",
318 self._attr_name,
319 exit_sdk_uri,
320 self.device.playing_status,
321 queue.current_item.queue_item_id if queue and queue.current_item else None,
322 )
323
324 # --- SDK event handlers ---
325
326 def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
327 """Handle general updates from the SDK (availability changes)."""
328 if not device.available:
329 self.logger.debug("Device %s became unavailable", self._attr_name)
330 self._update_ma_state_from_sdk_cache()
331 return
332 if device.supports_http_api:
333 self.logger.debug("Device %s available, ensuring subscriptions", self._attr_name)
334 self.mass.create_task(self._ensure_subscriptions_and_update())
335 else:
336 self._update_ma_state_from_sdk_cache()
337
338 def _handle_sdk_av_transport_event(
339 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
340 ) -> None:
341 """Handle AVTransport events from the SDK."""
342 event_data = self.device.event_data
343 if transport_state := event_data.get("TransportState"):
344 try:
345 sdk_status = PlayingStatus(transport_state)
346 except ValueError:
347 pass
348 else:
349 if sdk_status in (
350 PlayingStatus.PLAYING,
351 PlayingStatus.PAUSED,
352 PlayingStatus.LOADING,
353 ):
354 self.mass.create_task(self._sync_position())
355 self._update_ma_state_from_sdk_cache()
356
357 def _handle_sdk_rendering_control_event(
358 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
359 ) -> None:
360 """Handle RenderingControl events from the SDK."""
361 self._update_ma_state_from_sdk_cache()
362 for sv in state_variables:
363 if sv.name == "LastChange" and sv.value and "Slave" in str(sv.value):
364 self.mass.create_task(self._refresh_multiroom())
365 break
366
367 def _handle_sdk_play_queue_event(
368 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
369 ) -> None:
370 """Handle PlayQueue events from the SDK."""
371 self._update_ma_state_from_sdk_cache()
372
373 # --- Private helpers ---
374
375 def _update_ma_state_from_sdk_cache(self) -> None:
376 """Update MA state from SDK's cache/HTTP poll attributes."""
377 self._attr_available = self.device.available
378 if self.device.name != self._attr_name:
379 self._attr_name = self.device.name
380
381 if not self._attr_available:
382 self._attr_current_media = None
383 self._attr_active_source = None
384 self.update_state()
385 return
386
387 self._attr_volume_level = self.device.volume
388 self._attr_volume_muted = self.device.is_muted
389
390 # Followers have their state derived by MA from the leader.
391 snapshot = self._wiim_controller.get_group_snapshot(self.device.udn)
392 if snapshot.role == WiimGroupRole.FOLLOWER:
393 self.update_state()
394 return
395
396 media = self.device.current_media
397 device_uri = media.uri if media and media.uri else ""
398 play_mode = self.device.play_mode
399
400 # Playback state
401 if (new_state := self._resolve_playback_state(device_uri, play_mode)) is not None:
402 self._attr_playback_state = new_state
403
404 # Group members
405 managed_member_ids = [
406 f"{PLAYER_ID_PREFIX}{member_udn}"
407 for member_udn in snapshot.member_udns
408 if member_udn != self.device.udn
409 and self.mass.players.get_player(f"{PLAYER_ID_PREFIX}{member_udn}")
410 ]
411 self._attr_group_members = (
412 [self.player_id, *managed_member_ids] if managed_member_ids else []
413 )
414
415 if play_mode and play_mode != SOURCE_NETWORK and play_mode in INPUT_MODE_SOURCES:
416 self._attr_active_source = INPUT_MODE_SOURCES[play_mode].id
417 elif play_mode == SOURCE_NETWORK:
418 ma_queue = self.mass.player_queues.get(self.player_id)
419 assert ma_queue is not None
420 if device_uri == "wiimu_airplay":
421 self._attr_active_source = SOURCE_AIRPLAY
422 elif device_uri.startswith("spotify:"):
423 self._attr_active_source = SOURCE_SPOTIFY
424 elif ma_queue.current_item:
425 self._attr_active_source = self.player_id
426 else:
427 self._attr_active_source = SOURCE_UNKNOWN
428 else:
429 self._attr_active_source = None
430
431 source_display_name: str | None = None
432 if play_mode and play_mode in INPUT_MODE_SOURCES:
433 source_display_name = INPUT_MODE_SOURCES[play_mode].name
434 elif self._attr_active_source in PASSIVE_SOURCES:
435 source_display_name = PASSIVE_SOURCES[self._attr_active_source].name
436
437 is_ma_source = self._attr_active_source == self.player_id
438 sdk_has_metadata = bool(media and (media.title or media.artist or media.album))
439
440 if self._attr_active_source is None:
441 self._attr_current_media = None
442 elif is_ma_source and not sdk_has_metadata:
443 # Keep the metadata play_media set until the SDK catches up.
444 pass
445 else:
446 self._attr_current_media = PlayerMedia(
447 uri=(media.uri if media else None) or "",
448 title=(media.title if media else None) or source_display_name,
449 artist=media.artist if media else None,
450 album=media.album if media else None,
451 image_url=media.image_url if media else None,
452 duration=media.duration if media else None,
453 source_id=self._attr_active_source,
454 )
455
456 self._log_sdk_state_change()
457 self.update_state()
458
459 def _resolve_playback_state(
460 self, device_uri: str, play_mode: str | None
461 ) -> PlaybackState | None:
462 """
463 Map the SDK playing status to a MA playback state.
464
465 Returns ``None`` when the current state should be kept: either no status
466 is known yet, or the report is implausible and should not propagate.
467
468 :param device_uri: The media URI currently loaded on the device ("" if none).
469 :param play_mode: The device's current play mode (input source).
470 """
471 # widened annotation: the SDK types playing_status as non-optional, but
472 # we stay tolerant of a None from mocks/edge paths (as the code always has)
473 sdk_status: PlayingStatus | None = self.device.playing_status
474 if sdk_status is None:
475 return None
476 new_state = SDK_TO_MA_STATE.get(sdk_status, PlaybackState.IDLE)
477 # The device acks transport commands with a transient (false) PLAYING
478 # report while no media is loaded yet (observed during group session
479 # setup). Nothing can actually play in network mode without a URI, so
480 # keep the previous state instead of propagating the false start and
481 # the PLAYING->IDLE->PLAYING flicker it causes downstream. External
482 # inputs (line-in, Bluetooth, ...) legitimately play without a URI and
483 # are not affected.
484 if new_state == PlaybackState.PLAYING and play_mode == SOURCE_NETWORK and not device_uri:
485 return None
486 return new_state
487
488 def _log_sdk_state_change(self) -> None:
489 """Log a debug line whenever the SDK-reported URI or playing_status changes."""
490 new_sdk_uri = self.device.current_media.uri if self.device.current_media else None
491 new_sdk_status = self.device.playing_status
492 if (
493 new_sdk_uri == self._last_logged_sdk_uri
494 and new_sdk_status == self._last_logged_sdk_status
495 ):
496 return
497 ma_queue = self.mass.player_queues.get(self.player_id)
498 self.logger.debug(
499 "WiiM state changed on %s: uri=%r->%r, status=%s->%s | queue.current_item_id=%s",
500 self._attr_name,
501 self._last_logged_sdk_uri,
502 new_sdk_uri,
503 self._last_logged_sdk_status,
504 new_sdk_status,
505 ma_queue.current_item.queue_item_id if ma_queue and ma_queue.current_item else None,
506 )
507 self._last_logged_sdk_uri = new_sdk_uri
508 self._last_logged_sdk_status = new_sdk_status
509
510 async def _ensure_subscriptions_and_update(self) -> None:
511 """Re-subscribe to UPnP events and update state."""
512 try:
513 await self.device.ensure_subscriptions()
514 except WiimException as err:
515 self.logger.warning("Failed to re-subscribe for %s: %s", self._attr_name, err)
516 self._update_ma_state_from_sdk_cache()
517
518 async def _refresh_device_status(self) -> None:
519 """Fetch fresh transport state, play mode and volume from the device."""
520 if not self.device.available:
521 # the SDK owns availability detection and recovery; querying a device it
522 # already gave up on only holds up the poll of every other player.
523 return
524 try:
525 await self.device.async_update_http_status()
526 except WiimException as err:
527 self.logger.debug("Failed to refresh status for %s: %s", self._attr_name, err)
528 return
529 self._update_ma_state_from_sdk_cache()
530
531 async def _sync_position(self) -> None:
532 """Fetch fresh position from the device and update state."""
533 try:
534 await self.device.sync_device_duration_and_position()
535 except WiimException as err:
536 self.logger.debug("Failed to sync position for %s: %s", self._attr_name, err)
537 return
538 media = self.device.current_media
539 device_uri = media.uri if media else None
540 if device_uri and device_uri == self._ma_stream_uri:
541 self._ma_stream_uri = None
542 # The device reports its previous content's position until it loads our stream URI.
543 if self._ma_stream_uri is None and media is not None and media.position is not None:
544 self._attr_elapsed_time = media.position
545 self._attr_elapsed_time_last_updated = time.time()
546 self._update_ma_state_from_sdk_cache()
547
548 async def _refresh_multiroom(self) -> None:
549 """Refresh multiroom status from devices, then update all WiiM players."""
550 try:
551 await self._wiim_controller.async_update_all_multiroom_status()
552 except WiimException as err:
553 self.logger.debug("Failed to refresh multiroom status: %s", err)
554 for player in self.provider.players:
555 if isinstance(player, WiimPlayer):
556 player._update_ma_state_from_sdk_cache()
557
558 def _handle_command_error(self, action: str, err: WiimException) -> None:
559 """Handle a command error by logging and refreshing state."""
560 self.logger.warning("Command '%s' failed on %s: %s", action, self._attr_name, err)
561 self._update_ma_state_from_sdk_cache()
562