feat: native WireView Pro II monitoring (detection, serial protocol, live tab) #11

Merged
Pakobbix merged 4 commits from feat/wireview-monitoring into main 2026-10-03 11:44:01 +00:00
10 changed files with 1792 additions and 1 deletions
Showing only changes of commit 526b71b45f - Show all commits

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ dev: ## Create/refresh the dev environment (uv sync)
test: ## Run the test suite
$(UV) run python tests/test_security.py
$(UV) run python tests/test_rm_power.py
$(UV) run python tests/test_wireview.py
clean: ## Remove build artifacts
rm -rf frontend/dist frontend/node_modules
+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 };
+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 ──────────────────────────────────────────────────────────────
+659
View File
@@ -0,0 +1,659 @@
"""Native reader for the Thermal Grizzly WireView Pro II.
Talks to the 12 VHPWR connector monitor directly over its USB CDC/ACM
serial port (STM32, VID 0483 / PID 5740) — no exporter, no GUI, no kernel
module required. When the wireview-hwmon kernel module is loaded, the
sysfs node is used instead (the wireviewd daemon owns the port in that
case, so direct serial would corrupt frames).
Protocol notes (matches the firmware's DEVICE_STR_LEN=32 layout):
* No framing/CRC — a desynced read corrupts arbitrary fields for one
poll. Real frames always carry zero padding bytes and a fan duty
<= 100; anything else is discarded and the next poll realigns.
* The firmware occasionally stops answering the RTS welcome handshake
(observed after USB state changes) while still answering every data
command, so identification falls back to the vendor-data reply.
"""
import logging
import os
import struct
import time
import serial
log = logging.getLogger("nvcurve.wireview")
# ── Device identification ─────────────────────────────────────────────────────
USB_VENDOR_ID = "0483" # STMicroelectronics (CDC/ACM)
USB_PRODUCT_ID = "5740" # WireView Pro II normal mode
WELCOME_MESSAGE = "Thermal Grizzly WireView Pro II"
MAX_WELCOME_LENGTH = 64
# Vendor/product ids reported by CMD_READ_VENDOR_DATA (not the USB ids).
VENDOR_ID_THERMAL_GRIZZLY = 0xEF
PRODUCT_ID_PRO2 = 0x05
PRODUCT_ID_PRO2_NOCTUA = 0x06
DEVICE_NAMES = {
PRODUCT_ID_PRO2: "WireView Pro II",
PRODUCT_ID_PRO2_NOCTUA: "WireView Pro II Noctua Edition",
}
BAUD_RATE = 115200
READ_TIMEOUT_S = 1.0
# ── Serial protocol commands ──────────────────────────────────────────────────
CMD_READ_VENDOR_DATA = 0x01
CMD_READ_UID = 0x02
CMD_READ_SENSOR_VALUES = 0x04
CMD_READ_CONFIG = 0x05
CMD_SCREEN_CHANGE = 0x0C
CMD_READ_BUILD_INFO = 0x0D
SCREEN_RESUME_UPDATES = 0xF1
# ── Wire layout ───────────────────────────────────────────────────────────────
# SensorStruct (100 bytes, little-endian, pack=4):
# 4x int16 temperatures (0.1 °C): in, out, ext1, ext2
# uint16 Vdd (mV)
# uint8 fan duty (%)
# pad
# 6x { int16 voltage (mV), pad, uint32 current (mA), uint32 power (mW) }
# uint32 total power (mW)
# uint32 total current (mA)
# uint16 avg voltage (mV)
# uint8 PSU capability (0=600W, 1=450W, 2=300W, 3=150W)
# pad
# uint16 fault status mask
# uint16 fault log mask
SENSOR_STRUCT = struct.Struct(
"<4hHBx" + "".join("hxxII" for _ in range(6)) + "IIHBxHH"
)
SENSOR_STRUCT_SIZE = SENSOR_STRUCT.size # 100
# BuildStruct: VendorData(3) + ProductName(32) + BuildInfo(32) + NameLength(1)
BUILD_STRUCT_SIZE = 3 + 32 + 32 + 1
BUILD_INFO_OFFSET = 3 + 32 # 35
PSU_CAPABILITY_W = {0: 600, 1: 450, 2: 300, 3: 150}
# Fault bitmask (both the active status and the latched log use these bits).
FAULT_BITS = {
0: "Chip over-temperature",
1: "Sensor over-temperature",
2: "Over-current (OCP)",
3: "Wire over-current",
4: "Over-power (OPP)",
5: "Current imbalance",
}
def decode_faults(mask: int) -> list[str]:
"""Human-readable names of the active fault bits in a status/log mask."""
return [
FAULT_BITS[bit]
for bit in sorted(FAULT_BITS)
if mask & (1 << bit)
]
def is_supported_product(vendor_id: int, product_id: int) -> bool:
"""True for the products the Pro II protocol serves (5 and 6)."""
return (
vendor_id == VENDOR_ID_THERMAL_GRIZZLY
and product_id in (PRODUCT_ID_PRO2, PRODUCT_ID_PRO2_NOCTUA)
)
def parse_sensor_struct(buf: bytes) -> dict:
"""Decode a 100-byte sensor frame into a JSON-serializable sample.
Totals are computed from the per-pin readings (voltage * current),
matching the exporter's output; the device's own total fields are
not used.
"""
fields = SENSOR_STRUCT.unpack(buf)
ts_in, ts_out, ts_ext1, ts_ext2, _vdd, fan_duty = fields[:6]
pin_fields = fields[6:24]
(
_total_power,
_total_current,
_avg_voltage,
psu_cap,
fault_status,
fault_log,
) = fields[24:]
pins = []
power_total = 0.0
current_total = 0.0
for i in range(6):
voltage_v = pin_fields[i * 3] / 1000.0
current_a = pin_fields[i * 3 + 1] / 1000.0
power_w = pin_fields[i * 3 + 2] / 1000.0
pins.append(
{
"voltage_v": round(voltage_v, 3),
"current_a": round(current_a, 3),
"power_w": round(power_w, 3),
}
)
power_total += voltage_v * current_a
current_total += current_a
return {
"timestamp": time.time(),
"power_total_w": round(power_total, 3),
"current_total_a": round(current_total, 3),
"voltage_avg_v": (
round(power_total / current_total, 3) if current_total > 0 else 0.0
),
"pins": pins,
"temp_in_c": ts_in / 10.0,
"temp_out_c": ts_out / 10.0,
"temp_ext1_c": ts_ext1 / 10.0,
"temp_ext2_c": ts_ext2 / 10.0,
"fan_duty_pct": fan_duty,
"fault_status": fault_status,
"fault_log": fault_log,
"psu_capability_w": PSU_CAPABILITY_W.get(psu_cap, 0),
}
def sensor_frame_is_corrupt(buf: bytes) -> bool:
"""Corruption check for a sensor frame: real frames always carry zero
padding bytes and a fan duty <= 100. Wire layout: fan duty at offset
10, pad1 at 11, pad2 five bytes from the end (before the two 16-bit
fault masks)."""
if len(buf) < SENSOR_STRUCT_SIZE:
return True
return buf[10] > 100 or buf[11] != 0 or buf[SENSOR_STRUCT_SIZE - 5] != 0
# ── Device discovery ──────────────────────────────────────────────────────────
def _sysfs_matches_wireview(tty_sysfs_dir: str) -> bool:
"""Walk up from a resolved tty sysfs path to the USB device and check
its idVendor/idProduct."""
path = tty_sysfs_dir
while path and path != "/":
vid_file = os.path.join(path, "idVendor")
pid_file = os.path.join(path, "idProduct")
if os.path.isfile(vid_file) and os.path.isfile(pid_file):
try:
with open(vid_file) as f:
vid = f.read().strip().lower()
with open(pid_file) as f:
pid = f.read().strip().lower()
except OSError:
return False
return vid == USB_VENDOR_ID and pid == USB_PRODUCT_ID
path = os.path.dirname(path)
return False
def find_wireview_ports() -> list[str]:
"""Find /dev nodes of connected WireView Pro II devices.
Checks the stable /dev/wireview-pro2 symlink (created by the
99-wireview.rules udev rule) and falls back to a sysfs scan of all
ttyACM* ports matched by USB VID/PID.
"""
ports: set[str] = set()
link = "/dev/wireview-pro2"
if os.path.islink(link) or os.path.exists(link):
try:
target = os.path.realpath(link)
if os.path.exists(target):
ports.add(target)
except OSError:
pass
sys_class = "/sys/class/tty"
if os.path.isdir(sys_class):
try:
entries = os.listdir(sys_class)
except OSError:
entries = []
for entry in entries:
if not entry.startswith("ttyACM"):
continue
tty_dir = os.path.join(sys_class, entry)
try:
resolved = os.path.realpath(tty_dir)
except OSError:
continue
if _sysfs_matches_wireview(resolved):
ports.add(f"/dev/{entry}")
return sorted(ports)
def find_hwmon_path() -> str | None:
"""Find the wireview-hwmon sysfs node, if the kernel module is loaded."""
base = "/sys/class/hwmon"
if not os.path.isdir(base):
return None
try:
entries = os.listdir(base)
except OSError:
return None
for entry in entries:
name_path = os.path.join(base, entry, "name")
try:
with open(name_path) as f:
if f.read().strip().lower() == "wireview":
return os.path.join(base, entry)
except OSError:
continue
return None
# ── Serial transport ──────────────────────────────────────────────────────────
class WireViewSerialDevice:
"""Direct serial access to a WireView Pro II.
The port is opened and closed per transaction (open → flush → write →
read → close), matching the proven behavior of the exporter: it keeps
the port unheld between polls so other tools (official GUI, wireviewd)
can share the device, and a fresh open realigns a desynced stream.
"""
def __init__(self, port: str, baud: int = BAUD_RATE) -> None:
self._port = port
self._baud = baud
self._connected = False
self._rejected = False
self._vendor_id = 0
self._product_id = 0
self._firmware_version = ""
self._uid = ""
self._build = ""
self._config_version = -1
# ── Identity ──
@property
def connected(self) -> bool:
return self._connected
@property
def rejected(self) -> bool:
"""True when the device reported an unsupported product id. Callers
can memoize this so the port is not re-probed (and re-logged) on
every watchdog tick."""
return self._rejected
@property
def transport(self) -> str:
return "serial"
@property
def port(self) -> str:
return self._port
def info(self) -> dict:
return {
"device_name": DEVICE_NAMES.get(
self._product_id, "WireView Pro II"
),
"hw_rev": f"{self._vendor_id:02X}{self._product_id:02X}",
"firmware_version": self._firmware_version,
"uid": self._uid,
"build": self._build,
"transport": self.transport,
"port": self._port,
}
def node_exists(self) -> bool:
"""Whether the serial device node still exists (unplug check)."""
return os.path.exists(self._port)
# ── Connection ──
def connect(self) -> bool:
"""Identify the device and prepare it for sensor reads.
The welcome handshake (RTS edge) is the primary identification,
but the firmware occasionally stops answering it while still
answering every command — a supported vendor-data reply is
equally conclusive, so accept either.
"""
if self._connected:
return True
# The welcome handshake (RTS edge) is the primary identification, but
# the firmware occasionally stops answering it while still answering
# every command — the supported vendor-data reply below is equally
# conclusive, so the welcome is read for logging only.
welcome = self._read_welcome()
if welcome and welcome != WELCOME_MESSAGE:
log.debug(
"WireView: unexpected welcome string %r on %s",
welcome,
self._port,
)
vd = self._transaction(bytes([CMD_READ_VENDOR_DATA]), 3)
if vd is None or len(vd) < 3:
return False
vendor, product, fw = vd[0], vd[1], vd[2]
if not is_supported_product(vendor, product):
self._rejected = True
log.info(
"WireView: unsupported product %02X%02X on %s, skipped",
vendor,
product,
self._port,
)
return False
self._vendor_id = vendor
self._product_id = product
self._firmware_version = str(fw)
cfg = self._transaction(bytes([CMD_READ_CONFIG]), 4)
if cfg is None or len(cfg) < 3:
return False
self._config_version = cfg[2]
uid = self._transaction(bytes([CMD_READ_UID]), 12)
if uid is not None and len(uid) == 12:
self._uid = uid.hex().upper()
# Enable display updates just in case.
self._transaction(
bytes([CMD_SCREEN_CHANGE, SCREEN_RESUME_UPDATES]), 0
)
build = self._transaction(bytes([CMD_READ_BUILD_INFO]), BUILD_STRUCT_SIZE)
if build is not None and len(build) >= BUILD_INFO_OFFSET + 1:
self._build = (
build[BUILD_INFO_OFFSET : BUILD_INFO_OFFSET + 32]
.split(b"\x00")[0]
.decode("ascii", errors="replace")
)
self._connected = True
log.info(
"WireView connected on %s (%s, fw %s)",
self._port,
self.info()["hw_rev"],
self._firmware_version,
)
return True
def close(self) -> None:
self._connected = False
# ── Sensor reads ──
def read_sample(self) -> dict | None:
"""Read one sensor sample, or None when the device is unresponsive
or the frame is corrupt."""
if not self._connected:
return None
buf = self._transaction(bytes([CMD_READ_SENSOR_VALUES]), SENSOR_STRUCT_SIZE)
if buf is None or sensor_frame_is_corrupt(buf):
return None
return parse_sensor_struct(buf)
# ── Transport ──
def _read_welcome(self) -> str | None:
"""Assert RTS and read the NUL-terminated welcome string the device
answers with. Null when nothing (or no terminator) arrives in time."""
try:
ser = serial.Serial(self._port, self._baud, timeout=READ_TIMEOUT_S)
except (serial.SerialException, OSError):
return None
try:
ser.reset_input_buffer()
ser.rts = False
time.sleep(0.01)
ser.rts = True
time.sleep(0.01)
buf = bytearray()
deadline = time.monotonic() + READ_TIMEOUT_S
while len(buf) < MAX_WELCOME_LENGTH:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
ser.timeout = min(READ_TIMEOUT_S, remaining)
chunk = ser.read(MAX_WELCOME_LENGTH - len(buf))
if not chunk:
break
buf.extend(chunk)
if b"\x00" in chunk:
break
time.sleep(0.01)
ser.rts = False
nul = buf.find(b"\x00")
if nul >= 0:
return bytes(buf[:nul]).decode("ascii", errors="replace")
return None
except (serial.SerialException, OSError):
return None
finally:
ser.close()
def _transaction(self, cmd: bytes, response_size: int) -> bytes | None:
"""Open the port, send cmd, read exactly response_size bytes (one
second budget), close the port. None when the port is unavailable
or the reply is incomplete."""
try:
ser = serial.Serial(self._port, self._baud, timeout=READ_TIMEOUT_S)
except (serial.SerialException, OSError):
return None
try:
ser.reset_input_buffer()
if cmd:
ser.write(cmd)
if response_size == 0:
return b""
return self._read_exact(ser, response_size)
except (serial.SerialException, OSError):
return None
finally:
ser.close()
@staticmethod
def _read_exact(ser: serial.Serial, size: int) -> bytes | None:
"""Read exactly size bytes within one second, or None."""
buf = bytearray()
deadline = time.monotonic() + READ_TIMEOUT_S
while len(buf) < size:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
ser.timeout = min(READ_TIMEOUT_S, remaining)
chunk = ser.read(size - len(buf))
if not chunk:
return None
buf.extend(chunk)
return bytes(buf)
# ── hwmon (sysfs) transport ───────────────────────────────────────────────────
class WireViewHwmonDevice:
"""Reads the wireview-hwmon sysfs node (kernel module + wireviewd).
Used when the module is loaded: the daemon owns the serial port in
that case, so direct serial would corrupt frames.
"""
def __init__(self, hwmon_path: str) -> None:
self._path = hwmon_path
self._connected = False
@property
def connected(self) -> bool:
return self._connected
@property
def rejected(self) -> bool:
return False
@property
def transport(self) -> str:
return "hwmon"
@property
def port(self) -> str:
return self._path
def info(self) -> dict:
return {
"device_name": "WireView Pro II",
"hw_rev": "",
"firmware_version": "",
"uid": "",
"build": "",
"transport": self.transport,
"port": self._path,
}
def node_exists(self) -> bool:
return os.path.isdir(self._path)
def connect(self) -> bool:
if self._connected:
return True
name_path = os.path.join(self._path, "name")
try:
with open(name_path) as f:
if f.read().strip().lower() != "wireview":
return False
# Probe that the node actually serves data.
with open(os.path.join(self._path, "in0_input")) as f:
f.read().strip()
except OSError:
return False
self._connected = True
log.info("WireView connected via hwmon (%s)", self._path)
return True
def close(self) -> None:
self._connected = False
def read_sample(self) -> dict | None:
if not self._connected:
return None
try:
pin_voltage = [
self._read_int(f"in{i}_input") / 1000.0 for i in range(6)
]
pin_current = [
self._read_int(f"curr{i + 1}_input") / 1000.0 for i in range(6)
]
temp_in = self._read_temp("temp1_input")
temp_out = self._read_temp("temp2_input")
temp_ext1 = self._read_temp("temp3_input")
temp_ext2 = self._read_temp("temp4_input")
fault_status = self._read_int_or("fault_status_raw")
if fault_status is None:
fault_status = 0xFFFF if self._read_int("intrusion0_alarm") else 0
fault_log = self._read_int_or("fault_log_raw")
if fault_log is None:
fault_log = 0xFFFF if self._read_int("intrusion1_alarm") else 0
psu_cap_uw = self._read_int_or("power1_cap")
if psu_cap_uw is not None:
psu_capability = int(round(psu_cap_uw / 1_000_000.0))
else:
psu_cap = self._read_int_or("psu_cap")
psu_capability = PSU_CAPABILITY_W.get(psu_cap or 0, 0)
pwm = self._read_int_or("pwm1")
if pwm is not None:
fan_duty = int(round(min(255, max(0, pwm)) * 100 / 255.0))
else:
fan_duty = self._read_int("fan1_input")
power_total = sum(
v * i
for v, i in zip(pin_voltage, pin_current, strict=True)
)
current_total = sum(pin_current)
return {
"timestamp": time.time(),
"power_total_w": round(power_total, 3),
"current_total_a": round(current_total, 3),
"voltage_avg_v": (
round(power_total / current_total, 3)
if current_total > 0
else 0.0
),
"pins": [
{
"voltage_v": round(v, 3),
"current_a": round(i, 3),
"power_w": round(v * i, 3),
}
for v, i in zip(pin_voltage, pin_current, strict=True)
],
"temp_in_c": temp_in,
"temp_out_c": temp_out,
"temp_ext1_c": temp_ext1,
"temp_ext2_c": temp_ext2,
"fan_duty_pct": fan_duty,
"fault_status": fault_status,
"fault_log": fault_log,
"psu_capability_w": psu_capability,
}
except OSError:
return None
def _read_int(self, filename: str) -> int:
"""Read an integer sysfs attribute; 0 when missing or unreadable
(matches the exporter's ReadIntFile)."""
try:
with open(os.path.join(self._path, filename)) as f:
return int(f.read().strip())
except (OSError, ValueError):
return 0
def _read_int_or(self, filename: str) -> int | None:
try:
with open(os.path.join(self._path, filename)) as f:
return int(f.read().strip())
except (OSError, ValueError):
return None
def _read_temp(self, filename: str) -> float | None:
"""Read a temperature sysfs attribute (m°C); None when missing or
unreadable. NaN would poison the whole sample: the WebSocket
serializer emits a bare NaN token (invalid JSON) and the REST
JSONResponse rejects it with a 500."""
try:
with open(os.path.join(self._path, filename)) as f:
return int(f.read().strip()) / 1000.0
except (OSError, ValueError):
return None
def create_device() -> WireViewSerialDevice | WireViewHwmonDevice | None:
"""Create a device for the first available WireView, or None.
Preference: the wireview-hwmon sysfs node (the wireviewd daemon owns
the serial port in that case), otherwise direct serial on the first
matching /dev/ttyACM*.
"""
hwmon_path = find_hwmon_path()
if hwmon_path:
return WireViewHwmonDevice(hwmon_path)
ports = find_wireview_ports()
if ports:
return WireViewSerialDevice(ports[0])
return None
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"pydantic>=2.0",
"httpx>=0.27",
"bcrypt>=4.0",
"pyserial>=3.5",
]
[project.scripts]
+493
View File
@@ -0,0 +1,493 @@
"""Tests for the native WireView Pro II reader (nvcurve/wireview.py).
Standalone (no pytest required):
python tests/test_wireview.py
Also works under pytest. Covers the sensor-frame parser, corruption
detection, fault decoding, USB port discovery, and the hwmon (sysfs)
transport.
"""
import builtins
import io
import os
import struct
import sys
import tempfile
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from nvcurve import wireview as wv # noqa: E402
PASS = 0
FAIL = 0
def check(name: str, cond: bool) -> None:
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name}")
def make_frame(
ts=(392, 352, 273, 395),
vdd=11900,
fan=0,
pins=((11904, 6221, 74055),) * 6,
total_power=450568,
total_current=37863,
avg_voltage=11900,
psu_cap=0,
fault_status=0,
fault_log=0,
pad1=0,
pad2=0,
) -> bytes:
"""Build a 100-byte sensor frame with controllable fields."""
frame = struct.pack("<4hHB", *ts, vdd, fan)
frame += bytes([pad1])
for voltage, current, power in pins:
frame += struct.pack("<hxxII", voltage, current, power)
frame += struct.pack("<IIHB", total_power, total_current, avg_voltage, psu_cap)
frame += bytes([pad2])
frame += struct.pack("<HH", fault_status, fault_log)
assert len(frame) == wv.SENSOR_STRUCT_SIZE
return frame
# ── Sensor frame parser ───────────────────────────────────────────────────────
def test_parse_sensor_struct():
s = wv.parse_sensor_struct(make_frame())
check("temp in", abs(s["temp_in_c"] - 39.2) < 1e-9)
check("temp out", abs(s["temp_out_c"] - 35.2) < 1e-9)
check("temp ext1", abs(s["temp_ext1_c"] - 27.3) < 1e-9)
check("temp ext2", abs(s["temp_ext2_c"] - 39.5) < 1e-9)
check("fan duty", s["fan_duty_pct"] == 0)
check("psu capability", s["psu_capability_w"] == 600)
check("fault status", s["fault_status"] == 0)
check("fault log", s["fault_log"] == 0)
check("6 pins", len(s["pins"]) == 6)
pin = s["pins"][0]
check("pin voltage", abs(pin["voltage_v"] - 11.904) < 1e-9)
check("pin current", abs(pin["current_a"] - 6.221) < 1e-9)
check("pin power", abs(pin["power_w"] - 74.055) < 1e-9)
# Totals are computed from the pins (exporter behavior), not the
# device's total fields.
check(
"total current",
abs(s["current_total_a"] - round(6 * 6.221, 3)) < 1e-9,
)
check(
"total power",
abs(s["power_total_w"] - round(6 * 11.904 * 6.221, 3)) < 1e-9,
)
check(
"avg voltage",
abs(s["voltage_avg_v"] - 11.904) < 1e-3,
)
def test_parse_psu_capabilities():
for cap, watts in ((0, 600), (1, 450), (2, 300), (3, 150)):
s = wv.parse_sensor_struct(make_frame(psu_cap=cap))
check(f"psu cap {cap} -> {watts}W", s["psu_capability_w"] == watts)
def test_parse_negative_temps():
s = wv.parse_sensor_struct(make_frame(ts=(-10, 0, 555, 999)))
check("negative temp", abs(s["temp_in_c"] - (-1.0)) < 1e-9)
check("zero temp", s["temp_out_c"] == 0.0)
check("high temp", abs(s["temp_ext2_c"] - 99.9) < 1e-9)
# ── Corruption detection ──────────────────────────────────────────────────────
def test_corruption_check():
check("clean frame accepted", not wv.sensor_frame_is_corrupt(make_frame()))
check(
"fan duty > 100 rejected",
wv.sensor_frame_is_corrupt(make_frame(fan=101)),
)
check(
"fan duty 100 accepted",
not wv.sensor_frame_is_corrupt(make_frame(fan=100)),
)
check("pad1 dirty rejected", wv.sensor_frame_is_corrupt(make_frame(pad1=1)))
check("pad2 dirty rejected", wv.sensor_frame_is_corrupt(make_frame(pad2=1)))
check(
"short frame rejected",
wv.sensor_frame_is_corrupt(make_frame()[:50]),
)
# ── Fault decoding ────────────────────────────────────────────────────────────
def test_decode_faults():
check("no faults", wv.decode_faults(0) == [])
check(
"chip over-temp",
wv.decode_faults(1) == ["Chip over-temperature"],
)
check(
"over-power",
wv.decode_faults(16) == ["Over-power (OPP)"],
)
check(
"multiple faults",
wv.decode_faults(1 | 32)
== ["Chip over-temperature", "Current imbalance"],
)
check(
"unknown bits ignored",
wv.decode_faults(1 | 0x80) == ["Chip over-temperature"],
)
def test_product_support():
check("Pro II supported", wv.is_supported_product(0xEF, 0x05))
check("Noctua Edition supported", wv.is_supported_product(0xEF, 0x06))
check("WireView II unsupported", not wv.is_supported_product(0xEF, 0x07))
check("other vendor unsupported", not wv.is_supported_product(0x12, 0x05))
# ── USB port discovery ────────────────────────────────────────────────────────
def test_find_wireview_ports():
"""Fake a sysfs tree with one WireView (ttyACM0) and one other CDC
device (ttyACM1), plus the udev symlink."""
contents = {
"/sys/devices/fake/usb0/idVendor": "0483\n",
"/sys/devices/fake/usb0/idProduct": "5740\n",
"/sys/devices/fake/usb1/idVendor": "1234\n",
"/sys/devices/fake/usb1/idProduct": "5678\n",
}
def fake_islink(p):
return p == "/dev/wireview-pro2"
def fake_realpath(p):
return {
"/dev/wireview-pro2": "/dev/ttyACM0",
"/sys/class/tty/ttyACM0": "/sys/devices/fake/usb0",
"/sys/class/tty/ttyACM1": "/sys/devices/fake/usb1",
}.get(p, p)
def fake_isdir(p):
return p == "/sys/class/tty"
def fake_listdir(p):
return ["ttyACM0", "ttyACM1"] if p == "/sys/class/tty" else []
def fake_isfile(p):
return p in contents
def fake_exists(p):
return p == "/dev/ttyACM0"
def fake_open(p, *a, **k):
if p in contents:
return io.StringIO(contents[p])
return real_open(p, *a, **k)
real_open = open
with mock.patch.object(wv.os.path, "islink", fake_islink), mock.patch.object(
wv.os.path, "realpath", fake_realpath
), mock.patch.object(wv.os.path, "isdir", fake_isdir), mock.patch.object(
wv.os, "listdir", fake_listdir
), mock.patch.object(wv.os.path, "isfile", fake_isfile), mock.patch.object(
wv.os.path, "exists", fake_exists
), mock.patch.object(
wv.os.path, "dirname", os.path.dirname
), mock.patch.object(
builtins, "open", fake_open
):
ports = wv.find_wireview_ports()
check("exactly one port found", ports == ["/dev/ttyACM0"])
def test_find_wireview_ports_none():
with mock.patch.object(wv.os.path, "islink", lambda p: False), mock.patch.object(
wv.os.path, "isdir", lambda p: False
), mock.patch.object(wv.os.path, "exists", lambda p: False):
check("no ports", wv.find_wireview_ports() == [])
def test_find_hwmon_path():
with tempfile.TemporaryDirectory() as tmp:
# A non-wireview hwmon and a wireview one.
for name, dev in (("hwmon0", "coretemp"), ("hwmon1", "wireview")):
d = os.path.join(tmp, name)
os.makedirs(d)
with open(os.path.join(d, "name"), "w") as f:
f.write(dev + "\n")
real_join = os.path.join
real_listdir = os.listdir
def fake_join(*parts):
if parts and parts[0] == "/sys/class/hwmon":
parts = (tmp,) + parts[1:]
return real_join(*parts)
def fake_listdir(p):
if p == "/sys/class/hwmon":
return real_listdir(tmp)
return real_listdir(p)
with mock.patch.object(wv.os.path, "join", fake_join), mock.patch.object(
wv.os, "listdir", fake_listdir
):
found = wv.find_hwmon_path()
check("hwmon path found", found == os.path.join(tmp, "hwmon1"))
with mock.patch.object(wv.os.path, "isdir", lambda p: False):
check("hwmon absent", wv.find_hwmon_path() is None)
# ── hwmon transport ───────────────────────────────────────────────────────────
def test_hwmon_device():
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "hwmon2")
os.makedirs(p)
with open(os.path.join(p, "name"), "w") as f:
f.write("wireview\n")
for i in range(6):
with open(os.path.join(p, f"in{i}_input"), "w") as f:
f.write("11904\n")
with open(os.path.join(p, f"curr{i + 1}_input"), "w") as f:
f.write("6221\n")
for name, val in (
("temp1_input", "39200"),
("temp2_input", "35200"),
("temp3_input", "27300"),
("temp4_input", "39500"),
("fault_status_raw", "0"),
("fault_log_raw", "0"),
("power1_cap", "600000000"),
("pwm1", "128"),
):
with open(os.path.join(p, name), "w") as f:
f.write(val + "\n")
dev = wv.WireViewHwmonDevice(p)
check("hwmon connect", dev.connect())
check("hwmon node exists", dev.node_exists())
s = dev.read_sample()
check("hwmon sample", s is not None)
if s:
check("hwmon temp in", abs(s["temp_in_c"] - 39.2) < 1e-9)
check("hwmon fan duty ~50%", 49 <= s["fan_duty_pct"] <= 51)
check("hwmon psu cap", s["psu_capability_w"] == 600)
check("hwmon 6 pins", len(s["pins"]) == 6)
check(
"hwmon total current",
abs(s["current_total_a"] - round(6 * 6.221, 3)) < 1e-9,
)
dev.close()
check("hwmon closed", not dev.connected)
# A missing temp channel must yield null (not NaN): NaN would break
# the WebSocket JSON (bare NaN token) and 500 the REST endpoint.
os.remove(os.path.join(p, "temp3_input"))
dev3 = wv.WireViewHwmonDevice(p)
dev3.connect()
s3 = dev3.read_sample()
check("hwmon missing temp sample", s3 is not None)
if s3:
check("hwmon missing temp is None", s3["temp_ext1_c"] is None)
check("hwmon missing temp present", s3["temp_in_c"] == 39.2)
import json
check(
"hwmon sample JSON-safe",
json.dumps(s3, allow_nan=False) is not None,
)
dev3.close()
# Wrong device name is rejected.
with open(os.path.join(p, "name"), "w") as f:
f.write("coretemp\n")
dev2 = wv.WireViewHwmonDevice(p)
check("wrong name rejected", not dev2.connect())
def test_hwmon_legacy_attrs():
"""Older modules expose psu_cap/fan1_input instead of power1_cap/pwm1."""
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "hwmon3")
os.makedirs(p)
with open(os.path.join(p, "name"), "w") as f:
f.write("wireview\n")
for i in range(6):
with open(os.path.join(p, f"in{i}_input"), "w") as f:
f.write("12000\n")
with open(os.path.join(p, f"curr{i + 1}_input"), "w") as f:
f.write("1000\n")
for name, val in (
("temp1_input", "30000"),
("temp2_input", "30000"),
("temp3_input", "30000"),
("temp4_input", "30000"),
("psu_cap", "2"),
("fan1_input", "42"),
("intrusion0_alarm", "0"),
("intrusion1_alarm", "0"),
):
with open(os.path.join(p, name), "w") as f:
f.write(val + "\n")
dev = wv.WireViewHwmonDevice(p)
check("legacy connect", dev.connect())
s = dev.read_sample()
check("legacy sample", s is not None)
if s:
check("legacy psu cap 300W", s["psu_capability_w"] == 300)
check("legacy fan pct", s["fan_duty_pct"] == 42)
# ── Serial transport (pty-based fake device) ──────────────────────────────────
def _build_info_struct(product_name: str, build_info: str) -> bytes:
"""BuildStruct: VendorData(3) + ProductName(32) + BuildInfo(32) + NameLength(1)."""
return (
bytes([0xEF, 0x05, 1])
+ product_name.encode().ljust(32, b"\x00")
+ build_info.encode().ljust(32, b"\x00")
+ bytes([len(product_name)])
)
def _pty_fake_device(responses: dict):
"""Create a pty pair whose master side answers protocol commands in a
background thread. Returns (slave_path, master_fd, thread).
The fake never sends the welcome string, so the device exercises its
documented fallback: identification via the vendor-data reply.
"""
import pty
import threading
master, slave = pty.openpty()
slave_path = os.ttyname(slave)
def run():
while True:
try:
cmd = os.read(master, 1)
except OSError:
return
if not cmd:
return
resp = responses.get(cmd[0])
if resp:
try:
os.write(master, resp)
except OSError:
return
t = threading.Thread(target=run, daemon=True)
t.start()
return slave_path, master, t
def test_serial_device_protocol():
"""Full connect handshake + sensor read against a fake device."""
uid = bytes.fromhex("A7003100015045324B383120")
responses = {
wv.CMD_READ_VENDOR_DATA: bytes([0xEF, 0x05, 1]),
wv.CMD_READ_CONFIG: bytes([0, 0, 1, 0]), # version at offset 2
wv.CMD_READ_UID: uid,
wv.CMD_READ_BUILD_INFO: _build_info_struct(
"WireView Pro II", "TG-WV-PRO2-FW_20251211_1547"
),
wv.CMD_READ_SENSOR_VALUES: make_frame(),
# CMD_SCREEN_CHANGE expects no response.
}
slave_path, master, t = _pty_fake_device(responses)
try:
dev = wv.WireViewSerialDevice(slave_path)
check("serial connect", dev.connect())
check("serial not rejected", not dev.rejected)
info = dev.info()
check("serial device name", info["device_name"] == "WireView Pro II")
check("serial hw_rev", info["hw_rev"] == "EF05")
check("serial firmware", info["firmware_version"] == "1")
check("serial uid", info["uid"] == "A7003100015045324B383120")
check("serial build", info["build"] == "TG-WV-PRO2-FW_20251211_1547")
check("serial transport", info["transport"] == "serial")
s = dev.read_sample()
check("serial sample", s is not None)
if s:
check("serial sample temp", abs(s["temp_in_c"] - 39.2) < 1e-9)
check("serial sample pins", len(s["pins"]) == 6)
dev.close()
check("serial closed", not dev.connected)
finally:
os.close(master)
t.join(timeout=2)
def test_serial_device_rejected_product():
"""An unsupported product id is rejected and memoized via .rejected."""
responses = {wv.CMD_READ_VENDOR_DATA: bytes([0xEF, 0x07, 1])}
slave_path, master, t = _pty_fake_device(responses)
try:
dev = wv.WireViewSerialDevice(slave_path)
check("serial reject connect", not dev.connect())
check("serial rejected flag", dev.rejected)
finally:
os.close(master)
t.join(timeout=2)
def test_serial_device_no_response():
"""A silent port fails the connect (vendor-data read times out)."""
slave_path, master, t = _pty_fake_device({})
try:
dev = wv.WireViewSerialDevice(slave_path)
check("serial no-response connect", not dev.connect())
check("serial no-response not rejected", not dev.rejected)
check("serial no-response sample", dev.read_sample() is None)
finally:
os.close(master)
t.join(timeout=2)
def main() -> int:
print("wireview tests:")
test_parse_sensor_struct()
test_parse_psu_capabilities()
test_parse_negative_temps()
test_corruption_check()
test_decode_faults()
test_product_support()
test_find_wireview_ports()
test_find_wireview_ports_none()
test_find_hwmon_path()
test_hwmon_device()
test_hwmon_legacy_attrs()
test_serial_device_protocol()
test_serial_device_rejected_product()
test_serial_device_no_response()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
Generated
+11
View File
@@ -246,6 +246,7 @@ dependencies = [
{ name = "httpx" },
{ name = "nvidia-ml-py" },
{ name = "pydantic" },
{ name = "pyserial" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -261,6 +262,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "nvidia-ml-py", specifier = ">=12.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pyserial", specifier = ">=3.5" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
]
@@ -389,6 +391,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"