feat: native WireView Pro II monitoring (detection, serial protocol, live tab) #11
No files matched your search
@@ -25,6 +25,7 @@ dev: ## Create/refresh the dev environment (uv sync)
|
|||||||
test: ## Run the test suite
|
test: ## Run the test suite
|
||||||
$(UV) run python tests/test_security.py
|
$(UV) run python tests/test_security.py
|
||||||
$(UV) run python tests/test_rm_power.py
|
$(UV) run python tests/test_rm_power.py
|
||||||
|
$(UV) run python tests/test_wireview.py
|
||||||
|
|
||||||
clean: ## Remove build artifacts
|
clean: ## Remove build artifacts
|
||||||
rm -rf frontend/dist frontend/node_modules
|
rm -rf frontend/dist frontend/node_modules
|
||||||
@@ -15,6 +15,7 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
|
|||||||
> **Blackwell GPU memory** — This is a specialized fork with extended memory offset support (up to +3000 MHz) for Blackwell GPUs (RTX 50-series). \
|
> **Blackwell GPU memory** — This is a specialized fork with extended memory offset support (up to +3000 MHz) for Blackwell GPUs (RTX 50-series). \
|
||||||
> **Fan Controls** — There is an additional "Fans" tab to setup a customized fan curve, controlling all fans or individual fans. \
|
> **Fan Controls** — There is an additional "Fans" tab to setup a customized fan curve, controlling all fans or individual fans. \
|
||||||
> **Dashboard** — The default tab is an Dashboard with additional information (PCIe link speed, VBIOS information, Max Core Clock, Throttle Reason and much much more.) \
|
> **Dashboard** — The default tab is an Dashboard with additional information (PCIe link speed, VBIOS information, Max Core Clock, Throttle Reason and much much more.) \
|
||||||
|
> **WireView Pro II** — Native monitoring of the Thermal Grizzly WireView Pro II 12VHPWR connector: the device is auto-detected over USB and a dedicated tab shows live temperature, power and current readings — no external exporter or kernel module required. The tab only appears while the device is connected.\
|
||||||
> **Authentification** — For production deplyoment, I added authentification with bcrypt hashing to allow only one or multiple people to have access. \
|
> **Authentification** — For production deplyoment, I added authentification with bcrypt hashing to allow only one or multiple people to have access. \
|
||||||
> Installing the pre-built PyPI package will NOT include these features. You must build from source.
|
> Installing the pre-built PyPI package will NOT include these features. You must build from source.
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
|
|||||||
<td align="center"><img src="docs/performance.png" width="480" alt="Performance"></td>
|
<td align="center"><img src="docs/performance.png" width="480" alt="Performance"></td>
|
||||||
<td align="center"><img src="docs/fans.png" width="480" alt="Fans"></td>
|
<td align="center"><img src="docs/fans.png" width="480" alt="Fans"></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="docs/wireview.png" width="480" alt="WireView"></td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
+19
-1
@@ -11,10 +11,12 @@ import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
|
|||||||
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
|
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
|
||||||
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
|
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
|
||||||
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
|
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
|
||||||
|
import { WireViewPanel } from "./components/WireView/WireViewPanel.js";
|
||||||
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
|
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
|
||||||
import { api, onUnauthorized } from "./api/client.js";
|
import { api, onUnauthorized } from "./api/client.js";
|
||||||
import { LoginScreen } from "./components/Auth/LoginScreen.js";
|
import { LoginScreen } from "./components/Auth/LoginScreen.js";
|
||||||
import { useCurveStore } from "./store/curveStore.js";
|
import { useCurveStore } from "./store/curveStore.js";
|
||||||
|
import { useWireview } from "./hooks/useWireview.js";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { Loader, ChevronDown } from "lucide-react";
|
import { Loader, ChevronDown } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
@@ -94,9 +96,10 @@ function MainApp({
|
|||||||
const { dashboard, loading: dashboardLoading } = useDashboard();
|
const { dashboard, loading: dashboardLoading } = useDashboard();
|
||||||
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
|
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
|
||||||
useCurveStore();
|
useCurveStore();
|
||||||
|
const wireview = useWireview();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"dashboard" | "curve" | "performance" | "fans"
|
"dashboard" | "curve" | "performance" | "fans" | "wireview"
|
||||||
>("dashboard");
|
>("dashboard");
|
||||||
const [fanState, setFanState] = useState<FanState | null>(null);
|
const [fanState, setFanState] = useState<FanState | null>(null);
|
||||||
const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu");
|
const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu");
|
||||||
@@ -195,6 +198,15 @@ function MainApp({
|
|||||||
>
|
>
|
||||||
Fans
|
Fans
|
||||||
</button>
|
</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>
|
||||||
<div className="relative" ref={profileRef}>
|
<div className="relative" ref={profileRef}>
|
||||||
<button
|
<button
|
||||||
@@ -289,6 +301,12 @@ function MainApp({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 gap-4 items-start w-full">
|
||||||
<div className="flex-1 min-w-0">
|
<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_curve: FanPoint[] | null;
|
||||||
fan_targets: number[] | 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 };
|
||||||
@@ -72,6 +72,7 @@ from .profiles.native import (
|
|||||||
rename_profile,
|
rename_profile,
|
||||||
save_profile,
|
save_profile,
|
||||||
)
|
)
|
||||||
|
from .wireview import create_device, find_wireview_ports
|
||||||
from .safety import check_negative_freq_warnings, validate_write
|
from .safety import check_negative_freq_warnings, validate_write
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.server")
|
log = logging.getLogger("nvcurve.server")
|
||||||
@@ -103,6 +104,15 @@ def _open_browser_as_user(url: str) -> None:
|
|||||||
_state: dict[str, Any] = {
|
_state: dict[str, Any] = {
|
||||||
"gpus": {}, # dict[int, dict] mapping gpu_index -> gpu state
|
"gpus": {}, # dict[int, dict] mapping gpu_index -> gpu state
|
||||||
"config": default_config,
|
"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)
|
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(
|
async def _activate_fan_curve(
|
||||||
gpu_index: int, curve: list, fans: list[int] | None = None
|
gpu_index: int, curve: list, fans: list[int] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -380,6 +510,16 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Failed to initialize GPU %d: %s", idx, 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 ──────────────────────────────────────────
|
# ── Backward Compatibility Bridge ──────────────────────────────────────────
|
||||||
# NOTE: This auto-load path is for users running the server directly (e.g.
|
# 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).
|
# 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):
|
with suppress(asyncio.CancelledError):
|
||||||
await task
|
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():
|
for gpu_index, g_state in _state["gpus"].items():
|
||||||
if g_state.get("fan_poller_task"):
|
if g_state.get("fan_poller_task"):
|
||||||
g_state["fan_poller_task"].cancel()
|
g_state["fan_poller_task"].cancel()
|
||||||
@@ -1442,6 +1590,25 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
|||||||
return {"ok": True}
|
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 ────────────────────────────────────────────────────────────
|
# ── Write endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1832,6 +1999,51 @@ async def ws_curve(ws: WebSocket):
|
|||||||
g_state["curve_clients"].discard(ws)
|
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 ──────────────────────────────────────────────────────────────
|
# ── Frontend SPA ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
"""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
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError: # pyserial missing — the serial transport is disabled,
|
||||||
|
# but the rest of the server keeps running (a stale venv after a code
|
||||||
|
# update must not take the whole web server down).
|
||||||
|
serial = None
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.wireview")
|
||||||
|
|
||||||
|
_serial_missing_warned = False
|
||||||
|
|
||||||
|
# ── 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 _open_port(self):
|
||||||
|
"""Open the serial port, or None when pyserial is missing or the
|
||||||
|
port is unavailable."""
|
||||||
|
global _serial_missing_warned
|
||||||
|
if serial is None:
|
||||||
|
if not _serial_missing_warned:
|
||||||
|
_serial_missing_warned = True
|
||||||
|
log.warning(
|
||||||
|
"pyserial is not installed — WireView serial transport "
|
||||||
|
"disabled (install pyserial, e.g. `uv sync`)"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return serial.Serial(self._port, self._baud, timeout=READ_TIMEOUT_S)
|
||||||
|
except OSError: # SerialException is an OSError
|
||||||
|
return None
|
||||||
|
|
||||||
|
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."""
|
||||||
|
ser = self._open_port()
|
||||||
|
if ser is None:
|
||||||
|
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 OSError: # SerialException is an 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."""
|
||||||
|
ser = self._open_port()
|
||||||
|
if ser is None:
|
||||||
|
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 OSError: # SerialException is an OSError
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
ser.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_exact(ser: Any, 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
|
||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"bcrypt>=4.0",
|
"bcrypt>=4.0",
|
||||||
|
"pyserial>=3.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
"""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 test_serial_transport_missing_pyserial():
|
||||||
|
"""A missing pyserial degrades gracefully: connect fails, reads are
|
||||||
|
None, and the warning is logged once — not on every attempt."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
records: list[str] = []
|
||||||
|
|
||||||
|
class Capture(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
records.append(record.getMessage())
|
||||||
|
|
||||||
|
logger = logging.getLogger("nvcurve.wireview")
|
||||||
|
handler = Capture()
|
||||||
|
old_level = logger.level
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.WARNING)
|
||||||
|
try:
|
||||||
|
with mock.patch.object(wv, "serial", None):
|
||||||
|
wv._serial_missing_warned = False
|
||||||
|
dev = wv.WireViewSerialDevice("/dev/ttyACM99")
|
||||||
|
check("no-pyserial connect", not dev.connect())
|
||||||
|
check("no-pyserial not rejected", not dev.rejected)
|
||||||
|
check("no-pyserial sample", dev.read_sample() is None)
|
||||||
|
# A second attempt must not re-warn.
|
||||||
|
dev2 = wv.WireViewSerialDevice("/dev/ttyACM99")
|
||||||
|
check("no-pyserial second attempt", not dev2.connect())
|
||||||
|
warnings = [r for r in records if "pyserial" in r]
|
||||||
|
check("no-pyserial warns once", len(warnings) == 1)
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
logger.setLevel(old_level)
|
||||||
|
wv._serial_missing_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
test_serial_transport_missing_pyserial()
|
||||||
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -246,6 +246,7 @@ dependencies = [
|
|||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "nvidia-ml-py" },
|
{ name = "nvidia-ml-py" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
{ name = "pyserial" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -261,6 +262,7 @@ requires-dist = [
|
|||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "nvidia-ml-py", specifier = ">=12.0" },
|
{ name = "nvidia-ml-py", specifier = ">=12.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
|
{ name = "pyserial", specifier = ">=3.5" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|||||||
Reference in new issue
Block a user