pywiim 2.2.2__tar.gz → 2.2.4__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.2/pywiim.egg-info → pywiim-2.2.4}/PKG-INFO +1 -1
- {pywiim-2.2.2 → pywiim-2.2.4}/pyproject.toml +1 -1
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/__init__.py +1 -1
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/base.py +12 -2
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/constants.py +3 -1
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/loop_mode.py +58 -10
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/parser.py +28 -10
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/peq.py +5 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/capabilities.py +11 -26
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/verify_cli.py +5 -27
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/model_names.py +31 -1
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/playback.py +4 -8
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/properties.py +4 -8
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/profiles.py +20 -2
- {pywiim-2.2.2 → pywiim-2.2.4/pywiim.egg-info}/PKG-INFO +1 -1
- {pywiim-2.2.2 → pywiim-2.2.4}/LICENSE +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/README.md +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/__init__.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/audio_pro.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/audio_settings.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/bluetooth.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/device.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/diagnostics.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/endpoints.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/eq.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/firmware.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/group.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/lms.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/misc.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/playback.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/preset.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/ssl.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/subwoofer.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/api/timer.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/backoff.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/__init__.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/diagnostics.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/discovery_cli.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/group_test_cli.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/join_test_cli.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/cli/monitor_cli.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/client.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/device_capabilities.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/discovery.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/exceptions.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/group.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/group_helpers.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/mcp/__init__.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/mcp/__main__.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/mcp/config.example.json +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/mcp/config.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/mcp/context.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/mcp/server.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/metadata.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/models.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/normalize.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/__init__.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/audio.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/base.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/bluetooth.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/coverart.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/debounce.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/diagnostics.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/groupops.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/media.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/source_capabilities.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/statemgr.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/stream.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/stream_enricher.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/player/volume.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/polling.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/py.typed +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/role.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/state.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/upnp/__init__.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/upnp/client.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/upnp/eventer.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim/upnp/health.py +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim.egg-info/SOURCES.txt +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim.egg-info/dependency_links.txt +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim.egg-info/entry_points.txt +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim.egg-info/requires.txt +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/pywiim.egg-info/top_level.txt +0 -0
- {pywiim-2.2.2 → pywiim-2.2.4}/setup.cfg +0 -0
|
@@ -930,7 +930,12 @@ class BaseWiiMClient:
|
|
|
930
930
|
r = await self._request(API_ENDPOINT_STATUS)
|
|
931
931
|
vendor = self._capabilities.get("vendor")
|
|
932
932
|
data = r.parsed if isinstance(r.parsed, dict) else {}
|
|
933
|
-
parsed, self._last_track = parse_player_status(
|
|
933
|
+
parsed, self._last_track = parse_player_status(
|
|
934
|
+
data,
|
|
935
|
+
self._last_track,
|
|
936
|
+
vendor,
|
|
937
|
+
loop_mode_scheme=self._capabilities.get("loop_mode_scheme"),
|
|
938
|
+
)
|
|
934
939
|
return parsed
|
|
935
940
|
|
|
936
941
|
async def get_device_info(self) -> dict[str, Any]:
|
|
@@ -990,7 +995,12 @@ class BaseWiiMClient:
|
|
|
990
995
|
raise primary_err
|
|
991
996
|
|
|
992
997
|
data = r.parsed if isinstance(r.parsed, dict) else {}
|
|
993
|
-
parsed, self._last_track = parse_player_status(
|
|
998
|
+
parsed, self._last_track = parse_player_status(
|
|
999
|
+
data,
|
|
1000
|
+
self._last_track,
|
|
1001
|
+
self._capabilities.get("vendor"),
|
|
1002
|
+
loop_mode_scheme=self._capabilities.get("loop_mode_scheme"),
|
|
1003
|
+
)
|
|
994
1004
|
|
|
995
1005
|
# If artwork is missing or invalid and device supports getMetaInfo, try to fetch it
|
|
996
1006
|
entity_picture = parsed.get("entity_picture")
|
|
@@ -435,7 +435,9 @@ API_ENDPOINT_TRIGGER_OUT_STATUS = "/httpapi.asp?command=getTriggeroutStatus"
|
|
|
435
435
|
API_ENDPOINT_TRIGGER_OUT_SET = "/httpapi.asp?command=setTriggeroutStatus:"
|
|
436
436
|
|
|
437
437
|
# PEQ (Parametric Equalizer) endpoints - official WiiM LV2 PEQ API
|
|
438
|
-
# pluginURI: http://moddevices.com/plugins/caps/EqNp
|
|
438
|
+
# pluginURI (implemented in pywiim): http://moddevices.com/plugins/caps/EqNp
|
|
439
|
+
# Same endpoints also accept other LV2 plugins (e.g. graphic 10-band Eq10HP);
|
|
440
|
+
# pywiim does not implement Eq10HP — see docs/integration/API_REFERENCE.md (PEQ section).
|
|
439
441
|
API_ENDPOINT_PEQ_GET_BAND = "/httpapi.asp?command=EQGetLV2BandEx:"
|
|
440
442
|
API_ENDPOINT_PEQ_GET_SOURCE_BAND = "/httpapi.asp?command=EQGetLV2SourceBandEx:"
|
|
441
443
|
API_ENDPOINT_PEQ_SET_BAND = "/httpapi.asp?command=EQSetLV2Band:"
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
"""Loop mode mappings for different device vendors.
|
|
2
2
|
|
|
3
|
-
WiiM and Arylic devices use different
|
|
4
|
-
|
|
3
|
+
WiiM and Arylic devices use different ``loop_mode`` integer schemes. Prefer
|
|
4
|
+
:func:`resolve_loop_mode_mapping` with ``loop_mode_scheme`` from
|
|
5
|
+
:class:`pywiim.profiles.DeviceProfile` — some WiiM firmware (e.g. Ultra 5.2+)
|
|
6
|
+
uses LinkPlay/Arylic numbering while ``vendor`` remains ``wiim``.
|
|
7
|
+
See https://github.com/mjcumming/pywiim/issues/17
|
|
5
8
|
"""
|
|
6
9
|
|
|
7
10
|
from __future__ import annotations
|
|
8
11
|
|
|
9
|
-
from typing import NamedTuple
|
|
12
|
+
from typing import Any, NamedTuple
|
|
10
13
|
|
|
11
14
|
__all__ = [
|
|
12
15
|
"LoopModeMapping",
|
|
13
16
|
"get_loop_mode_mapping",
|
|
17
|
+
"get_loop_mode_mapping_for_scheme",
|
|
18
|
+
"resolve_loop_mode_mapping",
|
|
19
|
+
"resolve_loop_mode_mapping_for_player",
|
|
14
20
|
"WIIM_LOOP_MODE",
|
|
15
21
|
"ARYLIC_LOOP_MODE",
|
|
16
22
|
]
|
|
@@ -72,11 +78,6 @@ class LoopModeMapping(NamedTuple):
|
|
|
72
78
|
if loop_mode == self.normal:
|
|
73
79
|
return (False, False, False)
|
|
74
80
|
|
|
75
|
-
# Special case: loop_mode=5 is used by some sources (e.g., Spotify Connect)
|
|
76
|
-
# when they control playback externally. Treat as normal/unknown state.
|
|
77
|
-
if loop_mode == 5:
|
|
78
|
-
return (False, False, False)
|
|
79
|
-
|
|
80
81
|
# Unknown value - log and return safe default
|
|
81
82
|
import logging
|
|
82
83
|
|
|
@@ -134,8 +135,55 @@ LEGACY_BITFIELD_LOOP_MODE = LoopModeMapping(
|
|
|
134
135
|
)
|
|
135
136
|
|
|
136
137
|
|
|
138
|
+
def get_loop_mode_mapping_for_scheme(scheme: str) -> LoopModeMapping:
|
|
139
|
+
"""Return the mapping table for a profile ``loop_mode_scheme`` value.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
scheme: ``"wiim"``, ``"arylic"``, or ``"legacy"`` (case-insensitive).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The corresponding :class:`LoopModeMapping`.
|
|
146
|
+
"""
|
|
147
|
+
key = (scheme or "wiim").strip().lower()
|
|
148
|
+
if key == "arylic":
|
|
149
|
+
return ARYLIC_LOOP_MODE
|
|
150
|
+
if key == "legacy":
|
|
151
|
+
return LEGACY_BITFIELD_LOOP_MODE
|
|
152
|
+
return WIIM_LOOP_MODE
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_loop_mode_mapping(
|
|
156
|
+
*,
|
|
157
|
+
loop_mode_scheme: str | None = None,
|
|
158
|
+
vendor: str | None = None,
|
|
159
|
+
) -> LoopModeMapping:
|
|
160
|
+
"""Pick the correct mapping: prefer explicit scheme, then infer from vendor.
|
|
161
|
+
|
|
162
|
+
Use this (with ``loop_mode_scheme`` from :func:`pywiim.profiles.get_device_profile`)
|
|
163
|
+
anywhere shuffle/repeat is translated to or from ``loop_mode`` integers.
|
|
164
|
+
"""
|
|
165
|
+
if loop_mode_scheme is not None and str(loop_mode_scheme).strip() != "":
|
|
166
|
+
return get_loop_mode_mapping_for_scheme(loop_mode_scheme)
|
|
167
|
+
return get_loop_mode_mapping(vendor)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_loop_mode_mapping_for_player(player: Any) -> LoopModeMapping:
|
|
171
|
+
"""Resolve mapping from a :class:`pywiim.player.Player` (profile + client capabilities)."""
|
|
172
|
+
scheme: str | None = None
|
|
173
|
+
prof = getattr(player, "profile", None)
|
|
174
|
+
if prof is not None:
|
|
175
|
+
scheme = getattr(prof, "loop_mode_scheme", None)
|
|
176
|
+
if scheme is None:
|
|
177
|
+
scheme = player.client._capabilities.get("loop_mode_scheme")
|
|
178
|
+
vendor = player.client._capabilities.get("vendor")
|
|
179
|
+
return resolve_loop_mode_mapping(loop_mode_scheme=scheme, vendor=vendor)
|
|
180
|
+
|
|
181
|
+
|
|
137
182
|
def get_loop_mode_mapping(vendor: str | None) -> LoopModeMapping:
|
|
138
|
-
"""
|
|
183
|
+
"""Infer loop mode mapping from vendor string only (legacy helper).
|
|
184
|
+
|
|
185
|
+
Prefer :func:`resolve_loop_mode_mapping` with ``loop_mode_scheme`` from the
|
|
186
|
+
device profile when model/firmware uses a non-default scheme for this vendor.
|
|
139
187
|
|
|
140
188
|
Args:
|
|
141
189
|
vendor: Device vendor ("wiim", "arylic", "audio_pro", "linkplay_generic", or None)
|
|
@@ -144,7 +192,7 @@ def get_loop_mode_mapping(vendor: str | None) -> LoopModeMapping:
|
|
|
144
192
|
LoopModeMapping for the vendor
|
|
145
193
|
|
|
146
194
|
Note:
|
|
147
|
-
- WiiM devices use sequential values (0,1,2,3,4)
|
|
195
|
+
- WiiM devices use sequential values (0,1,2,3,4) in the *documented* WiiM scheme
|
|
148
196
|
- Arylic devices use a different sequential scheme (0,1,2,3,4,5)
|
|
149
197
|
- Audio Pro and generic LinkPlay devices default to Arylic mapping
|
|
150
198
|
- Unknown/None vendors default to WiiM mapping (most common)
|
|
@@ -82,7 +82,10 @@ def _normalize_time_value(value: int, field_name: str, source: str | None = None
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def parse_player_status(
|
|
85
|
-
raw: dict[str, Any],
|
|
85
|
+
raw: dict[str, Any],
|
|
86
|
+
last_track: str | None = None,
|
|
87
|
+
vendor: str | None = None,
|
|
88
|
+
loop_mode_scheme: str | None = None,
|
|
86
89
|
) -> tuple[dict[str, Any], str | None]:
|
|
87
90
|
"""Normalise *getPlayerStatusEx* / *getStatusEx* responses.
|
|
88
91
|
|
|
@@ -92,7 +95,9 @@ def parse_player_status(
|
|
|
92
95
|
Args:
|
|
93
96
|
raw: Raw API response dictionary
|
|
94
97
|
last_track: Previous track identifier for change detection
|
|
95
|
-
vendor: Device vendor
|
|
98
|
+
vendor: Device vendor (fallback when ``loop_mode_scheme`` is not set)
|
|
99
|
+
loop_mode_scheme: Profile scheme ``wiim`` / ``arylic`` / ``legacy`` for ``loop_mode``
|
|
100
|
+
decode (preferred over vendor alone; see ``get_device_profile``).
|
|
96
101
|
|
|
97
102
|
Returns:
|
|
98
103
|
Tuple of (parsed_data, new_last_track)
|
|
@@ -265,10 +270,9 @@ def parse_player_status(
|
|
|
265
270
|
|
|
266
271
|
# Only process play_mode if not already set
|
|
267
272
|
if "play_mode" not in data:
|
|
268
|
-
|
|
269
|
-
from .loop_mode import get_loop_mode_mapping
|
|
273
|
+
from .loop_mode import resolve_loop_mode_mapping
|
|
270
274
|
|
|
271
|
-
mapping =
|
|
275
|
+
mapping = resolve_loop_mode_mapping(loop_mode_scheme=loop_mode_scheme, vendor=vendor)
|
|
272
276
|
is_shuffle, is_repeat_one, is_repeat_all = mapping.from_loop_mode(loop_val)
|
|
273
277
|
|
|
274
278
|
# Map to play modes
|
|
@@ -469,7 +473,7 @@ def _handle_qobuz_connect_state_quirks(data: dict[str, Any], raw: dict[str, Any]
|
|
|
469
473
|
"""Handle Qobuz Connect state detection quirks.
|
|
470
474
|
|
|
471
475
|
Addresses GitHub issue #35: Qobuz Connect shows playing briefly then switches to idle.
|
|
472
|
-
|
|
476
|
+
Also handles HTTP ``status: "none"`` with live timeline/metadata (mjcumming/wiim#222).
|
|
473
477
|
|
|
474
478
|
Args:
|
|
475
479
|
data: Parsed data dictionary (modified in place)
|
|
@@ -477,16 +481,30 @@ def _handle_qobuz_connect_state_quirks(data: dict[str, Any], raw: dict[str, Any]
|
|
|
477
481
|
"""
|
|
478
482
|
current_status = data.get("play_status", "").lower()
|
|
479
483
|
|
|
480
|
-
# Only
|
|
481
|
-
|
|
482
|
-
|
|
484
|
+
# Only skip when the device already reports a normal transport state we should not override.
|
|
485
|
+
# Qobuz Connect often reports ``status: "none"`` while ``curpos`` / ``totlen`` and metadata
|
|
486
|
+
# still reflect an active stream (see mjcumming/wiim#222); ``none`` must not bypass this path.
|
|
487
|
+
if current_status in {
|
|
488
|
+
"play",
|
|
489
|
+
"playing",
|
|
490
|
+
"pause",
|
|
491
|
+
"paused",
|
|
492
|
+
"paused playback",
|
|
493
|
+
"load",
|
|
494
|
+
"loading",
|
|
495
|
+
"buffering",
|
|
496
|
+
"transitioning",
|
|
497
|
+
}:
|
|
498
|
+
return
|
|
483
499
|
|
|
484
500
|
# Enhanced detection: Look for multiple indicators that suggest active playback
|
|
485
501
|
# This mimics the improved logic from python-linkplay v0.2.9
|
|
486
502
|
|
|
487
503
|
title = data.get("title")
|
|
488
504
|
has_track_info = bool(title and isinstance(title, str) and title.strip() and title != "Unknown")
|
|
489
|
-
has_position_info =
|
|
505
|
+
has_position_info = (
|
|
506
|
+
data.get("position") is not None or raw.get("curpos") is not None or raw.get("offset_pts") is not None
|
|
507
|
+
)
|
|
490
508
|
has_duration_info = bool(data.get("duration") or raw.get("totlen"))
|
|
491
509
|
has_artwork = bool(data.get("entity_picture") or raw.get("cover") or raw.get("albumArtURI"))
|
|
492
510
|
|
|
@@ -8,6 +8,11 @@ equalization. Each band has four adjustable parameters:
|
|
|
8
8
|
- gain: -12–12 dB
|
|
9
9
|
|
|
10
10
|
The PEQ is identified by pluginURI ``http://moddevices.com/plugins/caps/EqNp``.
|
|
11
|
+
The same HTTP command family can target other LV2 plugins on the device (for
|
|
12
|
+
example the 10-band graphic plugin ``http://moddevices.com/plugins/caps/Eq10HP``);
|
|
13
|
+
this module implements **only** ``EqNp``. See ``docs/integration/API_REFERENCE.md``
|
|
14
|
+
(PEQ section) for integrators who need ``Eq10HP``.
|
|
15
|
+
|
|
11
16
|
All commands operate on a per-source basis (wifi, bluetooth, line-in, etc.).
|
|
12
17
|
|
|
13
18
|
It assumes the base client provides the ``_request`` coroutine. No state is
|
|
@@ -9,7 +9,7 @@ The capability detection system uses a multi-layer approach:
|
|
|
9
9
|
2. Device Type Detection (WiiM vs Legacy)
|
|
10
10
|
3. Firmware Version Detection
|
|
11
11
|
4. Generation Detection (Audio Pro: mkii, w_generation, original)
|
|
12
|
-
5. Endpoint Probing (runtime
|
|
12
|
+
5. Endpoint Probing (runtime tests; LED/12V trigger from static model hints, no mutating HTTP at connect)
|
|
13
13
|
6. Protocol Detection (HTTP/HTTPS, ports, client certs)
|
|
14
14
|
|
|
15
15
|
# pragma: allow-long-file capabilities-cohesive
|
|
@@ -37,14 +37,12 @@ from .api.constants import (
|
|
|
37
37
|
API_ENDPOINT_EQ_STATUS,
|
|
38
38
|
API_ENDPOINT_GET_CHANNEL_BALANCE,
|
|
39
39
|
API_ENDPOINT_PEQ_GET_LIST,
|
|
40
|
-
API_ENDPOINT_SET_LED,
|
|
41
40
|
API_ENDPOINT_SUBWOOFER_STATUS,
|
|
42
|
-
API_ENDPOINT_TRIGGER_OUT_STATUS,
|
|
43
41
|
PEQ_PLUGIN_URI,
|
|
44
42
|
)
|
|
45
43
|
from .api.subwoofer import is_valid_subwoofer_lpf_dict
|
|
46
44
|
from .exceptions import WiiMError
|
|
47
|
-
from .model_names import is_known_wiim_model, is_wiim_ultra
|
|
45
|
+
from .model_names import is_known_wiim_model, is_wiim_12v_trigger_model, is_wiim_ultra
|
|
48
46
|
from .models import DeviceInfo
|
|
49
47
|
from .normalize import normalize_vendor
|
|
50
48
|
from .profiles import detect_audio_pro_generation, detect_vendor, get_device_profile
|
|
@@ -448,36 +446,15 @@ class WiiMCapabilities:
|
|
|
448
446
|
|
|
449
447
|
capabilities["supports_peq"] = peq_supported
|
|
450
448
|
|
|
451
|
-
# Probe for 12V trigger support (WiiM Ultra / Pro / Pro Plus)
|
|
452
|
-
# getTriggeroutStatus returns {"status":0|1}; only some WiiM models have the hardware
|
|
453
|
-
try:
|
|
454
|
-
result = await client._request(API_ENDPOINT_TRIGGER_OUT_STATUS)
|
|
455
|
-
if isinstance(result.parsed, dict) and "status" in result.parsed:
|
|
456
|
-
capabilities["supports_trigger_out"] = True
|
|
457
|
-
_LOGGER.debug(
|
|
458
|
-
"Device %s supports 12V trigger (getTriggeroutStatus), result: %s",
|
|
459
|
-
client.host,
|
|
460
|
-
result.parsed,
|
|
461
|
-
)
|
|
462
|
-
except WiiMError:
|
|
463
|
-
_LOGGER.debug("Device %s does not support 12V trigger (getTriggeroutStatus)", client.host)
|
|
464
|
-
|
|
465
449
|
# Subwoofer (getSubLPF) — WiiM-only read probe; same endpoint as get_subwoofer_status_raw()
|
|
466
450
|
capabilities["supports_subwoofer"] = await _probe_supports_subwoofer(client, device_info, capabilities)
|
|
467
451
|
|
|
468
|
-
# Probe for Status Light (LED_SWITCH_SET) - "Status Light" in app; some devices use this instead of setLED
|
|
469
|
-
try:
|
|
470
|
-
await client._request(f"{API_ENDPOINT_SET_LED}0")
|
|
471
|
-
capabilities["supports_led_switch"] = True
|
|
472
|
-
_LOGGER.debug("Device %s supports Status Light (LED_SWITCH_SET)", client.host)
|
|
473
|
-
except WiiMError:
|
|
474
|
-
_LOGGER.debug("Device %s does not support Status Light (LED_SWITCH_SET)", client.host)
|
|
475
|
-
|
|
476
452
|
# Get device profile for profile-specific settings (like reboot command)
|
|
477
453
|
# Profile provides device-specific command variations
|
|
478
454
|
# See: https://github.com/mjcumming/wiim/issues/177
|
|
479
455
|
profile = get_device_profile(device_info)
|
|
480
456
|
capabilities["reboot_command"] = profile.endpoints.reboot_command
|
|
457
|
+
capabilities["loop_mode_scheme"] = profile.loop_mode_scheme
|
|
481
458
|
_LOGGER.debug(
|
|
482
459
|
"Device %s reboot command: %s (from profile %s)",
|
|
483
460
|
client.host,
|
|
@@ -554,6 +531,9 @@ def detect_device_capabilities(device_info: DeviceInfo) -> dict[str, Any]:
|
|
|
554
531
|
- supports_channel_balance: False until runtime probe (WiiM-only); see WiiMCapabilities
|
|
555
532
|
- supports_metadata: Whether device supports metadata
|
|
556
533
|
- status_endpoint: Preferred status endpoint path
|
|
534
|
+
- supports_led_switch: True for WiiM class (no HTTP probe); absent for non-WiiM until merge defaults
|
|
535
|
+
- supports_trigger_out: True only for WiiM models with known 12V hardware (Ultra/Pro/Pro Plus)
|
|
536
|
+
- loop_mode_scheme: Set during runtime ``detect_capabilities`` from ``get_device_profile`` merge
|
|
557
537
|
"""
|
|
558
538
|
# Detect and normalize vendor first
|
|
559
539
|
vendor = detect_vendor(device_info)
|
|
@@ -593,6 +573,11 @@ def detect_device_capabilities(device_info: DeviceInfo) -> dict[str, Any]:
|
|
|
593
573
|
capabilities["supports_sleep_timer"] = True # WiiM devices support sleep timer
|
|
594
574
|
capabilities["max_alarm_slots"] = 3 # WiiM supports 3 independent alarms
|
|
595
575
|
capabilities["supports_firmware_install"] = True # WiiM devices support firmware update installation via API
|
|
576
|
+
# Status LED (LED_SWITCH_SET): enable by device class only. Never probe with LED_SWITCH_SET:0
|
|
577
|
+
# at connect — it turns the LED off (user-visible mutation). See ADR 005 / ADR 016.
|
|
578
|
+
capabilities["supports_led_switch"] = True
|
|
579
|
+
# 12V trigger: known hardware (Ultra / Pro / Pro Plus) only — no HTTP probe/toggle at connect.
|
|
580
|
+
capabilities["supports_trigger_out"] = is_wiim_12v_trigger_model(device_info.model)
|
|
596
581
|
# Display/LCD on/off and brightness (setLightOperationBrightConfig) - WiiM Ultra only
|
|
597
582
|
if is_wiim_ultra(device_info.model):
|
|
598
583
|
capabilities["supports_display_config"] = True
|
|
@@ -1088,45 +1088,23 @@ class FeatureTester:
|
|
|
1088
1088
|
print(f" ✗ Error: {e}")
|
|
1089
1089
|
|
|
1090
1090
|
async def test_trigger_out(self) -> None:
|
|
1091
|
-
"""Test 12V trigger
|
|
1091
|
+
"""Test 12V trigger read path only (no set/toggle — would disturb connected gear)."""
|
|
1092
1092
|
print("\n⚡ Testing 12V Trigger...")
|
|
1093
1093
|
|
|
1094
1094
|
try:
|
|
1095
1095
|
if not self.client.capabilities.get("supports_trigger_out", False):
|
|
1096
1096
|
self.results["skipped"].append("trigger_out: Not supported")
|
|
1097
|
-
print(" ⊘ 12V trigger not supported (
|
|
1097
|
+
print(" ⊘ 12V trigger not supported (model class: Ultra / Pro / Pro Plus only)")
|
|
1098
1098
|
return
|
|
1099
1099
|
|
|
1100
1100
|
status = await self.client.get_trigger_out_status()
|
|
1101
1101
|
if status is None:
|
|
1102
1102
|
self.results["not_supported"].append("trigger_out: get_trigger_out_status")
|
|
1103
|
-
print(" ⊘ 12V trigger
|
|
1103
|
+
print(" ⊘ 12V trigger: get_trigger_out_status returned no value")
|
|
1104
1104
|
return
|
|
1105
1105
|
|
|
1106
|
-
self.results["passed"].append("trigger_out: get_trigger_out_status")
|
|
1107
|
-
print(f" ✓ get_trigger_out_status: {'ON' if status else 'OFF'}")
|
|
1108
|
-
|
|
1109
|
-
original_on = status
|
|
1110
|
-
try:
|
|
1111
|
-
await self.client.set_trigger_out(not original_on)
|
|
1112
|
-
await asyncio.sleep(0.3)
|
|
1113
|
-
check = await self.client.get_trigger_out_status()
|
|
1114
|
-
if check is not None and check == (not original_on):
|
|
1115
|
-
self.results["passed"].append("trigger_out: set_trigger_out")
|
|
1116
|
-
print(f" ✓ set_trigger_out({not original_on})")
|
|
1117
|
-
else:
|
|
1118
|
-
self.results["warnings"].append("trigger_out: set_trigger_out - value not verified")
|
|
1119
|
-
print(" ⚠️ set_trigger_out - readback not verified")
|
|
1120
|
-
await self.client.set_trigger_out(original_on)
|
|
1121
|
-
await asyncio.sleep(0.2)
|
|
1122
|
-
print(f" ✓ Restored trigger to {'ON' if original_on else 'OFF'}")
|
|
1123
|
-
except Exception as e:
|
|
1124
|
-
self.results["warnings"].append(f"trigger_out: set_trigger_out - {e}")
|
|
1125
|
-
print(f" ⚠️ set_trigger_out: {e}")
|
|
1126
|
-
try:
|
|
1127
|
-
await self.client.set_trigger_out(original_on)
|
|
1128
|
-
except Exception:
|
|
1129
|
-
pass
|
|
1106
|
+
self.results["passed"].append("trigger_out: get_trigger_out_status (read-only)")
|
|
1107
|
+
print(f" ✓ get_trigger_out_status: {'ON' if status else 'OFF'} (read-only; set not tested)")
|
|
1130
1108
|
|
|
1131
1109
|
except Exception as e:
|
|
1132
1110
|
error_str = str(e).lower()
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
|
|
7
|
-
__all__ = ["is_known_wiim_model", "is_wiim_ultra", "to_friendly_model_name"]
|
|
7
|
+
__all__ = ["is_known_wiim_model", "is_wiim_ultra", "is_wiim_12v_trigger_model", "to_friendly_model_name"]
|
|
8
8
|
|
|
9
9
|
# Raw `project` aliases seen on WiiM firmware variants.
|
|
10
10
|
# Keep this list conservative and add entries when confirmed by real devices.
|
|
@@ -69,6 +69,36 @@ def is_wiim_ultra(model: str | None) -> bool:
|
|
|
69
69
|
return key == "wiim_ultra" or (key.startswith("wiim_") and "ultra" in key)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
# Models known to ship a 12V trigger output (hardware). Do not infer from HTTP at connect:
|
|
73
|
+
# some stacks answer getTriggeroutStatus or accept setTriggeroutStatus without real hardware.
|
|
74
|
+
_WIIM_12V_TRIGGER_EXACT: frozenset[str] = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"wiim_ultra",
|
|
77
|
+
"wiim_pro",
|
|
78
|
+
"wiim_pro_plus",
|
|
79
|
+
"wiim_pro_with_gc4a",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_wiim_12v_trigger_model(model: str | None) -> bool:
|
|
85
|
+
"""Return True if this WiiM model is expected to have 12V trigger hardware.
|
|
86
|
+
|
|
87
|
+
Ultra, Pro, and Pro Plus lines ship the jack. We avoid runtime probing so we
|
|
88
|
+
never toggle trigger output during capability detection or verification.
|
|
89
|
+
"""
|
|
90
|
+
key = _normalize_model_key(model)
|
|
91
|
+
if not key:
|
|
92
|
+
return False
|
|
93
|
+
if key in _WIIM_12V_TRIGGER_EXACT:
|
|
94
|
+
return True
|
|
95
|
+
if key.startswith("wiim_") and "ultra" in key:
|
|
96
|
+
return True
|
|
97
|
+
if key.startswith("wiim_") and "pro_plus" in key:
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
72
102
|
def to_friendly_model_name(model: str | None) -> str | None:
|
|
73
103
|
"""Convert raw project model to branding-friendly name when known."""
|
|
74
104
|
key = _normalize_model_key(model)
|
|
@@ -32,7 +32,7 @@ class PlaybackControl:
|
|
|
32
32
|
"""
|
|
33
33
|
import time
|
|
34
34
|
|
|
35
|
-
from ..api.loop_mode import
|
|
35
|
+
from ..api.loop_mode import resolve_loop_mode_mapping_for_player
|
|
36
36
|
from .properties import PlayerProperties
|
|
37
37
|
|
|
38
38
|
props = PlayerProperties(self.player)
|
|
@@ -46,9 +46,7 @@ class PlaybackControl:
|
|
|
46
46
|
f"Supported sources: USB, Line In, Optical, Coaxial, Playlist, Preset."
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
vendor = self.player.client._capabilities.get("vendor")
|
|
51
|
-
mapping = get_loop_mode_mapping(vendor)
|
|
49
|
+
mapping = resolve_loop_mode_mapping_for_player(self.player)
|
|
52
50
|
|
|
53
51
|
# Get current repeat state
|
|
54
52
|
repeat_mode = props.repeat_mode
|
|
@@ -88,7 +86,7 @@ class PlaybackControl:
|
|
|
88
86
|
if mode_lower not in ("off", "one", "all"):
|
|
89
87
|
raise ValueError(f"Invalid repeat mode: {mode}. Valid values: 'off', 'one', 'all'")
|
|
90
88
|
|
|
91
|
-
from ..api.loop_mode import
|
|
89
|
+
from ..api.loop_mode import resolve_loop_mode_mapping_for_player
|
|
92
90
|
from .properties import PlayerProperties
|
|
93
91
|
|
|
94
92
|
props = PlayerProperties(self.player)
|
|
@@ -102,9 +100,7 @@ class PlaybackControl:
|
|
|
102
100
|
f"Supported sources: USB, Line In, Optical, Coaxial, Playlist, Preset."
|
|
103
101
|
)
|
|
104
102
|
|
|
105
|
-
|
|
106
|
-
vendor = self.player.client._capabilities.get("vendor")
|
|
107
|
-
mapping = get_loop_mode_mapping(vendor)
|
|
103
|
+
mapping = resolve_loop_mode_mapping_for_player(self.player)
|
|
108
104
|
|
|
109
105
|
# Get current shuffle state
|
|
110
106
|
shuffle_enabled = props.shuffle_state or False
|
|
@@ -687,12 +687,10 @@ class PlayerProperties:
|
|
|
687
687
|
loop_mode = getattr(self.player._status_model, "loop_mode", None)
|
|
688
688
|
if loop_mode is not None:
|
|
689
689
|
try:
|
|
690
|
-
from ..api.loop_mode import
|
|
690
|
+
from ..api.loop_mode import resolve_loop_mode_mapping_for_player
|
|
691
691
|
|
|
692
692
|
loop_val = int(loop_mode)
|
|
693
|
-
|
|
694
|
-
vendor = self.player.client._capabilities.get("vendor")
|
|
695
|
-
mapping = get_loop_mode_mapping(vendor)
|
|
693
|
+
mapping = resolve_loop_mode_mapping_for_player(self.player)
|
|
696
694
|
shuffle, _, _ = mapping.from_loop_mode(loop_val)
|
|
697
695
|
return shuffle
|
|
698
696
|
except (TypeError, ValueError):
|
|
@@ -731,12 +729,10 @@ class PlayerProperties:
|
|
|
731
729
|
loop_mode = getattr(self.player._status_model, "loop_mode", None)
|
|
732
730
|
if loop_mode is not None:
|
|
733
731
|
try:
|
|
734
|
-
from ..api.loop_mode import
|
|
732
|
+
from ..api.loop_mode import resolve_loop_mode_mapping_for_player
|
|
735
733
|
|
|
736
734
|
loop_val = int(loop_mode)
|
|
737
|
-
|
|
738
|
-
vendor = self.player.client._capabilities.get("vendor")
|
|
739
|
-
mapping = get_loop_mode_mapping(vendor)
|
|
735
|
+
mapping = resolve_loop_mode_mapping_for_player(self.player)
|
|
740
736
|
_, is_repeat_one, is_repeat_all = mapping.from_loop_mode(loop_val)
|
|
741
737
|
|
|
742
738
|
if is_repeat_one:
|
|
@@ -30,10 +30,11 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
import logging
|
|
32
32
|
import re
|
|
33
|
-
from dataclasses import dataclass, field
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
34
|
from typing import TYPE_CHECKING, Literal
|
|
35
35
|
|
|
36
|
-
from .
|
|
36
|
+
from .api.firmware import compare_firmware_versions
|
|
37
|
+
from .model_names import is_known_wiim_model, is_wiim_ultra
|
|
37
38
|
from .normalize import normalize_vendor
|
|
38
39
|
|
|
39
40
|
# Firmware version patterns for Audio Pro generation detection.
|
|
@@ -470,6 +471,16 @@ def _is_gen1_device(device_info: DeviceInfo) -> bool:
|
|
|
470
471
|
return False
|
|
471
472
|
|
|
472
473
|
|
|
474
|
+
def _wiim_ultra_uses_arylic_loop_mode_scheme(device_info: DeviceInfo) -> bool:
|
|
475
|
+
"""WiiM Ultra on firmware 5.2+ reports LinkPlay/Arylic ``loop_mode`` values (pywiim#17)."""
|
|
476
|
+
if not is_wiim_ultra(device_info.model):
|
|
477
|
+
return False
|
|
478
|
+
fw = device_info.firmware
|
|
479
|
+
if not fw or not str(fw).strip():
|
|
480
|
+
return False
|
|
481
|
+
return compare_firmware_versions(str(fw).strip(), "5.2.0") >= 0
|
|
482
|
+
|
|
483
|
+
|
|
473
484
|
def get_device_profile(device_info: DeviceInfo) -> DeviceProfile:
|
|
474
485
|
"""Get the appropriate profile for a device.
|
|
475
486
|
|
|
@@ -523,6 +534,13 @@ def get_device_profile(device_info: DeviceInfo) -> DeviceProfile:
|
|
|
523
534
|
# Other vendors - direct lookup
|
|
524
535
|
profile = PROFILES.get(vendor, PROFILES["linkplay_generic"])
|
|
525
536
|
|
|
537
|
+
if vendor == "wiim" and _wiim_ultra_uses_arylic_loop_mode_scheme(device_info):
|
|
538
|
+
profile = replace(profile, loop_mode_scheme="arylic")
|
|
539
|
+
_LOGGER.debug(
|
|
540
|
+
"Device %s: WiiM Ultra firmware uses Arylic loop_mode scheme (pywiim#17)",
|
|
541
|
+
device_info.name or device_info.model or "?",
|
|
542
|
+
)
|
|
543
|
+
|
|
526
544
|
# Check for Gen1 on any device type
|
|
527
545
|
if _is_gen1_device(device_info) and not profile.grouping.uses_wifi_direct:
|
|
528
546
|
_LOGGER.debug(
|
|
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
|