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.
- {pywiim-2.2.4/pywiim.egg-info → pywiim-2.2.5}/PKG-INFO +1 -1
- {pywiim-2.2.4 → pywiim-2.2.5}/pyproject.toml +1 -1
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/__init__.py +1 -1
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/constants.py +2 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/misc.py +28 -4
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/monitor_cli.py +151 -14
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/__init__.py +18 -3
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/base.py +3 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/statemgr.py +31 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/polling.py +56 -1
- {pywiim-2.2.4 → pywiim-2.2.5/pywiim.egg-info}/PKG-INFO +1 -1
- {pywiim-2.2.4 → pywiim-2.2.5}/LICENSE +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/README.md +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/audio_pro.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/audio_settings.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/base.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/bluetooth.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/device.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/diagnostics.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/endpoints.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/eq.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/firmware.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/group.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/lms.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/loop_mode.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/parser.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/peq.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/playback.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/preset.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/ssl.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/subwoofer.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/api/timer.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/backoff.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/capabilities.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/diagnostics.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/discovery_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/group_test_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/join_test_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/cli/verify_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/client.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/device_capabilities.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/discovery.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/exceptions.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/group.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/group_helpers.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/__main__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/config.example.json +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/config.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/context.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/mcp/server.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/metadata.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/model_names.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/models.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/normalize.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/audio.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/bluetooth.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/coverart.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/debounce.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/diagnostics.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/groupops.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/media.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/playback.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/properties.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/source_capabilities.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/stream.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/stream_enricher.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/player/volume.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/profiles.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/py.typed +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/role.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/state.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/client.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/eventer.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim/upnp/health.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/SOURCES.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/dependency_links.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/entry_points.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/requires.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/pywiim.egg-info/top_level.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.5}/setup.cfg +0 -0
|
@@ -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
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
538
|
-
if self.
|
|
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
|
-
|
|
541
|
-
self.player.client.capabilities.get("supports_trigger_out", False),
|
|
624
|
+
bool(self.player.supports_trigger_out),
|
|
542
625
|
now,
|
|
543
626
|
):
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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).
|
|
375
|
-
|
|
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,
|
|
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,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|