pywiim 2.2.4__tar.gz → 2.2.6__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.6}/PKG-INFO +1 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/pyproject.toml +1 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/__init__.py +1 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/constants.py +2 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/misc.py +28 -4
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/parser.py +1 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/monitor_cli.py +151 -14
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/client.py +2 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/__init__.py +18 -3
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/base.py +32 -7
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/coverart.py +147 -124
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/statemgr.py +39 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/polling.py +56 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/upnp/client.py +98 -0
- pywiim-2.2.6/pywiim/upnp/metadata.py +143 -0
- {pywiim-2.2.4 → pywiim-2.2.6/pywiim.egg-info}/PKG-INFO +1 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim.egg-info/SOURCES.txt +2 -1
- {pywiim-2.2.4 → pywiim-2.2.6}/LICENSE +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/README.md +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/audio_pro.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/audio_settings.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/base.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/bluetooth.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/device.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/diagnostics.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/endpoints.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/eq.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/firmware.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/group.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/lms.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/loop_mode.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/peq.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/playback.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/preset.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/ssl.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/subwoofer.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/api/timer.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/backoff.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/capabilities.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/diagnostics.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/discovery_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/group_test_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/join_test_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/cli/verify_cli.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/device_capabilities.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/discovery.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/exceptions.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/group.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/group_helpers.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/mcp/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/mcp/__main__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/mcp/config.example.json +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/mcp/config.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/mcp/context.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/mcp/server.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/metadata.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/model_names.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/models.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/normalize.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/audio.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/bluetooth.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/debounce.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/diagnostics.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/groupops.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/media.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/playback.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/properties.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/source_capabilities.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/stream.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/stream_enricher.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/player/volume.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/profiles.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/py.typed +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/role.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/state.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/upnp/__init__.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/upnp/eventer.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim/upnp/health.py +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim.egg-info/dependency_links.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim.egg-info/entry_points.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim.egg-info/requires.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/pywiim.egg-info/top_level.txt +0 -0
- {pywiim-2.2.4 → pywiim-2.2.6}/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]
|
|
@@ -422,6 +422,7 @@ def parse_player_status(
|
|
|
422
422
|
"google cast": "wifi",
|
|
423
423
|
"googlecast": "wifi",
|
|
424
424
|
"chromecast built-in": "wifi",
|
|
425
|
+
"cast": "wifi", # WiiM Amp reports vendor="CAST" during Cast sessions (pywiim #19)
|
|
425
426
|
# Apps that cast via Chromecast may report app name instead of "Chromecast".
|
|
426
427
|
"bbc sounds": "wifi",
|
|
427
428
|
"bbc iplayer": "wifi",
|
|
@@ -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:
|
|
@@ -283,7 +283,8 @@ class WiiMClient(
|
|
|
283
283
|
host_url = self._host_url
|
|
284
284
|
preferred_scheme = "https" if self.is_https else "http"
|
|
285
285
|
schemes = [preferred_scheme, "https" if preferred_scheme == "http" else "http"]
|
|
286
|
-
|
|
286
|
+
ports = (49152, 59152)
|
|
287
|
+
urls = [f"{scheme}://{host_url}:{port}/description.xml" for scheme in schemes for port in ports]
|
|
287
288
|
|
|
288
289
|
for url in urls:
|
|
289
290
|
try:
|
|
@@ -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
|
|
@@ -136,6 +139,9 @@ class PlayerBase:
|
|
|
136
139
|
# 0 = never attempted, >0 = timestamp of last attempt
|
|
137
140
|
self._last_upnp_attempt: float = 0
|
|
138
141
|
|
|
142
|
+
# LinkPlay GetInfoEx probe cache (None = unknown, True/False = probed)
|
|
143
|
+
self._getinfoex_supported: bool | None = None
|
|
144
|
+
|
|
139
145
|
# Availability tracking
|
|
140
146
|
self._available: bool = True # Assume available until proven otherwise
|
|
141
147
|
|
|
@@ -364,19 +370,38 @@ class PlayerBase:
|
|
|
364
370
|
try:
|
|
365
371
|
from ..upnp.client import UpnpClient
|
|
366
372
|
|
|
367
|
-
|
|
368
|
-
|
|
373
|
+
description_urls = [
|
|
374
|
+
f"http://{self.client.host}:49152/description.xml",
|
|
375
|
+
f"http://{self.client.host}:59152/description.xml",
|
|
376
|
+
]
|
|
369
377
|
|
|
370
378
|
_LOGGER.debug("Creating UPnP client for %s", self.client.host)
|
|
371
379
|
# Pass client's session to UPnP client for connection pooling
|
|
372
380
|
# Ensure session exists (client may create it lazily)
|
|
373
381
|
await self.client._ensure_session()
|
|
374
382
|
client_session = getattr(self.client, "_session", None)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
383
|
+
|
|
384
|
+
last_error: Exception | None = None
|
|
385
|
+
self._upnp_client = None
|
|
386
|
+
for description_url in description_urls:
|
|
387
|
+
try:
|
|
388
|
+
self._upnp_client = await UpnpClient.create(
|
|
389
|
+
self.client.host,
|
|
390
|
+
description_url,
|
|
391
|
+
session=client_session,
|
|
392
|
+
)
|
|
393
|
+
break
|
|
394
|
+
except Exception as err: # noqa: BLE001
|
|
395
|
+
last_error = err
|
|
396
|
+
_LOGGER.debug(
|
|
397
|
+
"UPnP description fetch failed for %s at %s: %s",
|
|
398
|
+
self.client.host,
|
|
399
|
+
description_url,
|
|
400
|
+
err,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if self._upnp_client is None:
|
|
404
|
+
raise last_error or RuntimeError("UPnP client creation failed")
|
|
380
405
|
|
|
381
406
|
# Initialize UPnP health tracker if not already present
|
|
382
407
|
if self._upnp_health_tracker is None:
|