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