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:
ARIA committed 2026-10-03 13:20:56 +02:00
1 parent cc102f26c1
commit 526b71b45f
10 files changed
+1792 -1

No files matched your search

+212
View File
@@ -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 ──────────────────────────────────────────────────────────────