pywiim 2.2.3__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.
Files changed (84) hide show
  1. {pywiim-2.2.3/pywiim.egg-info → pywiim-2.2.4}/PKG-INFO +1 -1
  2. {pywiim-2.2.3 → pywiim-2.2.4}/pyproject.toml +1 -1
  3. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/__init__.py +1 -1
  4. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/base.py +12 -2
  5. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/constants.py +3 -1
  6. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/loop_mode.py +58 -10
  7. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/parser.py +9 -5
  8. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/peq.py +5 -0
  9. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/capabilities.py +11 -26
  10. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/verify_cli.py +5 -27
  11. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/model_names.py +31 -1
  12. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/playback.py +4 -8
  13. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/properties.py +4 -8
  14. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/profiles.py +20 -2
  15. {pywiim-2.2.3 → pywiim-2.2.4/pywiim.egg-info}/PKG-INFO +1 -1
  16. {pywiim-2.2.3 → pywiim-2.2.4}/LICENSE +0 -0
  17. {pywiim-2.2.3 → pywiim-2.2.4}/README.md +0 -0
  18. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/__init__.py +0 -0
  19. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/audio_pro.py +0 -0
  20. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/audio_settings.py +0 -0
  21. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/bluetooth.py +0 -0
  22. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/device.py +0 -0
  23. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/diagnostics.py +0 -0
  24. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/endpoints.py +0 -0
  25. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/eq.py +0 -0
  26. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/firmware.py +0 -0
  27. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/group.py +0 -0
  28. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/lms.py +0 -0
  29. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/misc.py +0 -0
  30. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/playback.py +0 -0
  31. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/preset.py +0 -0
  32. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/ssl.py +0 -0
  33. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/subwoofer.py +0 -0
  34. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/api/timer.py +0 -0
  35. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/backoff.py +0 -0
  36. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/__init__.py +0 -0
  37. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/diagnostics.py +0 -0
  38. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/discovery_cli.py +0 -0
  39. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/group_test_cli.py +0 -0
  40. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/join_test_cli.py +0 -0
  41. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/cli/monitor_cli.py +0 -0
  42. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/client.py +0 -0
  43. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/device_capabilities.py +0 -0
  44. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/discovery.py +0 -0
  45. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/exceptions.py +0 -0
  46. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/group.py +0 -0
  47. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/group_helpers.py +0 -0
  48. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/mcp/__init__.py +0 -0
  49. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/mcp/__main__.py +0 -0
  50. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/mcp/config.example.json +0 -0
  51. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/mcp/config.py +0 -0
  52. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/mcp/context.py +0 -0
  53. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/mcp/server.py +0 -0
  54. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/metadata.py +0 -0
  55. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/models.py +0 -0
  56. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/normalize.py +0 -0
  57. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/__init__.py +0 -0
  58. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/audio.py +0 -0
  59. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/base.py +0 -0
  60. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/bluetooth.py +0 -0
  61. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/coverart.py +0 -0
  62. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/debounce.py +0 -0
  63. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/diagnostics.py +0 -0
  64. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/groupops.py +0 -0
  65. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/media.py +0 -0
  66. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/source_capabilities.py +0 -0
  67. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/statemgr.py +0 -0
  68. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/stream.py +0 -0
  69. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/stream_enricher.py +0 -0
  70. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/player/volume.py +0 -0
  71. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/polling.py +0 -0
  72. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/py.typed +0 -0
  73. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/role.py +0 -0
  74. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/state.py +0 -0
  75. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/upnp/__init__.py +0 -0
  76. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/upnp/client.py +0 -0
  77. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/upnp/eventer.py +0 -0
  78. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim/upnp/health.py +0 -0
  79. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim.egg-info/SOURCES.txt +0 -0
  80. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim.egg-info/dependency_links.txt +0 -0
  81. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim.egg-info/entry_points.txt +0 -0
  82. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim.egg-info/requires.txt +0 -0
  83. {pywiim-2.2.3 → pywiim-2.2.4}/pywiim.egg-info/top_level.txt +0 -0
  84. {pywiim-2.2.3 → pywiim-2.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.3
3
+ Version: 2.2.4
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.3"
7
+ version = "2.2.4"
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.3"
90
+ __version__ = "2.2.4"
91
91
  __all__ = [
92
92
  # Main client
93
93
  "WiiMClient",
@@ -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(data, self._last_track, vendor)
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(data, self._last_track, self._capabilities.get("vendor"))
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 loop mode value schemes. This module
4
- provides vendor-specific mappings to handle both correctly.
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
- """Get the loop mode mapping for a specific vendor.
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], last_track: str | None = None, vendor: str | None = None
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 for vendor-specific loop mode parsing
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
- # Use vendor-specific loop mode mapping
269
- from .loop_mode import get_loop_mode_mapping
273
+ from .loop_mode import resolve_loop_mode_mapping
270
274
 
271
- mapping = get_loop_mode_mapping(vendor)
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
@@ -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 capability testing)
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 output (WiiM Ultra / Pro / Pro Plus)."""
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 (WiiM Ultra/Pro/Pro Plus only)")
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 not supported (no response)")
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 get_loop_mode_mapping
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
- # Get vendor-specific loop mode mapping
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 get_loop_mode_mapping
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
- # Get vendor-specific loop mode mapping
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 get_loop_mode_mapping
690
+ from ..api.loop_mode import resolve_loop_mode_mapping_for_player
691
691
 
692
692
  loop_val = int(loop_mode)
693
- # Use vendor-specific mapping to interpret loop_mode
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 get_loop_mode_mapping
732
+ from ..api.loop_mode import resolve_loop_mode_mapping_for_player
735
733
 
736
734
  loop_val = int(loop_mode)
737
- # Use vendor-specific mapping to interpret loop_mode
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 .model_names import is_known_wiim_model
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.3
3
+ Version: 2.2.4
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