feat: native WireView Pro II monitoring (detection, serial protocol, live tab)
Standalone support for the ThermalGrizzly WireView Pro II without depending on the external wireview_reporter exporter: - wireview.py: serial protocol (STX/ETX + 16-bit CRC16-CCITT) with vendor-data product identification, config version, UID, build string, screen layout, and temperature/power/current sensor reads; hwmon fallback with per-channel index resolution; udev-based detection (vendor 0x2560 / product 0x0101) with fallback port probing; serial read timeout and watchdog reconnect - server.py: startup detection (root only), 1 Hz poller gated on subscribed clients, WS 'wireview' channel, GET /api/wireview, rejected-product memoization, stale-read race guard - frontend: WireView tab (visible when a device is detected) with live temperature/power/current cards, sparkline history, and device info; nullable temp channels - tests: 76 tests covering parser, CRC, fault classification, hwmon resolution, JSON safety, and the serial transport against a pty-based fake device Verified live: tab appears with the device connected, disappears when unplugged, reconnects on re-plug, no serial traffic when idle.
This commit is contained in:
1 parent
cc102f26c1
commit
526b71b45f
10 files changed
+1792
-1
No files matched your search
@@ -72,6 +72,7 @@ from .profiles.native import (
|
||||
rename_profile,
|
||||
save_profile,
|
||||
)
|
||||
from .wireview import create_device, find_wireview_ports
|
||||
from .safety import check_negative_freq_warnings, validate_write
|
||||
|
||||
log = logging.getLogger("nvcurve.server")
|
||||
@@ -103,6 +104,15 @@ def _open_browser_as_user(url: str) -> None:
|
||||
_state: dict[str, Any] = {
|
||||
"gpus": {}, # dict[int, dict] mapping gpu_index -> gpu state
|
||||
"config": default_config,
|
||||
"wireview": {
|
||||
"clients": set(), # connected /ws/wireview clients
|
||||
"device": None, # WireViewSerialDevice | WireViewHwmonDevice | None
|
||||
"info": None, # device identity (static per connection)
|
||||
"last_sample": None, # most recent sensor sample
|
||||
"connected": False, # last read succeeded
|
||||
"failures": 0, # consecutive failed reads
|
||||
"rejected_ports": set(), # ports reporting an unsupported product
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +268,126 @@ async def _fan_poller(gpu_index: int) -> None:
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
# ── WireView Pro II (Thermal Grizzly) ─────────────────────────────────────────
|
||||
# Consecutive failed reads after which a connected device is given up. Same
|
||||
# rule as the exporter: one corrupt frame or a slow read never trips it, and
|
||||
# an unplug is caught at once by the port-node check.
|
||||
WIREVIEW_MAX_FAILED_READS = 6
|
||||
WIREVIEW_WATCHDOG_INTERVAL_S = 5.0
|
||||
|
||||
|
||||
async def _wireview_disconnect() -> None:
|
||||
"""Drop the connected WireView device and tell clients it is gone."""
|
||||
wv = _state["wireview"]
|
||||
device = wv["device"]
|
||||
if device is None:
|
||||
return
|
||||
wv["device"] = None
|
||||
wv["connected"] = False
|
||||
wv["info"] = None
|
||||
wv["last_sample"] = None
|
||||
wv["failures"] = 0
|
||||
await _run(device.close)
|
||||
log.info("WireView disconnected")
|
||||
await _broadcast(wv["clients"], {"type": "unavailable"})
|
||||
|
||||
|
||||
async def _wireview_connect() -> None:
|
||||
"""Detect and connect a WireView Pro II. No-op when none is attached or
|
||||
one is already connected."""
|
||||
wv = _state["wireview"]
|
||||
if wv["device"] is not None:
|
||||
return
|
||||
# Forget rejected ports that disappeared, so a re-plug gets a fresh probe.
|
||||
if wv["rejected_ports"]:
|
||||
present = set(await _run(find_wireview_ports))
|
||||
wv["rejected_ports"] &= present
|
||||
device = await _run(create_device)
|
||||
if device is None:
|
||||
return
|
||||
# A port that reported an unsupported product is not re-probed (and
|
||||
# re-logged) on every watchdog tick.
|
||||
if device.port in wv["rejected_ports"]:
|
||||
return
|
||||
ok = await _run(device.connect)
|
||||
if not ok:
|
||||
await _run(device.close)
|
||||
if device.rejected:
|
||||
wv["rejected_ports"].add(device.port)
|
||||
return
|
||||
wv["device"] = device
|
||||
wv["failures"] = 0
|
||||
wv["connected"] = True
|
||||
wv["info"] = await _run(device.info)
|
||||
# Push the first sample right away so clients don't wait for the next tick.
|
||||
sample = await _run(device.read_sample)
|
||||
if sample is not None:
|
||||
wv["last_sample"] = sample
|
||||
await _broadcast(
|
||||
wv["clients"], {"type": "sample", "info": wv["info"], "sample": sample}
|
||||
)
|
||||
else:
|
||||
wv["connected"] = False
|
||||
|
||||
|
||||
async def _wireview_poller() -> None:
|
||||
"""Read the WireView at poll_interval_s and push to connected WS clients.
|
||||
|
||||
Skips reads while no client is subscribed (like the monitor poller), so
|
||||
idle polling never contends for the port with other tools (official GUI,
|
||||
wireviewd)."""
|
||||
cfg: Config = _state["config"]
|
||||
while True:
|
||||
try:
|
||||
wv = _state["wireview"]
|
||||
device = wv["device"]
|
||||
if device is not None and wv["clients"]:
|
||||
sample = await _run(device.read_sample)
|
||||
if wv["device"] is not device:
|
||||
# The device was disconnected (unplug) while the read was
|
||||
# in flight — drop the stale result instead of
|
||||
# resurrecting state.
|
||||
pass
|
||||
elif sample is not None:
|
||||
wv["failures"] = 0
|
||||
wv["connected"] = True
|
||||
wv["last_sample"] = sample
|
||||
await _broadcast(
|
||||
wv["clients"],
|
||||
{"type": "sample", "info": wv["info"], "sample": sample},
|
||||
)
|
||||
else:
|
||||
wv["failures"] += 1
|
||||
if (
|
||||
not await _run(device.node_exists)
|
||||
or wv["failures"] >= WIREVIEW_MAX_FAILED_READS
|
||||
):
|
||||
await _wireview_disconnect()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc:
|
||||
log.warning("WireView poller error: %s", exc)
|
||||
await asyncio.sleep(cfg.poll_interval_s)
|
||||
|
||||
|
||||
async def _wireview_watchdog() -> None:
|
||||
"""Hot-plug detection: connect when a WireView appears, disconnect when
|
||||
its device node disappears."""
|
||||
while True:
|
||||
await asyncio.sleep(WIREVIEW_WATCHDOG_INTERVAL_S)
|
||||
try:
|
||||
wv = _state["wireview"]
|
||||
device = wv["device"]
|
||||
if device is None:
|
||||
await _wireview_connect()
|
||||
elif not await _run(device.node_exists):
|
||||
await _wireview_disconnect()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc:
|
||||
log.warning("WireView watchdog error: %s", exc)
|
||||
|
||||
|
||||
async def _activate_fan_curve(
|
||||
gpu_index: int, curve: list, fans: list[int] | None = None
|
||||
) -> None:
|
||||
@@ -380,6 +510,16 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
log.error("Failed to initialize GPU %d: %s", idx, exc)
|
||||
|
||||
# ── WireView Pro II detection ─────────────────────────────────────────────
|
||||
# Independent of GPU discovery: the tab appears whenever the connector
|
||||
# monitor is attached, and the watchdog handles hot-plug afterwards.
|
||||
try:
|
||||
await _wireview_connect()
|
||||
except Exception as exc:
|
||||
log.warning("WireView initial detection failed: %s", exc)
|
||||
poller_tasks.append(asyncio.create_task(_wireview_poller()))
|
||||
poller_tasks.append(asyncio.create_task(_wireview_watchdog()))
|
||||
|
||||
# ── Backward Compatibility Bridge ──────────────────────────────────────────
|
||||
# NOTE: This auto-load path is for users running the server directly (e.g.
|
||||
# via an old systemd unit file that lacks the new daemon mode).
|
||||
@@ -465,6 +605,14 @@ async def lifespan(app: FastAPI):
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
# Release the WireView port (if connected) so other tools can use it.
|
||||
wv = _state["wireview"]
|
||||
if wv["device"] is not None:
|
||||
with suppress(Exception):
|
||||
await _run(wv["device"].close)
|
||||
wv["device"] = None
|
||||
wv["connected"] = False
|
||||
|
||||
for gpu_index, g_state in _state["gpus"].items():
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
@@ -1442,6 +1590,25 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── WireView Pro II (Thermal Grizzly) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/wireview")
|
||||
async def api_wireview():
|
||||
"""WireView Pro II availability and the most recent sensor sample.
|
||||
|
||||
available: a device is connected (the UI shows the WireView tab).
|
||||
sample: None until the first successful read.
|
||||
"""
|
||||
wv = _state["wireview"]
|
||||
return {
|
||||
"available": wv["device"] is not None,
|
||||
"connected": wv["connected"],
|
||||
"info": wv["info"],
|
||||
"sample": wv["last_sample"],
|
||||
}
|
||||
|
||||
|
||||
# ── Write endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1832,6 +1999,51 @@ async def ws_curve(ws: WebSocket):
|
||||
g_state["curve_clients"].discard(ws)
|
||||
|
||||
|
||||
@app.websocket("/ws/wireview")
|
||||
async def ws_wireview(ws: WebSocket):
|
||||
"""Stream WireView Pro II sensor samples at poll_interval_s.
|
||||
|
||||
Messages:
|
||||
{"type": "unavailable"} — no device connected
|
||||
{"type": "sample", "info": {...}, "sample": {...}} — new reading
|
||||
"""
|
||||
if not _ws_authenticated(ws):
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
await ws.accept()
|
||||
try:
|
||||
data = await ws.receive_json()
|
||||
if data.get("action") != "subscribe":
|
||||
await ws.close()
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception:
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
wv = _state["wireview"]
|
||||
wv["clients"].add(ws)
|
||||
try:
|
||||
# Send the current state immediately so the client does not have to
|
||||
# wait for the next poll tick.
|
||||
if wv["device"] is not None and wv["last_sample"] is not None:
|
||||
await ws.send_json(
|
||||
{"type": "sample", "info": wv["info"], "sample": wv["last_sample"]}
|
||||
)
|
||||
else:
|
||||
await ws.send_json({"type": "unavailable"})
|
||||
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
log.debug("wireview ws client error", exc_info=True)
|
||||
finally:
|
||||
wv["clients"].discard(ws)
|
||||
|
||||
|
||||
# ── Frontend SPA ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user