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
+19
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user