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

+19 -1
View File
@@ -11,10 +11,12 @@ import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { WireViewPanel } from "./components/WireView/WireViewPanel.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore.js";
import { useWireview } from "./hooks/useWireview.js";
import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";
@@ -94,9 +96,10 @@ function MainApp({
const { dashboard, loading: dashboardLoading } = useDashboard();
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
useCurveStore();
const wireview = useWireview();
const [activeTab, setActiveTab] = useState<
"dashboard" | "curve" | "performance" | "fans"
"dashboard" | "curve" | "performance" | "fans" | "wireview"
>("dashboard");
const [fanState, setFanState] = useState<FanState | null>(null);
const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu");
@@ -195,6 +198,15 @@ function MainApp({
>
Fans
</button>
{wireview.available && (
<button
onClick={() => setActiveTab("wireview")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors flex items-center gap-2 ${activeTab === "wireview" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
>
WireView
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</button>
)}
</div>
<div className="relative" ref={profileRef}>
<button
@@ -289,6 +301,12 @@ function MainApp({
/>
</div>
</div>
) : activeTab === "wireview" ? (
<WireViewPanel
info={wireview.info}
sample={wireview.sample}
history={wireview.history}
/>
) : (
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0">
@@ -0,0 +1,309 @@
import { GaugeCard } from "../Monitor/GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { WireViewInfo, WireViewSample } from "../../types.js";
interface Props {
info: WireViewInfo | null;
sample: WireViewSample | null;
history: WireViewSample[];
}
// Per-pin over-current protection of the 12 VHPWR connector (A).
const PIN_OCP_A = 55;
const FAULT_NAMES: Record<number, string> = {
0: "Chip over-temperature",
1: "Sensor over-temperature",
2: "Over-current (OCP)",
3: "Wire over-current",
4: "Over-power (OPP)",
5: "Current imbalance",
};
function decodeFaults(mask: number): string[] {
return Object.entries(FAULT_NAMES)
.filter(([bit]) => mask & (1 << Number(bit)))
.map(([, name]) => name);
}
function pluck(history: WireViewSample[], key: keyof WireViewSample): number[] {
return history.map((s) => (s[key] as number | null) ?? 0);
}
function FaultBadge({
label,
mask,
}: {
label: string;
mask: number;
}) {
const faults = decodeFaults(mask);
return (
<div
className={`rounded-lg p-3 border flex flex-col gap-1.5 min-w-0 ${
faults.length > 0
? "bg-red-500/10 border-red-500/40"
: "bg-zinc-900 border-zinc-800"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-zinc-500 uppercase tracking-wider">
{label}
</span>
<span
className={`text-xs font-mono ${
faults.length > 0 ? "text-red-400" : "text-zinc-600"
}`}
>
0x{mask.toString(16).toUpperCase().padStart(4, "0")}
</span>
</div>
{faults.length > 0 ? (
<div className="flex flex-wrap gap-1">
{faults.map((f) => (
<span
key={f}
className="px-1.5 py-0.5 rounded bg-red-500/20 border border-red-500/40 text-red-300 text-[11px] font-medium"
>
{f}
</span>
))}
</div>
) : (
<div className="text-sm text-emerald-400 font-medium">No faults</div>
)}
</div>
);
}
function PinCard({
index,
voltage,
current,
power,
}: {
index: number;
voltage: number;
current: number;
power: number;
}) {
const loadPct = Math.min(100, (current / PIN_OCP_A) * 100);
const barColor =
loadPct >= 90 ? "#f87171" : loadPct >= 75 ? "#fbbf24" : "#34d399";
return (
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-1.5 min-w-0">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">
Pin {index + 1}
</span>
<span className="text-[10px] font-mono text-zinc-600">
{current.toFixed(1)} / {PIN_OCP_A} A
</span>
</div>
<div className="grid grid-cols-3 gap-1 text-center">
<div>
<div className="text-[10px] text-zinc-600">V</div>
<div className="text-sm font-mono font-semibold text-violet-300">
{voltage.toFixed(2)}
</div>
</div>
<div>
<div className="text-[10px] text-zinc-600">A</div>
<div className="text-sm font-mono font-semibold text-cyan-300">
{current.toFixed(2)}
</div>
</div>
<div>
<div className="text-[10px] text-zinc-600">W</div>
<div className="text-sm font-mono font-semibold text-pink-300">
{power.toFixed(1)}
</div>
</div>
</div>
<div className="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${loadPct}%`, backgroundColor: barColor }}
/>
</div>
</div>
);
}
function InfoItem({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
if (!value) return null;
return (
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs text-zinc-500">{label}</span>
<span
className="text-sm font-mono text-zinc-200 truncate"
title={value}
>
{value}
</span>
</div>
);
}
export function WireViewPanel({ info, sample, history }: Props) {
if (!sample) {
return (
<div className="bg-zinc-900 rounded-lg p-10 text-center text-zinc-500 flex flex-col items-center gap-2">
<div className="text-lg font-medium text-zinc-400">
WireView Pro II not connected
</div>
<div className="text-sm">
Plug in the device — the tab appears automatically once it is
detected.
</div>
</div>
);
}
const psuCap = sample.psu_capability_w > 0 ? sample.psu_capability_w : 600;
return (
<div className="flex flex-col gap-4 w-full">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="bg-zinc-900 rounded-lg p-4 border border-zinc-800 flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span className="text-lg font-semibold text-zinc-100">
{info?.device_name ?? "WireView Pro II"}
</span>
</div>
<span className="text-sm font-mono text-zinc-400">
{info?.hw_rev ? `HW ${info.hw_rev}` : ""}
{info?.firmware_version ? ` · FW ${info.firmware_version}` : ""}
</span>
<span className="text-sm font-mono text-zinc-400">
PSU {psuCap} W
</span>
<span className="text-xs font-mono text-zinc-600 ml-auto">
{info?.transport === "hwmon"
? `hwmon: ${info.port}`
: `serial: ${info?.port ?? ""}`}
</span>
</div>
{/* ── Main gauges ────────────────────────────────────────────── */}
<div className="bg-zinc-900 rounded-lg p-4">
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold pb-3 border-b border-zinc-800">
12 VHPWR Connector
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
<GaugeCard
label="Total Power"
value={fmt.watts(sample.power_total_w, 1)}
history={pluck(history, "power_total_w")}
color="#f472b6"
max={psuCap}
/>
<GaugeCard
label="Total Current"
value={`${sample.current_total_a.toFixed(2)} A`}
history={pluck(history, "current_total_a")}
color="#38bdf8"
max={(psuCap / 12) * 1.1}
/>
<GaugeCard
label="Avg Voltage"
value={`${sample.voltage_avg_v.toFixed(3)} V`}
history={pluck(history, "voltage_avg_v")}
color="#a78bfa"
max={13}
/>
<GaugeCard
label="Fan Duty"
value={fmt.pct(sample.fan_duty_pct)}
history={pluck(history, "fan_duty_pct")}
color="#fb923c"
max={100}
/>
</div>
</div>
{/* ── Per-pin ────────────────────────────────────────────────── */}
<div className="bg-zinc-900 rounded-lg p-4">
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold pb-3 border-b border-zinc-800">
Per-Pin Readings
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mt-3">
{sample.pins.map((pin, i) => (
<PinCard
key={i}
index={i}
voltage={pin.voltage_v}
current={pin.current_a}
power={pin.power_w}
/>
))}
</div>
</div>
{/* ── Temperatures ───────────────────────────────────────────── */}
<div className="bg-zinc-900 rounded-lg p-4">
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold pb-3 border-b border-zinc-800">
Temperatures
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
<GaugeCard
label="Temp In"
value={fmt.celsius(sample.temp_in_c, 1)}
history={pluck(history, "temp_in_c")}
color="#f87171"
max={90}
/>
<GaugeCard
label="Temp Out"
value={fmt.celsius(sample.temp_out_c, 1)}
history={pluck(history, "temp_out_c")}
color="#fb923c"
max={90}
/>
<GaugeCard
label="Ext Sensor 1"
value={fmt.celsius(sample.temp_ext1_c, 1)}
history={pluck(history, "temp_ext1_c")}
color="#fbbf24"
max={90}
/>
<GaugeCard
label="Ext Sensor 2"
value={fmt.celsius(sample.temp_ext2_c, 1)}
history={pluck(history, "temp_ext2_c")}
color="#a78bfa"
max={90}
/>
</div>
</div>
{/* ── Faults + device info ───────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<FaultBadge label="Fault Status" mask={sample.fault_status} />
<FaultBadge label="Fault Log (latched)" mask={sample.fault_log} />
</div>
<div className="bg-zinc-900 rounded-lg p-4">
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold pb-3 border-b border-zinc-800">
Device
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-3">
<InfoItem label="Hardware Revision" value={info?.hw_rev} />
<InfoItem label="Firmware" value={info?.firmware_version} />
<InfoItem label="Build" value={info?.build} />
<InfoItem label="UID" value={info?.uid} />
<InfoItem label="Transport" value={info?.transport} />
<InfoItem label="Port" value={info?.port} />
</div>
</div>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useRef, useState } from "react";
import { createWsConnection } from "../api/websocket.js";
import type {
WireViewInfo,
WireViewSample,
WireViewWsMessage,
} from "../types.js";
const MAX_HISTORY = 120; // ~2 min at 1 s polling
export function useWireview() {
const [available, setAvailable] = useState(false);
const [info, setInfo] = useState<WireViewInfo | null>(null);
const [sample, setSample] = useState<WireViewSample | null>(null);
const [history, setHistory] = useState<WireViewSample[]>([]);
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
wsRef.current = createWsConnection<WireViewWsMessage>(
"/ws/wireview",
(msg) => {
if (msg.type === "sample") {
setAvailable(true);
setInfo(msg.info);
setSample(msg.sample);
setHistory((h) => [...h.slice(-(MAX_HISTORY - 1)), msg.sample]);
} else {
setAvailable(false);
setInfo(null);
setSample(null);
setHistory([]);
}
},
() => {},
);
return () => wsRef.current?.close();
}, []);
return { available, info, sample, history };
}
+47
View File
@@ -145,3 +145,50 @@ export interface ProfileData {
fan_curve: FanPoint[] | null;
fan_targets: number[] | null;
}
// ── WireView Pro II (Thermal Grizzly 12 VHPWR connector monitor) ──
export interface WireViewPin {
voltage_v: number;
current_a: number;
power_w: number;
}
export interface WireViewSample {
timestamp: number;
power_total_w: number;
current_total_a: number;
voltage_avg_v: number;
pins: WireViewPin[];
// Temperatures are null when the source has no channel for them
// (e.g. an hwmon module without external-sensor channels).
temp_in_c: number | null;
temp_out_c: number | null;
temp_ext1_c: number | null;
temp_ext2_c: number | null;
fan_duty_pct: number;
fault_status: number;
fault_log: number;
psu_capability_w: number;
}
export interface WireViewInfo {
device_name: string;
hw_rev: string;
firmware_version: string;
uid: string;
build: string;
transport: "serial" | "hwmon";
port: string;
}
export interface WireViewState {
available: boolean;
connected: boolean;
info: WireViewInfo | null;
sample: WireViewSample | null;
}
export type WireViewWsMessage =
| { type: "unavailable" }
| { type: "sample"; info: WireViewInfo; sample: WireViewSample };