pywiim 2.2.1__tar.gz → 2.2.3__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 (84) hide show
  1. {pywiim-2.2.1/pywiim.egg-info → pywiim-2.2.3}/PKG-INFO +1 -1
  2. {pywiim-2.2.1 → pywiim-2.2.3}/pyproject.toml +1 -1
  3. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/__init__.py +1 -1
  4. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/parser.py +19 -5
  5. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/subwoofer.py +25 -2
  6. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/capabilities.py +76 -2
  7. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/client.py +24 -3
  8. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/__init__.py +2 -2
  9. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/statemgr.py +4 -13
  10. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/polling.py +1 -1
  11. {pywiim-2.2.1 → pywiim-2.2.3/pywiim.egg-info}/PKG-INFO +1 -1
  12. {pywiim-2.2.1 → pywiim-2.2.3}/LICENSE +0 -0
  13. {pywiim-2.2.1 → pywiim-2.2.3}/README.md +0 -0
  14. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/__init__.py +0 -0
  15. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/audio_pro.py +0 -0
  16. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/audio_settings.py +0 -0
  17. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/base.py +0 -0
  18. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/bluetooth.py +0 -0
  19. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/constants.py +0 -0
  20. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/device.py +0 -0
  21. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/diagnostics.py +0 -0
  22. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/endpoints.py +0 -0
  23. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/eq.py +0 -0
  24. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/firmware.py +0 -0
  25. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/group.py +0 -0
  26. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/lms.py +0 -0
  27. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/loop_mode.py +0 -0
  28. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/misc.py +0 -0
  29. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/peq.py +0 -0
  30. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/playback.py +0 -0
  31. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/preset.py +0 -0
  32. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/ssl.py +0 -0
  33. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/timer.py +0 -0
  34. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/backoff.py +0 -0
  35. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/__init__.py +0 -0
  36. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/diagnostics.py +0 -0
  37. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/discovery_cli.py +0 -0
  38. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/group_test_cli.py +0 -0
  39. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/join_test_cli.py +0 -0
  40. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/monitor_cli.py +0 -0
  41. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/verify_cli.py +0 -0
  42. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/device_capabilities.py +0 -0
  43. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/discovery.py +0 -0
  44. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/exceptions.py +0 -0
  45. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/group.py +0 -0
  46. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/group_helpers.py +0 -0
  47. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/__init__.py +0 -0
  48. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/__main__.py +0 -0
  49. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/config.example.json +0 -0
  50. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/config.py +0 -0
  51. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/context.py +0 -0
  52. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/server.py +0 -0
  53. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/metadata.py +0 -0
  54. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/model_names.py +0 -0
  55. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/models.py +0 -0
  56. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/normalize.py +0 -0
  57. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/audio.py +0 -0
  58. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/base.py +0 -0
  59. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/bluetooth.py +0 -0
  60. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/coverart.py +0 -0
  61. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/debounce.py +0 -0
  62. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/diagnostics.py +0 -0
  63. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/groupops.py +0 -0
  64. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/media.py +0 -0
  65. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/playback.py +0 -0
  66. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/properties.py +0 -0
  67. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/source_capabilities.py +0 -0
  68. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/stream.py +0 -0
  69. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/stream_enricher.py +0 -0
  70. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/volume.py +0 -0
  71. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/profiles.py +0 -0
  72. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/py.typed +0 -0
  73. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/role.py +0 -0
  74. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/state.py +0 -0
  75. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/__init__.py +0 -0
  76. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/client.py +0 -0
  77. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/eventer.py +0 -0
  78. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/health.py +0 -0
  79. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/SOURCES.txt +0 -0
  80. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/dependency_links.txt +0 -0
  81. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/entry_points.txt +0 -0
  82. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/requires.txt +0 -0
  83. {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/top_level.txt +0 -0
  84. {pywiim-2.2.1 → pywiim-2.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.1
3
+ Version: 2.2.3
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.2.1"
7
+ version = "2.2.3"
8
8
  description = "Python library for WiiM/LinkPlay device communication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -87,7 +87,7 @@ from .profiles import (
87
87
  from .role import RoleDetectionResult, detect_role
88
88
  from .state import GroupStateSynchronizer, StateSynchronizer
89
89
 
90
- __version__ = "2.2.1"
90
+ __version__ = "2.2.3"
91
91
  __all__ = [
92
92
  # Main client
93
93
  "WiiMClient",
@@ -469,7 +469,7 @@ def _handle_qobuz_connect_state_quirks(data: dict[str, Any], raw: dict[str, Any]
469
469
  """Handle Qobuz Connect state detection quirks.
470
470
 
471
471
  Addresses GitHub issue #35: Qobuz Connect shows playing briefly then switches to idle.
472
- This implements the enhanced state detection logic that was added in python-linkplay v0.2.9.
472
+ Also handles HTTP ``status: "none"`` with live timeline/metadata (mjcumming/wiim#222).
473
473
 
474
474
  Args:
475
475
  data: Parsed data dictionary (modified in place)
@@ -477,16 +477,30 @@ def _handle_qobuz_connect_state_quirks(data: dict[str, Any], raw: dict[str, Any]
477
477
  """
478
478
  current_status = data.get("play_status", "").lower()
479
479
 
480
- # Only apply workaround when status appears to be incorrectly reported as stopped/idle
481
- if current_status not in {"stop", "stopped", "idle", ""}:
482
- return # Status appears correct, don't interfere
480
+ # Only skip when the device already reports a normal transport state we should not override.
481
+ # Qobuz Connect often reports ``status: "none"`` while ``curpos`` / ``totlen`` and metadata
482
+ # still reflect an active stream (see mjcumming/wiim#222); ``none`` must not bypass this path.
483
+ if current_status in {
484
+ "play",
485
+ "playing",
486
+ "pause",
487
+ "paused",
488
+ "paused playback",
489
+ "load",
490
+ "loading",
491
+ "buffering",
492
+ "transitioning",
493
+ }:
494
+ return
483
495
 
484
496
  # Enhanced detection: Look for multiple indicators that suggest active playback
485
497
  # This mimics the improved logic from python-linkplay v0.2.9
486
498
 
487
499
  title = data.get("title")
488
500
  has_track_info = bool(title and isinstance(title, str) and title.strip() and title != "Unknown")
489
- has_position_info = bool(data.get("position") or raw.get("curpos") or raw.get("offset_pts"))
501
+ has_position_info = (
502
+ data.get("position") is not None or raw.get("curpos") is not None or raw.get("offset_pts") is not None
503
+ )
490
504
  has_duration_info = bool(data.get("duration") or raw.get("totlen"))
491
505
  has_artwork = bool(data.get("entity_picture") or raw.get("cover") or raw.get("albumArtURI"))
492
506
 
@@ -42,6 +42,27 @@ from .constants import (
42
42
  SUBWOOFER_PHASE_180,
43
43
  )
44
44
 
45
+ _SUBWOOFER_LPF_KNOWN_KEYS = frozenset(
46
+ {"cross", "plugged", "sub_delay", "main_filter", "sub_filter", "mix_sub", "output_mode"}
47
+ )
48
+
49
+
50
+ def is_valid_subwoofer_lpf_dict(parsed: Any) -> bool:
51
+ """True when *parsed* is a real getSubLPF status object (not an API error wrapper).
52
+
53
+ Some vendors return HTTP 200 with JSON like ``{\"error\": \"unsupported_command\"}``;
54
+ those must not be treated as subwoofer support or coerced into :class:`SubwooferStatus`.
55
+ """
56
+ if not isinstance(parsed, dict):
57
+ return False
58
+ if "error" in parsed:
59
+ return False
60
+ keys = parsed.keys()
61
+ if keys & _SUBWOOFER_LPF_KNOWN_KEYS:
62
+ return True
63
+ st = parsed.get("status")
64
+ return isinstance(st, int) and ("level" in parsed or "phase" in parsed)
65
+
45
66
 
46
67
  @dataclass
47
68
  class SubwooferStatus:
@@ -161,7 +182,7 @@ class SubwooferAPI:
161
182
  """
162
183
  try:
163
184
  response = await self._request(API_ENDPOINT_SUBWOOFER_STATUS) # type: ignore[attr-defined]
164
- if isinstance(response.parsed, dict):
185
+ if isinstance(response.parsed, dict) and is_valid_subwoofer_lpf_dict(response.parsed):
165
186
  return SubwooferStatus.from_dict(response.parsed)
166
187
  return None
167
188
  except WiiMError:
@@ -175,7 +196,9 @@ class SubwooferAPI:
175
196
  """
176
197
  try:
177
198
  response = await self._request(API_ENDPOINT_SUBWOOFER_STATUS) # type: ignore[attr-defined]
178
- return response.parsed if isinstance(response.parsed, dict) else None
199
+ if isinstance(response.parsed, dict) and is_valid_subwoofer_lpf_dict(response.parsed):
200
+ return response.parsed
201
+ return None
179
202
  except WiiMError:
180
203
  return None
181
204
 
@@ -25,6 +25,7 @@ The capability detection system uses a multi-layer approach:
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ import asyncio
28
29
  import logging
29
30
  from typing import Any
30
31
  from urllib.parse import quote
@@ -37,9 +38,11 @@ from .api.constants import (
37
38
  API_ENDPOINT_GET_CHANNEL_BALANCE,
38
39
  API_ENDPOINT_PEQ_GET_LIST,
39
40
  API_ENDPOINT_SET_LED,
41
+ API_ENDPOINT_SUBWOOFER_STATUS,
40
42
  API_ENDPOINT_TRIGGER_OUT_STATUS,
41
43
  PEQ_PLUGIN_URI,
42
44
  )
45
+ from .api.subwoofer import is_valid_subwoofer_lpf_dict
43
46
  from .exceptions import WiiMError
44
47
  from .model_names import is_known_wiim_model, is_wiim_ultra
45
48
  from .models import DeviceInfo
@@ -87,6 +90,69 @@ def _channel_balance_probe_success(response: ApiResponse) -> bool:
87
90
  return True
88
91
 
89
92
 
93
+ def _subwoofer_probe_error_definitively_unsupported(err: Exception) -> bool:
94
+ """True when the error indicates the endpoint/command is not available."""
95
+ s = str(err).lower()
96
+ return "unknown command" in s or "404" in s or "not found" in s
97
+
98
+
99
+ async def _probe_supports_subwoofer(client: Any, device_info: DeviceInfo, capabilities: dict[str, Any]) -> bool | None:
100
+ """Runtime probe for getSubLPF (same backing as get_subwoofer_status_raw).
101
+
102
+ Returns:
103
+ True if supported, False if definitively unsupported, None for WiiM when
104
+ the probe stays inconclusive after retries (transient errors).
105
+ """
106
+ if not capabilities.get("is_wiim_device"):
107
+ return False
108
+
109
+ host = getattr(client, "host", "?")
110
+ model = device_info.model or "unknown"
111
+ max_attempts = 3
112
+ last_err: Exception | None = None
113
+
114
+ for attempt in range(max_attempts):
115
+ try:
116
+ r = await client._request(API_ENDPOINT_SUBWOOFER_STATUS)
117
+ if is_valid_subwoofer_lpf_dict(r.parsed):
118
+ if attempt:
119
+ _LOGGER.debug("getSubLPF succeeded on retry for host=%s model=%s", host, model)
120
+ return True
121
+ raw_preview = (r.raw or "")[:200] if r.raw else None
122
+ _LOGGER.debug(
123
+ "Subwoofer probe host=%s model=%s: unsupported or invalid response "
124
+ "(expected subwoofer dict); parsed_type=%s raw_preview=%r",
125
+ host,
126
+ model,
127
+ type(r.parsed).__name__,
128
+ raw_preview,
129
+ )
130
+ return False
131
+ except Exception as err:
132
+ last_err = err
133
+ if _subwoofer_probe_error_definitively_unsupported(err):
134
+ _LOGGER.debug(
135
+ "Subwoofer probe host=%s model=%s: unsupported (error=%s)",
136
+ host,
137
+ model,
138
+ err,
139
+ )
140
+ return False
141
+ if attempt < max_attempts - 1 and is_legacy_firmware_error(err):
142
+ await asyncio.sleep(0.15)
143
+ continue
144
+ break
145
+
146
+ _LOGGER.warning(
147
+ "Subwoofer probe inconclusive for host=%s model=%s after %d attempts: %s",
148
+ host,
149
+ model,
150
+ max_attempts,
151
+ last_err,
152
+ )
153
+ return None
154
+
155
+
90
156
  __all__ = [
91
157
  "WiiMCapabilities",
92
158
  "detect_device_capabilities",
@@ -115,12 +181,17 @@ class WiiMCapabilities:
115
181
  self._firmware_versions: dict[str, str] = {}
116
182
  self._device_types: dict[str, str] = {}
117
183
 
118
- async def detect_capabilities(self, client: Any, device_info: DeviceInfo) -> dict[str, Any]:
184
+ def invalidate_device(self, device_id: str) -> None:
185
+ """Drop cached capabilities for one device (host:uuid) so the next detect re-probes."""
186
+ self._capabilities.pop(device_id, None)
187
+
188
+ async def detect_capabilities(self, client: Any, device_info: DeviceInfo, *, force: bool = False) -> dict[str, Any]:
119
189
  """Probe device capabilities and cache results.
120
190
 
121
191
  Args:
122
192
  client: WiiM API client instance (must have _request method and host attribute)
123
193
  device_info: Device information from getStatusEx
194
+ force: When True, ignore cached capabilities and re-run all probes.
124
195
 
125
196
  Returns:
126
197
  Dictionary of device capabilities with vendor, device type, firmware,
@@ -128,7 +199,7 @@ class WiiMCapabilities:
128
199
  """
129
200
  device_id = f"{client.host}:{device_info.uuid}"
130
201
 
131
- if device_id in self._capabilities:
202
+ if not force and device_id in self._capabilities:
132
203
  # Return cached capabilities, but ensure vendor is normalized
133
204
  cached = self._capabilities[device_id].copy()
134
205
  if "vendor" not in cached or not cached.get("vendor"):
@@ -391,6 +462,9 @@ class WiiMCapabilities:
391
462
  except WiiMError:
392
463
  _LOGGER.debug("Device %s does not support 12V trigger (getTriggeroutStatus)", client.host)
393
464
 
465
+ # Subwoofer (getSubLPF) — WiiM-only read probe; same endpoint as get_subwoofer_status_raw()
466
+ capabilities["supports_subwoofer"] = await _probe_supports_subwoofer(client, device_info, capabilities)
467
+
394
468
  # Probe for Status Light (LED_SWITCH_SET) - "Status Light" in app; some devices use this instead of setLED
395
469
  try:
396
470
  await client._request(f"{API_ENDPOINT_SET_LED}0")
@@ -140,7 +140,7 @@ class WiiMClient(
140
140
  self._capabilities_detected = capabilities is not None
141
141
  self._detecting_capabilities = False # Flag to prevent recursion
142
142
 
143
- async def _detect_capabilities(self) -> dict[str, Any]:
143
+ async def _detect_capabilities(self, force: bool = False) -> dict[str, Any]:
144
144
  """Detect device capabilities and update client configuration.
145
145
 
146
146
  This method is called automatically on first use if capabilities were not
@@ -151,13 +151,17 @@ class WiiMClient(
151
151
  - Protocol preferences
152
152
  - Generation-specific quirks
153
153
 
154
+ Args:
155
+ force: When True, bypass the early return and re-run probing (used by
156
+ :meth:`refresh_capabilities` so OTA firmware can change support flags).
157
+
154
158
  Returns:
155
159
  Dictionary of detected capabilities.
156
160
 
157
161
  Raises:
158
162
  WiiMError: If capability detection fails.
159
163
  """
160
- if self._capabilities_detected:
164
+ if self._capabilities_detected and not force:
161
165
  return self._capabilities
162
166
 
163
167
  try:
@@ -165,7 +169,7 @@ class WiiMClient(
165
169
  device_info = await BaseWiiMClient.get_device_info_model(self)
166
170
 
167
171
  # Detect capabilities using the capability detector
168
- capabilities = await self._capability_detector.detect_capabilities(self, device_info)
172
+ capabilities = await self._capability_detector.detect_capabilities(self, device_info, force=force)
169
173
 
170
174
  # Ensure vendor is normalized (safety check)
171
175
  from .normalize import normalize_vendor
@@ -227,6 +231,7 @@ class WiiMClient(
227
231
  upnp_caps = await self._safe_collect_upnp_description_capabilities()
228
232
  if upnp_caps:
229
233
  capabilities.update(upnp_caps)
234
+ capabilities.setdefault("supports_subwoofer", False)
230
235
  self._capabilities.update(capabilities)
231
236
  self._capabilities_detected = True
232
237
  return self._capabilities
@@ -235,6 +240,22 @@ class WiiMClient(
235
240
  self._capabilities_detected = True
236
241
  return self._capabilities
237
242
 
243
+ async def refresh_capabilities(self, force: bool = True) -> dict[str, Any]:
244
+ """Re-run capability detection including runtime probes (e.g. after firmware OTA).
245
+
246
+ Args:
247
+ force: When True (default), drop the detector cache for this device so
248
+ probes such as getSubLPF run again.
249
+
250
+ Returns:
251
+ Updated capabilities dict (same object as :attr:`capabilities`).
252
+ """
253
+ device_info = await BaseWiiMClient.get_device_info_model(self)
254
+ if force:
255
+ self._capability_detector.invalidate_device(f"{self.host}:{device_info.uuid}")
256
+ self._capabilities_detected = False
257
+ return await self._detect_capabilities(force=force)
258
+
238
259
  async def _safe_collect_upnp_description_capabilities(self) -> dict[str, Any]:
239
260
  """Collect UPnP description.xml metadata without raising on errors."""
240
261
  try:
@@ -1210,10 +1210,10 @@ class Player(PlayerBase):
1210
1210
 
1211
1211
  @property
1212
1212
  def supports_subwoofer(self) -> bool:
1213
- """Whether subwoofer control is supported (WiiM Ultra with firmware 5.2+)."""
1213
+ """Whether subwoofer control is supported (probed via getSubLPF at capability detection)."""
1214
1214
  if not self.client:
1215
1215
  return False
1216
- return bool(self.client.capabilities.get("supports_subwoofer", False))
1216
+ return self.client.capabilities.get("supports_subwoofer") is True
1217
1217
 
1218
1218
  @property
1219
1219
  def supports_trigger_out(self) -> bool:
@@ -919,34 +919,25 @@ class StateManager:
919
919
  _LOGGER.debug("Failed to fetch Bluetooth history for %s: %s", self.player.host, err)
920
920
  self.player._bluetooth_history = []
921
921
 
922
- # Subwoofer Status - Fetch on full refresh or periodically (every 60s)
923
- # Only available on WiiM Ultra with firmware 5.2+
924
- # Subwoofer settings are "set and forget" config, so infrequent polling is fine
925
- subwoofer_supported = self.player.client.capabilities.get("supports_subwoofer", None)
922
+ # Subwoofer status supports_subwoofer is set in capabilities detection (getSubLPF probe).
923
+ # Here we only refresh the cached dict when support is True or still unknown (None).
924
+ subwoofer_supported = self.player.client.capabilities.get("supports_subwoofer")
926
925
  should_fetch_subwoofer = full or (
927
926
  self._polling_strategy
928
927
  and self._polling_strategy.should_fetch_subwoofer(
929
928
  self.player._last_subwoofer_check, subwoofer_supported, now=now
930
929
  )
931
930
  )
932
- # First time: probe to see if subwoofer is supported (subwoofer_supported is None)
933
- if should_fetch_subwoofer or subwoofer_supported is None:
931
+ if should_fetch_subwoofer and subwoofer_supported is not False:
934
932
  try:
935
933
  subwoofer_status = await self.player.client.get_subwoofer_status_raw()
936
934
  if subwoofer_status is not None:
937
935
  self.player._subwoofer_status = subwoofer_status
938
936
  self.player._last_subwoofer_check = now
939
- # Mark as supported if we got a valid response
940
937
  if subwoofer_supported is None:
941
938
  self.player.client._capabilities["supports_subwoofer"] = True
942
- else:
943
- # API returned None - not supported
944
- if subwoofer_supported is None:
945
- self.player.client._capabilities["supports_subwoofer"] = False
946
939
  except Exception as err:
947
940
  _LOGGER.debug("Failed to fetch subwoofer status for %s: %s", self.player.host, err)
948
- if subwoofer_supported is None:
949
- self.player.client._capabilities["supports_subwoofer"] = False
950
941
 
951
942
  async def _finalize_refresh(self) -> None:
952
943
  """Finalize refresh: sync group state, propagate metadata, notify callback."""
@@ -315,7 +315,7 @@ class PollingStrategy:
315
315
 
316
316
  Args:
317
317
  last_fetch_time: Timestamp of last subwoofer status fetch
318
- subwoofer_supported: Whether device supports subwoofer endpoint
318
+ subwoofer_supported: True if supported, False if not, None if capability probe was inconclusive
319
319
  now: Current time (defaults to time.time())
320
320
 
321
321
  Returns:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.1
3
+ Version: 2.2.3
4
4
  Summary: Python library for WiiM/LinkPlay device communication
5
5
  Author-email: Michael Cumming <mjcumming@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes