diff --git a/.gitignore b/.gitignore index c8e4c18..049f0b0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ # Node frontend/node_modules/ node_modules/ +.vitest/ # IDE/Misc .vscode/ diff --git a/README.md b/README.md index e6c0ba3..f268c3b 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ 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). \ > **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.) \ -> **WireView Pro II** — Native monitoring of the Thermal Grizzly WireView Pro II 12VHPWR connector: a dedicated tab shows live temperature, power and current readings — no external exporter or kernel module required. The host needs a one-time setup (udev rule + port access, see [WireView Pro II Setup](docs/WireView.md)); after that the device is auto-detected over USB and the tab only appears while it is connected. \ -> **Authentification** — For production deplyoment, I added authentification with bcrypt hashing to allow only one or multiple people to have access. \ +> **WireView Pro II** — Native monitoring of the Thermal Grizzly WireView Pro II 12VHPWR connector: a dedicated tab shows live temperature, power and current readings — no external exporter or kernel module required. It also raises a big warning in the app header (visible on all tabs) when the current between two power pins diverges by more than 2 A — the signature of a bad pin contact (faulty cable/adapter) that can overheat and melt the connector. The host needs a one-time setup (udev rule + port access, see [WireView Pro II Setup](docs/WireView.md)); after that the device is auto-detected over USB and the tab only appears while it is connected. \ +> **Authentication** — For production deployment, I added authentication 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. diff --git a/docs/WireView.md b/docs/WireView.md index a3b1636..dd1ac5e 100644 --- a/docs/WireView.md +++ b/docs/WireView.md @@ -74,6 +74,12 @@ ls -l /dev/wireview-pro2 # -> /dev/ttyACM0 With the device plugged in, start (or restart) the NVCurve server. The **WireView** tab appears in the web UI and `GET /api/wireview` returns the device info and live samples. The tab disappears when the device is unplugged and reappears on re-plug — no restart needed. +## Pin current imbalance warning + +The 12 VHPWR connector is the most fragile part of a PC's power delivery: if one power pin has a bad contact (worn cable, faulty adapter, loose plug), the remaining pins are forced to carry the missing current. Under heavy load this can overheat and melt the connector — in rare cases even start a fire. + +NVCurve watches for this: if the current between any two power pins diverges by more than **2 A**, a big warning appears in the app header (visible on every tab, not just WireView) naming the deviating pins (e.g. `Pin 5 (5.6 A) vs Pin 6 (3.5 A)`). It is debounced — the imbalance must persist for several seconds (~7 s) before the warning shows, so single-sample glitches and short load ramps (e.g. AI workloads) do not flash it. When you see it, reduce the load and replace the cable or adapter — do not keep using the connector. + ## Alternative: wireview-hwmon kernel module If the official `wireview-hwmon` kernel module and its `wireviewd` daemon are installed, the daemon owns the serial port and NVCurve automatically reads the sysfs node instead. No udev rule is needed in that case. diff --git a/docs/wireview.png b/docs/wireview.png index 6024605..95ea463 100644 Binary files a/docs/wireview.png and b/docs/wireview.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0c1c79..02898f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/vite": "^4.2.1", + "@testing-library/react": "^16.3.3", "@types/d3": "^7.4.3", "@types/node": "^24.10.1", "@types/react": "^19.2.7", @@ -27,10 +28,12 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "happy-dom": "^20.14.5", "tailwindcss": "^4.2.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^5.0.3" } }, "node_modules/@babel/code-frame": { @@ -225,6 +228,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -971,9 +984,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1879,6 +1892,74 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2163,6 +2244,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2214,6 +2302,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", @@ -2526,6 +2631,54 @@ "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2566,6 +2719,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2589,6 +2753,27 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2654,6 +2839,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2685,6 +2883,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3202,6 +3410,17 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3212,6 +3431,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -3233,6 +3460,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3472,6 +3719,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3482,6 +3739,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3630,6 +3897,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4149,6 +4435,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4212,6 +4509,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4303,9 +4614,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4354,6 +4665,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4385,6 +4726,14 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4524,6 +4873,13 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4571,10 +4927,30 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4775,6 +5151,109 @@ } } }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4791,6 +5270,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4801,6 +5293,28 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9c9191a..4839e49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -20,6 +21,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/vite": "^4.2.1", + "@testing-library/react": "^16.3.3", "@types/d3": "^7.4.3", "@types/node": "^24.10.1", "@types/react": "^19.2.7", @@ -29,9 +31,11 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "happy-dom": "^20.14.5", "tailwindcss": "^4.2.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^5.0.3" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5bed3f4..ef52593 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ 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 { useWireviewImbalance } from "./hooks/useWireviewImbalance.js"; import { Toaster } from "sonner"; import { Loader, ChevronDown } from "lucide-react"; import { useState, useRef, useEffect } from "react"; @@ -98,6 +99,7 @@ function MainApp({ const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore(); const wireview = useWireview(); + const wireImbalance = useWireviewImbalance(wireview.sample); const [activeTab, setActiveTab] = useState< "dashboard" | "curve" | "performance" | "fans" | "wireview" @@ -171,6 +173,26 @@ function MainApp({ )} + {wireImbalance && ( +
+ ⚠ + + WireView: current imbalance {wireImbalance.diff.toFixed(1)} A — + Pin {wireImbalance.hiPin} ({wireImbalance.maxI.toFixed(1)} A) vs + Pin {wireImbalance.loPin} ({wireImbalance.minI.toFixed(1)} A) + + + Bad pin contact — reduce load, replace cable/adapter + + +
+ )} +
{/* Tab Header and Profile Selector */}
diff --git a/frontend/src/components/WireView/WireViewPanel.tsx b/frontend/src/components/WireView/WireViewPanel.tsx index b5f76bd..2eebd26 100644 --- a/frontend/src/components/WireView/WireViewPanel.tsx +++ b/frontend/src/components/WireView/WireViewPanel.tsx @@ -8,8 +8,10 @@ interface Props { history: WireViewSample[]; } -// Per-pin over-current protection of the 12 VHPWR connector (A). -const PIN_OCP_A = 55; +// Per-pin current rating of the 12 VHPWR connector (A). The bar's +// full scale is 10 A (rating + headroom); red at/above the 9.2 A rating. +const PIN_RATED_A = 9.2; +const PIN_BAR_MAX_A = 10; const FAULT_NAMES: Record = { 0: "Chip over-temperature", @@ -87,9 +89,13 @@ function PinCard({ current: number; power: number; }) { - const loadPct = Math.min(100, (current / PIN_OCP_A) * 100); + const loadPct = Math.min(100, (current / PIN_BAR_MAX_A) * 100); const barColor = - loadPct >= 90 ? "#f87171" : loadPct >= 75 ? "#fbbf24" : "#34d399"; + current >= PIN_RATED_A + ? "#f87171" + : loadPct >= 75 + ? "#fbbf24" + : "#34d399"; return (
@@ -97,7 +103,7 @@ function PinCard({ Pin {index + 1} - {current.toFixed(1)} / {PIN_OCP_A} A + {current.toFixed(1)} / {PIN_RATED_A} A
diff --git a/frontend/src/hooks/useWireviewImbalance.test.ts b/frontend/src/hooks/useWireviewImbalance.test.ts new file mode 100644 index 0000000..9b4164b --- /dev/null +++ b/frontend/src/hooks/useWireviewImbalance.test.ts @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { useWireviewImbalance } from "./useWireviewImbalance.js"; +import type { WireViewSample } from "../types.js"; + +/** Build a minimal sample with the given per-pin currents (A). */ +function sample(pinCurrents: number[]): WireViewSample { + return { + timestamp: Date.now(), + power_total_w: 0, + current_total_a: pinCurrents.reduce((a, b) => a + b, 0), + pin_imbalance_a: 0, + voltage_avg_v: 12, + pins: pinCurrents.map((a) => ({ + voltage_v: 12, + current_a: a, + power_w: 12 * a, + })), + temp_in_c: null, + temp_out_c: null, + temp_ext1_c: null, + temp_ext2_c: null, + fan_duty_pct: 0, + fault_status: 0, + fault_log: 0, + psu_capability_w: 600, + }; +} + +const BALANCED = [4.5, 4.6, 4.4, 4.8, 5.0, 4.2]; // spread 0.8 A +const IMBALANCED = [4.5, 4.6, 4.4, 4.8, 5.6, 3.5]; // spread 2.1 A + +function useTestHook() { + const [s, setS] = useState(null); + const alert = useWireviewImbalance(s); + return { alert, setSample: setS }; +} + +/** + * Feed `times` fresh samples (new object identity, like the websocket). + * Each sample must be a separate state update — the hook counts per sample, + * so the loop is intentional (not batchable). + */ +function feed( + setSample: (s: WireViewSample) => void, + currents: number[], + times: number, +) { + for (let i = 0; i < times; i++) { + act(() => setSample(sample(currents))); + } +} + +describe("useWireviewImbalance", () => { + it("returns null for a null sample", () => { + const { result } = renderHook(() => useTestHook()); + expect(result.current.alert).toBeNull(); + }); + + it("stays hidden while pins are balanced", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, BALANCED, 10); + expect(result.current.alert).toBeNull(); + }); + + it("shows only after 7 consecutive imbalanced samples", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, IMBALANCED, 6); + expect(result.current.alert).toBeNull(); + feed(result.current.setSample, IMBALANCED, 1); + expect(result.current.alert).not.toBeNull(); + }); + + it("reports the correct pins and spread", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, IMBALANCED, 7); + const alert = result.current.alert!; + expect(alert.diff).toBeCloseTo(2.1, 5); + expect(alert.hiPin).toBe(5); // 5.6 A + expect(alert.loPin).toBe(6); // 3.5 A + expect(alert.maxI).toBeCloseTo(5.6, 5); + expect(alert.minI).toBeCloseTo(3.5, 5); + }); + + it("a single balanced sample in the streak resets the counter", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, IMBALANCED, 6); + feed(result.current.setSample, BALANCED, 1); + feed(result.current.setSample, IMBALANCED, 6); + expect(result.current.alert).toBeNull(); + feed(result.current.setSample, IMBALANCED, 1); + expect(result.current.alert).not.toBeNull(); + }); + + it("keeps showing for 5 samples after the imbalance clears, then hides", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, IMBALANCED, 7); + expect(result.current.alert).not.toBeNull(); + feed(result.current.setSample, BALANCED, 4); + expect(result.current.alert).not.toBeNull(); // grace period + feed(result.current.setSample, BALANCED, 1); + expect(result.current.alert).toBeNull(); + }); + + it("stays shown (with updated values) while the imbalance persists", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, IMBALANCED, 7); + feed(result.current.setSample, [4.5, 4.6, 4.4, 4.8, 6.1, 3.2], 1); + const alert = result.current.alert!; + expect(alert.diff).toBeCloseTo(2.9, 5); + expect(alert.hiPin).toBe(5); + expect(alert.loPin).toBe(6); + }); + + it("clears immediately when the sample disappears (device unplugged)", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, IMBALANCED, 7); + expect(result.current.alert).not.toBeNull(); + act(() => result.current.setSample(null)); + expect(result.current.alert).toBeNull(); + }); + + it("ignores samples with fewer than 2 pins", () => { + const { result } = renderHook(() => useTestHook()); + feed(result.current.setSample, [10], 5); + expect(result.current.alert).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useWireviewImbalance.ts b/frontend/src/hooks/useWireviewImbalance.ts new file mode 100644 index 0000000..097ca70 --- /dev/null +++ b/frontend/src/hooks/useWireviewImbalance.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; +import type { WireViewSample } from "../types.js"; + +// Current difference between any two pins that indicates a bad contact. +const PIN_IMBALANCE_A = 2; +// Show only after the imbalance holds for this many consecutive samples (~1 s each). +// AI workloads produce frequent short load ramps that skew one pin transiently, +// so the threshold is deliberately high: a bad contact stays imbalanced, a ramp does not. +const SHOW_AFTER = 7; +// Keep showing this many samples after the imbalance clears (grace period). +const HIDE_AFTER = 5; + +export interface ImbalanceAlert { + diff: number; + maxI: number; + minI: number; + /** 1-based pin index carrying the most current */ + hiPin: number; + /** 1-based pin index carrying the least current */ + loPin: number; +} + +/** + * Debounced pin-current imbalance detection. + * + * A single sample can transiently skew (load change, measurement glitch), so + * the alert only appears after the imbalance persists for a few samples, and + * stays visible briefly after it clears instead of flickering. + */ +export function useWireviewImbalance( + sample: WireViewSample | null, +): ImbalanceAlert | null { + const [alert, setAlert] = useState(null); + const alertRef = useRef(null); + const streak = useRef(0); + const grace = useRef(0); + + useEffect(() => { + function update(next: ImbalanceAlert | null) { + alertRef.current = next; + setAlert(next); + } + + if (!sample || sample.pins.length < 2) { + streak.current = 0; + grace.current = 0; + if (alertRef.current) update(null); + return; + } + + const currents = sample.pins.map((p) => p.current_a); + const maxI = Math.max(...currents); + const minI = Math.min(...currents); + const diff = maxI - minI; + + if (diff > PIN_IMBALANCE_A) { + streak.current += 1; + grace.current = 0; + if (streak.current >= SHOW_AFTER) { + update({ + diff, + maxI, + minI, + hiPin: currents.indexOf(maxI) + 1, + loPin: currents.indexOf(minI) + 1, + }); + } + } else { + streak.current = 0; + if (alertRef.current) { + grace.current += 1; + if (grace.current >= HIDE_AFTER) update(null); + } + } + }, [sample]); + + return alert; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 40dc8df..3499a01 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -178,6 +178,8 @@ export interface WireViewSample { timestamp: number; power_total_w: number; current_total_a: number; + /** Spread (A) between the highest- and lowest-loaded pin */ + pin_imbalance_a: number; voltage_avg_v: number; pins: WireViewPin[]; // Temperatures are null when the source has no channel for them diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..6aa22e8 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/nvcurve/wireview.py b/nvcurve/wireview.py index 2be0c22..0c25b8e 100644 --- a/nvcurve/wireview.py +++ b/nvcurve/wireview.py @@ -152,10 +152,16 @@ def parse_sensor_struct(buf: bytes) -> dict: power_total += voltage_v * current_a current_total += current_a + # Spread between the highest- and lowest-loaded pin; a growing spread + # indicates a bad pin contact (see the frontend imbalance warning). + pin_currents = [p["current_a"] for p in pins] + pin_imbalance = round(max(pin_currents) - min(pin_currents), 3) + return { "timestamp": time.time(), "power_total_w": round(power_total, 3), "current_total_a": round(current_total, 3), + "pin_imbalance_a": pin_imbalance, "voltage_avg_v": ( round(power_total / current_total, 3) if current_total > 0 else 0.0 ), @@ -608,11 +614,14 @@ class WireViewHwmonDevice: for v, i in zip(pin_voltage, pin_current, strict=True) ) current_total = sum(pin_current) + pin_currents = [round(i, 3) for i in pin_current] + pin_imbalance = round(max(pin_currents) - min(pin_currents), 3) return { "timestamp": time.time(), "power_total_w": round(power_total, 3), "current_total_a": round(current_total, 3), + "pin_imbalance_a": pin_imbalance, "voltage_avg_v": ( round(power_total / current_total, 3) if current_total > 0 diff --git a/tests/test_wireview.py b/tests/test_wireview.py index 2f2a8fb..28b2591 100644 --- a/tests/test_wireview.py +++ b/tests/test_wireview.py @@ -89,6 +89,18 @@ def test_parse_sensor_struct(): "total power", abs(s["power_total_w"] - round(6 * 11.904 * 6.221, 3)) < 1e-9, ) + check( + "pin imbalance (uniform)", + s["pin_imbalance_a"] == 0.0, + ) + # Imbalance is the spread between the highest- and lowest-loaded pin. + uneven = wv.parse_sensor_struct( + make_frame(pins=((11904, 6221, 74055),) * 5 + ((11900, 1100, 13090),)) + ) + check( + "pin imbalance (uneven)", + abs(uneven["pin_imbalance_a"] - 5.121) < 1e-9, + ) check( "avg voltage", abs(s["voltage_avg_v"] - 11.904) < 1e-3, @@ -296,6 +308,7 @@ def test_hwmon_device(): "hwmon total current", abs(s["current_total_a"] - round(6 * 6.221, 3)) < 1e-9, ) + check("hwmon pin imbalance (uniform)", s["pin_imbalance_a"] == 0.0) dev.close() check("hwmon closed", not dev.connected)