/
/
/
1"""Wiim Player implementation."""
2
3from __future__ import annotations
4
5import time
6import typing
7from typing import TYPE_CHECKING, Any
8
9from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType
10from music_assistant_models.errors import PlayerCommandFailed
11from music_assistant_models.player import DeviceInfo
12from pywiim import WiiMClient
13from wiim import PlayingStatus, WiimDevice
14from wiim.exceptions import WiimException
15
16from music_assistant.constants import (
17 EXTERNAL_PAUSE_IDLE_TIMEOUT,
18 create_sample_rates_config_entry,
19)
20from music_assistant.helpers.upnp import create_didl_metadata
21from music_assistant.models.player import Player, PlayerMedia
22
23from .constants import (
24 BACKEND_OFFICIAL,
25 INPUT_MODE_SOURCES,
26 PASSIVE_SOURCES,
27 SOURCE_AIRPLAY,
28 SOURCE_ID_TO_INPUT_MODE,
29 SOURCE_NETWORK,
30 SOURCE_SPOTIFY,
31 SOURCE_UNKNOWN,
32)
33from .grouping import NativeGroupRole
34
35if TYPE_CHECKING:
36 from async_upnp_client.client import UpnpService, UpnpStateVariable
37 from music_assistant_models.config_entries import ConfigEntry
38
39 from .grouping import NativeGroupCoordinator
40 from .provider import WiimProvider
41
42SDK_TO_MA_STATE: dict[PlayingStatus, PlaybackState] = {
43 PlayingStatus.PLAYING: PlaybackState.PLAYING,
44 PlayingStatus.PAUSED: PlaybackState.PAUSED,
45 PlayingStatus.STOPPED: PlaybackState.IDLE,
46 PlayingStatus.LOADING: PlaybackState.PLAYING,
47}
48
49
50class WiimPlayer(Player):
51 """Wiim Player in Music Assistant."""
52
53 linkplay_backend = BACKEND_OFFICIAL
54
55 # the device reports a source the app released as plain 'paused', indistinguishable
56 # from a real pause, so only the grace period can tell us the session is over.
57 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
58
59 def __init__(
60 self,
61 provider: WiimProvider,
62 player_id: str,
63 device: WiimDevice,
64 mac_address: str | None = None,
65 ) -> None:
66 """Initialize the Player."""
67 super().__init__(provider, player_id)
68
69 self._attr_name = device.name
70 self._attr_type = PlayerType.PLAYER
71 self._attr_supported_features = {
72 PlayerFeature.PLAY_MEDIA,
73 PlayerFeature.ENQUEUE,
74 PlayerFeature.GAPLESS_PLAYBACK,
75 PlayerFeature.SEEK,
76 PlayerFeature.VOLUME_SET,
77 PlayerFeature.VOLUME_MUTE,
78 PlayerFeature.PAUSE,
79 PlayerFeature.SET_MEMBERS,
80 PlayerFeature.SELECT_SOURCE,
81 }
82 self.device = device
83 self._wiim_controller = provider.wiim_controller
84
85 self._attr_device_info = DeviceInfo(
86 model=device.model_name,
87 manufacturer=device.manufacturer or "WiiM",
88 software_version=device.firmware_version,
89 )
90 self._attr_device_info.add_identifier(IdentifierType.UUID, device.udn.removeprefix("uuid:"))
91 if device.ip_address:
92 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, device.ip_address)
93 if mac_address:
94 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
95
96 device.general_event_callback = self._handle_sdk_general_device_update
97 device.rendering_control_event_callback = self._handle_sdk_rendering_control_event
98 device.av_transport_event_callback = self._handle_sdk_av_transport_event
99 device.play_queue_event_callback = self._handle_sdk_play_queue_event
100
101 self._last_logged_sdk_uri: str | None = None
102 self._last_logged_sdk_status: PlayingStatus | None = None
103 self._ma_stream_uri: str | None = None
104 self._native_groups: NativeGroupCoordinator = provider.native_groups
105 # a copy of the low-level client's detected capabilities, reused to pre-seed each
106 # fresh command client so topology reads do not re-probe the device
107 self._command_capabilities: dict[str, Any] | None = None
108
109 @property
110 def supported_features(self) -> set[PlayerFeature]:
111 """Return the supported features of the player."""
112 return self._attr_supported_features
113
114 @property
115 def native_available(self) -> bool:
116 """Return whether the device is currently reachable for grouping commands."""
117 return self.device.available
118
119 @property
120 def native_ip(self) -> str | None:
121 """Return the device's current IP address, if known."""
122 return self.device.ip_address
123
124 @property
125 def native_device_udn(self) -> str:
126 """Return the device UDN used for official SDK grouping commands."""
127 return self.device.udn
128
129 @property
130 def can_group_with(self) -> set[str]:
131 """Return the ids of the peers of either backend this player can group with."""
132 return self._native_groups.can_group_with(self)
133
134 @property
135 def grouping_locked(self) -> bool:
136 """Withdraw grouping while following a group MA has not discovered."""
137 # An unknown-leader follower belongs to an external group MA cannot see the leader
138 # of; locking here keeps core from offering it as a grouping target for a regroup
139 # the coordinator would then have to refuse.
140 return self._native_groups.is_unknown_leader_follower(self.player_id)
141
142 def is_native_group_compatible(self, other: Player) -> bool:
143 """Only reachable coordinator-approved peers of either backend group natively."""
144 return other.player_id in self.can_group_with
145
146 def make_command_client(self) -> WiiMClient:
147 """
148 Return a command-only LinkPlay client bound to this device's current address.
149
150 The official backend owns playback and eventing through the WiiM SDK; this
151 low-level client is used only for native topology reads and cross-backend grouping
152 commands, borrows the shared session and is never closed. Any capabilities detected
153 by an earlier client are passed on so a fresh client does not re-probe the device.
154 """
155 if not (ip := self.device.ip_address):
156 raise PlayerCommandFailed(f"Cannot command {self.player_id}: device address unknown")
157 capabilities = dict(self._command_capabilities) if self._command_capabilities else None
158 return WiiMClient(ip, session=self.mass.http_session, capabilities=capabilities)
159
160 def store_command_capabilities(self, capabilities: dict[str, Any]) -> None:
161 """
162 Cache a copy of a command client's detected capabilities for later reuse.
163
164 :param capabilities: The capabilities a command client detected for this device.
165 """
166 # an empty dict means detection has not produced anything to reuse; caching it would
167 # wrongly mark future clients as already-detected and skip real probing
168 if capabilities:
169 self._command_capabilities = dict(capabilities)
170
171 def on_native_group_update(self) -> None:
172 """Re-publish state after the topology coordinator changed this player's role."""
173 # role/membership are derived by the coordinator, not from this player's own
174 # attributes, so force a recalculation (e.g. synced_to) even when idle.
175 self.mark_state_dirty()
176 self._update_ma_state_from_sdk_cache()
177
178 # --- Lifecycle ---
179
180 async def setup(self) -> None:
181 """Handle logic when the player is set up in the Player controller."""
182 for mode_name in self.device.supported_input_modes:
183 if mode_name in INPUT_MODE_SOURCES and mode_name != SOURCE_NETWORK:
184 self._attr_source_list.append(INPUT_MODE_SOURCES[mode_name])
185 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_AIRPLAY])
186 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_SPOTIFY])
187 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_UNKNOWN])
188 self._attr_needs_poll = True
189 self._attr_poll_interval = 5
190
191 async def poll(self) -> None:
192 """Poll player for transport state and position updates."""
193 await self._sync_position()
194 await self._refresh_device_status()
195 # slow, TTL-gated re-read of the native slave list (no busy polling loop)
196 await self._native_groups.refresh_leader(self)
197 self._attr_poll_interval = 5 if self._attr_playback_state == PlaybackState.PLAYING else 30
198
199 async def on_unload(self) -> None:
200 """Handle logic when the player is unloaded from the Player controller."""
201 await super().on_unload()
202 self._native_groups.unregister(self.player_id)
203 self._native_groups.schedule_reconcile()
204 self.device.general_event_callback = None
205 self.device.av_transport_event_callback = None
206 self.device.rendering_control_event_callback = None
207 self.device.play_queue_event_callback = None
208 try:
209 await self._wiim_controller.remove_device(self.device.udn)
210 await self.device.disconnect()
211 except Exception:
212 self.logger.exception("Error tearing down WiiM device %s", self.name)
213 self.logger.debug("Player %s unloaded, SDK resources released", self.name)
214
215 async def get_config_entries(self) -> list[ConfigEntry]:
216 """Return player-specific config entries."""
217 return [
218 create_sample_rates_config_entry(
219 max_sample_rate=192000,
220 safe_max_sample_rate=192000,
221 max_bit_depth=24,
222 safe_max_bit_depth=24,
223 ),
224 ]
225
226 # --- Player commands ---
227
228 async def play_media(self, media: PlayerMedia) -> None:
229 """Play media command."""
230 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
231 didl_metadata = create_didl_metadata(media, url=stream_url)
232 self.set_current_media(
233 uri=stream_url,
234 title=media.title,
235 artist=media.artist,
236 album=media.album,
237 image_url=media.image_url,
238 duration=media.duration,
239 source_id=media.source_id,
240 clear_all=True,
241 )
242 self._attr_elapsed_time = 0
243 self._attr_elapsed_time_last_updated = time.time()
244 self._ma_stream_uri = stream_url
245 try:
246 await self.device.async_play(uri=stream_url, metadata=didl_metadata)
247 except WiimException as err:
248 # The device never took our stream, so the guard must not outlive the attempt.
249 self._ma_stream_uri = None
250 self._handle_command_error("play_media", err)
251 return
252 self._update_ma_state_from_sdk_cache()
253
254 async def enqueue_next_media(self, media: PlayerMedia) -> None:
255 """Handle enqueuing of the next queue item on the player."""
256 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
257 didl_metadata = create_didl_metadata(media, url=stream_url)
258 self.logger.debug(
259 "enqueue_next_media on %s: queue_item_id=%s, uri=%s",
260 self._attr_name,
261 media.queue_item_id,
262 stream_url,
263 )
264 try:
265 await self.device._invoke_upnp_action(
266 "AVTransport",
267 "SetNextAVTransportURI",
268 NextURI=stream_url,
269 NextURIMetaData=didl_metadata,
270 )
271 except WiimException as err:
272 self.logger.warning("Enqueue failed on %s: %s", self._attr_name, err)
273
274 async def play(self) -> None:
275 """Play command."""
276 try:
277 await self.device.async_play()
278 except WiimException as err:
279 self._handle_command_error("play", err)
280 return
281 await self._sync_position()
282
283 async def pause(self) -> None:
284 """Pause command."""
285 try:
286 await self.device.async_pause()
287 except WiimException as err:
288 self._handle_command_error("pause", err)
289 return
290 await self._sync_position()
291
292 async def stop(self) -> None:
293 """Stop command."""
294 self._attr_active_source = None
295 self._attr_current_media = None
296 self._ma_stream_uri = None
297 try:
298 await self.device.async_stop()
299 except WiimException as err:
300 self._handle_command_error("stop", err)
301 return
302 self._update_ma_state_from_sdk_cache()
303
304 async def seek(self, position: int) -> None:
305 """Seek to position in seconds."""
306 try:
307 await self.device.async_seek(position)
308 except WiimException as err:
309 self._handle_command_error("seek", err)
310
311 async def volume_set(self, volume_level: int) -> None:
312 """Handle VOLUME_SET command on the player."""
313 try:
314 await self.device.async_set_volume(volume_level)
315 except WiimException as err:
316 self._handle_command_error("volume_set", err)
317 return
318 self._update_ma_state_from_sdk_cache()
319
320 async def volume_mute(self, muted: bool) -> None:
321 """Handle VOLUME MUTE command on the player."""
322 try:
323 await self.device.async_set_mute(muted)
324 except WiimException as err:
325 self._handle_command_error("volume_mute", err)
326 return
327 self._update_ma_state_from_sdk_cache()
328
329 async def select_source(self, source: str) -> None:
330 """
331 Handle SELECT SOURCE command on the player.
332
333 :param source: The source(id) to select, as defined in the source_list.
334 """
335 sdk_mode = SOURCE_ID_TO_INPUT_MODE.get(source)
336 if not sdk_mode:
337 self.logger.warning("Unknown source '%s' for %s", source, self.display_name)
338 return
339 try:
340 await self.device.async_set_play_mode(sdk_mode)
341 except WiimException as err:
342 self._handle_command_error("select_source", err)
343 return
344 self._update_ma_state_from_sdk_cache()
345
346 async def set_members(
347 self,
348 player_ids_to_add: list[str] | None = None,
349 player_ids_to_remove: list[str] | None = None,
350 ) -> None:
351 """Handle SET_MEMBERS command on the player."""
352 queue = self.mass.player_queues.get(self.player_id)
353 pq_data = self.mass.player_queues.queue_data_or_none(self.player_id) if queue else None
354 entry_sdk_uri = self.device.current_media.uri if self.device.current_media else None
355 self.logger.debug(
356 "set_members entry on %s: add=%s, remove=%s | "
357 "queue.current_item_id=%s, queue.next_item_id=%s, "
358 "queue.next_item_id_enqueued=%s | "
359 "sdk.current_uri=%s, sdk.playing_status=%s",
360 self._attr_name,
361 player_ids_to_add,
362 player_ids_to_remove,
363 queue.current_item.queue_item_id if queue and queue.current_item else None,
364 queue.next_item.queue_item_id if queue and queue.next_item else None,
365 pq_data.next_item_id_enqueued if pq_data else None,
366 entry_sdk_uri,
367 self.device.playing_status,
368 )
369 try:
370 await self._native_groups.set_members(self, player_ids_to_add, player_ids_to_remove)
371 finally:
372 exit_sdk_uri = self.device.current_media.uri if self.device.current_media else None
373 self.logger.debug(
374 "set_members exit on %s: sdk.current_uri=%s, sdk.playing_status=%s, "
375 "queue.current_item_id=%s",
376 self._attr_name,
377 exit_sdk_uri,
378 self.device.playing_status,
379 queue.current_item.queue_item_id if queue and queue.current_item else None,
380 )
381
382 # --- SDK event handlers ---
383
384 def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
385 """Handle general updates from the SDK (availability changes)."""
386 if not device.available:
387 self.logger.debug("Device %s became unavailable", self._attr_name)
388 self._update_ma_state_from_sdk_cache()
389 return
390 if device.supports_http_api:
391 self.logger.debug("Device %s available, ensuring subscriptions", self._attr_name)
392 self.mass.create_task(self._ensure_subscriptions_and_update())
393 else:
394 self._update_ma_state_from_sdk_cache()
395
396 def _handle_sdk_av_transport_event(
397 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
398 ) -> None:
399 """Handle AVTransport events from the SDK."""
400 event_data = self.device.event_data
401 if transport_state := event_data.get("TransportState"):
402 try:
403 sdk_status = PlayingStatus(transport_state)
404 except ValueError:
405 pass
406 else:
407 if sdk_status in (
408 PlayingStatus.PLAYING,
409 PlayingStatus.PAUSED,
410 PlayingStatus.LOADING,
411 ):
412 self.mass.create_task(self._sync_position())
413 self._update_ma_state_from_sdk_cache()
414
415 def _handle_sdk_rendering_control_event(
416 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
417 ) -> None:
418 """Handle RenderingControl events from the SDK."""
419 self._update_ma_state_from_sdk_cache()
420 for sv in state_variables:
421 if sv.name == "LastChange" and sv.value and "Slave" in str(sv.value):
422 # a slave was added/removed: force a live topology + self-role re-read,
423 # deduplicated so a burst of events coalesces into one refresh.
424 self._schedule_topology_refresh()
425 break
426
427 def _handle_sdk_play_queue_event(
428 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
429 ) -> None:
430 """Handle PlayQueue events from the SDK."""
431 self._update_ma_state_from_sdk_cache()
432
433 # --- Private helpers ---
434
435 def _update_ma_state_from_sdk_cache(self) -> None:
436 """Update MA state from SDK's cache/HTTP poll attributes."""
437 was_available = self._attr_available
438 self._attr_available = self.device.available
439 self._republish_peers_if_availability_changed(was_available)
440 if self.device.name != self._attr_name:
441 self._attr_name = self.device.name
442
443 if not self._attr_available:
444 self._attr_current_media = None
445 self._attr_active_source = None
446 self.update_state()
447 return
448
449 self._attr_volume_level = self.device.volume
450 self._attr_volume_muted = self.device.is_muted
451
452 # The coordinator is the single native topology authority across both backends; this
453 # mapper only reads the role, never writes it. The self-follower signal is fed from a
454 # live group read on the poll/event refresh path.
455 if self._native_groups.role_of(self.player_id) == NativeGroupRole.FOLLOWER:
456 self._publish_follower_state()
457 return
458
459 media = self.device.current_media
460 device_uri = media.uri if media and media.uri else ""
461 play_mode = self.device.play_mode
462
463 # Playback state
464 if (new_state := self._resolve_playback_state(device_uri, play_mode)) is not None:
465 self._attr_playback_state = new_state
466
467 # Group members
468 self._attr_group_members = self._native_groups.members_of(self.player_id)
469
470 if play_mode and play_mode != SOURCE_NETWORK and play_mode in INPUT_MODE_SOURCES:
471 self._attr_active_source = INPUT_MODE_SOURCES[play_mode].id
472 elif play_mode == SOURCE_NETWORK:
473 # the queue is registered just after the player, so an early poll can precede it
474 ma_queue = self.mass.player_queues.get(self.player_id)
475 if device_uri == "wiimu_airplay":
476 self._attr_active_source = SOURCE_AIRPLAY
477 elif device_uri.startswith("spotify:"):
478 self._attr_active_source = SOURCE_SPOTIFY
479 elif ma_queue and ma_queue.current_item:
480 self._attr_active_source = self.player_id
481 else:
482 self._attr_active_source = SOURCE_UNKNOWN
483 else:
484 self._attr_active_source = None
485
486 source_display_name: str | None = None
487 if play_mode and play_mode in INPUT_MODE_SOURCES:
488 source_display_name = INPUT_MODE_SOURCES[play_mode].name
489 elif self._attr_active_source in PASSIVE_SOURCES:
490 source_display_name = PASSIVE_SOURCES[self._attr_active_source].name
491
492 is_ma_source = self._attr_active_source == self.player_id
493 sdk_has_metadata = bool(media and (media.title or media.artist or media.album))
494
495 if self._attr_active_source is None:
496 self._attr_current_media = None
497 elif is_ma_source and not sdk_has_metadata:
498 # Keep the metadata play_media set until the SDK catches up.
499 pass
500 else:
501 self._attr_current_media = PlayerMedia(
502 uri=(media.uri if media else None) or "",
503 title=(media.title if media else None) or source_display_name,
504 artist=media.artist if media else None,
505 album=media.album if media else None,
506 image_url=media.image_url if media else None,
507 duration=media.duration if media else None,
508 source_id=self._attr_active_source,
509 )
510
511 self._log_sdk_state_change()
512 self.update_state()
513
514 def _resolve_playback_state(
515 self, device_uri: str, play_mode: str | None
516 ) -> PlaybackState | None:
517 """
518 Map the SDK playing status to a MA playback state.
519
520 Returns ``None`` when the current state should be kept: either no status
521 is known yet, or the report is implausible and should not propagate.
522
523 :param device_uri: The media URI currently loaded on the device ("" if none).
524 :param play_mode: The device's current play mode (input source).
525 """
526 # widened annotation: the SDK types playing_status as non-optional, but
527 # we stay tolerant of a None from mocks/edge paths (as the code always has)
528 sdk_status: PlayingStatus | None = self.device.playing_status
529 if sdk_status is None:
530 return None
531 new_state = SDK_TO_MA_STATE.get(sdk_status, PlaybackState.IDLE)
532 # The device acks transport commands with a transient (false) PLAYING
533 # report while no media is loaded yet (observed during group session
534 # setup). Nothing can actually play in network mode without a URI, so
535 # keep the previous state instead of propagating the false start and
536 # the PLAYING->IDLE->PLAYING flicker it causes downstream. External
537 # inputs (line-in, Bluetooth, ...) legitimately play without a URI and
538 # are not affected.
539 if new_state == PlaybackState.PLAYING and play_mode == SOURCE_NETWORK and not device_uri:
540 return None
541 return new_state
542
543 def _log_sdk_state_change(self) -> None:
544 """Log a debug line whenever the SDK-reported URI or playing_status changes."""
545 new_sdk_uri = self.device.current_media.uri if self.device.current_media else None
546 new_sdk_status = self.device.playing_status
547 if (
548 new_sdk_uri == self._last_logged_sdk_uri
549 and new_sdk_status == self._last_logged_sdk_status
550 ):
551 return
552 ma_queue = self.mass.player_queues.get(self.player_id)
553 self.logger.debug(
554 "WiiM state changed on %s: uri=%r->%r, status=%s->%s | queue.current_item_id=%s",
555 self._attr_name,
556 self._last_logged_sdk_uri,
557 new_sdk_uri,
558 self._last_logged_sdk_status,
559 new_sdk_status,
560 ma_queue.current_item.queue_item_id if ma_queue and ma_queue.current_item else None,
561 )
562 self._last_logged_sdk_uri = new_sdk_uri
563 self._last_logged_sdk_status = new_sdk_status
564
565 async def _ensure_subscriptions_and_update(self) -> None:
566 """Re-subscribe to UPnP events and update state."""
567 try:
568 await self.device.ensure_subscriptions()
569 except WiimException as err:
570 self.logger.warning("Failed to re-subscribe for %s: %s", self._attr_name, err)
571 self._update_ma_state_from_sdk_cache()
572
573 async def _refresh_device_status(self) -> None:
574 """Fetch fresh transport state, play mode and volume from the device."""
575 if not self.device.available:
576 # the SDK owns availability detection and recovery; querying a device it
577 # already gave up on only holds up the poll of every other player.
578 return
579 try:
580 await self.device.async_update_http_status()
581 except WiimException as err:
582 self.logger.debug("Failed to refresh status for %s: %s", self._attr_name, err)
583 return
584 self._update_ma_state_from_sdk_cache()
585
586 async def _sync_position(self) -> None:
587 """Fetch fresh position from the device and update state."""
588 try:
589 await self.device.sync_device_duration_and_position()
590 except WiimException as err:
591 self.logger.debug("Failed to sync position for %s: %s", self._attr_name, err)
592 return
593 media = self.device.current_media
594 device_uri = media.uri if media else None
595 if device_uri and device_uri == self._ma_stream_uri:
596 self._ma_stream_uri = None
597 # The device reports its previous content's position until it loads our stream URI.
598 if self._ma_stream_uri is None and media is not None and media.position is not None:
599 self._attr_elapsed_time = media.position
600 self._attr_elapsed_time_last_updated = time.time()
601 self._update_ma_state_from_sdk_cache()
602
603 def _handle_command_error(self, action: str, err: WiimException) -> None:
604 """Handle a command error by logging and refreshing state."""
605 self.logger.warning("Command '%s' failed on %s: %s", action, self._attr_name, err)
606 self._update_ma_state_from_sdk_cache()
607
608 def _republish_peers_if_availability_changed(self, was_available: bool) -> None:
609 """Re-publish every native peer when this device's availability just flipped."""
610 # availability changes whether every peer can offer this device as a native grouping
611 # candidate, which no topology reconcile would otherwise pick up.
612 if was_available != self._attr_available:
613 self._native_groups.schedule_republish()
614
615 def _publish_follower_state(self) -> None:
616 """Publish the volume-only state of a native follower and clear its playback."""
617 # MA derives a follower's playback from the leader, so this player publishes only its
618 # own volume/mute and manages no members. Clear the follower-owned state first so the
619 # active-output clear below (which publishes immediately) never emits the stale
620 # pre-group playback, then drop any active output so the final state mirrors the
621 # leader (synced_to) or falls back to idle instead of a stale linked protocol; it is
622 # left cleared on leaving, where normal playback reselects an output.
623 self._attr_playback_state = PlaybackState.IDLE
624 self._attr_active_source = None
625 self._attr_current_media = None
626 self._attr_group_members = []
627 if self.active_output_protocol is not None:
628 self.set_active_output_protocol(None)
629 self.update_state()
630
631 def _schedule_topology_refresh(self) -> None:
632 """Force one live topology + self-role re-read, deduplicated over Slave-event bursts."""
633 # abort_existing=False deduplicates on the shared task_id: while a forced read is in
634 # flight, further events neither start a second read (leading-edge throttle) nor
635 # cancel the running one, so a burst neither hammers the device nor starves
636 # reconciliation. Passing the coroutine function lets the runtime drop a deduplicated
637 # call cleanly.
638 self.mass.create_task(
639 self._native_groups.refresh_leader,
640 self,
641 task_id=f"wiim_topology_{self.player_id}",
642 force=True,
643 )
644