feat: wireview pin-imbalance warning, per-pin rating bars, pin_imbalance_a #18

Merged
Pakobbix merged 1 commits from feat/wireview-imbalance-warning into main 2026-10-03 20:15:53 +00:00
14 changed files with 810 additions and 18 deletions
Showing only changes of commit b69b7502b3 - Show all commits

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ __pycache__/
# Node # Node
frontend/node_modules/ frontend/node_modules/
node_modules/ node_modules/
.vitest/
# IDE/Misc # IDE/Misc
.vscode/ .vscode/
+2 -2
View File
@@ -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). \ > **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: 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. \ > **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. \
> **Authentification** — For production deplyoment, I added authentification with bcrypt hashing to allow only one or multiple people to have access. \ > **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. > Installing the pre-built PyPI package will NOT include these features. You must build from source.
<table> <table>
+6
View File
@@ -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. 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 ## 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. 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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 151 KiB

+524 -10
View File
@@ -18,6 +18,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@testing-library/react": "^16.3.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
@@ -27,10 +28,12 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"happy-dom": "^20.14.5",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^5.0.3"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -225,6 +228,16 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -971,9 +984,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1879,6 +1892,74 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "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": { "node_modules/@types/d3": {
"version": "7.4.3", "version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -2163,6 +2244,13 @@
"@types/d3-selection": "*" "@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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2214,6 +2302,23 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.2", "version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", "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" "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": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2566,6 +2719,17 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2589,6 +2753,27 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "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": "^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": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2685,6 +2883,16 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3202,6 +3410,17 @@
"robust-predicates": "^3.0.2" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3212,6 +3431,14 @@
"node": ">=8" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.353", "version": "1.5.353",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
@@ -3233,6 +3460,26 @@
"node": ">=10.13.0" "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": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -3472,6 +3719,16 @@
"node": ">=4.0" "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": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3482,6 +3739,16 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3630,6 +3897,25 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "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" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4212,6 +4509,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4303,9 +4614,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4354,6 +4665,36 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4385,6 +4726,14 @@
"react": "^19.2.6" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4524,6 +4873,13 @@
"node": ">=0.10.0" "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": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4571,10 +4927,30 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4791,6 +5270,19 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4801,6 +5293,28 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+5 -1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -20,6 +21,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@testing-library/react": "^16.3.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
@@ -29,9 +31,11 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"happy-dom": "^20.14.5",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^5.0.3"
} }
} }
+22
View File
@@ -18,6 +18,7 @@ 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 { useWireview } from "./hooks/useWireview.js";
import { useWireviewImbalance } from "./hooks/useWireviewImbalance.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";
@@ -98,6 +99,7 @@ function MainApp({
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
useCurveStore(); useCurveStore();
const wireview = useWireview(); const wireview = useWireview();
const wireImbalance = useWireviewImbalance(wireview.sample);
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"dashboard" | "curve" | "performance" | "fans" | "wireview" "dashboard" | "curve" | "performance" | "fans" | "wireview"
@@ -171,6 +173,26 @@ function MainApp({
</div> </div>
)} )}
{wireImbalance && (
<div className="bg-red-500/15 border-b-2 border-red-500 text-red-300 px-4 py-2.5 flex items-center justify-center gap-3 flex-wrap">
<span className="text-xl leading-none">⚠</span>
<span className="text-base font-bold">
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)
</span>
<span className="text-sm text-red-200/80">
Bad pin contact — reduce load, replace cable/adapter
</span>
<button
onClick={() => setActiveTab("wireview")}
className="text-sm font-semibold text-red-200 hover:text-white underline underline-offset-2"
>
View
</button>
</div>
)}
<main className="flex-1 p-4 flex flex-col gap-4 w-full max-w-[1600px] mx-auto"> <main className="flex-1 p-4 flex flex-col gap-4 w-full max-w-[1600px] mx-auto">
{/* Tab Header and Profile Selector */} {/* Tab Header and Profile Selector */}
<div className="flex justify-between items-end border-b border-zinc-800 pb-2"> <div className="flex justify-between items-end border-b border-zinc-800 pb-2">
@@ -8,8 +8,10 @@ interface Props {
history: WireViewSample[]; history: WireViewSample[];
} }
// Per-pin over-current protection of the 12 VHPWR connector (A). // Per-pin current rating of the 12 VHPWR connector (A). The bar's
const PIN_OCP_A = 55; // 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<number, string> = { const FAULT_NAMES: Record<number, string> = {
0: "Chip over-temperature", 0: "Chip over-temperature",
@@ -87,9 +89,13 @@ function PinCard({
current: number; current: number;
power: 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 = const barColor =
loadPct >= 90 ? "#f87171" : loadPct >= 75 ? "#fbbf24" : "#34d399"; current >= PIN_RATED_A
? "#f87171"
: loadPct >= 75
? "#fbbf24"
: "#34d399";
return ( return (
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-1.5 min-w-0"> <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"> <div className="flex items-center justify-between">
@@ -97,7 +103,7 @@ function PinCard({
Pin {index + 1} Pin {index + 1}
</span> </span>
<span className="text-[10px] font-mono text-zinc-600"> <span className="text-[10px] font-mono text-zinc-600">
{current.toFixed(1)} / {PIN_OCP_A} A {current.toFixed(1)} / {PIN_RATED_A} A
</span> </span>
</div> </div>
<div className="grid grid-cols-3 gap-1 text-center"> <div className="grid grid-cols-3 gap-1 text-center">
@@ -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<WireViewSample | null>(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();
});
});
@@ -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<ImbalanceAlert | null>(null);
const alertRef = useRef<ImbalanceAlert | null>(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;
}
+2
View File
@@ -178,6 +178,8 @@ export interface WireViewSample {
timestamp: number; timestamp: number;
power_total_w: number; power_total_w: number;
current_total_a: number; current_total_a: number;
/** Spread (A) between the highest- and lowest-loaded pin */
pin_imbalance_a: number;
voltage_avg_v: number; voltage_avg_v: number;
pins: WireViewPin[]; pins: WireViewPin[];
// Temperatures are null when the source has no channel for them // Temperatures are null when the source has no channel for them
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom",
include: ["src/**/*.test.ts"],
},
});
+9
View File
@@ -152,10 +152,16 @@ def parse_sensor_struct(buf: bytes) -> dict:
power_total += voltage_v * current_a power_total += voltage_v * current_a
current_total += 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 { return {
"timestamp": time.time(), "timestamp": time.time(),
"power_total_w": round(power_total, 3), "power_total_w": round(power_total, 3),
"current_total_a": round(current_total, 3), "current_total_a": round(current_total, 3),
"pin_imbalance_a": pin_imbalance,
"voltage_avg_v": ( "voltage_avg_v": (
round(power_total / current_total, 3) if current_total > 0 else 0.0 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) for v, i in zip(pin_voltage, pin_current, strict=True)
) )
current_total = sum(pin_current) 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 { return {
"timestamp": time.time(), "timestamp": time.time(),
"power_total_w": round(power_total, 3), "power_total_w": round(power_total, 3),
"current_total_a": round(current_total, 3), "current_total_a": round(current_total, 3),
"pin_imbalance_a": pin_imbalance,
"voltage_avg_v": ( "voltage_avg_v": (
round(power_total / current_total, 3) round(power_total / current_total, 3)
if current_total > 0 if current_total > 0
+13
View File
@@ -89,6 +89,18 @@ def test_parse_sensor_struct():
"total power", "total power",
abs(s["power_total_w"] - round(6 * 11.904 * 6.221, 3)) < 1e-9, 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( check(
"avg voltage", "avg voltage",
abs(s["voltage_avg_v"] - 11.904) < 1e-3, abs(s["voltage_avg_v"] - 11.904) < 1e-3,
@@ -296,6 +308,7 @@ def test_hwmon_device():
"hwmon total current", "hwmon total current",
abs(s["current_total_a"] - round(6 * 6.221, 3)) < 1e-9, 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() dev.close()
check("hwmon closed", not dev.connected) check("hwmon closed", not dev.connected)