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