pywiim 2.2.4__tar.gz → 2.2.5__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.4/pywiim.egg-info → pywiim-2.2.5}/PKG-INFO +1 -1
  2. {pywiim-2.2.4 → pywiim-2.2.5}/pyproject.toml +1 -1
  3. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/__init__.py +1 -1
  4. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/constants.py +2 -0
  5. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/misc.py +28 -4
  6. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/monitor_cli.py +151 -14
  7. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/__init__.py +18 -3
  8. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/base.py +3 -0
  9. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/statemgr.py +31 -0
  10. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/polling.py +56 -1
  11. {pywiim-2.2.4 → pywiim-2.2.5/pywiim.egg-info}/PKG-INFO +1 -1
  12. {pywiim-2.2.4 → pywiim-2.2.5}/LICENSE +0 -0
  13. {pywiim-2.2.4 → pywiim-2.2.5}/README.md +0 -0
  14. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/__init__.py +0 -0
  15. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/audio_pro.py +0 -0
  16. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/audio_settings.py +0 -0
  17. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/base.py +0 -0
  18. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/bluetooth.py +0 -0
  19. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/device.py +0 -0
  20. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/diagnostics.py +0 -0
  21. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/endpoints.py +0 -0
  22. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/eq.py +0 -0
  23. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/firmware.py +0 -0
  24. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/group.py +0 -0
  25. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/lms.py +0 -0
  26. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/loop_mode.py +0 -0
  27. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/parser.py +0 -0
  28. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/peq.py +0 -0
  29. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/playback.py +0 -0
  30. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/preset.py +0 -0
  31. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/ssl.py +0 -0
  32. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/subwoofer.py +0 -0
  33. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/timer.py +0 -0
  34. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/backoff.py +0 -0
  35. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/capabilities.py +0 -0
  36. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/__init__.py +0 -0
  37. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/diagnostics.py +0 -0
  38. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/discovery_cli.py +0 -0
  39. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/group_test_cli.py +0 -0
  40. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/join_test_cli.py +0 -0
  41. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/verify_cli.py +0 -0
  42. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/client.py +0 -0
  43. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/device_capabilities.py +0 -0
  44. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/discovery.py +0 -0
  45. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/exceptions.py +0 -0
  46. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/group.py +0 -0
  47. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/group_helpers.py +0 -0
  48. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/__init__.py +0 -0
  49. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/__main__.py +0 -0
  50. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/config.example.json +0 -0
  51. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/config.py +0 -0
  52. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/context.py +0 -0
  53. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/server.py +0 -0
  54. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/metadata.py +0 -0
  55. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/model_names.py +0 -0
  56. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/models.py +0 -0
  57. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/normalize.py +0 -0
  58. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/audio.py +0 -0
  59. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/bluetooth.py +0 -0
  60. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/coverart.py +0 -0
  61. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/debounce.py +0 -0
  62. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/diagnostics.py +0 -0
  63. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/groupops.py +0 -0
  64. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/media.py +0 -0
  65. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/playback.py +0 -0
  66. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/properties.py +0 -0
  67. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/source_capabilities.py +0 -0
  68. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/stream.py +0 -0
  69. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/stream_enricher.py +0 -0
  70. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/volume.py +0 -0
  71. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/profiles.py +0 -0
  72. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/py.typed +0 -0
  73. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/role.py +0 -0
  74. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/state.py +0 -0
  75. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/__init__.py +0 -0
  76. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/client.py +0 -0
  77. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/eventer.py +0 -0
  78. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/health.py +0 -0
  79. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/SOURCES.txt +0 -0
  80. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/dependency_links.txt +0 -0
  81. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/entry_points.txt +0 -0
  82. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/requires.txt +0 -0
  83. {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/top_level.txt +0 -0
  84. {pywiim-2.2.4 → pywiim-2.2.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.4
3
+ Version: 2.2.5
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.4"
7
+ version = "2.2.5"
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.4"
90
+ __version__ = "2.2.5"
91
91
  __all__ = [
92
92
  # Main client
93
93
  "WiiMClient",
@@ -385,6 +385,8 @@ API_ENDPOINT_SET_CHANNEL_BALANCE = "/httpapi.asp?command=setChannelBalance:"
385
385
 
386
386
  # Miscellaneous endpoints
387
387
  API_ENDPOINT_SET_LED = "/httpapi.asp?command=LED_SWITCH_SET:"
388
+ # Read-only status LED state (plain text 0|1 on many WiiM devices; safe at connect — no mutation)
389
+ API_ENDPOINT_GET_LED_SWITCH = "/httpapi.asp?command=LED_SWITCH_GET"
388
390
  # Arylic: query LED state via MCU ASCII (getMCUASCIICmd:LED); may not be on all devices
389
391
  API_ENDPOINT_GET_LED_MCU = "/httpapi.asp?command=getMCUASCIICmd:LED"
390
392
  API_ENDPOINT_SET_BUTTONS = "/httpapi.asp?command=Button_Enable_SET:"
@@ -22,6 +22,7 @@ from ..exceptions import WiiMError
22
22
  from .constants import (
23
23
  API_ENDPOINT_DISPLAY_CONFIG,
24
24
  API_ENDPOINT_GET_LED_MCU,
25
+ API_ENDPOINT_GET_LED_SWITCH,
25
26
  API_ENDPOINT_SET_BUTTONS,
26
27
  API_ENDPOINT_SET_LED,
27
28
  API_ENDPOINT_TRIGGER_OUT_SET,
@@ -126,11 +127,21 @@ class MiscAPI:
126
127
  # LED Indicator (ADR 005: on/off; read assumes on if unavailable)
127
128
  # ------------------------------------------------------------------
128
129
 
130
+ @staticmethod
131
+ def _parse_led_switch_get_response(raw: str | None, parsed: Any) -> bool | None:
132
+ """Parse LED_SWITCH_GET (plain 0/1) or similar short text responses."""
133
+ text = (raw or "").strip()
134
+ if not text and parsed is not None and not isinstance(parsed, dict):
135
+ text = str(parsed).strip()
136
+ if text in ("0", "1"):
137
+ return text == "1"
138
+ return None
139
+
129
140
  async def get_led_indicator(self) -> bool:
130
141
  """Read LED indicator state from the device.
131
142
 
132
- Tries getStatusEx (led/LED/led_switch fields) and, for Arylic,
133
- getMCUASCIICmd:LED. If read fails or no API exists, returns True
143
+ Tries LED_SWITCH_GET (read-only on WiiM), getStatusEx led fields, and
144
+ for Arylic getMCUASCIICmd:LED. If read fails or no API exists, returns True
134
145
  (assume on) and logs a warning. No persistent state between sessions.
135
146
 
136
147
  Returns:
@@ -139,7 +150,20 @@ class MiscAPI:
139
150
  caps = getattr(self, "_capabilities", {}) or {}
140
151
  vendor = caps.get("vendor", "")
141
152
 
142
- # 1. Try getStatusEx / get_device_info for led/LED/led_switch
153
+ # 1. LED_SWITCH_GET read-only; does not mutate hardware (WiiM Pro/Ultra class)
154
+ if caps.get("supports_led_switch") or caps.get("is_wiim_device"):
155
+ try:
156
+ result = await self._request(API_ENDPOINT_GET_LED_SWITCH) # type: ignore[attr-defined]
157
+ state = self._parse_led_switch_get_response(
158
+ getattr(result, "raw", None),
159
+ getattr(result, "parsed", None),
160
+ )
161
+ if state is not None:
162
+ return state
163
+ except WiiMError:
164
+ pass
165
+
166
+ # 2. Try getStatusEx / get_device_info for led/LED/led_switch
143
167
  try:
144
168
  info = await self.get_device_info() # type: ignore[attr-defined]
145
169
  if isinstance(info, dict):
@@ -157,7 +181,7 @@ class MiscAPI:
157
181
  except WiiMError:
158
182
  pass
159
183
 
160
- # 2. Arylic: try getMCUASCIICmd:LED (e.g. response "LED:1" or "LED:0")
184
+ # 3. Arylic: try getMCUASCIICmd:LED (e.g. response "LED:1" or "LED:0")
161
185
  if vendor == "arylic":
162
186
  try:
163
187
  result = await self._request(API_ENDPOINT_GET_LED_MCU) # type: ignore[attr-defined]
@@ -66,6 +66,18 @@ class PlayerMonitor:
66
66
  self.last_preset_check = 0.0
67
67
  self.last_audio_output_check = 0.0 # Track when to fetch audio output status
68
68
  self.last_trigger_out_check = 0.0 # Track when to fetch 12V trigger status
69
+ self.last_subwoofer_check = 0.0 # Monitor-local subwoofer poll (faster than library tier)
70
+ self.last_led_indicator_check = 0.0 # Monitor-local status LED poll
71
+ # Faster than PollingStrategy.CONFIGURATION_INTERVAL (60s) for live testing in this CLI only.
72
+ self.hardware_poll_interval = 10.0
73
+
74
+ def _should_fetch_hardware_status(self, last_fetch_time: float, supported: bool, now: float) -> bool:
75
+ """Whether to poll trigger/subwoofer/LED (monitor uses shorter interval than player.refresh)."""
76
+ if not supported:
77
+ return False
78
+ if last_fetch_time is None or last_fetch_time == 0:
79
+ return True
80
+ return (now - last_fetch_time) >= self.hardware_poll_interval
69
81
 
70
82
  def _format_source_name(self, source: str) -> str:
71
83
  """Format source name for display, handling acronyms correctly.
@@ -98,6 +110,38 @@ class PlayerMonitor:
98
110
 
99
111
  return " ".join(formatted_words)
100
112
 
113
+ def _format_trigger_out_display(self) -> str | None:
114
+ """Format 12V trigger line for monitor output, or None if unsupported."""
115
+ if not self.player.supports_trigger_out:
116
+ return None
117
+ trigger_on = self.player.trigger_out_on
118
+ if trigger_on is True:
119
+ state_str = "ON"
120
+ elif trigger_on is False:
121
+ state_str = "OFF"
122
+ else:
123
+ state_str = "unknown"
124
+ if self.last_trigger_out_check:
125
+ age = int(time.time() - self.last_trigger_out_check)
126
+ return f"12V Trigger: {state_str} (read {age}s ago)"
127
+ return f"12V Trigger: {state_str}"
128
+
129
+ def _format_led_indicator_display(self) -> str | None:
130
+ """Format status LED line for monitor output, or None if unsupported."""
131
+ if not self.player.supports_led_indicator:
132
+ return None
133
+ led_on = self.player.led_indicator_on
134
+ if led_on is True:
135
+ state_str = "ON"
136
+ elif led_on is False:
137
+ state_str = "OFF"
138
+ else:
139
+ state_str = "unknown"
140
+ if self.last_led_indicator_check:
141
+ age = int(time.time() - self.last_led_indicator_check)
142
+ return f"Status LED: {state_str} (read {age}s ago)"
143
+ return f"Status LED: {state_str}"
144
+
101
145
  def _detect_callback_host(self) -> str | None:
102
146
  """Detect the local network IP address for UPnP callback URL.
103
147
 
@@ -146,6 +190,12 @@ class PlayerMonitor:
146
190
  "shuffle": self.player.shuffle_state,
147
191
  "repeat": self.player.repeat_mode,
148
192
  }
193
+ if self.player.supports_trigger_out:
194
+ current_state["trigger_out"] = self.player.trigger_out_on
195
+ if self.player.supports_subwoofer:
196
+ current_state["subwoofer"] = self.player.subwoofer_enabled
197
+ if self.player.supports_led_indicator:
198
+ current_state["led_indicator"] = self.player.led_indicator_on
149
199
 
150
200
  # Track state changes
151
201
  if current_state != self.last_state:
@@ -324,6 +374,27 @@ class PlayerMonitor:
324
374
  except WiiMError:
325
375
  self.last_multiroom = {} # Request failed, will retry in monitoring loop
326
376
 
377
+ # Initial hardware reads (player.refresh uses ~60s tier; monitor polls faster for testing)
378
+ now = time.time()
379
+ if self.player.supports_trigger_out:
380
+ try:
381
+ await self.player.get_trigger_out_status()
382
+ self.last_trigger_out_check = now
383
+ except Exception:
384
+ pass
385
+ if self.player.supports_subwoofer:
386
+ try:
387
+ await self.player.get_subwoofer_status()
388
+ self.last_subwoofer_check = now
389
+ except Exception:
390
+ pass
391
+ if self.player.supports_led_indicator:
392
+ try:
393
+ await self.player.get_led_indicator()
394
+ self.last_led_indicator_check = now
395
+ except Exception:
396
+ pass
397
+
327
398
  # Use player.role as source of truth (updated by refresh() via _synchronize_group_state())
328
399
  self.previous_role = self.player.role # Initialize previous_role to avoid false positives
329
400
  self.last_group_info_check = time.time() # Initialize check time
@@ -343,6 +414,19 @@ class PlayerMonitor:
343
414
  print(f" Vendor: {self.player.client.capabilities.get('vendor', 'unknown')}")
344
415
  if self.player.client.capabilities.get("supports_channel_balance"):
345
416
  print(" Channel balance: HTTP get (refreshed with EQ interval in monitor; no UPnP)")
417
+ if (
418
+ self.player.supports_trigger_out
419
+ or self.player.supports_subwoofer
420
+ or self.player.supports_led_indicator
421
+ ):
422
+ print(
423
+ f" Hardware poll: trigger/subwoofer/status LED every "
424
+ f"{self.hardware_poll_interval:.0f}s (library refresh ~60s)"
425
+ )
426
+ if self.player.supports_trigger_out:
427
+ trigger_line = self._format_trigger_out_display()
428
+ if trigger_line:
429
+ print(f" {trigger_line}")
346
430
  print(f" Role: {self.player.role}")
347
431
  print(f" pywiim: v{__version__}")
348
432
 
@@ -534,19 +618,41 @@ class PlayerMonitor:
534
618
  pass # Don't fail if audio output fetch fails
535
619
  self.last_audio_output_check = now
536
620
 
537
- # 12V trigger status (same interval as audio output when supported)
538
- if self.strategy and self.strategy.should_fetch_audio_output(
621
+ # 12V trigger + subwoofer (monitor-local fast poll for testing; not library CONFIGURATION_INTERVAL)
622
+ if self._should_fetch_hardware_status(
539
623
  self.last_trigger_out_check,
540
- False,
541
- self.player.client.capabilities.get("supports_trigger_out", False),
624
+ bool(self.player.supports_trigger_out),
542
625
  now,
543
626
  ):
544
- if self.player.client.capabilities.get("supports_trigger_out", False):
545
- try:
546
- await self.player.get_trigger_out_status()
547
- except Exception:
548
- pass
549
- self.last_trigger_out_check = now
627
+ try:
628
+ status = await self.player.get_trigger_out_status()
629
+ if status is not None:
630
+ self.last_trigger_out_check = now
631
+ except Exception:
632
+ pass
633
+
634
+ if self._should_fetch_hardware_status(
635
+ self.last_subwoofer_check,
636
+ bool(self.player.supports_subwoofer),
637
+ now,
638
+ ):
639
+ try:
640
+ sub = await self.player.get_subwoofer_status()
641
+ if sub is not None:
642
+ self.last_subwoofer_check = now
643
+ except Exception:
644
+ pass
645
+
646
+ if self._should_fetch_hardware_status(
647
+ self.last_led_indicator_check,
648
+ bool(self.player.supports_led_indicator),
649
+ now,
650
+ ):
651
+ try:
652
+ await self.player.get_led_indicator()
653
+ self.last_led_indicator_check = now
654
+ except Exception:
655
+ pass
550
656
 
551
657
  # Check for role changes (player.role is updated by refresh() via _synchronize_group_state())
552
658
  role_changed = current_role != old_role
@@ -998,10 +1104,12 @@ class PlayerMonitor:
998
1104
 
999
1105
  # ===== HARDWARE (12V TRIGGER / SUBWOOFER) =====
1000
1106
  hardware_parts = []
1001
- if self.player.supports_trigger_out:
1002
- trigger_on = self.player.trigger_out_on
1003
- trigger_str = "ON" if trigger_on else "OFF" if trigger_on is False else "—"
1004
- hardware_parts.append(f"12V Trigger: {trigger_str}")
1107
+ trigger_line = self._format_trigger_out_display()
1108
+ if trigger_line:
1109
+ hardware_parts.append(trigger_line)
1110
+ led_line = self._format_led_indicator_display()
1111
+ if led_line:
1112
+ hardware_parts.append(led_line)
1005
1113
  if self.player.supports_subwoofer and self.player.subwoofer_status:
1006
1114
  sub = self.player.subwoofer_status
1007
1115
  enabled = sub.get("status", 0) == 1
@@ -1294,6 +1402,17 @@ class PlayerMonitor:
1294
1402
  else:
1295
1403
  status_parts.append("Bal —")
1296
1404
 
1405
+ trigger_line = self._format_trigger_out_display()
1406
+ if trigger_line:
1407
+ # Compact for single-line mode: "Trigger ON" / "Trigger OFF"
1408
+ compact = trigger_line.replace("12V Trigger: ", "Trigger ").split(" (read")[0]
1409
+ status_parts.append(compact)
1410
+
1411
+ led_line = self._format_led_indicator_display()
1412
+ if led_line:
1413
+ compact_led = led_line.replace("Status LED: ", "LED ").split(" (read")[0]
1414
+ status_parts.append(compact_led)
1415
+
1297
1416
  # Source (capitalize properly)
1298
1417
  source = self.player.source or "none"
1299
1418
  if source != "none":
@@ -1438,6 +1557,12 @@ class PlayerMonitor:
1438
1557
 
1439
1558
  # Device Status
1440
1559
  print("\n📱 Device Status:")
1560
+ trigger_line = self._format_trigger_out_display()
1561
+ if trigger_line:
1562
+ print(f" • {trigger_line}")
1563
+ led_line = self._format_led_indicator_display()
1564
+ if led_line:
1565
+ print(f" • {led_line}")
1441
1566
  print(f" • Available: {'✅' if self.player.available else '❌'}")
1442
1567
  if self.player.device_info:
1443
1568
  print(f" • Name: {self.player.device_info.name}")
@@ -1470,6 +1595,9 @@ Examples:
1470
1595
 
1471
1596
  # Enable verbose UPnP event logging (shows full event JSON/XML)
1472
1597
  wiim-monitor 192.168.1.68 --upnp-verbose
1598
+
1599
+ # Faster trigger/subwoofer polling while testing against the WiiM app (default 10s)
1600
+ wiim-monitor 192.168.1.68 --hardware-poll 5
1473
1601
  """,
1474
1602
  )
1475
1603
  parser.add_argument(
@@ -1502,6 +1630,14 @@ Examples:
1502
1630
  action="store_true",
1503
1631
  help="Enable verbose UPnP event logging (shows full event JSON/XML data)",
1504
1632
  )
1633
+ parser.add_argument(
1634
+ "--hardware-poll",
1635
+ type=float,
1636
+ default=10.0,
1637
+ metavar="SECONDS",
1638
+ help="Poll 12V trigger, subwoofer, and status LED every N seconds in this CLI (default: 10). "
1639
+ "Does not change library/HA refresh (~60s).",
1640
+ )
1505
1641
 
1506
1642
  args = parser.parse_args()
1507
1643
  device_ip = args.device_ip
@@ -1526,6 +1662,7 @@ Examples:
1526
1662
  monitor.player._on_state_changed = monitor.on_state_changed # Set callback
1527
1663
  monitor.use_tui = use_tui # Set TUI mode preference
1528
1664
  monitor.upnp_verbose = args.upnp_verbose # Set UPnP verbose logging flag
1665
+ monitor.hardware_poll_interval = max(1.0, float(args.hardware_poll))
1529
1666
 
1530
1667
  # Store callback host override for use in setup
1531
1668
  if callback_host_override:
@@ -371,12 +371,17 @@ class Player(PlayerBase):
371
371
  # === LED Indicator (ADR 005) ===
372
372
 
373
373
  async def get_led_indicator(self) -> bool:
374
- """Read LED indicator state (on/off). Assumes on if device has no read API."""
375
- return await self.client.get_led_indicator()
374
+ """Read LED indicator state (on/off). Updates ``led_indicator_on`` cache."""
375
+ state = await self.client.get_led_indicator()
376
+ self._led_indicator_on = state
377
+ return state
376
378
 
377
379
  async def set_led_indicator(self, enabled: bool) -> None:
378
380
  """Set LED indicator on or off (LED_SWITCH_SET)."""
379
381
  await self.client.set_led_switch(enabled)
382
+ self._led_indicator_on = enabled
383
+ if self._on_state_changed:
384
+ self._on_state_changed()
380
385
 
381
386
  async def get_channel_balance(self) -> float | None:
382
387
  """Read stereo channel balance from the device and refresh the player cache.
@@ -1226,10 +1231,20 @@ class Player(PlayerBase):
1226
1231
  def trigger_out_on(self) -> bool | None:
1227
1232
  """Cached 12V trigger state (True=on, False=off, None=unknown).
1228
1233
 
1229
- Updated when get_trigger_out_status() or set_trigger_out() is called.
1234
+ Updated when get_trigger_out_status() or set_trigger_out() is called, and on
1235
+ configuration-tier refresh during player.refresh() (ADR 019).
1230
1236
  """
1231
1237
  return self._trigger_out_on
1232
1238
 
1239
+ @property
1240
+ def led_indicator_on(self) -> bool | None:
1241
+ """Cached status LED state (True=on, False=off, None=unknown).
1242
+
1243
+ Updated when get_led_indicator() or set_led_indicator() is called, and on
1244
+ configuration-tier refresh when LED_SWITCH_GET is supported.
1245
+ """
1246
+ return self._led_indicator_on
1247
+
1233
1248
  def upnp_health_status(self) -> dict[str, Any] | None:
1234
1249
  """UPnP event health statistics.
1235
1250
 
@@ -119,6 +119,9 @@ class PlayerBase:
119
119
  # Only available on WiiM Ultra with firmware 5.2+
120
120
  self._subwoofer_status: dict[str, Any] | None = None
121
121
  self._trigger_out_on: bool | None = None # 12V trigger state (None = unknown)
122
+ self._last_trigger_out_check: float = 0
123
+ self._led_indicator_on: bool | None = None # Status LED (None = unknown)
124
+ self._last_led_indicator_check: float = 0
122
125
  self._last_subwoofer_check: float = 0
123
126
 
124
127
  # Stereo channel balance (-1.0 … 1.0); WiiM HTTP only, gated by supports_channel_balance
@@ -939,6 +939,37 @@ class StateManager:
939
939
  except Exception as err:
940
940
  _LOGGER.debug("Failed to fetch subwoofer status for %s: %s", self.player.host, err)
941
941
 
942
+ # 12V trigger — supports_trigger_out from static model (ADR 016/019); configuration-tier read.
943
+ trigger_out_supported = bool(self.player.client.capabilities.get("supports_trigger_out", False))
944
+ should_fetch_trigger_out = full or (
945
+ self._polling_strategy
946
+ and self._polling_strategy.should_fetch_trigger_out(
947
+ self.player._last_trigger_out_check, trigger_out_supported, now=now
948
+ )
949
+ )
950
+ if should_fetch_trigger_out and trigger_out_supported:
951
+ try:
952
+ trigger_on = await self.player.get_trigger_out_status()
953
+ if trigger_on is not None:
954
+ self.player._last_trigger_out_check = now
955
+ except Exception as err:
956
+ _LOGGER.debug("Failed to fetch 12V trigger status for %s: %s", self.player.host, err)
957
+
958
+ # Status LED — supports_led_switch from device class (ADR 005); read via LED_SWITCH_GET when available
959
+ led_supported = bool(self.player.client.capabilities.get("supports_led_switch", False))
960
+ should_fetch_led = full or (
961
+ self._polling_strategy
962
+ and self._polling_strategy.should_fetch_led_indicator(
963
+ self.player._last_led_indicator_check, led_supported, now=now
964
+ )
965
+ )
966
+ if should_fetch_led and led_supported:
967
+ try:
968
+ await self.player.get_led_indicator()
969
+ self.player._last_led_indicator_check = now
970
+ except Exception as err:
971
+ _LOGGER.debug("Failed to fetch LED indicator status for %s: %s", self.player.host, err)
972
+
942
973
  async def _finalize_refresh(self) -> None:
943
974
  """Finalize refresh: sync group state, propagate metadata, notify callback."""
944
975
  # Synchronize group state from device state
@@ -63,7 +63,9 @@ class PollingStrategy:
63
63
  # Polling interval constants (in seconds)
64
64
  FAST_POLL_INTERVAL = 1.0 # Active Playback / UI Responsiveness
65
65
  NORMAL_POLL_INTERVAL = 5.0 # Idle / Background
66
- CONFIGURATION_INTERVAL = 60.0 # Bluetooth, EQ, Device Info
66
+ CONFIGURATION_INTERVAL = 60.0 # Bluetooth, EQ presets, device info
67
+ # Faster tier for user-toggled hardware (12V trigger, subwoofer) — light GETs, visible in HA/monitor
68
+ HARDWARE_STATUS_INTERVAL = 15.0
67
69
  METADATA_CHECK_INTERVAL = 1.0 # Check for track changes
68
70
 
69
71
  # Legacy device intervals (longer for older devices)
@@ -334,6 +336,59 @@ class PollingStrategy:
334
336
  # Fetch every 60s
335
337
  return (now - last_fetch_time) >= self.CONFIGURATION_INTERVAL
336
338
 
339
+ def should_fetch_trigger_out(
340
+ self,
341
+ last_fetch_time: float,
342
+ trigger_out_supported: bool,
343
+ now: float | None = None,
344
+ ) -> bool:
345
+ """Check if 12V trigger status should be fetched.
346
+
347
+ Fetch logic:
348
+ 1. On startup (last_fetch_time == 0)
349
+ 2. Every 60s (background consistency)
350
+ 3. Only if device supports 12V trigger (static model class)
351
+
352
+ Args:
353
+ last_fetch_time: Timestamp of last trigger status fetch
354
+ trigger_out_supported: From capabilities["supports_trigger_out"]
355
+ now: Current time (defaults to time.time())
356
+
357
+ Returns:
358
+ True if trigger status should be fetched
359
+ """
360
+ if not trigger_out_supported:
361
+ return False
362
+
363
+ if now is None:
364
+ now = time.time()
365
+
366
+ if last_fetch_time is None or last_fetch_time == 0:
367
+ return True
368
+
369
+ return (now - last_fetch_time) >= self.CONFIGURATION_INTERVAL
370
+
371
+ def should_fetch_led_indicator(
372
+ self,
373
+ last_fetch_time: float,
374
+ led_supported: bool,
375
+ now: float | None = None,
376
+ ) -> bool:
377
+ """Check if status LED (LED indicator) state should be fetched.
378
+
379
+ Same cadence as other configuration-tier hardware reads (~60s).
380
+ """
381
+ if not led_supported:
382
+ return False
383
+
384
+ if now is None:
385
+ now = time.time()
386
+
387
+ if last_fetch_time is None or last_fetch_time == 0:
388
+ return True
389
+
390
+ return (now - last_fetch_time) >= self.CONFIGURATION_INTERVAL
391
+
337
392
  def should_fetch_device_info(
338
393
  self,
339
394
  last_fetch_time: float,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.4
3
+ Version: 2.2.5
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