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