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.
- {pywiim-2.2.1/pywiim.egg-info → pywiim-2.2.3}/PKG-INFO +1 -1
- {pywiim-2.2.1 → pywiim-2.2.3}/pyproject.toml +1 -1
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/__init__.py +1 -1
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/parser.py +19 -5
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/subwoofer.py +25 -2
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/capabilities.py +76 -2
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/client.py +24 -3
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/__init__.py +2 -2
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/statemgr.py +4 -13
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/polling.py +1 -1
- {pywiim-2.2.1 → pywiim-2.2.3/pywiim.egg-info}/PKG-INFO +1 -1
- {pywiim-2.2.1 → pywiim-2.2.3}/LICENSE +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/README.md +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/__init__.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/audio_pro.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/audio_settings.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/base.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/bluetooth.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/constants.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/device.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/diagnostics.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/endpoints.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/eq.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/firmware.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/group.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/lms.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/loop_mode.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/misc.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/peq.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/playback.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/preset.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/ssl.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/api/timer.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/backoff.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/__init__.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/diagnostics.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/discovery_cli.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/group_test_cli.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/join_test_cli.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/monitor_cli.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/cli/verify_cli.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/device_capabilities.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/discovery.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/exceptions.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/group.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/group_helpers.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/__init__.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/__main__.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/config.example.json +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/config.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/context.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/mcp/server.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/metadata.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/model_names.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/models.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/normalize.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/audio.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/base.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/bluetooth.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/coverart.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/debounce.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/diagnostics.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/groupops.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/media.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/playback.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/properties.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/source_capabilities.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/stream.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/stream_enricher.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/player/volume.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/profiles.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/py.typed +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/role.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/state.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/__init__.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/client.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/eventer.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim/upnp/health.py +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/SOURCES.txt +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/dependency_links.txt +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/entry_points.txt +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/requires.txt +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/pywiim.egg-info/top_level.txt +0 -0
- {pywiim-2.2.1 → pywiim-2.2.3}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1213
|
+
"""Whether subwoofer control is supported (probed via getSubLPF at capability detection)."""
|
|
1214
1214
|
if not self.client:
|
|
1215
1215
|
return False
|
|
1216
|
-
return
|
|
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
|
|
923
|
-
#
|
|
924
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|