pywiim 2.3.0__tar.gz → 2.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {pywiim-2.3.0/pywiim.egg-info → pywiim-2.3.2}/PKG-INFO +1 -1
  2. {pywiim-2.3.0 → pywiim-2.3.2}/pyproject.toml +1 -1
  3. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/__init__.py +1 -1
  4. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/base.py +9 -1
  5. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/diagnostics.py +58 -20
  6. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/__init__.py +36 -5
  7. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/media.py +157 -2
  8. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/properties.py +67 -18
  9. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/statemgr.py +7 -0
  10. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/profiles.py +8 -25
  11. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/state.py +116 -27
  12. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/client.py +32 -17
  13. pywiim-2.3.2/pywiim/upnp/playqueue.py +165 -0
  14. {pywiim-2.3.0 → pywiim-2.3.2/pywiim.egg-info}/PKG-INFO +1 -1
  15. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/SOURCES.txt +2 -1
  16. {pywiim-2.3.0 → pywiim-2.3.2}/LICENSE +0 -0
  17. {pywiim-2.3.0 → pywiim-2.3.2}/README.md +0 -0
  18. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/__init__.py +0 -0
  19. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/audio_pro.py +0 -0
  20. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/audio_settings.py +0 -0
  21. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/bluetooth.py +0 -0
  22. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/constants.py +0 -0
  23. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/device.py +0 -0
  24. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/endpoints.py +0 -0
  25. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/eq.py +0 -0
  26. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/firmware.py +0 -0
  27. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/group.py +0 -0
  28. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/lms.py +0 -0
  29. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/loop_mode.py +0 -0
  30. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/misc.py +0 -0
  31. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/parser.py +0 -0
  32. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/peq.py +0 -0
  33. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/playback.py +0 -0
  34. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/preset.py +0 -0
  35. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/ssl.py +0 -0
  36. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/subwoofer.py +0 -0
  37. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/timer.py +0 -0
  38. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/backoff.py +0 -0
  39. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/capabilities.py +0 -0
  40. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/__init__.py +0 -0
  41. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/diagnostics.py +0 -0
  42. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/discovery_cli.py +0 -0
  43. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/group_test_cli.py +0 -0
  44. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/join_test_cli.py +0 -0
  45. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/monitor_cli.py +0 -0
  46. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/verify_cli.py +0 -0
  47. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/client.py +0 -0
  48. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/device_capabilities.py +0 -0
  49. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/discovery.py +0 -0
  50. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/exceptions.py +0 -0
  51. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/group.py +0 -0
  52. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/group_helpers.py +0 -0
  53. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/__init__.py +0 -0
  54. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/__main__.py +0 -0
  55. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/config.example.json +0 -0
  56. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/config.py +0 -0
  57. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/context.py +0 -0
  58. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/server.py +0 -0
  59. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/metadata.py +0 -0
  60. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/model_names.py +0 -0
  61. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/models.py +0 -0
  62. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/normalize.py +0 -0
  63. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/audio.py +0 -0
  64. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/base.py +0 -0
  65. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/bluetooth.py +0 -0
  66. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/coverart.py +0 -0
  67. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/debounce.py +0 -0
  68. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/diagnostics.py +0 -0
  69. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/groupops.py +0 -0
  70. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/playback.py +0 -0
  71. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/source_capabilities.py +0 -0
  72. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/stream.py +0 -0
  73. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/stream_enricher.py +0 -0
  74. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/volume.py +0 -0
  75. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/polling.py +0 -0
  76. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/py.typed +0 -0
  77. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/role.py +0 -0
  78. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/__init__.py +0 -0
  79. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/eventer.py +0 -0
  80. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/health.py +0 -0
  81. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/metadata.py +0 -0
  82. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/dependency_links.txt +0 -0
  83. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/entry_points.txt +0 -0
  84. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/requires.txt +0 -0
  85. {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/top_level.txt +0 -0
  86. {pywiim-2.3.0 → pywiim-2.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Python library for WiiM/LinkPlay device communication
5
5
  Author-email: Michael Cumming <mjcumming@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywiim"
7
- version = "2.3.0"
7
+ version = "2.3.2"
8
8
  description = "Python library for WiiM/LinkPlay device communication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -102,7 +102,7 @@ from .profiles import (
102
102
  from .role import RoleDetectionResult, detect_role
103
103
  from .state import GroupStateSynchronizer, StateSynchronizer
104
104
 
105
- __version__ = "2.3.0"
105
+ __version__ = "2.3.2"
106
106
  __all__ = [
107
107
  # Main client
108
108
  "WiiMClient",
@@ -489,7 +489,15 @@ class BaseWiiMClient:
489
489
  except Exception: # noqa: BLE001
490
490
  pass # Device info not available, continue without it
491
491
 
492
- raise WiiMRequestError(
492
+ error_cls = (
493
+ WiiMConnectionError
494
+ if isinstance(
495
+ err,
496
+ (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, WiiMConnectionError),
497
+ )
498
+ else WiiMRequestError
499
+ )
500
+ raise error_cls(
493
501
  f"Request failed after {retry_count} attempts: {err}",
494
502
  endpoint=endpoint,
495
503
  attempts=retry_count,
@@ -11,6 +11,21 @@ from .constants import API_ENDPOINT_DEBUG_INFO
11
11
 
12
12
  _LOGGER = logging.getLogger(__name__)
13
13
 
14
+ # Tried after the profile/capability command if the device returns "unknown command".
15
+ # WiiM Amp (and some current WiiM firmware) rejects `reboot` and accepts StartRebootTime:1.
16
+ # Audio Pro devices use StartRebootTime:0.
17
+ _REBOOT_FALLBACKS = ("StartRebootTime:1", "StartRebootTime:0", "reboot")
18
+
19
+
20
+ def _is_unknown_reboot_command(resp: Any) -> bool:
21
+ """Return True when the device rejected the reboot command as unknown."""
22
+ if resp is None:
23
+ return False
24
+ raw = getattr(resp, "raw", None)
25
+ parsed = getattr(resp, "parsed", None)
26
+ text = str(raw if raw not in (None, "") else parsed or "").lower()
27
+ return "unknown command" in text
28
+
14
29
 
15
30
  class DiagnosticsAPI:
16
31
  """Low-level device maintenance helpers.
@@ -27,51 +42,74 @@ class DiagnosticsAPI:
27
42
  even if the device stops responding.
28
43
 
29
44
  The reboot command varies by device:
30
- - WiiM devices: "reboot"
45
+ - WiiM devices (legacy): "reboot"
46
+ - WiiM Amp / current WiiM firmware: "StartRebootTime:1"
31
47
  - Audio Pro devices: "StartRebootTime:0"
32
48
 
33
- The correct command is determined from device capabilities/profile.
34
- See: https://github.com/mjcumming/wiim/issues/177
49
+ The first command comes from device capabilities/profile. If the device
50
+ returns "unknown command", remaining fallbacks are tried and the working
51
+ command is cached on the client. See: https://github.com/mjcumming/wiim/issues/260
35
52
 
36
53
  Raises:
37
54
  WiiMError: If the request fails before the device reboots.
38
55
  """
39
- try:
40
- # Get reboot command from capabilities (set from device profile)
41
- # Default to "reboot" for WiiM and most LinkPlay devices
42
- reboot_command = self._capabilities.get("reboot_command", "reboot") # type: ignore[attr-defined]
43
- endpoint = f"/httpapi.asp?command={reboot_command}"
56
+ primary = self._capabilities.get("reboot_command", "reboot") # type: ignore[attr-defined]
57
+ commands: list[str] = [primary]
58
+ for fallback in _REBOOT_FALLBACKS:
59
+ if fallback not in commands:
60
+ commands.append(fallback)
61
+
62
+ last_err: Exception | None = None
63
+ for command in commands:
64
+ endpoint = f"/httpapi.asp?command={command}"
65
+ _LOGGER.debug("Sending reboot command: %s", command)
66
+ try:
67
+ resp = await self._request_reboot(endpoint)
68
+ except Exception as err:
69
+ last_err = err
70
+ error_str = str(err).lower()
71
+ if "unknown command" in error_str:
72
+ _LOGGER.debug("Reboot command %s not supported, trying fallback", command)
73
+ continue
74
+ # Connection drop / empty body: command was likely accepted
75
+ _LOGGER.info("Reboot command sent to device (device may not respond): %s", err)
76
+ return
44
77
 
45
- _LOGGER.debug("Sending reboot command: %s", reboot_command)
78
+ if _is_unknown_reboot_command(resp):
79
+ _LOGGER.debug("Reboot command %s returned unknown command, trying fallback", command)
80
+ continue
46
81
 
47
- # Send reboot command - device may not respond after this
48
- # Use a custom request method that handles empty responses gracefully
49
- await self._request_reboot(endpoint)
50
- except Exception as err:
51
- # Reboot commands often don't return proper responses
52
- # Log the attempt but don't fail the service call
53
- _LOGGER.info("Reboot command sent to device (device may not respond): %s", err)
54
- # Don't re-raise - reboot command was sent successfully
82
+ if command != primary:
83
+ self._capabilities["reboot_command"] = command # type: ignore[attr-defined]
84
+ _LOGGER.info("Reboot command %s succeeded; cached for this device", command)
85
+ return
86
+
87
+ if last_err:
88
+ _LOGGER.info("Reboot command sent to device (device may not respond): %s", last_err)
55
89
 
56
- async def _request_reboot(self, endpoint: str) -> None:
90
+ async def _request_reboot(self, endpoint: str) -> Any:
57
91
  """Special request method for reboot that handles empty responses gracefully.
58
92
 
59
93
  Args:
60
94
  endpoint: The reboot endpoint to call.
61
95
 
96
+ Returns:
97
+ ApiResponse from the device, or None when the device dropped the
98
+ connection after accepting the command.
99
+
62
100
  Raises:
63
101
  WiiMError: If the request fails for reasons other than expected reboot behavior.
64
102
  """
65
103
  try:
66
104
  # Try to send the reboot command
67
- await self._request(endpoint) # type: ignore[attr-defined]
105
+ return await self._request(endpoint) # type: ignore[attr-defined]
68
106
  except Exception as err:
69
107
  # If the request fails due to parsing issues (common with reboot),
70
108
  # we still consider it successful since the command was sent
71
109
  error_str = str(err).lower()
72
110
  if any(x in error_str for x in ["expecting value", "json decode", "empty response"]):
73
111
  _LOGGER.info("Reboot command sent successfully (device stopped responding as expected)")
74
- return
112
+ return None
75
113
  else:
76
114
  # Re-raise other types of errors
77
115
  raise
@@ -522,6 +522,22 @@ class Player(PlayerBase):
522
522
  if self._on_state_changed:
523
523
  self._on_state_changed()
524
524
 
525
+ async def set_main_speaker_bass(self, enabled: bool) -> None:
526
+ """Enable or disable bass output to the main speakers.
527
+
528
+ When disabled, frequencies below the crossover go only to the subwoofer.
529
+ When enabled, bass is also sent to the main speakers.
530
+
531
+ Args:
532
+ enabled: True to send bass to main speakers, False to filter it out.
533
+ """
534
+ await self.client.set_main_speaker_bass(enabled)
535
+ if self._subwoofer_status:
536
+ # API uses inverted logic: main_filter=1 means bass filtered from mains
537
+ self._subwoofer_status["main_filter"] = 0 if enabled else 1
538
+ if self._on_state_changed:
539
+ self._on_state_changed()
540
+
525
541
  # === Display (WiiM Ultra; ADR 005) ===
526
542
 
527
543
  async def set_display_enabled(
@@ -1234,6 +1250,20 @@ class Player(PlayerBase):
1234
1250
  return False
1235
1251
  return self.client.capabilities.get("supports_subwoofer") is True
1236
1252
 
1253
+ @property
1254
+ def main_speaker_bass(self) -> bool | None:
1255
+ """Whether bass is sent to the main speakers.
1256
+
1257
+ Returns:
1258
+ True if mains receive bass, False if the main-speaker bass filter
1259
+ is active, None if subwoofer status has not been read.
1260
+ """
1261
+ if self._subwoofer_status is None:
1262
+ return None
1263
+ if "main_filter" not in self._subwoofer_status:
1264
+ return None
1265
+ return int(self._subwoofer_status.get("main_filter", 0)) == 0
1266
+
1237
1267
  @property
1238
1268
  def supports_trigger_out(self) -> bool:
1239
1269
  """Whether 12V trigger output is supported (WiiM Ultra / Pro / Pro Plus)."""
@@ -1377,18 +1407,19 @@ class Player(PlayerBase):
1377
1407
 
1378
1408
  @property
1379
1409
  def supports_queue_browse(self) -> bool:
1380
- """Whether full queue retrieval is available (UPnP ContentDirectory).
1410
+ """Whether full queue retrieval is available.
1381
1411
 
1382
- Only available on WiiM Amp and Ultra when a USB drive is connected.
1383
- Most WiiM devices (Mini, Pro, Pro Plus) do not support this.
1412
+ True for ContentDirectory (Amp/Ultra + USB) or PlayQueue BrowseQueue
1413
+ (WiiM Pro / Pro Plus after a queue has been created).
1384
1414
  """
1385
1415
  return self._properties.supports_queue_browse
1386
1416
 
1387
1417
  @property
1388
1418
  def supports_queue_add(self) -> bool:
1389
- """Whether adding items to queue is supported (UPnP AVTransport).
1419
+ """Whether adding items to queue is supported.
1390
1420
 
1391
- Available on most devices with UPnP support.
1421
+ True for AVTransport AddURIToQueue or PlayQueue
1422
+ CreateQueue/AppendTracksInQueueEx/PlayQueueWithIndex.
1392
1423
  """
1393
1424
  return self._properties.supports_queue_add
1394
1425
 
@@ -9,6 +9,14 @@ from typing import TYPE_CHECKING, Any, Literal
9
9
  from xml.etree import ElementTree as ET
10
10
 
11
11
  from ..exceptions import WiiMError
12
+ from ..upnp.playqueue import (
13
+ CURRENT_QUEUE,
14
+ build_empty_playlist,
15
+ build_track_playlist,
16
+ parse_queue_context,
17
+ playqueue_supports_enqueue,
18
+ service_has_action,
19
+ )
12
20
  from .source_capabilities import SOURCE_CAPABILITIES, source_supports_native_notification_prompt
13
21
 
14
22
  if TYPE_CHECKING:
@@ -175,9 +183,9 @@ class MediaControl:
175
183
  # Call API (raises on failure)
176
184
  await self.player.client.stop()
177
185
 
178
- # Update cached state immediately (optimistic)
186
+ # Update cached state immediately (optimistic). "stop" normalizes to idle.
179
187
  if self.player._status_model:
180
- self.player._status_model.play_state = "stop"
188
+ self.player._status_model.play_state = "idle"
181
189
 
182
190
  # Update state synchronizer (for immediate property reads)
183
191
  self.player._state_synchronizer.update_from_http({"play_state": "stop"})
@@ -393,9 +401,20 @@ class MediaControl:
393
401
 
394
402
  return result
395
403
 
404
+ def _use_playqueue_enqueue(self) -> bool:
405
+ """Prefer vendor PlayQueue when AVTransport has no AddURIToQueue."""
406
+ upnp = self.player._upnp_client
407
+ if not upnp:
408
+ return False
409
+ if service_has_action(upnp.av_transport, "AddURIToQueue"):
410
+ return False
411
+ return playqueue_supports_enqueue(upnp.play_queue)
412
+
396
413
  async def add_to_queue(self, url: str, metadata: str = "") -> None:
397
414
  """Add URL to end of queue (requires UPnP client).
398
415
 
416
+ Uses PlayQueue on WiiM firmware that lacks AVTransport AddURIToQueue.
417
+
399
418
  Args:
400
419
  url: URL to add to queue.
401
420
  metadata: Optional DIDL-Lite metadata.
@@ -403,6 +422,10 @@ class MediaControl:
403
422
  if not self.player._upnp_client:
404
423
  raise WiiMError("Queue management requires UPnP client.")
405
424
 
425
+ if self._use_playqueue_enqueue():
426
+ await self._playqueue_add(url, metadata)
427
+ return
428
+
406
429
  await self.player._upnp_client.async_call_action(
407
430
  "AVTransport",
408
431
  "AddURIToQueue",
@@ -425,6 +448,10 @@ class MediaControl:
425
448
  if not self.player._upnp_client:
426
449
  raise WiiMError("Queue management requires UPnP client.")
427
450
 
451
+ if self._use_playqueue_enqueue():
452
+ await self._playqueue_insert_next(url, metadata)
453
+ return
454
+
428
455
  await self.player._upnp_client.async_call_action(
429
456
  "AVTransport",
430
457
  "InsertURIToQueue",
@@ -443,6 +470,74 @@ class MediaControl:
443
470
  elif enqueue == "next":
444
471
  await self.insert_next(url)
445
472
 
473
+ async def _playqueue_call(self, action: str, arguments: dict[str, Any]) -> dict[str, Any]:
474
+ """Call a PlayQueue action."""
475
+ upnp = self.player._upnp_client
476
+ if not upnp:
477
+ raise WiiMError("Queue management requires UPnP client.")
478
+ return await upnp.async_call_action("play_queue", action, arguments)
479
+
480
+ async def _playqueue_ensure(self) -> None:
481
+ """Create CurrentQueue if BrowseQueue says it does not exist."""
482
+ try:
483
+ await self._playqueue_call("BrowseQueue", {"QueueName": CURRENT_QUEUE})
484
+ except Exception:
485
+ await self._playqueue_call(
486
+ "CreateQueue",
487
+ {"QueueContext": build_empty_playlist()},
488
+ )
489
+
490
+ async def _playqueue_append(
491
+ self,
492
+ url: str,
493
+ metadata: str = "",
494
+ *,
495
+ start_index: int = 0,
496
+ play: int = 0,
497
+ ) -> None:
498
+ """Append one track via AppendTracksInQueueEx."""
499
+ await self._playqueue_call(
500
+ "AppendTracksInQueueEx",
501
+ {
502
+ "QueueContext": build_track_playlist(url, metadata),
503
+ "Direction": 1,
504
+ "StartIndex": start_index,
505
+ "Play": play,
506
+ "Action": "",
507
+ },
508
+ )
509
+
510
+ async def _playqueue_add(self, url: str, metadata: str = "") -> None:
511
+ """Add URL to the end of CurrentQueue without starting playback."""
512
+ await self._playqueue_ensure()
513
+ await self._playqueue_append(url, metadata)
514
+ _LOGGER.debug("Added URL to PlayQueue on %s", self.player.host)
515
+
516
+ async def _playqueue_insert_next(self, url: str, metadata: str = "") -> None:
517
+ """Insert URL after the current PlayQueue index."""
518
+ await self._playqueue_ensure()
519
+ start_index = 1
520
+ try:
521
+ result = await self._playqueue_call("GetQueueIndex", {"QueueName": CURRENT_QUEUE})
522
+ current = result.get("CurrentIndex")
523
+ if current is not None:
524
+ start_index = int(current)
525
+ except Exception as err:
526
+ _LOGGER.debug("GetQueueIndex failed on %s, appending after index 1: %s", self.player.host, err)
527
+ await self._playqueue_append(url, metadata, start_index=start_index)
528
+ _LOGGER.debug("Inserted URL next in PlayQueue on %s", self.player.host)
529
+
530
+ async def _playqueue_get(self) -> list[dict[str, Any]]:
531
+ """Browse CurrentQueue and parse Track entries."""
532
+ try:
533
+ result = await self._playqueue_call("BrowseQueue", {"QueueName": CURRENT_QUEUE})
534
+ except Exception as err:
535
+ _LOGGER.debug("PlayQueue BrowseQueue empty or failed on %s: %s", self.player.host, err)
536
+ return []
537
+ items = parse_queue_context((result or {}).get("QueueContext", "") or "")
538
+ _LOGGER.debug("Retrieved %d PlayQueue items from %s", len(items), self.player.host)
539
+ return items
540
+
446
541
  async def play_preset(self, preset: int, index: int | None = None) -> None:
447
542
  """Play a preset by number, optionally starting at a track index.
448
543
 
@@ -705,6 +800,9 @@ class MediaControl:
705
800
  if not self.player._upnp_client:
706
801
  raise WiiMError("Queue retrieval requires UPnP client.")
707
802
 
803
+ if self._use_playqueue_enqueue():
804
+ return await self._playqueue_get()
805
+
708
806
  try:
709
807
  # Browse queue using ContentDirectory service
710
808
  result = await self.player._upnp_client.browse_queue(
@@ -748,6 +846,27 @@ class MediaControl:
748
846
  if queue_position < 0:
749
847
  raise WiiMError(f"Invalid queue position: {queue_position} (must be >= 0)")
750
848
 
849
+ if self._use_playqueue_enqueue():
850
+ try:
851
+ await self._playqueue_call(
852
+ "PlayQueueWithIndex",
853
+ {"QueueName": CURRENT_QUEUE, "Index": queue_position + 1},
854
+ )
855
+ _LOGGER.debug(
856
+ "Started PlayQueue playback at position %d on %s",
857
+ queue_position,
858
+ self.player.host,
859
+ )
860
+ return
861
+ except Exception as err:
862
+ _LOGGER.warning(
863
+ "Failed to play PlayQueue position %d on %s: %s",
864
+ queue_position,
865
+ self.player.host,
866
+ err,
867
+ )
868
+ raise WiiMError(f"Failed to play queue position {queue_position}: {err}") from err
869
+
751
870
  try:
752
871
  # Use Seek with TRACK_NR unit to jump to queue position
753
872
  # UPnP uses 1-based track numbers, so add 1 to 0-based position
@@ -794,6 +913,33 @@ class MediaControl:
794
913
  if queue_position < 0:
795
914
  raise WiiMError(f"Invalid queue position: {queue_position} (must be >= 0)")
796
915
 
916
+ if self._use_playqueue_enqueue():
917
+ index = queue_position + 1
918
+ try:
919
+ await self._playqueue_call(
920
+ "RemoveTracksInQueue",
921
+ {
922
+ "QueueName": CURRENT_QUEUE,
923
+ "RangStart": index,
924
+ "RangEnd": index,
925
+ "Action": "",
926
+ },
927
+ )
928
+ _LOGGER.debug(
929
+ "Removed PlayQueue item at position %d from %s",
930
+ queue_position,
931
+ self.player.host,
932
+ )
933
+ return
934
+ except Exception as err:
935
+ _LOGGER.warning(
936
+ "Failed to remove PlayQueue position %d on %s: %s",
937
+ queue_position,
938
+ self.player.host,
939
+ err,
940
+ )
941
+ raise WiiMError(f"Failed to remove queue position {queue_position}: {err}") from err
942
+
797
943
  try:
798
944
  # RemoveTrackFromQueue uses ObjectID which is typically "Q:0/position"
799
945
  # UPnP uses 1-based track numbers
@@ -835,6 +981,15 @@ class MediaControl:
835
981
  if not self.player._upnp_client:
836
982
  raise WiiMError("Queue management requires UPnP client.")
837
983
 
984
+ if self._use_playqueue_enqueue():
985
+ try:
986
+ await self._playqueue_call("DeleteQueue", {"QueueName": CURRENT_QUEUE})
987
+ _LOGGER.debug("Cleared PlayQueue on %s", self.player.host)
988
+ return
989
+ except Exception as err:
990
+ _LOGGER.warning("Failed to clear PlayQueue on %s: %s", self.player.host, err)
991
+ raise WiiMError(f"Failed to clear queue: {err}") from err
992
+
838
993
  try:
839
994
  await self.player._upnp_client.async_call_action(
840
995
  "av_transport",
@@ -9,6 +9,7 @@ from ..device_capabilities import filter_plm_inputs, get_device_inputs
9
9
  from ..metadata import is_valid_image_url, is_valid_metadata_value
10
10
  from ..normalize import HARDWARE_SOURCE_KEYS as _HARDWARE_SOURCE_KEYS
11
11
  from ..normalize import canonical_source_key, source_rename_reverse
12
+ from ..upnp.playqueue import playqueue_supports_enqueue, service_has_action
12
13
  from .source_capabilities import SourceCapability, get_source_capabilities
13
14
 
14
15
  if TYPE_CHECKING:
@@ -184,12 +185,10 @@ class PlayerProperties:
184
185
  Returns True when device has no active media. This indicates the device
185
186
  is not playing and has no media to resume.
186
187
 
187
- Note: "stop" states are normalized to "pause" (modern UX), so stopped
188
- devices are considered paused, not idle. Use is_paused to check for
189
- both paused and stopped states.
188
+ Note: HTTP/UPnP "stop" and "stopped" normalize to idle.
190
189
  """
191
190
  state = self.play_state
192
- return state is None or state in ("idle", "none")
191
+ return state is None or state in ("idle", "none", "stop", "stopped")
193
192
 
194
193
  @property
195
194
  def is_buffering(self) -> bool:
@@ -239,11 +238,22 @@ class PlayerProperties:
239
238
  # === Media Metadata ===
240
239
 
241
240
  def _status_field(self, *names: str) -> str | None:
242
- """Return the first non-empty attribute from merged state or cached status."""
243
- # First try merged state (combines HTTP + UPnP)
244
- merged = self.player._state_synchronizer.get_merged_state()
241
+ """Return the first non-empty attribute from merged state or cached status.
242
+
243
+ Only fall back to the raw cached status for names the synchronizer has
244
+ never resolved. If it resolved a name to no value (live stream, source
245
+ change), that decision must not be overridden by leftover firmware
246
+ fields still sitting on ``_status_model`` (wiim #263).
247
+ """
248
+ state_obj = self.player._state_synchronizer.get_state_object()
249
+ names_unresolved_by_synchronizer: list[str] = []
245
250
  for n in names:
246
- val = merged.get(n)
251
+ field = getattr(state_obj, n, None)
252
+ if field is None:
253
+ names_unresolved_by_synchronizer.append(n)
254
+ continue
255
+
256
+ val = field.value
247
257
  if val is not None:
248
258
  # Field-aware filtering: artwork fields must be valid URLs,
249
259
  # other metadata must be non-placeholder values.
@@ -270,11 +280,18 @@ class PlayerProperties:
270
280
  if val not in (None, ""):
271
281
  return str(val) if val is not None else None
272
282
 
273
- # Fallback to cached status if synchronizer has no data yet
274
- if self.player._status_model is None:
283
+ # Fallback to cached status only before the first merge. After that the
284
+ # raw status model is a leftover firmware snapshot and must not reopen
285
+ # fields the synchronizer already decided are empty (wiim #263).
286
+ if (
287
+ self.player._status_model is None
288
+ or not names_unresolved_by_synchronizer
289
+ or state_obj.http_last_update is not None
290
+ or state_obj.upnp_last_update is not None
291
+ ):
275
292
  return None
276
293
 
277
- for n in names:
294
+ for n in names_unresolved_by_synchronizer:
278
295
  if hasattr(self.player._status_model, n):
279
296
  val = getattr(self.player._status_model, n)
280
297
  if n in {"image_url", "entity_picture", "cover_url"}:
@@ -311,6 +328,11 @@ class PlayerProperties:
311
328
  if title:
312
329
  return title
313
330
 
331
+ # URL filename is only a stand-in for URL playback. Hardware inputs and
332
+ # idle/stop must not keep showing the last play_url() filename (#263).
333
+ if self.is_idle or self._is_physical_input_source():
334
+ return None
335
+
314
336
  # Fallback: extract filename from last played URL
315
337
  url = self.player._last_played_url
316
338
  if url:
@@ -335,6 +357,26 @@ class PlayerProperties:
335
357
 
336
358
  return None
337
359
 
360
+ def _is_physical_input_source(self) -> bool:
361
+ """Return True for passthrough inputs that never use play_url() metadata."""
362
+ source = (self.source or "").strip().lower().replace("-", "_")
363
+ return source in {
364
+ "line_in",
365
+ "linein",
366
+ "optical",
367
+ "coaxial",
368
+ "coax",
369
+ "aux",
370
+ "hdmi",
371
+ "phono",
372
+ "rca",
373
+ "line_in_2",
374
+ "linein_2",
375
+ "bluetooth",
376
+ "usb_dac",
377
+ "usbdac",
378
+ }
379
+
338
380
  @property
339
381
  def media_artist(self) -> str | None:
340
382
  """Current track artist from cached status."""
@@ -1713,24 +1755,31 @@ class PlayerProperties:
1713
1755
 
1714
1756
  @property
1715
1757
  def supports_queue_browse(self) -> bool:
1716
- """Whether full queue retrieval is available (UPnP ContentDirectory).
1758
+ """Whether full queue retrieval is available.
1717
1759
 
1718
- Only available on WiiM Amp and Ultra when a USB drive is connected.
1719
- Most WiiM devices (Mini, Pro, Pro Plus) do not support this.
1760
+ True when ContentDirectory is present (Amp/Ultra + USB) or when the
1761
+ vendor PlayQueue service can BrowseQueue (WiiM Pro / Pro Plus).
1720
1762
  """
1721
1763
  if not self.player._upnp_client:
1722
1764
  return False
1723
- return self.player._upnp_client.content_directory is not None
1765
+ if self.player._upnp_client.content_directory is not None:
1766
+ return True
1767
+ return service_has_action(self.player._upnp_client.play_queue, "BrowseQueue")
1724
1768
 
1725
1769
  @property
1726
1770
  def supports_queue_add(self) -> bool:
1727
- """Whether adding items to queue is supported (UPnP AVTransport).
1771
+ """Whether adding items to queue is supported.
1728
1772
 
1729
- Available on most devices with UPnP support.
1773
+ True when AVTransport advertises ``AddURIToQueue`` (Sonos-style) or
1774
+ when PlayQueue advertises CreateQueue + AppendTracksInQueueEx +
1775
+ PlayQueueWithIndex (WiiM Pro / Pro Plus). Presence of AVTransport
1776
+ alone is not enough.
1730
1777
  """
1731
1778
  if not self.player._upnp_client:
1732
1779
  return False
1733
- return self.player._upnp_client.av_transport is not None
1780
+ if service_has_action(self.player._upnp_client.av_transport, "AddURIToQueue"):
1781
+ return True
1782
+ return playqueue_supports_enqueue(self.player._upnp_client.play_queue)
1734
1783
 
1735
1784
  @property
1736
1785
  def supports_queue_count(self) -> bool:
@@ -670,6 +670,13 @@ class StateManager:
670
670
  self._last_source = current_source
671
671
  elif source_changed:
672
672
  self._last_source = current_source
673
+ # play_url() filename fallback must not survive a source switch (#263)
674
+ self.player._last_played_url = None
675
+ if status:
676
+ status.title = None
677
+ status.artist = None
678
+ status.album = None
679
+ status.duration = None
673
680
 
674
681
  # Check if we need to enrich metadata (title/artist are Unknown - common with Bluetooth AVRCP)
675
682
  status_title = status.title if status else None