Compare commits

..
10 Commits
Author SHA1 Message Date
Pakobbix cc102f26c1 Merge pull request 'feat: experimental NVIDIA power control via RM ioctl interface' (#10) from feat/rm-power-control into main
Reviewed-on: #10
2026-09-17 20:48:46 +00:00
ARIA e148c83622 feat: experimental NVIDIA power control via RM ioctl interface
Adds an experimental power-cap mode using the undocumented RM ioctl
interface (based on panchovix's LACT PR #1205) to set power limits
below the VBIOS minimum (down to 30 W).

- hal/rm_power.py: RM ioctl power-cap read/write/reset + runtime probe
- limits.py: power_cap_mode (nvml/ioctl) with support detection
- config.py: persist power_cap_mode per GPU
- profiles: record/apply power_cap_mode
- server.py: POST /api/limits validates ioctl support (409 on failure)
- cli.py: profile save falls back to persisted mode
- client.py: power_cap_mode in Limits
- frontend: toggle + warning with panchovix attribution (LACT #1205)
- tests: test_rm_power.py (unit) + integration coverage
- Makefile: add test_rm_power.py to make test

Also includes automated linter reformatting (prettier, ruff, shellcheck,
isort, markdownlint) that the linter would apply anyway.
2026-09-17 22:44:27 +02:00
ARIA a8462e696c Add single-command installation: install.sh, Makefile, frontend build hook
- hatch_build.py: custom hatchling build hook that compiles the React
  frontend (npm ci + build) when frontend/dist is missing or stale, so
  'uv tool install git+https://gitea.zephyre.one/Pakobbix/nvcurve.git'
  works as a single command
- install.sh: curl|bash installer (checks prerequisites, auto-installs uv,
  clones and installs)
- Makefile: dev targets (frontend, install, dev, test, clean)
- README/docs: document the one-liner, clone, and direct-git installs
2026-09-16 10:43:23 +02:00
Pakobbix d810c44478 Merge pull request 'security: harden web server, daemon socket, and write paths' (#9) from security/hardening into main
Reviewed-on: #9
2026-09-10 14:20:57 +00:00
ARIA 39701c12ff security: harden web server, daemon socket, and write paths
Security review findings, fixed and verified:

Critical
- Fix unauthenticated arbitrary file read: the SPA catch-all route
  joined the raw URL path onto the dist dir without containment, so
  encoded '..' segments (/%2e%2e/etc/passwd) leaked any file readable
  by the root server. Resolve with realpath and reject paths outside
  the dist dir (fail-closed 404).

High
- Daemon socket: serve_start no longer accepts caller-chosen
  host/port. The socket is world-connectable (unprivileged CLI users),
  so callers could previously rebind the root web server to 0.0.0.0.
  The daemon now always binds the operator-configured address and
  reports it in the response; the CLI warns on mismatch.

Medium
- Remove the per-request max_delta_khz override from the API: the
  server-enforced safety cap is now authoritative. CLI direct paths
  (write, profile apply, verify) honor the configured cap; --max-delta
  still overrides for explicit root use.
- Snapshot restore: confine filepath to the snapshot directory
  (realpath containment; blocks symlink escapes).
- Login lockout: honor X-Forwarded-For only for peers listed in the
  new trusted_proxies config (rightmost untrusted hop), so the
  per-IP lockout works behind a reverse proxy. Spoofed headers from
  untrusted peers are ignored.
- /api/shutdown: new allow_api_shutdown config (default true);
  shared systems can disable the API shutdown path.

TLS (opt-in, like auth)
- New ssl_certfile/ssl_keyfile config + CLI flags (serve start,
  service install/configure, --no-ssl to disable). When active:
  HTTPS for UI/API, wss:// for WebSockets, Secure session cookie,
  CLI auto-switches to https://. Cert/key paths are validated up
  front with a clear error instead of a silent uvicorn crash.

Tests & docs
- tests/test_security.py: standalone regression tests (no new deps)
  covering SPA containment, snapshot containment, cap removal,
  client-IP derivation, proxy normalization, TLS scheme detection,
  and daemon host/port hardening.
- README + Usage-Guide: TLS section, new config keys, updated
  security notes.
2026-09-10 16:19:21 +02:00
Pakobbix 8956fc9d7b Merge pull request 'feat: full fan control — all fans or individual fans' (#8) from feat/multi-fan-control into main
Reviewed-on: #8
2026-09-10 13:50:55 +00:00
ARIA 6cb33187d3 feat: full fan control — all fans or individual fans
The fan curve previously only controlled fan index 0; secondary fans
stayed on driver control. The curve can now target all fans (new
default) or any individual fan(s).

Backend:
- hal/fans.py: get_num_fans() via nvmlDeviceGetNumFans; get_fan_info()
  returns per-fan speeds; set_fan_speed() accepts a fan index list
  (None = all fans; all-fans mode is lenient toward driver-locked
  fans, explicit lists are strict); reset_fan() restores all fans.
- server.py: per-GPU fan_targets state; the poller applies the curve to
  all target fans and logs write failures (once per distinct error);
  activation validates targets against the hardware (stale indices fall
  back to all fans); POST /api/fans accepts fans, POST /api/fans/speed
  accepts a fan index, GET /api/fans returns num_fans/fans/fan_targets.
- Persistence format is now {"curve": ..., "fans": ...}; legacy
  bare-curve entries migrate to "all fans" at startup.
- Profiles save/apply fan_targets alongside fan_curve.
- MonitoringSample carries per-fan speeds for live gauges.

Frontend:
- Fans tab: All / Fan 1 / Fan 2 / ... selector with live per-fan %;
  the selection is applied together with the curve.
- Live monitor: per-fan gauges with sparklines for multi-fan GPUs.
2026-09-10 15:48:28 +02:00
Pakobbix 34a9bc6d6e Merge pull request 'Clean up LSP diagnostics across backend and frontend' (#7) from cleanup/lint-modernization into main
Reviewed-on: #7
2026-09-08 21:58:42 +00:00
ARIA 930e56bd07 Clean up LSP diagnostics across backend and frontend
Backend (nvcurve/):
- hal/fans.py, hal/limits.py, hal/gpu.py: replace conditional pynvml
  imports with the established 'pynvml: Any = _pynvml_import' pattern
  (fixes ~50 'possibly unbound' errors); type the result dicts; guard
  query_interface() results; explicit uuid/pci-bus parsing (int, hex
  convention documented); modernize Optional[T] -> T | None
- cli.py: fix 'curve_state' possibly-unbound and snap_path None handling
  in cmd_setup; wrap unchecked int()/open()/makedirs() calls in
  try/except with clean CLI errors; add module logger for silent
  except-pass blocks; raise ... from exc; fix unused loop vars and
  set-comprehension
- hal/snapshot.py: filepath: str | None; wrap all file ops; sorted
  imports; remove unused CT_POINTS import
- daemon.py: extract 0o666 to _SOCKET_MODE constant (intentional for
  /run sockets) with nosemgrep
- server.py: nosemgrep for Python 3.7-compat false positive (project
  requires >= 3.12); log previously-swallowed exception
- profiles/native.py, profiles/apply.py: wrap file ops and int(k)
  profile-key parsing; sorted imports; modernize typing

Frontend (frontend/src):
- Add .js extensions to all relative imports (standard TS-ESM; Vite
  resolves .js -> .ts)
- React.FormEvent (deprecated in React 19 types) -> React.SubmitEvent
- catch (e: any) -> catch (e: unknown) + instanceof Error narrowing
- React-hooks: move ref writes from render into effects; convert
  viewport reset to render-phase state adjustment; split
  selectPoint(index, multi) into selectPoint + togglePoint (no flag
  argument); remove non-null assertion
- Static inline styles -> Tailwind classes (dynamic positioning/cursor
  styles kept)
- Remove non-standard 'container' option from scrollIntoView (browsers
  ignore unknown options) which had orphaned a @ts-expect-error
- Object.fromEntries for Map -> Record conversion

Tooling:
- .gitignore: ignore .codegraph/ local tool data

Verified: tsc --noEmit, vite production build, python imports, and
full LSP scan (0 errors/warnings in both projects).
2026-09-08 23:57:30 +02:00
Pakobbix fa944c9576 Update README.md 2026-09-02 15:44:49 +00:00
52 changed files with 4507 additions and 1214 deletions

No files matched your search

+3 -1
View File
@@ -14,4 +14,6 @@ node_modules/
/dist/
/build/
*.egg-info/
.claude
.claude
# Local tool data
.codegraph/
+30
View File
@@ -0,0 +1,30 @@
# NVCurve — developer convenience targets.
#
# End users don't need make: run ./install.sh (see README "Installation").
UV ?= uv
NPM ?= npm
.PHONY: help frontend frontend-dev install dev test clean
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
frontend: ## Build the React frontend into frontend/dist
cd frontend && $(NPM) ci && $(NPM) run build
frontend-dev: ## Run the Vite dev server (hot reload)
cd frontend && $(NPM) run dev
install: ## Install nvcurve as a uv tool (builds frontend if missing/stale)
$(UV) tool install --force .
dev: ## Create/refresh the dev environment (uv sync)
$(UV) sync
test: ## Run the test suite
$(UV) run python tests/test_security.py
$(UV) run python tests/test_rm_power.py
clean: ## Remove build artifacts
rm -rf frontend/dist frontend/node_modules
+44 -13
View File
@@ -13,8 +13,9 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
> [!IMPORTANT]
> **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. \
> **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.) \
> **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.
<table>
@@ -36,20 +37,28 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
- **Root/sudo access** (required for GPU hardware interactions)
## Installation from Source
## Installation
### One-liner
```bash
git clone <this-repo-url>.git
curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
```
The script checks prerequisites (installs `uv` if missing), clones the repo, and installs NVCurve — the React frontend is compiled automatically during the build.
### From a clone
```bash
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
cd nvcurve
./install.sh
```
# Build the React frontend
cd frontend
npm install
npm run build
cd ..
### Direct from git (no clone, no script)
# Install the Python package (includes bundled frontend)
uv tool install .
```bash
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
```
After installation, verify hardware compatibility:
@@ -77,6 +86,20 @@ sudo nvcurve user remove alice # remove a user
Adding the first user enables authentication immediately; removing the last user disables it. See the [Usage Guide](docs/Usage-Guide.md#authentication-multi-user) for details.
## TLS (HTTPS)
The server speaks **plain HTTP by default**. For network access (e.g. behind a reverse proxy or on a LAN), you can enable TLS so the web UI, API, and WebSocket all run over HTTPS — the session cookie is then marked `Secure`.
```bash
# One-off (this server run only)
nvcurve serve start --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
# Persistent (stored in /etc/nvcurve/config.json; used by the daemon too)
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
```
With TLS enabled the UI is at `https://<host>:8042` and the CLI switches to `https://` automatically. A self-signed certificate works for local use (the browser will warn); for multi-user setups use a certificate your browser trusts (e.g. via your internal CA or a reverse proxy).
## Systemd Service
Install the daemon for automatic profile loading on boot and optional web server auto-start:
@@ -148,6 +171,10 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
"max_delta_khz": 3000000,
"auto_snapshot": true,
"max_snapshots": 20,
"ssl_certfile": null,
"ssl_keyfile": null,
"trusted_proxies": [],
"allow_api_shutdown": true,
"auto_load_profiles": {
"idx:0": "my_profile"
}
@@ -159,9 +186,12 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
| `host` | Web server bind address (`0.0.0.0` for network access) |
| `port` | Web server port (default `8042`) |
| `auto_serve` | Auto-start web server on boot |
| `max_delta_khz` | Safety cap for frequency offsets (default 3000 MHz) |
| `max_delta_khz` | Safety cap for frequency offsets (default 3000 MHz). Enforced server-side; API clients cannot raise it per request |
| `auto_snapshot` | Save snapshot before every write |
| `max_snapshots` | Max snapshots to keep (`0` = unlimited) |
| `ssl_certfile` / `ssl_keyfile` | TLS certificate/key — enables HTTPS when both are set (default: off) |
| `trusted_proxies` | Proxy IPs whose `X-Forwarded-For` is trusted for the login lockout (e.g. `["127.0.0.1"]` for a local reverse proxy) |
| `allow_api_shutdown` | Allow authenticated users to stop the server via `POST /api/shutdown` (set `false` on shared systems; use systemd instead) |
| `auto_load_profiles` | Per-GPU profile to apply on boot (`{gpu_key: profile_name}`) |
The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key with `nvcurve gpus`.
@@ -178,10 +208,11 @@ The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key wi
```bash
cd nvcurve
git pull
cd frontend && npm run build && cd ..
uv tool install .
uv tool install --force .
```
The frontend is rebuilt automatically if it is missing or older than the frontend sources. If you modified frontend code locally, run `make frontend` first (or `rm -rf frontend/dist`).
If running as a systemd service:
```bash
+29 -19
View File
@@ -29,43 +29,52 @@ sudo pacman -S uv
pip install uv
```
## Installation from Source
## Installation
### Step 1: Clone the Repository
### Option 1: One-liner (recommended)
```bash
git clone <this-repo-url>.git
curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
```
The script checks prerequisites (installs `uv` if missing), clones the repository, and installs NVCurve. The React frontend is compiled automatically during the build by a hatchling build hook (`hatch_build.py`).
### Option 2: From a clone
```bash
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
cd nvcurve
./install.sh
```
### Step 2: Build the Frontend
The frontend is a React + TypeScript + Vite application in the `frontend/` directory.
Equivalent manual steps (what the script does):
```bash
cd frontend
npm install
npm run build
cd ..
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
cd nvcurve
uv tool install . # frontend is built automatically if missing/stale
```
This produces a `dist/` directory with the compiled static assets. The hatch build system bundles `frontend/dist` into the Python package.
### Step 3: Install the Python Package
### Option 3: Direct from git (no clone, no script)
```bash
uv tool install .
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
```
This installs `nvcurve` as a system-wide tool with the bundled frontend.
To install a specific branch:
### Step 4: Verify
```bash
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git@<branch>"
```
### Verify
```bash
nvcurve setup
```
This performs four checks:
1. **NvAPI function probe** — verifies all required functions resolve in your driver
2. **Curve read** — reads and displays your current V/F curve as a baseline
3. **Write-verify** — writes `+5 MHz` to a safe point, reads it back, and confirms the change
@@ -94,10 +103,11 @@ nvcurve serve start
```bash
cd nvcurve
git pull
cd frontend && npm run build && cd ..
uv tool install .
uv tool install --force .
```
The frontend is rebuilt automatically if it is missing or older than the frontend sources. If you modified frontend code locally, run `make frontend` first (or `rm -rf frontend/dist`).
If running as a systemd service:
```bash
@@ -116,7 +126,7 @@ source ~/.local/bin/env # or wherever uv installed
### Frontend not loading in the web UI
Verify that `frontend/dist` exists and contains built assets. If the directory is empty or missing, rebuild with `npm run build` and reinstall with `uv tool install .`.
Verify that the installed package contains the frontend. If `frontend/dist` is empty or missing, rebuild with `make frontend` (or `cd frontend && npm ci && npm run build`) and reinstall with `uv tool install --force .`.
### NvAPI functions not found
+2 -2
View File
@@ -17,7 +17,7 @@ NVCurve provides two ways to interact with your GPU:
- **Per-Point Curve Editing** — Adjust the frequency offset for any individual voltage point on the V/F curve.
- **Extended Memory Offset** — Memory clock offset up to +3000 MHz (Blackwell GPUs). Standard NVCurve caps at +1000 MHz.
- **Fan Curve Control** — Custom temperature-to-fan-speed curves via NVML, adjustable through the web UI and savable in profiles.
- **Fan Curve Control** — Custom temperature-to-fan-speed curves via NVML (all fans or individual fans), adjustable through the web UI and savable in profiles.
- **Curve Flattening** — Select multiple points and flatten them to a common frequency using anchor-point targeting.
- **Live Monitoring** — Track GPU voltage, clock speed, temperature, and power draw in real time via NvAPI and NVML.
- **Profile Management** — Save, load, and switch between named profiles. Set a default profile that auto-applies on startup.
@@ -29,7 +29,7 @@ NVCurve provides two ways to interact with your GPU:
NVCurve consists of two components:
| Component | Description |
|---|---|
| --- | --- |
| **Python Backend** | Talks directly to `libnvidia-api.so` (via ctypes) and `libnvidia-ml.so` to read and write GPU hardware state. Exposes functionality through a FastAPI REST + WebSocket server. |
| **React Frontend** | Runs in the browser and communicates with the backend over HTTP and WebSockets. Handles curve visualization, point editing, live monitoring, and profile management. |
+40 -1
View File
@@ -84,6 +84,23 @@ The monitoring panel shows real-time GPU metrics:
Data is streamed via WebSocket from the backend at a configurable poll interval (default: 1 second).
### Performance Limits
The Performance panel controls the board power limit and the memory clock offset. The power limit slider is bounded by the GPU's VBIOS minimum and maximum (shown at the slider ends); changes are applied on **Apply** and reset to the hardware default on **Reset**.
#### Experimental NVIDIA power control
On compatible drivers, an **Experimental NVIDIA power control** checkbox appears in the Performance panel. Enabling it switches power-limit application from the standard NVML call to an undocumented driver (RM) interface, which **permits caps below the VBIOS minimum, down to 30 W**. The native maximum still applies.
> **Warning.** This uses an undocumented driver interface for *all* power limits, including resets. It may cause instability or stop working after a driver update. Enable it at your own risk. The option is clearly labelled with a red warning in the UI, and profiles saved while it is enabled are marked accordingly.
Notes:
- The checkbox only appears when the driver exposes a compatible RM power layout (detected with a read-only probe — no writes).
- In this mode there is **no automatic fallback** to NVML: if the RM route fails, the error is reported rather than silently switching backends.
- The mode is per-GPU and persisted across server restarts. Reset restores the default through the same route, so it can also clear a previously-set below-minimum cap.
- The CLI reports availability via `nvcurve read --diag` ("Experimental RM power: available").
### Multi-GPU
When multiple NVIDIA GPUs are detected, a GPU selector dropdown appears in the status bar. Switching GPUs resets pending edits, selection state, and monitoring for the new target.
@@ -145,6 +162,27 @@ Adding the first user **switches the server into authenticated mode immediately*
- The user store file should stay root-owned and `0600` (the CLI enforces this).
- The web UI and API are still only as safe as the network path to the server — bind to a trusted interface (`--host`) and/or firewall the port. Authentication protects against casual access, not a determined network attacker.
- The `nvcurve user` commands and the user store require root; day-to-day sign-in does not.
- The login lockout is keyed by client IP. Behind a reverse proxy all clients share the proxy's IP — set `trusted_proxies` in `/etc/nvcurve/config.json` (e.g. `["127.0.0.1"]`) so the lockout uses the real client IP from `X-Forwarded-For`. The header is only honoured for peers you list there (it is spoofable otherwise).
- On shared systems consider setting `allow_api_shutdown: false` so users cannot stop the server via the API (manage it with systemd instead).
- The frequency safety cap (`max_delta_khz`) is enforced by the server from its config; API clients cannot raise it per request. The CLI's `--max-delta` (root-only, direct hardware path) can still override it for a single write.
## TLS (HTTPS)
The server speaks **plain HTTP by default**. When you expose it beyond localhost, enable TLS so credentials and session cookies are not sent in cleartext:
```bash
# Persistent (stored in /etc/nvcurve/config.json, used by the daemon too)
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
# One-off
nvcurve serve start --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
```
- Both files must be set for TLS to activate; the UI then lives at `https://<host>:8042` and the WebSocket upgrades to `wss://` automatically.
- To disable TLS again: `sudo nvcurve service configure --no-ssl` (removes the certificate/key from the config).
- The session cookie gets the `Secure` flag, so it is only sent over HTTPS.
- A self-signed certificate is fine for a home LAN (the browser shows a warning); for multi-user setups use a certificate your browser trusts.
- The CLI detects TLS from the config/runtime info and switches to `https://` automatically.
## CLI Reference
@@ -251,13 +289,14 @@ nvcurve service uninstall
sudo nvcurve service configure --auto-serve
sudo nvcurve service configure --no-auto-serve
sudo nvcurve service configure --host 0.0.0.0 --port 8042
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
```
## Configuration Files
| File | Purpose |
| --- | --- |
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) |
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, TLS, safety cap, default profiles) |
| `/etc/nvcurve/profiles/*.json` | Saved profiles |
| `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes |
| `/run/nvcurve.json` | Runtime server info (host, port, PID) |
+18 -18
View File
@@ -1,24 +1,24 @@
import { useGpu } from "./hooks/useGpu";
import { useCurve } from "./hooks/useCurve";
import { useMonitor } from "./hooks/useMonitor";
import { useDashboard } from "./hooks/useDashboard";
import { StatusBar } from "./components/Monitor/StatusBar";
import { LiveMonitor } from "./components/Monitor/LiveMonitor";
import { Dashboard } from "./components/Dashboard/Dashboard";
import { CurveEditor } from "./components/CurveEditor/CurveEditor";
import { PointTable } from "./components/PointTable/PointTable";
import { PerformancePanel } from "./components/Limits/PerformancePanel";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor";
import { FanMonitor } from "./components/Monitor/FanMonitor";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
import { ProfilePanel } from "./components/Profiles/ProfilePanel";
import { api, onUnauthorized } from "./api/client";
import { LoginScreen } from "./components/Auth/LoginScreen";
import { useCurveStore } from "./store/curveStore";
import { useGpu } from "./hooks/useGpu.js";
import { useCurve } from "./hooks/useCurve.js";
import { useMonitor } from "./hooks/useMonitor.js";
import { useDashboard } from "./hooks/useDashboard.js";
import { StatusBar } from "./components/Monitor/StatusBar.js";
import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
import { Dashboard } from "./components/Dashboard/Dashboard.js";
import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore.js";
import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types";
import type { FanState } from "./types.js";
type AuthState = "checking" | "login" | "ok";
+5 -5
View File
@@ -8,7 +8,7 @@ import type {
FanState,
FanPoint,
DashboardInfo,
} from "../types";
} from "../types.js";
export class ApiError extends Error {
status: number;
@@ -168,9 +168,9 @@ export const api = {
/** Fan control */
fans: (gpuIndex: number) => get<FanState>("/fans", gpuIndex),
updateFans: (curve: FanPoint[], gpuIndex: number) =>
post("/fans", { curve }, gpuIndex),
updateFans: (curve: FanPoint[], gpuIndex: number, fans?: number[] | null) =>
post("/fans", { curve, fans: fans ?? null }, gpuIndex),
resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number) =>
post("/fans/speed", { fan_pct: fanPct }, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number, fan?: number | null) =>
post("/fans/speed", { fan_pct: fanPct, fan: fan ?? null }, gpuIndex),
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Loader, Lock, User } from "lucide-react";
import { api, ApiError } from "../../api/client";
import { api, ApiError } from "../../api/client.js";
interface Props {
onSuccess: (username: string) => void;
@@ -12,7 +12,7 @@ export function LoginScreen({ onSuccess }: Props) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
async function submit(e: React.SubmitEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
import { ZoomIn, RotateCcw, Minus } from 'lucide-react';
import { useCurveStore } from '../../store/curveStore';
import type { VFPoint } from '../../types';
import { useState, useMemo } from "react";
import { ZoomIn, RotateCcw, Minus } from "lucide-react";
import { useCurveStore } from "../../store/curveStore.js";
import type { VFPoint } from "../../types.js";
interface Props {
/** All curve points — used by global offset slider */
@@ -18,23 +18,44 @@ interface Props {
onZoomChange: (factor: number) => void;
}
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) {
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore();
export function CurveToolbar({
activePts,
onResetZoom,
isZoomed,
readOnly,
zoomFactor,
onZoomChange,
}: Props) {
const {
pendingDeltas,
selectedPoints,
anchorPoint,
curve,
stageRangeEdit,
flattenToAnchor,
} = useCurveStore();
const [offsetMhz, setOffsetMhz] = useState(0);
const uniformDeltaMhz = useMemo(() => {
if (activePts.length === 0) return 0;
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD);
const firstD =
pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every(
(p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD,
);
return uniform ? firstD / 1000 : null;
}, [activePts, pendingDeltas]);
useEffect(() => {
if (uniformDeltaMhz !== null) {
setOffsetMhz(uniformDeltaMhz);
}
}, [uniformDeltaMhz]);
// Sync the slider to the uniform delta when it changes (adjust state during
// render instead of an effect; undefined sentinel so the first render syncs).
const [lastUniformDelta, setLastUniformDelta] = useState<
number | null | undefined
>();
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
setLastUniformDelta(uniformDeltaMhz);
setOffsetMhz(uniformDeltaMhz);
}
function handleOffsetChange(mhz: number) {
setOffsetMhz(mhz);
@@ -44,7 +65,10 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
return (
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
{/* Zoom control */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40" title="Zoom x-axis (Alt+scroll also works)">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40"
title="Zoom x-axis (Alt+scroll also works)"
>
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
<input
type="range"
@@ -55,7 +79,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
onChange={(e) => onZoomChange(Number(e.target.value))}
className="w-20 h-1 cursor-pointer accent-cyan-400"
/>
<span className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? 'text-cyan-400' : 'text-zinc-600'}`}>
<span
className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? "text-cyan-400" : "text-zinc-600"}`}
>
{zoomFactor.toFixed(1)}×
</span>
{isZoomed && (
@@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
{/* Global offset slider — GPU only */}
{!readOnly && uniformDeltaMhz !== null && (
<div className="flex items-center gap-1.5 min-w-[260px]">
<span className="text-zinc-500 text-xs whitespace-nowrap">Global Offset</span>
<span className="text-zinc-500 text-xs whitespace-nowrap">
Global Offset
</span>
<input
type="range"
min={-1000}
@@ -84,46 +112,63 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
value={offsetMhz}
onChange={(e) => handleOffsetChange(Number(e.target.value))}
className="w-32 accent-cyan-400"
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`}
title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
/>
<span
className={[
'text-xs font-mono w-16',
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500',
].join(' ')}
"text-xs font-mono w-16",
offsetMhz > 0
? "text-cyan-400"
: offsetMhz < 0
? "text-orange-400"
: "text-zinc-500",
].join(" ")}
>
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz
{offsetMhz > 0 ? "+" : ""}
{offsetMhz} MHz
</span>
</div>
)}
{/* Flatten — visible when 2+ points are selected */}
{!readOnly && selectedPoints.size >= 2 && (() => {
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find(p => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{!readOnly &&
selectedPoints.size >= 2 &&
(() => {
const anchor =
anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find((p) => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{/* Legend — right-aligned */}
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" /> effective</span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" /> pending</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-yellow-400" /> current</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" />{" "}
effective
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" />{" "}
pending
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-yellow-400" />{" "}
current
</span>
</div>
</div>
);
@@ -1,12 +1,12 @@
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { fmt } from "../../utils/units.js";
import type { VFPoint } from "../../types.js";
interface Props {
point: VFPoint;
/** Pending delta in kHz (from store), if any */
pendingDeltaKhz?: number;
/** True if this point is being held up by monotonicity enforcement */
isClamped?: boolean;
point: VFPoint;
/** Pending delta in kHz (from store), if any */
pendingDeltaKhz?: number;
/** True if this point is being held up by monotonicity enforcement */
isClamped?: boolean;
}
/**
@@ -14,57 +14,70 @@ interface Props {
* SVG so it's never clipped by the SVG viewport.
*/
export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
const hasPending = pendingDeltaKhz !== undefined;
const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0;
const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0;
const pendingEffMhz = hasPending ? point.freq_mhz + deltaChange / 1000 : null;
const hasPending = pendingDeltaKhz !== undefined;
const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0;
const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0;
const pendingEffMhz = hasPending
? point.freq_mhz + deltaChange / 1000
: null;
return (
<div
style={{
position: 'absolute',
right: 16,
bottom: 16,
pointerEvents: 'none',
zIndex: 50,
width: 172,
}}
className="bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
>
<div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200">
<span className="text-zinc-400">Volt: </span>{fmt.mv(point.volt_mv, 1)}
</div>
<div className="text-zinc-200">
<span className="text-zinc-400">Offset: </span>
<span className={point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-400'}>
{point.delta_khz > 0 ? '+' : ''}{fmt.mhz(point.delta_mhz, 1)}
</span>
</div>
<div className="text-emerald-300 font-semibold">
<span className="text-zinc-400">Eff.: </span>{fmt.mhz(point.freq_mhz, 0)}
{isClamped && <span className="text-amber-500 ml-1">⇡</span>}
</div>
{isClamped && (
<div className="text-amber-500/80 text-[10px] mt-0.5">
Clamped by lower-voltage point
</div>
)}
{hasPending && (
<>
<div className="border-t border-zinc-700 mt-1.5 pt-1.5">
return (
<div className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl">
<div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200">
<span className="text-zinc-400">Pending: </span>
<span className={pendingMhz > 0 ? 'text-cyan-400' : pendingMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}>
{pendingMhz > 0 ? '+' : ''}{pendingMhz.toFixed(1)} MHz
</span>
<span className="text-zinc-400">Volt: </span>
{fmt.mv(point.volt_mv, 1)}
</div>
<div className="text-cyan-300 font-semibold">
<span className="text-zinc-400">→ Eff.: </span>{fmt.mhz(pendingEffMhz, 0)}
<div className="text-zinc-200">
<span className="text-zinc-400">Offset: </span>
<span
className={
point.delta_khz > 0
? "text-emerald-400"
: point.delta_khz < 0
? "text-red-400"
: "text-zinc-400"
}
>
{point.delta_khz > 0 ? "+" : ""}
{fmt.mhz(point.delta_mhz, 1)}
</span>
</div>
</div>
</>
)}
</div>
);
<div className="text-emerald-300 font-semibold">
<span className="text-zinc-400">Eff.: </span>
{fmt.mhz(point.freq_mhz, 0)}
{isClamped && <span className="text-amber-500 ml-1">⇡</span>}
</div>
{isClamped && (
<div className="text-amber-500/80 text-[10px] mt-0.5">
Clamped by lower-voltage point
</div>
)}
{hasPending && (
<>
<div className="border-t border-zinc-700 mt-1.5 pt-1.5">
<div className="text-zinc-200">
<span className="text-zinc-400">Pending: </span>
<span
className={
pendingMhz > 0
? "text-cyan-400"
: pendingMhz < 0
? "text-orange-400"
: "text-zinc-400"
}
>
{pendingMhz > 0 ? "+" : ""}
{pendingMhz.toFixed(1)} MHz
</span>
</div>
<div className="text-cyan-300 font-semibold">
<span className="text-zinc-400">→ Eff.: </span>
{fmt.mhz(pendingEffMhz, 0)}
</div>
</div>
</>
)}
</div>
);
}
@@ -1,7 +1,7 @@
import { Loader } from "lucide-react";
import { GaugeCard } from "../Monitor/GaugeCard";
import { fmt } from "../../utils/units";
import type { MonitoringSample, DashboardInfo } from "../../types";
import { GaugeCard } from "../Monitor/GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample, DashboardInfo } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
+117 -17
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Check, X, RotateCcw, Plus } from "lucide-react";
import { api } from "../../api/client";
import { useCurveStore } from "../../store/curveStore";
import type { FanPoint, FanState } from "../../types";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { FanInfo, FanPoint, FanState } from "../../types.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
function defaultCurve(): FanPoint[] {
return [
@@ -44,6 +44,29 @@ function yToFan(y: number) {
return Math.round(FAN_MAX - ((y - PAD.top) / PLOT_H) * (FAN_MAX - FAN_MIN));
}
function sameTargets(
a: number[] | null | undefined,
b: number[] | null | undefined,
) {
if (a === null || a === undefined) return b === null || b === undefined;
if (b === null || b === undefined) return false;
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
function fanLabel(targets: number[] | null): string {
return targets === null
? "all fans"
: targets.map((i) => `Fan ${i + 1}`).join(", ");
}
const chipCls = (selected: boolean) =>
`px-2 py-0.5 rounded-full border text-xs font-medium transition-colors ${
selected
? "bg-cyan-500/15 border-cyan-500/40 text-cyan-300"
: "bg-zinc-800 border-zinc-700 text-zinc-500 hover:text-zinc-300"
}`;
export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
const { selectedGpuIndex } = useCurveStore();
const [fanState, setFanState] = useState<FanState | null>(null);
@@ -54,6 +77,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
const [confirmApply, setConfirmApply] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [dragIdx, setDragIdx] = useState<number | null>(null);
// Fan selection: undefined = no pending change (follow server state),
// null = all fans, list = specific fan indices.
const [fanSel, setFanSel] = useState<number[] | null | undefined>(undefined);
const svgRef = useRef<SVGSVGElement>(null);
async function fetchFans() {
@@ -61,6 +87,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setLoading(true);
const data = await api.fans(selectedGpuIndex);
setFanState(data);
setFanSel(undefined);
if (data.curve && data.curve.length > 0) {
setPending(null);
}
@@ -71,7 +98,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
}
}
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFans();
}, [selectedGpuIndex]);
@@ -80,20 +110,47 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
const curveActive = fanState?.curve_active ?? false;
const isDefaults = !pending && !fanState?.curve;
const fanTargets =
fanSel === undefined ? (fanState?.fan_targets ?? null) : fanSel;
const fansChanged =
fanSel !== undefined && !sameTargets(fanSel, fanState?.fan_targets ?? null);
const fanList: FanInfo[] =
fanState?.fans && fanState.fans.length > 0
? fanState.fans
: [{ index: 0, fan_pct: null }];
function toggleFan(idx: number) {
const numFans = fanState?.num_fans ?? 1;
let list: number[];
if (fanTargets === null) {
// Start from all fans, then drop the toggled one
list = Array.from({ length: numFans }, (_, i) => i).filter(
(i) => i !== idx,
);
} else {
list = fanTargets.includes(idx)
? fanTargets.filter((i) => i !== idx)
: [...fanTargets, idx].sort((a, b) => a - b);
}
if (list.length === 0) return; // keep at least one fan selected
setFanSel(list.length === numFans ? null : list);
}
async function handleApply() {
const curveToApply = pending ?? fanState?.curve ?? defaultCurve();
if (curveToApply.length < 2) return;
setBusy(true);
setError(null);
try {
await api.updateFans(curveToApply, selectedGpuIndex);
await api.updateFans(curveToApply, selectedGpuIndex, fanTargets);
setPending(null);
setFanSel(undefined);
setConfirmApply(false);
await fetchFans();
onChanged?.();
toast.success("Fan curve applied");
} catch (e: any) {
setError(e.message ?? String(e));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
@@ -106,12 +163,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
try {
await api.resetFans(selectedGpuIndex);
setPending(null);
setFanSel(undefined);
setConfirmReset(false);
await fetchFans();
onChanged?.();
toast.success("Fan control reset to automatic");
} catch (e: any) {
setError(e.message ?? String(e));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
@@ -248,9 +306,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
<button
onClick={() => {
setPending(null);
setFanSel(undefined);
setError(null);
}}
disabled={!hasPending || busy}
disabled={(!hasPending && !fansChanged) || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<X size={12} />
@@ -267,6 +326,48 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
</div>
</div>
{/* Fan selection */}
<div className="px-4 py-2 border-b border-zinc-800/60 flex items-center gap-2 flex-wrap">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Fans
</span>
{fanState?.num_fans === 0 ? (
<span className="text-xs text-zinc-600">
No fans available on this GPU
</span>
) : (
<>
<button
onClick={() => setFanSel(null)}
className={chipCls(fanTargets === null)}
title="Control all fans"
>
All
</button>
{fanList.map((f) => (
<button
key={f.index}
onClick={() => toggleFan(f.index)}
className={chipCls(
fanTargets === null || fanTargets.includes(f.index),
)}
title={`Control Fan ${f.index + 1} with the curve`}
>
Fan {f.index + 1}
<span className="ml-1.5 font-mono text-[10px] opacity-80">
{f.fan_pct !== null ? `${Math.round(f.fan_pct)}%` : "—"}
</span>
</button>
))}
</>
)}
{fansChanged && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
Fans: {fanLabel(fanTargets)}
</span>
)}
</div>
{/* Error banner */}
{error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
@@ -294,8 +395,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
ref={svgRef}
width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none"
style={{ touchAction: "none" }}
className="max-w-full cursor-crosshair select-none touch-none"
onClick={handleCanvasClick}
onPointerMove={handlePointerMove}
>
@@ -410,8 +510,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
style={{ touchAction: "none" }}
className="cursor-grab active:cursor-grabbing touch-none"
onPointerDown={(e) => {
e.stopPropagation();
handlePointerDown(i);
@@ -556,13 +655,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{/* Info */}
<div className="px-4 pb-3 text-[10px] text-zinc-600">
{curveActive
? "Fan curve is active. Server adjusts fan speed based on GPU temperature."
? `Fan curve is active — controlling ${fanLabel(fanTargets)}. Server adjusts fan speed based on GPU temperature.`
: isDefaults
? "These are default values. Click Apply to enable curve control, or edit points first."
: "Apply a curve to enable automatic fan control based on temperature."}
<span className="block mt-1 text-zinc-700">
Drag points to adjust · click the chart to add a point · click the ✕
(chart or table) to remove one.
(chart or table) to remove one · pick which fans the curve controls
above.
</span>
</div>
</div>
@@ -570,7 +670,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{confirmApply && (
<ConfirmDialog
message="Apply fan curve?"
detail="Fan control will switch to curve-based mode. The server will adjust fan speed based on GPU temperature."
detail={`Fan control will switch to curve-based mode for ${fanLabel(fanTargets)}. The server will adjust fan speed based on GPU temperature.`}
confirmLabel="Apply"
onConfirm={handleApply}
onCancel={() => setConfirmApply(false)}
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { Check, X, RotateCcw } from 'lucide-react';
import { api } from '../../api/client';
import { useCurveStore } from '../../store/curveStore';
import type { LimitsState } from '../../types';
import { toast } from 'sonner';
import { ConfirmDialog } from '../common/ConfirmDialog';
import { useState, useEffect } from "react";
import { Check, X, RotateCcw } from "lucide-react";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { LimitsState } from "../../types.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>;
type Pending = Pick<LimitsState, "power_limit_w" | "mem_offset_mhz">;
export function PerformancePanel() {
const { selectedGpuIndex } = useCurveStore();
@@ -23,13 +23,18 @@ export function PerformancePanel() {
setLoading(true);
setLimits(await api.limits(selectedGpuIndex));
} catch {
toast.error('Failed to load performance limits');
toast.error("Failed to load performance limits");
} finally {
setLoading(false);
}
}
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]);
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchLimits();
}, [selectedGpuIndex]);
async function handleApply() {
setBusy(true);
@@ -40,9 +45,9 @@ export function PerformancePanel() {
setPending({});
setConfirmApply(false);
await fetchLimits();
toast.success('Performance limits applied');
} catch (e: any) {
setError(e.message ?? String(e));
toast.success("Performance limits applied");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
@@ -58,15 +63,30 @@ export function PerformancePanel() {
setPending({});
setConfirmReset(false);
await fetchLimits();
toast.success('Performance limits reset to defaults');
} catch (e: any) {
setError(e.message ?? String(e));
toast.success("Performance limits reset to defaults");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
}
}
async function handleModeChange(enabled: boolean) {
setBusy(true);
try {
await api.updateLimits(
{ power_cap_mode: enabled ? "ioctl" : "nvml" },
selectedGpuIndex,
);
await fetchLimits();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
if (loading && !limits) {
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
@@ -96,10 +116,11 @@ export function PerformancePanel() {
return (
<>
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Performance</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Performance
</span>
{hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
@@ -117,7 +138,10 @@ export function PerformancePanel() {
Apply
</button>
<button
onClick={() => { setPending({}); setError(null); }}
onClick={() => {
setPending({});
setError(null);
}}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -139,25 +163,100 @@ export function PerformancePanel() {
{error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {error}</span>
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
<button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
✕
</button>
</div>
)}
<div className="flex flex-col divide-y divide-zinc-800">
{/* ── Experimental NVIDIA power control ─────────────────────────── */}
{(limits.rm_power_supported || limits.power_cap_mode === "ioctl") && (
<div className="px-4 py-3 flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={limits.power_cap_mode === "ioctl"}
disabled={busy}
onChange={(e) => handleModeChange(e.target.checked)}
className="accent-red-500"
/>
<span className="text-xs text-zinc-300">
Experimental NVIDIA power control
</span>
</label>
{limits.rm_power_supported ? (
<div
role="alert"
className={
"px-2.5 py-1.5 rounded border text-xs leading-relaxed " +
(limits.power_cap_mode === "ioctl"
? "bg-red-950/80 border-red-500 text-red-300"
: "bg-red-950/40 border-red-800 text-red-400")
}
>
<span className="font-bold">⚠ WARNING:</span> uses an
undocumented driver interface for ALL power limits, including
resets. Allows values below the VBIOS minimum (down to 30 W)
and may cause instability or stop working after driver
updates. Enable at your own risk. Based on the work of{" "}
<a
href="https://github.com/ilya-zlobintsev/LACT/pull/1205"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-red-200"
>
panchovix
</a>{" "}
(LACT PR #1205).
</div>
) : (
<div
role="alert"
className="px-2.5 py-1.5 rounded border border-red-500 bg-red-950/80 text-red-300 text-xs leading-relaxed"
>
<span className="font-bold">
⚠ Interface not currently available.
</span>
The driver no longer exposes the RM power interface (it may
have been updated). Experimental mode is still enabled, so
power-limit changes will fail. Uncheck to switch back to the
standard NVML mode.
</div>
)}
</div>
)}
{/* ── Board Power Limit ─────────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Board Power Limit</span>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500 uppercase tracking-wider">
Board Power Limit
</span>
{limits.power_cap_mode === "ioctl" &&
limits.min_power_limit_w_native != null && (
<span
className="text-xs text-red-400/80 font-mono"
title="Native VBIOS minimum — experimental mode allows lower"
>
VBIOS min {limits.min_power_limit_w_native} W
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<input
type="number"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => {
onChange={(e) => {
const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v }));
if (!isNaN(v))
setPending((p) => ({ ...p, power_limit_w: v }));
}}
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/>
@@ -165,32 +264,44 @@ export function PerformancePanel() {
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-8 text-right">{pwrMin}</span>
<span className="text-xs text-zinc-600 font-mono w-8 text-right">
{pwrMin}
</span>
<input
type="range"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))}
onChange={(e) =>
setPending((p) => ({
...p,
power_limit_w: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/>
<span className="text-xs text-zinc-600 font-mono w-8">{pwrMax}</span>
<span className="text-xs text-zinc-600 font-mono w-8">
{pwrMax}
</span>
</div>
</div>
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Memory Clock Offset</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider">
Memory Clock Offset
</span>
<div className="flex items-center gap-1.5">
<input
type="number"
min={memMin}
max={memMax}
value={memVal}
onChange={e => {
onChange={(e) => {
const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v }));
if (!isNaN(v))
setPending((p) => ({ ...p, mem_offset_mhz: v }));
}}
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/>
@@ -198,20 +309,28 @@ export function PerformancePanel() {
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-10 text-right">{memMin}</span>
<span className="text-xs text-zinc-600 font-mono w-10 text-right">
{memMin}
</span>
<input
type="range"
min={memMin}
max={memMax}
step={1}
value={memVal}
onChange={e => setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))}
onChange={(e) =>
setPending((p) => ({
...p,
mem_offset_mhz: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/>
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span>
<span className="text-xs text-zinc-600 font-mono w-10">
+{memMax}
</span>
</div>
</div>
</div>
</div>
+45 -18
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample, FanPoint } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample, FanPoint } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -16,7 +16,10 @@ function pluck<K extends keyof MonitoringSample>(
return history.map((s) => (s[key] as number | null) ?? 0);
}
function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): number | null {
function computeTargetFan(
curve: FanPoint[] | null,
tempC: number | null,
): number | null {
if (!curve || !tempC || curve.length < 2) return null;
for (let i = 0; i < curve.length - 1; i++) {
@@ -36,19 +39,30 @@ function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): numbe
return curve[curve.length - 1].fan_pct;
}
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) {
const fanHistory = pluck(history, 'fan_pct');
const tempHistory = pluck(history, 'temp_c');
export function FanMonitor({
monitor,
history,
fanCurve,
fanCurveActive,
}: Props) {
const fanHistory = pluck(history, "fan_pct");
const tempHistory = pluck(history, "temp_c");
const fans = monitor?.fans ?? null;
const multiFan = fans !== null && fans.length > 1;
const currentTemp = monitor?.temp_c ?? null;
const targetFan = computeTargetFan(fanCurve, currentTemp);
const targetFanHistory = history.map((s) => computeTargetFan(fanCurve, s.temp_c) ?? 0);
const targetFanHistory = history.map(
(s) => computeTargetFan(fanCurve, s.temp_c) ?? 0,
);
return (
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
{fanCurveActive && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
Curve Active
@@ -56,13 +70,26 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
)}
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Fan Speed"
value={fmt.pct(monitor?.fan_pct)}
history={fanHistory}
color="#fb923c"
max={100}
/>
{multiFan ? (
fans!.map((_, i) => (
<GaugeCard
key={i}
label={`Fan ${i + 1}`}
value={fmt.pct(fans![i])}
history={history.map((s) => s.fans?.[i] ?? s.fan_pct ?? 0)}
color="#fb923c"
max={100}
/>
))
) : (
<GaugeCard
label="Fan Speed"
value={fmt.pct(monitor?.fan_pct)}
history={fanHistory}
color="#fb923c"
max={100}
/>
)}
<GaugeCard
label="GPU Temp"
value={fmt.celsius(monitor?.temp_c)}
@@ -73,7 +100,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
{fanCurveActive && (
<GaugeCard
label="Target Fan"
value={targetFan !== null ? `${targetFan}%` : '—'}
value={targetFan !== null ? `${targetFan}%` : "—"}
history={targetFanHistory}
color="#fbbf24"
max={100}
@@ -82,7 +109,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
<div className="mt-auto">
<GaugeCard
label="Fan Mode"
value={fanCurveActive ? 'Curve' : 'Auto'}
value={fanCurveActive ? "Curve" : "Auto"}
/>
</div>
</div>
+41 -39
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -19,48 +19,50 @@ export function LiveMonitor({ monitor, history }: Props) {
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'clock_mhz')}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, 'voltage_mv')}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, 'gpu_util_pct')}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? 'Unknown'}
history={pluck(history, 'pstate')}
color="#a8a29e"
max={15}
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, "clock_mhz")}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, "voltage_mv")}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, "gpu_util_pct")}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? "Unknown"}
history={pluck(history, "pstate")}
color="#a8a29e"
max={15}
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -17,35 +17,38 @@ function pluck<K extends keyof MonitoringSample>(
export function PerformanceMonitor({ monitor, history }: Props) {
const memUsed = monitor?.mem_used_mib ?? null;
const memTotal = monitor?.mem_total_mib ?? null;
const memLabel = memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: '—';
const memLabel =
memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: "—";
return (
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Mem Clock"
value={fmt.mhz(monitor?.mem_clock_mhz)}
history={pluck(history, 'mem_clock_mhz')}
history={pluck(history, "mem_clock_mhz")}
color="#67e8f9"
max={20000}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="VRAM Used"
value={memLabel}
history={pluck(history, 'mem_used_mib')}
history={pluck(history, "mem_used_mib")}
color="#a78bfa"
max={memTotal ?? 32768}
/>
@@ -8,9 +8,9 @@ import {
LogOut,
User,
} from "lucide-react";
import type { GpuInfo, MonitoringSample } from "../../types";
import { fmt } from "../../utils/units";
import { useCurveStore } from "../../store/curveStore";
import type { GpuInfo, MonitoringSample } from "../../types.js";
import { fmt } from "../../utils/units.js";
import { useCurveStore } from "../../store/curveStore.js";
import { useState, useRef, useEffect } from "react";
interface Props {
+58 -22
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { useCurveStore } from '../../store/curveStore';
import { useState, useRef, useEffect } from "react";
import { fmt } from "../../utils/units.js";
import type { VFPoint } from "../../types.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props {
point: VFPoint;
@@ -14,17 +14,28 @@ interface Props {
onMouseEnter?: () => void;
}
export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) {
export function PointRow({
point,
isCurrent,
isSelected,
isClamped,
pendingDeltaKhz,
shouldAutoScroll,
onMouseDown,
onMouseEnter,
}: Props) {
const { stageEdit } = useCurveStore();
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const trRef = useRef<HTMLTableRowElement>(null);
useEffect(() => {
if (shouldAutoScroll && trRef.current) {
// @ts-expect-error: the typing seems to not include the valid 'container' option
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' });
trRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [shouldAutoScroll]);
@@ -36,11 +47,19 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
const displayEffMhz = point.freq_mhz + deltaChange / 1000;
const deltaColor = hasPending
? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400'
: point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500';
? displayDeltaKhz > 0
? "text-cyan-400"
: displayDeltaKhz < 0
? "text-orange-400"
: "text-zinc-400"
: point.delta_khz > 0
? "text-emerald-400"
: point.delta_khz < 0
? "text-red-400"
: "text-zinc-500";
function startEdit() {
setInputValue((displayDeltaMhz).toFixed(1));
setInputValue(displayDeltaMhz.toFixed(1));
setEditing(true);
setTimeout(() => {
inputRef.current?.select();
@@ -64,9 +83,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<tr
ref={trRef}
className={[
'border-b border-zinc-800 text-xs font-mono cursor-pointer',
isCurrent ? 'bg-yellow-400/10' : isSelected ? 'bg-cyan-500/10' : 'hover:bg-zinc-800/50',
].join(' ')}
"border-b border-zinc-800 text-xs font-mono cursor-pointer",
isCurrent
? "bg-yellow-400/10"
: isSelected
? "bg-cyan-500/10"
: "hover:bg-zinc-800/50",
].join(" ")}
onMouseDown={(e) => {
if (editing) return;
onMouseDown?.(e);
@@ -80,7 +103,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
{/* Offset — click to edit inline */}
<td className={`px-3 py-1 ${deltaColor}`} onClick={(e) => { e.stopPropagation(); startEdit(); }}>
<td
className={`px-3 py-1 ${deltaColor}`}
onClick={(e) => {
e.stopPropagation();
startEdit();
}}
>
{editing ? (
<input
ref={inputRef}
@@ -90,28 +119,35 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
onChange={(e) => setInputValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); }
if (e.key === 'Escape') cancelEdit();
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
commitEdit();
}
if (e.key === "Escape") cancelEdit();
}}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs"
style={{ fontFamily: 'monospace' }}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs font-mono"
/>
) : (
<span title="Click to edit">
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz
{displayDeltaKhz > 0 ? "+" : ""}
{displayDeltaMhz.toFixed(1)} MHz
</span>
)}
</td>
{/* Eff. Freq */}
<td className={`px-3 py-1 font-semibold ${hasPending ? 'text-cyan-200' : 'text-zinc-100'}`}>
<td
className={`px-3 py-1 font-semibold ${hasPending ? "text-cyan-200" : "text-zinc-100"}`}
>
{fmt.mhz(displayEffMhz, 0)}
{isClamped && !hasPending && (
<span
className="ml-1 text-amber-500 cursor-help"
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
>⇡</span>
>
⇡
</span>
)}
</td>
@@ -1,8 +1,11 @@
import { useState, useMemo, useEffect } from 'react';
import { PointRow } from './PointRow';
import type { VFPoint } from '../../types';
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers';
import { useCurveStore } from '../../store/curveStore';
import { useState, useMemo, useEffect } from "react";
import { PointRow } from "./PointRow.js";
import type { VFPoint } from "../../types.js";
import {
findCurrentPoint,
detectClampedPoints,
} from "../../utils/curveHelpers.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props {
points: VFPoint[];
@@ -11,22 +14,32 @@ interface Props {
}
export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore();
const {
pendingDeltas,
selectedPoints,
selectPoint,
togglePoint,
selectRange,
} = useCurveStore();
const currentPoint = findCurrentPoint(points, currentVoltageMv);
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
useEffect(() => {
function onUp() { setDragStartIdx(null); }
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
function onUp() {
setDragStartIdx(null);
}
window.addEventListener("mouseup", onUp);
return () => window.removeEventListener("mouseup", onUp);
}, []);
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">Points</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">
Points
</span>
{!readOnly && pendingDeltas.size > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{pendingDeltas.size} staged
@@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
{readOnly && (
<span className="text-xs text-zinc-600 italic">read-only</span>
)}
<span className="ml-auto text-xs text-zinc-600">{points.length} points</span>
<span className="ml-auto text-xs text-zinc-600">
{points.length} points
</span>
{!readOnly && selectedPoints.size === 1 && (
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
<button
onClick={() => {
const idx = Array.from(selectedPoints)[0];
const beforeIdxs = points.filter(p => p.index <= idx).map(p => p.index);
const beforeIdxs = points
.filter((p) => p.index <= idx)
.map((p) => p.index);
selectRange(beforeIdxs);
}}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -51,7 +68,9 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<button
onClick={() => {
const idx = Array.from(selectedPoints)[0];
const afterIdxs = points.filter(p => p.index >= idx).map(p => p.index);
const afterIdxs = points
.filter((p) => p.index >= idx)
.map((p) => p.index);
selectRange(afterIdxs);
}}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -66,9 +85,15 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<thead className="sticky top-0 bg-zinc-900 z-10">
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Voltage</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Offset</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Eff. Freq</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Voltage
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Offset
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Eff. Freq
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900" />
</tr>
</thead>
@@ -81,34 +106,48 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
isSelected={selectedPoints.has(p.index)}
isClamped={clampedPoints.has(p.index)}
pendingDeltaKhz={pendingDeltas.get(p.index)}
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)}
onMouseDown={readOnly ? undefined : (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index, false);
}
} else if (e.ctrlKey || e.metaKey) {
selectPoint(p.index, true);
} else {
setDragStartIdx(p.index);
selectPoint(p.index, false);
}
}}
onMouseEnter={readOnly ? undefined : () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
}
}}
shouldAutoScroll={
selectedPoints.size === 1 && selectedPoints.has(p.index)
}
onMouseDown={
readOnly
? undefined
: (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index);
}
} else if (e.ctrlKey || e.metaKey) {
togglePoint(p.index);
} else {
setDragStartIdx(p.index);
selectPoint(p.index);
}
}
}
onMouseEnter={
readOnly
? undefined
: () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
}
}
}
/>
))}
</tbody>
@@ -8,10 +8,14 @@ import {
Star,
Fan,
} from "lucide-react";
import { api } from "../../api/client";
import type { ProfileData } from "../../types";
import { api } from "../../api/client.js";
import type { ProfileData } from "../../types.js";
import { toast } from "sonner";
import { useCurveStore } from "../../store/curveStore";
import { useCurveStore } from "../../store/curveStore.js";
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
interface ProfilePanelProps {
activeProfile: string | null;
@@ -65,37 +69,42 @@ export function ProfilePanel({
setAutoLoadProfile(name);
if (name) toast.success(`"${name}" will load on server start`);
else toast.success("Auto-load cleared");
} catch (e: any) {
toast.error(
"Failed to update default profile: " + (e.message || String(e)),
);
} catch (e: unknown) {
toast.error("Failed to update default profile: " + errMsg(e));
}
}
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProfiles();
}, [selectedGpuIndex]);
useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus();
else setNewName("");
}, [isSaveOpen]);
useEffect(() => {
if (renamingName) renameInputRef.current?.focus();
}, [renamingName]);
async function handleSave(e: React.FormEvent) {
function closeSaveForm() {
setIsSaveOpen(false);
setNewName("");
}
async function handleSave(e: React.SubmitEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
setIsSaving(true);
await api.saveProfile(newName.trim(), selectedGpuIndex);
toast.success(`Profile "${newName.trim()}" saved`);
setIsSaveOpen(false);
closeSaveForm();
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to save: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to save: " + errMsg(e));
} finally {
setIsSaving(false);
}
@@ -107,8 +116,8 @@ export function ProfilePanel({
await api.applyProfile(name, selectedGpuIndex);
onProfileApplied(name);
toast.success(`"${name}" applied`);
} catch (e: any) {
toast.error(`Failed to apply "${name}": ` + (e.message || String(e)));
} catch (e: unknown) {
toast.error(`Failed to apply "${name}": ` + errMsg(e));
} finally {
setApplyingName(null);
}
@@ -123,14 +132,14 @@ export function ProfilePanel({
if (autoLoadProfile === name) setAutoLoadProfile(null);
setDeletingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to delete: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to delete: " + errMsg(e));
} finally {
setIsDeleting(false);
}
}
async function handleRename(e: React.FormEvent, oldName: string) {
async function handleRename(e: React.SubmitEvent, oldName: string) {
e.preventDefault();
if (!renameValue.trim() || renameValue.trim() === oldName) {
setRenamingName(null);
@@ -144,8 +153,8 @@ export function ProfilePanel({
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
setRenamingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to rename: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to rename: " + errMsg(e));
} finally {
setIsRenaming(false);
}
@@ -179,7 +188,7 @@ export function ProfilePanel({
value={newName}
onChange={(e) => setNewName(e.target.value)}
disabled={isSaving}
onKeyDown={(e) => e.key === "Escape" && setIsSaveOpen(false)}
onKeyDown={(e) => e.key === "Escape" && closeSaveForm()}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/>
<button
@@ -191,7 +200,7 @@ export function ProfilePanel({
</button>
<button
type="button"
onClick={() => setIsSaveOpen(false)}
onClick={closeSaveForm}
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
>
Cancel
@@ -336,6 +345,11 @@ export function ProfilePanel({
{badges && (
<p className="text-xs text-zinc-500">{badges}</p>
)}
{p.power_cap_mode === "ioctl" && (
<p className="text-xs text-red-400 font-medium">
⚠ experimental power (below VBIOS min)
</p>
)}
</div>
</div>
@@ -10,16 +10,17 @@ interface Props {
export function ConfirmDialog({
message,
detail,
confirmLabel = 'Confirm',
confirmLabel = "Confirm",
isDestructive = false,
onConfirm,
onCancel,
}: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.7)' }}
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
@@ -35,11 +36,11 @@ export function ConfirmDialog({
<button
onClick={onConfirm}
className={[
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
"px-3 py-1.5 rounded text-xs font-semibold transition-colors",
isDestructive
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-emerald-600 hover:bg-emerald-500 text-white',
].join(' ')}
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(" ")}
>
{confirmLabel}
</button>
+9 -7
View File
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { CurveState } from '../types';
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client.js";
import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from "../store/curveStore.js";
import type { CurveState } from "../types.js";
export function useCurve() {
const { curve, setCurve, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
@@ -15,7 +17,7 @@ export function useCurve() {
// Subscribe to /ws/curve for push updates after writes
wsRef.current = createWsConnection<CurveState>(
'/ws/curve',
"/ws/curve",
(data) => setCurve(data),
setWsStatus,
selectedGpuIndex,
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { useCurveStore } from "../store/curveStore";
import type { DashboardInfo } from "../types";
import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore.js";
import type { DashboardInfo } from "../types.js";
interface DashboardState {
gpuIndex: number;
@@ -23,17 +23,17 @@ export function useDashboard() {
useEffect(() => {
let cancelled = false;
api
.dashboard(selectedGpuIndex)
.then((data) => {
(async () => {
try {
const data = await api.dashboard(selectedGpuIndex);
if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data, done: true });
})
.catch((err) => {
} catch (err) {
console.error("Failed to load dashboard info:", err);
if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
});
}
})();
return () => {
cancelled = true;
};
+5 -4
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useCurveStore } from '../store/curveStore';
import { useEffect } from "react";
import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore.js";
export function useGpu() {
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore();
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } =
useCurveStore();
useEffect(() => {
api.gpus().then(setAvailableGpus).catch(console.error);
+10 -7
View File
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { MonitoringSample } from '../types';
import { useEffect, useRef, useState } from "react";
import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from "../store/curveStore.js";
import type { MonitoringSample } from "../types.js";
export function useMonitor() {
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } =
useCurveStore();
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
wsRef.current = createWsConnection<MonitoringSample>(
'/ws/monitor',
"/ws/monitor",
pushMonitor,
setWsStatus,
selectedGpuIndex,
+95 -45
View File
@@ -1,7 +1,12 @@
import { create } from 'zustand';
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types';
import { api } from '../api/client';
import { toast } from 'sonner';
import { create } from "zustand";
import type {
CurveState,
GpuInfo,
MonitoringSample,
VFPoint,
} from "../types.js";
import { api } from "../api/client.js";
import { toast } from "sonner";
const HISTORY_SIZE = 120; // ~60s at 2Hz
@@ -50,7 +55,9 @@ interface CurveStore {
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
// Selection actions
selectPoint: (index: number, multi?: boolean) => void;
selectPoint: (index: number) => void;
/** Toggle a point in the selection (Shift/Ctrl+click); updates the anchor. */
togglePoint: (index: number) => void;
selectRange: (indices: number[]) => void;
clearSelection: () => void;
/**
@@ -80,7 +87,16 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
setAvailableGpus: (availableGpus) => set({ availableGpus }),
setSelectedGpuIndex: (selectedGpuIndex) => {
set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null });
set({
selectedGpuIndex,
curve: null,
gpuInfo: null,
monitor: null,
monitorHistory: [],
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
});
},
setCurve: (curve) => set({ curve }),
setGpuInfo: (gpuInfo) => set({ gpuInfo }),
@@ -95,7 +111,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
stageEdit: (pointIndex, deltaKhz) =>
set((s) => {
const next = new Map(s.pendingDeltas);
const point = s.curve?.points.find(p => p.index === pointIndex);
const point = s.curve?.points.find((p) => p.index === pointIndex);
if (point && point.delta_khz === deltaKhz) {
next.delete(pointIndex);
} else {
@@ -108,7 +124,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
set((s) => {
const next = new Map(s.pendingDeltas);
edits.forEach((deltaKhz, index) => {
const point = s.curve?.points.find(p => p.index === index);
const point = s.curve?.points.find((p) => p.index === index);
if (point && point.delta_khz === deltaKhz) {
next.delete(index);
} else {
@@ -128,27 +144,39 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
}),
discardEdits: () =>
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }),
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
}),
applyEdits: async (onSuccess) => {
const { pendingDeltas, selectedGpuIndex } = get();
if (pendingDeltas.size === 0) return;
// Convert Map to plain record for the API
const deltas: Record<number, number> = {};
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
const deltas: Record<number, number> = Object.fromEntries(pendingDeltas);
try {
const result = await api.writeDeltas(deltas, selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
if (result?.freq_warnings?.length) {
toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)');
toast.warning(
"Curve applied — driver clamped some points to 0 MHz (negative freq delta)",
);
} else {
toast.success('Curve applied successfully');
toast.success("Curve applied successfully");
}
onSuccess();
} catch (e: any) {
toast.error('Failed to apply curve: ' + (e.message || String(e)));
} catch (e: unknown) {
toast.error(
"Failed to apply curve: " +
(e instanceof Error ? e.message : String(e)),
);
}
},
@@ -156,35 +184,48 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
const { selectedGpuIndex } = get();
try {
await api.resetCurve(selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
toast.success('Curve reset to hardware defaults');
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
toast.success("Curve reset to hardware defaults");
onSuccess();
} catch (e: any) {
toast.error('Failed to reset curve: ' + (e.message || String(e)));
} catch (e: unknown) {
toast.error(
"Failed to reset curve: " +
(e instanceof Error ? e.message : String(e)),
);
}
},
selectPoint: (index, multi = false) =>
selectPoint: (index) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor: number | null = index;
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
}
return { selectedPoints: next, anchorPoint: anchor };
}),
togglePoint: (index) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor = s.anchorPoint;
if (multi) {
if (next.has(index)) {
next.delete(index);
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null;
} else {
next.add(index);
anchor = index; // last explicitly added point is the new anchor
if (next.has(index)) {
next.delete(index);
if (anchor === index) {
const last = [...next].at(-1);
anchor = last ?? null;
}
} else {
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
anchor = index;
}
next.add(index);
anchor = index; // last explicitly added point is the new anchor
}
return { selectedPoints: next, anchorPoint: anchor };
}),
@@ -193,29 +234,38 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
// Bulk selects don't change the anchor — preserve it if still in the new selection.
set((s) => {
const next = new Set(indices);
const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null;
const anchor =
s.anchorPoint !== null && next.has(s.anchorPoint)
? s.anchorPoint
: null;
return { selectedPoints: next, anchorPoint: anchor };
}),
clearSelection: () =>
set({ selectedPoints: new Set(), anchorPoint: null }),
clearSelection: () => set({ selectedPoints: new Set(), anchorPoint: null }),
flattenToAnchor: () => {
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get();
const {
selectedPoints,
anchorPoint,
pendingDeltas,
curve,
stageMultiEdit,
} = get();
if (selectedPoints.size < 2 || !curve) return;
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchor =
anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorPt = curve.points.find(p => p.index === anchor);
const anchorPt = curve.points.find((p) => p.index === anchor);
if (!anchorPt) return;
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
const edits = new Map<number, number>();
for (const idx of selectedPoints) {
const pt = curve.points.find(p => p.index === idx);
const pt = curve.points.find((p) => p.index === idx);
if (!pt) continue;
edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
}
+17
View File
@@ -25,6 +25,7 @@ export interface MonitoringSample {
temp_c: number | null;
power_w: number | null;
fan_pct: number | null;
fans: (number | null)[] | null;
pstate: number | null;
pstate_label: string | null;
mem_used_bytes: number | null;
@@ -97,7 +98,11 @@ export interface LimitsState {
power_limit_w: number | null;
default_power_limit_w: number | null;
min_power_limit_w: number | null;
min_power_limit_w_native: number | null;
max_power_limit_w: number | null;
// "nvml" (default) or "ioctl" (experimental RM power control)
power_cap_mode: "nvml" | "ioctl";
rm_power_supported: boolean;
// Clock offsets — current values
gpc_offset_mhz: number | null;
mem_offset_mhz: number | null;
@@ -111,13 +116,22 @@ export interface FanPoint {
fan_pct: number;
}
export interface FanInfo {
index: number;
fan_pct: number | null;
}
export interface FanState {
fan_pct: number | null;
fans: FanInfo[] | null;
num_fans: number | null;
fan_mode: "auto" | "curve" | null;
min_fan_pct: number | null;
max_fan_pct: number | null;
curve: FanPoint[] | null;
curve_active: boolean;
// Fan indices controlled by the active curve; null = all fans.
fan_targets: number[] | null;
}
export interface ProfileData {
@@ -126,5 +140,8 @@ export interface ProfileData {
curve_deltas: Record<string, number>;
mem_offset_mhz: number | null;
power_limit_w: number | null;
// "nvml" (default) or "ioctl" (experimental RM power control)
power_cap_mode: "nvml" | "ioctl" | null;
fan_curve: FanPoint[] | null;
fan_targets: number[] | null;
}
+31 -29
View File
@@ -1,4 +1,4 @@
import type { VFPoint } from '../types';
import type { VFPoint } from "../types.js";
/**
* Approximate reference frequency (MHz) for a point: effective − delta.
@@ -14,35 +14,37 @@ import type { VFPoint } from '../types';
* useful as a faint reference line ("where this point would sit with no boost").
*/
export function refBaseMhz(p: VFPoint): number {
return p.freq_mhz - p.delta_mhz;
return p.freq_mhz - p.delta_mhz;
}
/** Find which VF point the GPU is currently near based on voltage reading */
export function findCurrentPoint(
points: VFPoint[],
voltage_mv: number | null,
points: VFPoint[],
voltage_mv: number | null,
): VFPoint | null {
if (voltage_mv == null || points.length === 0) return null;
return points.reduce((best, p) =>
Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv) ? p : best,
);
if (voltage_mv == null || points.length === 0) return null;
return points.reduce((best, p) =>
Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv)
? p
: best,
);
}
/** Voltage domain extent, with padding */
export function voltExtent(points: VFPoint[], padMv = 20): [number, number] {
if (points.length === 0) return [600, 1100];
const min = Math.min(...points.map((p) => p.volt_mv));
const max = Math.max(...points.map((p) => p.volt_mv));
return [min - padMv, max + padMv];
if (points.length === 0) return [600, 1100];
const min = Math.min(...points.map((p) => p.volt_mv));
const max = Math.max(...points.map((p) => p.volt_mv));
return [min - padMv, max + padMv];
}
/** Frequency domain extent for the effective (boosted) curve, with padding */
export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
if (points.length === 0) return [1000, 3000];
const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]);
const min = Math.min(...allFreqs);
const max = Math.max(...points.map((p) => p.freq_mhz));
return [min - padMhz, max + padMhz];
if (points.length === 0) return [1000, 3000];
const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]);
const min = Math.min(...allFreqs);
const max = Math.max(...points.map((p) => p.freq_mhz));
return [min - padMhz, max + padMhz];
}
/**
@@ -59,19 +61,19 @@ export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
* point 104 has +315 MHz → effective also 3907 MHz (clamped).
*/
export function detectClampedPoints(points: VFPoint[]): Set<number> {
const clamped = new Set<number>();
let ceiling = -Infinity;
let ceilingOffset = -Infinity;
const clamped = new Set<number>();
let ceiling = -Infinity;
let ceilingOffset = -Infinity;
for (const p of points) {
if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) {
clamped.add(p.index);
}
if (p.freq_mhz > ceiling) {
ceiling = p.freq_mhz;
ceilingOffset = p.delta_khz;
}
for (const p of points) {
if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) {
clamped.add(p.index);
}
if (p.freq_mhz > ceiling) {
ceiling = p.freq_mhz;
ceilingOffset = p.delta_khz;
}
}
return clamped;
return clamped;
}
+74
View File
@@ -0,0 +1,74 @@
"""Custom hatchling build hook: build the React frontend if it is missing or stale.
This makes NVCurve installable with a single command, e.g.::
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
The hook runs inside the isolated build environment right before the wheel
(or sdist) is assembled. If ``frontend/dist`` does not exist yet — or is older
than the frontend sources — it compiles the frontend using the host's ``npm``
(PATH is inherited from the environment).
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
# Frontend inputs that must be newer than dist/index.html to trigger a rebuild.
_FRONTEND_INPUTS = (
"src",
"index.html",
"vite.config.ts",
"package.json",
"tsconfig.json",
)
class FrontendBuildHook(BuildHookInterface):
"""Build ``frontend/dist`` with npm when it is missing or stale."""
PLUGIN_NAME = "custom"
def initialize(self, version: str, build_data: dict) -> None:
frontend = os.path.join(self.root, "frontend")
dist_index = os.path.join(frontend, "dist", "index.html")
if not self._needs_build(frontend, dist_index):
return
npm = shutil.which("npm")
if npm is None:
raise RuntimeError(
"npm not found on PATH. Node.js 18+ and npm are required to build "
"the NVCurve frontend. Install them and retry, or use install.sh "
"which checks prerequisites for you."
)
print(
"[nvcurve] frontend/dist missing or stale — building frontend with npm ...",
file=sys.stderr,
)
subprocess.run([npm, "ci", "--no-audit", "--no-fund"], cwd=frontend, check=True)
subprocess.run([npm, "run", "build"], cwd=frontend, check=True)
@staticmethod
def _needs_build(frontend: str, dist_index: str) -> bool:
if not os.path.isfile(dist_index):
return True
dist_mtime = os.path.getmtime(dist_index)
for name in _FRONTEND_INPUTS:
path = os.path.join(frontend, name)
if os.path.isfile(path):
if os.path.getmtime(path) > dist_mtime:
return True
elif os.path.isdir(path):
for root, _dirs, files in os.walk(path):
for file in files:
if os.path.getmtime(os.path.join(root, file)) > dist_mtime:
return True
return False
Executable
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# NVCurve single-command installer.
#
# curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
#
# or from a local clone:
#
# git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git && cd nvcurve && ./install.sh
#
# The React frontend is compiled automatically during the Python build
# (see hatch_build.py), so Node.js 18+ and npm must be available.
#
# Environment:
# NVCURVE_BRANCH branch to install (default: main)
set -euo pipefail
REPO_URL="https://gitea.zephyre.one/Pakobbix/nvcurve.git"
BRANCH="${NVCURVE_BRANCH:-main}"
fail() {
echo "error: $*" >&2
exit 1
}
# --- prerequisites -----------------------------------------------------------
command -v git >/dev/null 2>&1 ||
fail "git is required. Install it first."
command -v node >/dev/null 2>&1 ||
fail "Node.js 18+ is required (e.g. 'sudo pacman -S nodejs npm' or 'sudo apt install nodejs npm')."
command -v npm >/dev/null 2>&1 ||
fail "npm is required (usually installed together with Node.js)."
if ! command -v uv >/dev/null 2>&1; then
echo "uv not found — installing it from https://astral.sh/uv ..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null 2>&1 ||
fail "uv installation failed. Install uv manually: https://docs.astral.sh/uv/"
fi
# --- locate the source tree ---------------------------------------------------
if [ -f pyproject.toml ] && [ -d frontend ]; then
src="$(pwd)"
echo "Installing from current directory: $src"
else
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "Cloning $REPO_URL (branch: $BRANCH) ..."
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$tmp/nvcurve"
src="$tmp/nvcurve"
fi
# --- install -------------------------------------------------------------------
# The frontend is built automatically by the build hook (hatch_build.py).
uv tool install --force "$src"
echo
echo "NVCurve installed."
echo " Verify your GPU: nvcurve setup"
echo " Start the web UI: nvcurve"
+309 -73
View File
@@ -31,13 +31,14 @@ First-time / diagnostic commands (bypass server, escalate to root):
import argparse
import json
import logging
import os
import struct
import sys
import time
from .client import ApiError, NvCurveClient, ServerNotRunning
from .config import Config, default_config
from .config import Config, default_config, normalize_trusted_proxies, tls_enabled
from .nvapi.constants import (
CT_BASE,
CT_POINTS,
@@ -48,6 +49,8 @@ from .nvapi.constants import (
VFP_STRIDE,
)
log = logging.getLogger("nvcurve.cli")
# ── Utilities ─────────────────────────────────────────────────────────────────
@@ -69,8 +72,8 @@ def parse_range(s: str):
raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'")
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'")
except ValueError as exc:
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") from exc
if a > b:
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
if a < 0 or b >= CT_POINTS:
@@ -98,7 +101,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
current_idx = None
if voltage:
for i, (f, v) in enumerate(points):
for i, (_f, v) in enumerate(points):
if v > 0 and abs(v - voltage) < 10000:
current_idx = i
break
@@ -214,7 +217,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
if offsets:
nonzero = sum(1 for o in offsets if o != 0)
if nonzero > 0:
vals = set(o for o in offsets if o != 0)
vals = {o for o in offsets if o != 0}
if len(vals) == 1:
print(
f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz "
@@ -316,7 +319,7 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0):
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True),
]
for name, fid, size, ver, needs_mask in probes:
for name, fid, size, ver, _needs_mask in probes:
ptr = query_interface(fid)
resolved = "resolved" if ptr else "NOT FOUND"
print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}")
@@ -402,6 +405,11 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0):
print(f" Default: {fmt_w(def_w)}")
if min_w is not None and max_w is not None:
print(f" Range: {min_w} – {max_w} W")
if pwr.get("rm_power_supported"):
print(
" Experimental RM power: available (opt-in via web UI or profile;"
" extends range to 30 W)"
)
# ── Privilege / browser helpers ───────────────────────────────────────────────
@@ -420,8 +428,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
except Exception as exc:
log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser
webbrowser.open(url)
@@ -445,7 +453,7 @@ def require_root():
]
try:
# PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages.
os.execvp(
os.execvp( # noqa: S606 — intentional re-exec via sudo
"sudo",
[
"sudo",
@@ -469,6 +477,21 @@ _PERSISTENT_CONFIG_FILE = (
)
_DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock"
def _configured_max_delta() -> int:
"""Frequency safety cap from the persistent config (built-in default fallback).
The operator-configured cap is authoritative for all direct-hardware
write paths (write, profile apply, verify) unless explicitly overridden
with --max-delta.
"""
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
return json.load(f).get("max_delta_khz", default_config.max_delta_khz)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return default_config.max_delta_khz
_ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"}
@@ -501,7 +524,7 @@ def _safe_host(host: str, cfg: Config) -> str:
0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local
server address, just not usable as a client connection target.
"""
if host in ("0.0.0.0", "::"):
if host in ("0.0.0.0", "::"): # noqa: S104 — comparison only, no binding here
return "127.0.0.1"
if host not in _ALLOWED_HOSTS:
print(
@@ -514,7 +537,7 @@ def _safe_host(host: str, cfg: Config) -> str:
def _log_file() -> str:
return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log"
return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" # noqa: S108
def _read_server_info() -> dict | None:
@@ -545,12 +568,18 @@ def _discover_server_url(cfg: Config) -> str:
1. /run/nvcurve.json — runtime info written by the running server process
2. /etc/nvcurve/config.json — persistent config written by `service install`
3. Config defaults — 127.0.0.1:8042
The scheme is https when TLS is configured (or reported by the running
server), http otherwise.
"""
scheme = "https" if tls_enabled(cfg) else "http"
# 1. Runtime info (most accurate — reflects the actual running port)
info = _read_server_info()
if info:
host = _safe_host(info["host"], cfg)
return f"http://{host}:{info['port']}"
s = "https" if info.get("tls") else scheme
return f"{s}://{host}:{info['port']}"
# 2. Persistent config (survives reboots; written by `service install`)
try:
@@ -558,12 +587,12 @@ def _discover_server_url(cfg: Config) -> str:
data = json.load(f)
host = _safe_host(data.get("host", cfg.host), cfg)
port = data.get("port", cfg.port)
return f"http://{host}:{port}"
return f"{scheme}://{host}:{port}"
except (FileNotFoundError, json.JSONDecodeError, KeyError):
pass
# 3. Hardcoded defaults
return f"http://{cfg.host}:{cfg.port}"
return f"{scheme}://{cfg.host}:{cfg.port}"
# ── Subcommand handlers ───────────────────────────────────────────────────────
@@ -737,8 +766,14 @@ def cmd_inspect(args):
def cmd_write(args):
delta_khz = int(args.delta * 1000)
max_delta_khz = int(args.max_delta * 1000) if args.max_delta is not None else None
try:
delta_khz = int(args.delta * 1000)
max_delta_khz = (
int(args.max_delta * 1000) if args.max_delta is not None else None
)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
point_deltas = {}
if args.reset:
@@ -814,7 +849,7 @@ def cmd_write(args):
}
effective_max = (
max_delta_khz if max_delta_khz is not None else default_config.max_delta_khz
max_delta_khz if max_delta_khz is not None else _configured_max_delta()
)
errors = validate_write(point_deltas, effective_max)
if errors:
@@ -837,14 +872,15 @@ def cmd_write(args):
print(f"Write OK — {len(point_deltas)} point(s) updated.")
try:
curve_state = None
if not args.glob:
curve_state, _ = read_curve(gpu, gpu_name)
if curve_state:
vfp_freqs = [p.freq_khz for p in curve_state.points]
for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []):
print(f"WARNING: {w}")
except Exception:
pass
except Exception as exc:
log.debug("Post-write curve check failed: %s", exc)
def cmd_verify(args):
@@ -854,8 +890,13 @@ def cmd_verify(args):
from .hal.gpu import get_gpu
from .hal.snapshot import save as snapshot_save
from .hal.vfcurve import read_clock_offsets, write_offsets
from .safety import validate_write
delta_khz = int(args.delta * 1000)
try:
delta_khz = int(args.delta * 1000)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
if args.point is not None:
points = [args.point]
@@ -867,6 +908,13 @@ def cmd_verify(args):
point_deltas = dict.fromkeys(points, delta_khz)
# Enforce the operator-configured safety cap (same as cmd_write).
errors = validate_write(point_deltas, _configured_max_delta())
if errors:
for e in errors:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0))
print("=== Write-Verify Cycle ===")
@@ -1016,8 +1064,11 @@ def _profile_config_write(key: str, value) -> None:
data.pop(key, None)
else:
data[key] = value
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
try:
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
def _gpu_stable_key_offline(gpu_index: int) -> str | None:
@@ -1066,8 +1117,11 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None:
profiles[gpu_key] = name
if not profiles:
data.pop("auto_load_profiles", None)
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
try:
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
def cmd_profile(args):
@@ -1094,8 +1148,8 @@ def cmd_profile(args):
profiles.append(
{"name": name, "curve_deltas": p.get("curve_deltas", {})}
)
except Exception:
pass
except Exception as exc:
log.debug("Skipping unreadable profile %s: %s", path, exc)
if not profiles:
print("No profiles found.")
return
@@ -1117,7 +1171,7 @@ def cmd_profile(args):
require_root()
try:
_profile_config_set_default(gpu_index, None if clearing else args.name)
except ValueError as e:
except (ValueError, RuntimeError) as e:
print(f"Error: {e}", file=sys.stderr)
return
if clearing:
@@ -1158,12 +1212,33 @@ def cmd_profile(args):
power_limit_w = None
mem_offset_mhz = None
# Capture the GPU's power-cap mode: prefer the running server (most
# current), else fall back to the persisted per-GPU mode from config
# (so a profile saved while the server is down or auth is enabled
# still records the GPU's actual mode rather than assuming nvml).
power_cap_mode = "nvml"
try:
from .client import NvCurveClient
base = getattr(args, "server", None) or _discover_server_url(default_config)
limits = NvCurveClient(base=base, gpu_index=gpu_index).limits()
if limits.get("power_cap_mode") in ("nvml", "ioctl"):
power_cap_mode = limits["power_cap_mode"]
except Exception as exc:
log.debug("Could not read power-cap mode from server: %s", exc)
gpu_key = _gpu_stable_key_offline(gpu_index)
if gpu_key is not None:
persisted = default_config.power_cap_modes.get(gpu_key)
if persisted in ("nvml", "ioctl"):
power_cap_mode = persisted
data = ProfileData(
name=args.name,
gpu_name=gpu_name,
curve_deltas=curve_deltas,
mem_offset_mhz=mem_offset_mhz,
power_limit_w=power_limit_w,
power_cap_mode=power_cap_mode,
)
filepath = save_profile(default_config.profile_dir, data)
print(f"Saved profile '{args.name}' to {filepath}")
@@ -1200,13 +1275,21 @@ def cmd_profile(args):
errs.append(f"Mem offset: {msg}")
if profile.power_limit_w is not None:
ok, msg = set_power_limit(profile.power_limit_w, gpu_index)
mode = profile.power_cap_mode or "nvml"
ok, msg = set_power_limit(profile.power_limit_w, gpu_index, mode)
if not ok:
errs.append(f"Power limit: {msg}")
if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
errors = validate_write(deltas, default_config.max_delta_khz)
try:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
except ValueError:
print(
f"Profile '{args.name}' has invalid curve point keys.",
file=sys.stderr,
)
sys.exit(1)
errors = validate_write(deltas, _configured_max_delta())
if errors:
errs.append("Curve: " + "; ".join(errors))
else:
@@ -1328,7 +1411,11 @@ def cmd_setup(args):
"""One-shot hardware compatibility check: diag → read → write-verify → restore."""
explicit_point = getattr(args, "point", None)
verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0
verify_delta_khz = int(verify_delta_mhz * 1000)
try:
verify_delta_khz = int(verify_delta_mhz * 1000)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
require_root()
@@ -1452,13 +1539,20 @@ def cmd_setup(args):
print()
print("Step 4/4 Restoring snapshot")
print()
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
if ok:
print(" Hardware state restored to baseline.")
else:
if snap_path is None:
print(
" WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr
" WARNING: Snapshot save failed — cannot restore baseline.",
file=sys.stderr,
)
else:
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
if ok:
print(" Hardware state restored to baseline.")
else:
print(
" WARNING: Restore failed. Run: nvcurve snapshot restore",
file=sys.stderr,
)
print()
print(sep)
@@ -1509,41 +1603,59 @@ def cmd_service(args):
"WantedBy=multi-user.target\n"
)
with open(unit_path, "w") as f:
f.write(unit)
try:
with open(unit_path, "w") as f:
f.write(unit)
except OSError as exc:
print(f"Failed to write {unit_path}: {exc}", file=sys.stderr)
return
print(f"Unit file written to {unit_path}")
# Write persistent config.
os.makedirs("/etc/nvcurve", exist_ok=True)
try:
os.makedirs("/etc/nvcurve", exist_ok=True)
except OSError as exc:
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
return
persistent_cfg: dict = {}
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
persistent_cfg = json.load(f)
except Exception:
pass
except Exception as exc:
log.debug("Could not read persistent config: %s", exc)
host = getattr(args, "host", "127.0.0.1")
port = getattr(args, "port", 8042)
auto_serve = getattr(args, "auto_serve", False)
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(persistent_cfg, f, indent=2)
if getattr(args, "ssl_certfile", None):
persistent_cfg["ssl_certfile"] = args.ssl_certfile
if getattr(args, "ssl_keyfile", None):
persistent_cfg["ssl_keyfile"] = args.ssl_keyfile
try:
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(persistent_cfg, f, indent=2)
except OSError as exc:
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
return
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
scheme = (
"https"
if persistent_cfg.get("ssl_certfile") and persistent_cfg.get("ssl_keyfile")
else "http"
)
if auto_serve:
print(f" Web server will auto-start on boot at {host}:{port}")
print(f" Web server will auto-start on boot at {scheme}://{host}:{port}")
else:
print(
f" Web server default: {host}:{port} (start on demand: nvcurve serve start)"
f" Web server default: {scheme}://{host}:{port} "
"(start on demand: nvcurve serve start)"
)
try:
subprocess.run(["systemctl", "daemon-reload"], check=True)
was_active = (
subprocess.run(
["systemctl", "is-active", "--quiet", "nvcurve"],
).returncode
== 0
)
probe = subprocess.run(["systemctl", "is-active", "--quiet", "nvcurve"])
was_active = probe.returncode == 0
subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True)
print("Service enabled and started.")
@@ -1560,7 +1672,7 @@ def cmd_service(args):
print(" systemctl status nvcurve")
print(" journalctl -u nvcurve -f")
print(" nvcurve service uninstall")
except subprocess.CalledProcessError as e:
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"systemctl failed: {e}", file=sys.stderr)
elif action == "uninstall":
@@ -1660,25 +1772,31 @@ def cmd_service(args):
auto_serve = pcfg.get("auto_serve", False)
host = pcfg.get("host", "127.0.0.1")
port = pcfg.get("port", 8042)
tls = bool(pcfg.get("ssl_certfile") and pcfg.get("ssl_keyfile"))
print()
print(f"web server auto-start: {'on' if auto_serve else 'off'}")
print(f"web server address: {host}:{port}")
print(f"web server address: {'https' if tls else 'http'}://{host}:{port}")
print(f"web server TLS: {'on' if tls else 'off'}")
print()
print(
"Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P]"
"Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P] [--ssl-certfile C --ssl-keyfile K]"
)
elif action == "configure":
require_root()
import subprocess
os.makedirs("/etc/nvcurve", exist_ok=True)
try:
os.makedirs("/etc/nvcurve", exist_ok=True)
except OSError as exc:
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
return
pcfg: dict = {}
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
pcfg = json.load(f)
except Exception:
pass
except Exception as exc:
log.debug("Could not read persistent config: %s", exc)
if hasattr(args, "auto_serve") and args.auto_serve is not None:
pcfg["auto_serve"] = args.auto_serve
@@ -1686,13 +1804,27 @@ def cmd_service(args):
pcfg["host"] = args.host
if hasattr(args, "port") and args.port is not None:
pcfg["port"] = args.port
if getattr(args, "ssl_certfile", None):
pcfg["ssl_certfile"] = args.ssl_certfile
if getattr(args, "ssl_keyfile", None):
pcfg["ssl_keyfile"] = args.ssl_keyfile
if getattr(args, "no_ssl", False):
pcfg.pop("ssl_certfile", None)
pcfg.pop("ssl_keyfile", None)
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(pcfg, f, indent=2)
try:
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(pcfg, f, indent=2)
except OSError as exc:
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
return
print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):")
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
print(f" host: {pcfg.get('host', '127.0.0.1')}")
print(f" port: {pcfg.get('port', 8042)}")
print(
f" TLS: {'on' if pcfg.get('ssl_certfile') and pcfg.get('ssl_keyfile') else 'off'}"
)
if os.path.exists(unit_path):
try:
@@ -1712,11 +1844,31 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
host = getattr(args, "host", cfg.host)
port = getattr(args, "port", cfg.port)
# Optional TLS (CLI flags override the persistent config).
ssl_certfile = getattr(args, "ssl_certfile", None)
ssl_keyfile = getattr(args, "ssl_keyfile", None)
if ssl_certfile:
cfg.ssl_certfile = ssl_certfile
if ssl_keyfile:
cfg.ssl_keyfile = ssl_keyfile
# --direct: skip daemon round-trip (used when the daemon itself spawns us).
if getattr(args, "direct", False):
require_root()
with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
try:
with open(_SERVER_INFO_FILE, "w") as f:
json.dump(
{
"pid": os.getpid(),
"host": host,
"port": port,
"tls": tls_enabled(cfg),
},
f,
)
except OSError as exc:
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
return
try:
from .server import run as server_run
@@ -1733,13 +1885,34 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
return
# Prefer daemon socket: no root required, daemon manages the server process.
resp = _daemon_send({"cmd": "serve_start", "host": host, "port": port})
# The daemon always binds the configured host/port (callers cannot choose
# the interface), so report the address from the daemon's response.
resp = _daemon_send({"cmd": "serve_start"})
if resp is not None:
if resp.get("ok"):
print(f"Web server starting (PID {resp['pid']}) at http://{host}:{port}")
rhost = resp.get("host", host)
rport = resp.get("port", port)
if (rhost, rport) != (host, port):
print(
f"Note: daemon uses the configured bind address {rhost}:{rport} "
"(change with: nvcurve service configure --host/--port)",
file=sys.stderr,
)
if (ssl_certfile or ssl_keyfile) and not resp.get("tls"):
print(
"Note: --ssl-certfile/--ssl-keyfile are ignored while the daemon "
"manages the server — the daemon uses the TLS settings from "
"/etc/nvcurve/config.json (set with: nvcurve service configure "
"--ssl-certfile/--ssl-keyfile)",
file=sys.stderr,
)
scheme = "https" if resp.get("tls") else "http"
print(
f"Web server starting (PID {resp['pid']}) at {scheme}://{rhost}:{rport}"
)
if open_browser:
time.sleep(1.5)
_open_browser_as_user(f"http://{host}:{port}")
_open_browser_as_user(f"{scheme}://{rhost}:{rport}")
else:
print(f"Daemon: {resp.get('error')}", file=sys.stderr)
return
@@ -1749,7 +1922,8 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
info = _read_server_info()
if info:
url = f"http://{info['host']}:{info['port']}"
scheme = "https" if info.get("tls") else "http"
url = f"{scheme}://{info['host']}:{info['port']}"
print(f"Server is already running (PID {info['pid']}) at {url}.")
if open_browser:
_open_browser_as_user(url)
@@ -1769,12 +1943,20 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
"--port",
str(port),
]
if ssl_certfile:
cmd += ["--ssl-certfile", ssl_certfile]
if ssl_keyfile:
cmd += ["--ssl-keyfile", ssl_keyfile]
if getattr(args, "gpu_index", 0):
cmd += ["--gpu", str(args.gpu_index)]
log_path = _log_file()
print("Starting nvcurve server in background...")
with open(log_path, "a") as lf:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
try:
with open(log_path, "a") as lf:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
except OSError as exc:
print(f"Failed to open log file {log_path}: {exc}", file=sys.stderr)
return
print(f"Server starting (PID {p.pid}). Logs: {log_path}")
if open_browser:
time.sleep(1.5)
@@ -1782,8 +1964,20 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
return
# Foreground mode — write info file so clients can discover host:port.
with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
try:
with open(_SERVER_INFO_FILE, "w") as f:
json.dump(
{
"pid": os.getpid(),
"host": host,
"port": port,
"tls": tls_enabled(cfg),
},
f,
)
except OSError as exc:
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
return
try:
from .server import run as server_run
@@ -1990,6 +2184,12 @@ Examples:
"--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)"
)
p_start.add_argument("--port", type=int, default=8042, help="Port (default 8042)")
p_start.add_argument(
"--ssl-certfile", default=None, help="TLS certificate (enables HTTPS)"
)
p_start.add_argument(
"--ssl-keyfile", default=None, help="TLS private key (enables HTTPS)"
)
p_start.add_argument(
"--detach", "-d", action="store_true", help="Run in background"
)
@@ -2024,6 +2224,16 @@ Examples:
default=8042,
help="Default web server port (stored in config)",
)
p_install.add_argument(
"--ssl-certfile",
default=None,
help="TLS certificate (stored in config; enables HTTPS)",
)
p_install.add_argument(
"--ssl-keyfile",
default=None,
help="TLS private key (stored in config; enables HTTPS)",
)
p_configure = s_svc.add_parser(
"configure", help="Update config and restart daemon (escalates to root)"
@@ -2043,6 +2253,19 @@ Examples:
)
p_configure.add_argument("--host", default=None, help="Web server bind address")
p_configure.add_argument("--port", type=int, default=None, help="Web server port")
p_configure.add_argument(
"--ssl-certfile",
default=None,
help="TLS certificate (stored in config; enables HTTPS)",
)
p_configure.add_argument(
"--ssl-keyfile", default=None, help="TLS private key (stored in config)"
)
p_configure.add_argument(
"--no-ssl",
action="store_true",
help="Disable TLS (remove certificate/key from config)",
)
s_svc.add_parser("uninstall", help="Remove systemd service (escalates to root)")
s_svc.add_parser("start", help="Start systemd service (escalates to root)")
@@ -2083,9 +2306,14 @@ def main():
"users_file",
"host",
"port",
"ssl_certfile",
"ssl_keyfile",
"allow_api_shutdown",
):
if key in data:
setattr(cfg, key, data[key])
if "trusted_proxies" in data:
cfg.trusted_proxies = normalize_trusted_proxies(data["trusted_proxies"])
if "auto_load_profiles" in data:
# Keys are stable GPU identifiers (UUID, "pci:XXXX", or "idx:N")
cfg.auto_load_profiles = dict(data["auto_load_profiles"])
@@ -2095,8 +2323,16 @@ def main():
if "fan_curves" in data:
# Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"])
except Exception:
pass
if "power_cap_modes" in data:
# Per-GPU experimental power-cap mode. "nvml" is the default
# (the server treats it as unset); keep only valid values.
cfg.power_cap_modes = {
str(k): str(v)
for k, v in dict(data["power_cap_modes"]).items()
if str(v) in ("nvml", "ioctl")
}
except Exception as exc:
log.debug("Could not load user config: %s", exc)
base_url = args.server or _discover_server_url(cfg)
client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0))
@@ -2148,8 +2384,8 @@ def main():
if os.path.exists(_SERVER_INFO_FILE):
try:
os.remove(_SERVER_INFO_FILE)
except OSError:
pass
except OSError as exc:
log.debug("Could not remove %s: %s", _SERVER_INFO_FILE, exc)
except ApiError as e:
if e.status_code == 401:
print(
+8 -10
View File
@@ -120,18 +120,13 @@ class NvCurveClient:
def write_curve(
self,
deltas: dict[int, int],
max_delta_khz: int | None = None,
) -> dict:
body: dict = {"deltas": deltas}
if max_delta_khz is not None:
body["max_delta_khz"] = max_delta_khz
return self._post("/api/curve/write", body)
# The server enforces its configured safety cap; clients cannot
# override it per request.
return self._post("/api/curve/write", {"deltas": deltas})
def write_global(self, delta_khz: int, max_delta_khz: int | None = None) -> dict:
body: dict = {"delta_khz": delta_khz}
if max_delta_khz is not None:
body["max_delta_khz"] = max_delta_khz
return self._post("/api/curve/write/global", body)
def write_global(self, delta_khz: int) -> dict:
return self._post("/api/curve/write/global", {"delta_khz": delta_khz})
def reset_curve(self) -> dict:
return self._post("/api/curve/reset")
@@ -150,6 +145,9 @@ class NvCurveClient:
def snapshots(self) -> list:
return self._get("/api/snapshots")
def limits(self) -> dict:
return self._get("/api/limits")
# ── Profiles ─────────────────────────────────────────────────────────────
def profiles(self) -> dict:
+46 -2
View File
@@ -17,6 +17,21 @@ class Config:
host: str = "127.0.0.1"
port: int = 8042
# Optional TLS: when both are set, the server serves HTTPS and the
# session cookie is marked Secure. Off by default (plain HTTP).
ssl_certfile: str | None = None
ssl_keyfile: str | None = None
# Proxy IPs (e.g. a reverse proxy on 127.0.0.1) whose X-Forwarded-For
# header is trusted for the login brute-force lockout. JSON array in
# config.json (a comma-separated string is also accepted and normalized).
# Without this, all proxied clients share the proxy's IP.
trusted_proxies: list[str] = field(default_factory=list)
# Allow any authenticated user to stop the server via POST /api/shutdown.
# Set false on shared systems; manage the service via systemd instead.
allow_api_shutdown: bool = True
snapshot_dir: str = "/var/cache/nvcurve/snapshots"
profile_dir: str = "/etc/nvcurve/profiles"
@@ -34,9 +49,38 @@ class Config:
# curve applied via the UI survives server restarts (fan control itself is
# volatile — the driver reverts to automatic mode on reboot).
# Key = stable GPU identifier (same as auto_load_profiles).
# Value = list of {"temp_c": int, "fan_pct": int} sorted by temp_c.
fan_curves: dict[str, list] = field(default_factory=dict)
# Value = {"curve": [{"temp_c": int, "fan_pct": int}, ...] sorted by temp_c,
# "fans": [fan indices] | None (None = all fans)}.
# Legacy entries (bare curve list) are migrated at load time.
fan_curves: dict[str, object] = field(default_factory=dict)
# Per-GPU power-cap mode: "nvml" (default, never stored) or "ioctl"
# (experimental RM power control — permits caps below the VBIOS minimum).
# Key = stable GPU identifier (same as auto_load_profiles).
power_cap_modes: dict[str, str] = field(default_factory=dict)
# Module-level default config instance.
default_config = Config()
def tls_enabled(cfg: Config) -> bool:
"""True when both TLS files are configured (server serves HTTPS)."""
return bool(cfg.ssl_certfile and cfg.ssl_keyfile)
def normalize_trusted_proxies(value) -> list[str]:
"""Normalize a trusted_proxies config value to a list of IP strings.
Accepts a JSON array (the documented format) or a comma-separated string
(tolerated for convenience). Normalizing matters because the server does
exact list membership tests — a raw string would degrade to substring
matching (e.g. "127.0.0.1" in "127.0.0.10").
"""
if value is None:
return []
if isinstance(value, str):
return [h.strip() for h in value.split(",") if h.strip()]
if isinstance(value, (list, tuple)):
return [str(h).strip() for h in value if str(h).strip()]
return []
+34 -10
View File
@@ -7,10 +7,16 @@ Protocol: newline-delimited JSON, one request → one response, connection close
Commands:
{"cmd": "ping"}
{"cmd": "serve_start", "host": "127.0.0.1", "port": 8042}
{"cmd": "serve_start"}
{"cmd": "serve_stop"}
{"cmd": "serve_status"}
The socket is world-connectable (unprivileged users drive it via the CLI),
so the command surface is deliberately minimal: serve_start ALWAYS binds the
configured host/port from /etc/nvcurve/config.json — callers cannot choose
the bind address (no ad-hoc 0.0.0.0 exposure). Changing the bind address is
an operator action via `nvcurve service configure`.
Requires root.
"""
@@ -23,7 +29,7 @@ import signal
import subprocess
import sys
from .config import Config
from .config import Config, normalize_trusted_proxies
log = logging.getLogger("nvcurve.daemon")
@@ -38,7 +44,13 @@ _cfg: Config | None = None # Config instance, set in run()
# ── Socket command handlers ────────────────────────────────────────────────────
async def _handle_serve_start(host: str, port: int) -> dict:
async def _handle_serve_start() -> dict:
"""Start the web server on the *configured* host/port.
The bind address is taken from /etc/nvcurve/config.json only — the
socket is reachable by unprivileged users, so callers must not be able
to choose the interface (e.g. binding 0.0.0.0 to expose the API).
"""
global _server_proc
if _server_proc is not None and _server_proc.poll() is None:
return {
@@ -47,6 +59,8 @@ async def _handle_serve_start(host: str, port: int) -> dict:
"pid": _server_proc.pid,
}
host = _cfg.host if _cfg is not None else "127.0.0.1"
port = _cfg.port if _cfg is not None else 8042
cmd = [
sys.executable,
"-m",
@@ -72,7 +86,13 @@ async def _handle_serve_start(host: str, port: int) -> dict:
except OSError as exc:
return {"ok": False, "error": f"cannot open log file {log_path}: {exc}"}
log.info("Web server started (PID %d)", _server_proc.pid)
return {"ok": True, "pid": _server_proc.pid}
return {
"ok": True,
"pid": _server_proc.pid,
"host": host,
"port": port,
"tls": bool(_cfg and _cfg.ssl_certfile and _cfg.ssl_keyfile),
}
async def _handle_serve_stop() -> dict:
@@ -105,9 +125,7 @@ async def _dispatch(req: dict) -> dict:
elif cmd == "serve_start":
if _cfg is None:
return {"ok": False, "error": "config not initialized"}
host = req.get("host", _cfg.host)
port = req.get("port", _cfg.port)
return await _handle_serve_start(host, port)
return await _handle_serve_start()
elif cmd == "serve_stop":
return await _handle_serve_stop()
elif cmd == "serve_status":
@@ -174,9 +192,13 @@ def run() -> None:
"profile_dir",
"host",
"port",
"ssl_certfile",
"ssl_keyfile",
):
if key in cfg_data:
setattr(_cfg, key, cfg_data[key])
if "trusted_proxies" in cfg_data:
_cfg.trusted_proxies = normalize_trusted_proxies(cfg_data["trusted_proxies"])
# Apply auto-load profiles in a subprocess so the daemon process itself
# never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low.
@@ -207,10 +229,12 @@ async def _serve_socket(auto_serve: bool = False) -> None:
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
# The socket must be connectable by unprivileged users: the CLI runs as the
# regular user and talks to this root daemon over the socket. 0o666 is
# intentional (standard for /run daemon sockets).
# intentional — the command surface is restricted accordingly (serve_start
# always uses the configured host/port; see module docstring).
# pi-lens-ignore: S103
_SOCKET_MODE = 0o666
os.chmod(
SOCKET_PATH, 0o666
SOCKET_PATH, _SOCKET_MODE
) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
log.info("Daemon listening on %s", SOCKET_PATH)
@@ -219,7 +243,7 @@ async def _serve_socket(auto_serve: bool = False) -> None:
log.warning("auto_serve requested but config not initialized")
else:
log.info("auto_serve enabled — starting web server on boot")
await _handle_serve_start(_cfg.host, _cfg.port)
await _handle_serve_start()
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
+161 -49
View File
@@ -1,26 +1,33 @@
"""Hardware Abstraction Layer for Fan Control.
Uses NVML (via pynvml) for all operations:
- nvmlDeviceGetFanSpeed_v2 : read current fan speed % for a fan index
- nvmlDeviceSetFanSpeed_v2 : set fan speed % for a fan index
- nvmlDeviceGetMinMaxFanSpeed: get min/max fan speed constraints
- nvmlDeviceGetTemperature : read GPU temp for curve interpolation
- nvmlDeviceGetNumFans : number of fans on the device
- nvmlDeviceGetFanSpeed_v2 : read current fan speed % for a fan index
- nvmlDeviceSetFanSpeed_v2 : set fan speed % for a fan index
- nvmlDeviceSetDefaultFanSpeed_v2 : restore automatic control for a fan index
- nvmlDeviceGetMinMaxFanSpeed : get min/max fan speed constraints
- nvmlDeviceGetTemperature : read GPU temp for curve interpolation
Fans are addressed by 0-based index. Passing ``fans=None`` to the set/reset
helpers means "all fans on the device".
"""
import ctypes
import logging
from typing import List, Optional
from typing import Any
try:
import pynvml
import pynvml as _pynvml_import
_NVML_AVAILABLE = True
except ImportError:
_pynvml_import = None
_NVML_AVAILABLE = False
log = logging.getLogger("nvcurve.hal.fans")
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml_import
# We use fan index 0 (first/primary fan) for all operations.
_FAN_INDEX = 0
log = logging.getLogger("nvcurve.hal.fans")
def _get_handle(gpu_index: int):
@@ -30,13 +37,42 @@ def _get_handle(gpu_index: int):
return pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
def _num_fans(handle) -> int:
"""Return the number of fans on the device (>= 1 on query failure)."""
try:
return max(0, int(pynvml.nvmlDeviceGetNumFans(handle)))
except pynvml.NVMLError:
# GetNumFans unsupported: assume at least the primary fan exists.
return 1
def get_num_fans(gpu_index: int = 0) -> int:
"""Return the number of fans on the GPU (0 if NVML is unavailable)."""
if not _NVML_AVAILABLE:
return 0
try:
return _num_fans(_get_handle(gpu_index))
except pynvml.NVMLError as exc:
log.warning("get_num_fans: %s", exc)
return 0
def get_fan_info(gpu_index: int = 0) -> dict:
"""Return current fan state: fan_pct, fan_mode, min_fan_pct, max_fan_pct.
"""Return current fan state for all fans.
Returns a dict with:
fan_pct : current speed % of fan 0 (legacy, None on failure)
fans : [{"index": i, "fan_pct": pct | None}, ...] per fan
num_fans : number of fans on the device
fan_mode : None (the server derives "auto"/"curve")
min_fan_pct / max_fan_pct : device-wide speed constraints
Returns None values on failure.
"""
out = {
out: dict[str, Any] = {
"fan_pct": None,
"fans": [],
"num_fans": 0,
"fan_mode": None,
"min_fan_pct": None,
"max_fan_pct": None,
@@ -45,42 +81,103 @@ def get_fan_info(gpu_index: int = 0) -> dict:
return out
try:
handle = _get_handle(gpu_index)
# Get current fan speed using v2 API (fan index 0)
try:
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed_v2(handle, _FAN_INDEX))
except pynvml.NVMLError:
# Fallback to legacy v1 API
try:
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed(handle))
except pynvml.NVMLError:
pass
# Get min/max fan speed constraints
try:
min_s = ctypes.c_uint(0)
max_s = ctypes.c_uint(0)
pynvml.nvmlDeviceGetMinMaxFanSpeed(handle, min_s, max_s)
out["min_fan_pct"] = int(min_s.value)
out["max_fan_pct"] = int(max_s.value)
except pynvml.NVMLError:
out["min_fan_pct"] = 0
out["max_fan_pct"] = 100
except pynvml.NVMLError as exc:
log.warning("get_fan_info: %s", exc)
return out
out["num_fans"] = _num_fans(handle)
# Per-fan speeds via the v2 API; fan 0 falls back to the legacy v1 API.
for i in range(out["num_fans"]):
pct: float | None = None
try:
pct = float(pynvml.nvmlDeviceGetFanSpeed_v2(handle, i))
except pynvml.NVMLError:
if i == 0:
try:
pct = float(pynvml.nvmlDeviceGetFanSpeed(handle))
except pynvml.NVMLError:
pct = None
out["fans"].append({"index": i, "fan_pct": pct})
out["fan_pct"] = out["fans"][0]["fan_pct"] if out["fans"] else None
# Get min/max fan speed constraints
try:
min_s = ctypes.c_uint(0)
max_s = ctypes.c_uint(0)
pynvml.nvmlDeviceGetMinMaxFanSpeed(handle, min_s, max_s)
out["min_fan_pct"] = int(min_s.value)
out["max_fan_pct"] = int(max_s.value)
except pynvml.NVMLError:
out["min_fan_pct"] = 0
out["max_fan_pct"] = 100
return out
def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
"""Set fan speed to a percentage (0-100) on the primary fan."""
pct = max(0, min(100, int(pct)))
def set_fan_speed(
gpu_index: int,
pct: int,
fans: list[int] | None = None,
) -> tuple[bool, str]:
"""Set fan speed to a percentage (0-100).
fans=None targets every fan on the device; fans=[0, 1] targets the
listed fan indices. In all-fans mode, fans the driver does not allow
manual control of (e.g. driver-mirrored secondary fans) are skipped
with a warning instead of failing the whole operation; explicit fan
lists are strict and fail if any selected fan cannot be set.
"""
try:
pct = max(0, min(100, int(pct)))
except (TypeError, ValueError):
return False, "Invalid fan speed"
if not _NVML_AVAILABLE:
return False, "NVML not available"
try:
handle = _get_handle(gpu_index)
pynvml.nvmlDeviceSetFanSpeed_v2(handle, _FAN_INDEX, pct)
num_fans = _num_fans(handle)
if fans is None:
targets = list(range(num_fans))
strict = False
else:
targets: list[int] = []
for f in fans:
try:
f = int(f)
except (TypeError, ValueError):
return False, f"Invalid fan index: {f!r}"
if f < 0 or f >= num_fans:
return False, f"Fan index {f} out of range (0-{num_fans - 1})"
targets.append(f)
strict = True
if not targets:
return False, "No fans selected"
if not targets:
return False, "No fans available on this GPU"
skipped: list[str] = []
for i in targets:
try:
pynvml.nvmlDeviceSetFanSpeed_v2(handle, i, pct)
except pynvml.NVMLError as exc:
if strict:
log.warning(
"set_fan_speed(%d, fan %d, %d): %s", gpu_index, i, pct, exc
)
return False, str(exc)
# All-fans mode: secondary fans may be driver-controlled and
# reject manual writes; skip them and report in the message.
log.debug("set_fan_speed: fan %d not settable: %s", i, exc)
skipped.append(f"fan {i + 1}")
if skipped:
return (
True,
f"OK ({len(skipped)} fan(s) not manually controllable: {', '.join(skipped)})",
)
return True, "OK"
except pynvml.NVMLError as exc:
log.warning("set_fan_speed(%d, %d): %s", gpu_index, pct, exc)
@@ -88,10 +185,11 @@ def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
"""Restore automatic fan control.
"""Restore automatic fan control for all fans.
Tries nvidia-smi --fan=default first (most reliable), then falls back to
NVML nvmlDeviceSetDefaultFanSpeed_v2.
Tries nvidia-smi --fan=default first (most reliable, resets all fans on
the device), then falls back to NVML nvmlDeviceSetDefaultFanSpeed_v2
per fan index.
"""
if not _NVML_AVAILABLE:
return False, "NVML not available"
@@ -102,7 +200,9 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
try:
ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
capture_output=True, text=True, timeout=10,
capture_output=True,
text=True,
timeout=10,
)
if ret.returncode == 0:
return True, "OK"
@@ -114,28 +214,34 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
except Exception as exc:
log.debug("nvidia-smi -fan default error: %s", exc)
# Fallback: use NVML to reset to default fan speed
# Fallback: use NVML to reset every fan to default speed
try:
handle = _get_handle(gpu_index)
pynvml.nvmlDeviceSetDefaultFanSpeed_v2(handle, _FAN_INDEX)
for i in range(_num_fans(handle)):
try:
pynvml.nvmlDeviceSetDefaultFanSpeed_v2(handle, i)
except pynvml.NVMLError as exc:
log.debug("reset_fan: fan %d: %s", i, exc)
return True, "OK"
except pynvml.NVMLError as exc:
return False, f"Failed to reset fan: {exc}"
def get_temp(gpu_index: int = 0) -> Optional[float]:
def get_temp(gpu_index: int = 0) -> float | None:
"""Read current GPU temperature in °C."""
if not _NVML_AVAILABLE:
return None
try:
handle = _get_handle(gpu_index)
return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU))
return float(
pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
)
except pynvml.NVMLError as exc:
log.debug("get_temp: %s", exc)
return None
def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
def interpolate_fan_speed(curve: list[dict], temp_c: float) -> int | None:
"""Interpolate target fan speed from a curve at a given temperature.
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
@@ -144,7 +250,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
if not curve or len(curve) < 2:
return None
temp = float(temp_c)
try:
temp = float(temp_c)
except (TypeError, ValueError):
return None
# Find the two surrounding points
for i in range(len(curve) - 1):
@@ -157,7 +266,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
if t0 <= temp <= t1:
fraction = (temp - t0) / (t1 - t0)
result = f0 + fraction * (f1 - f0)
return max(0, min(100, int(round(result))))
try:
return max(0, min(100, int(round(result))))
except (TypeError, ValueError):
return None
# Outside range: clamp to first or last point
if temp <= curve[0]["temp_c"]:
@@ -165,7 +277,7 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
return max(0, min(100, curve[-1]["fan_pct"]))
def validate_curve(curve: List[dict]) -> tuple[bool, str]:
def validate_curve(curve: list[dict]) -> tuple[bool, str]:
"""Validate a fan curve.
Returns (True, "OK") or (False, error_message).
+38 -20
View File
@@ -1,12 +1,17 @@
"""GPU discovery and initialization."""
import contextlib
import ctypes
import logging
import sys
from typing import Any
from ..nvapi.bootstrap import query_interface
from ..nvapi.constants import FUNC
from ..nvapi.types import GpuInfo
log = logging.getLogger("nvcurve.hal.gpu")
def init_nvapi() -> None:
"""Initialize NvAPI. Must be called before any GPU operations."""
@@ -19,7 +24,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
"""Return (gpu_handles_array, count). Exits if no GPUs found."""
gpus = (ctypes.c_void_p * 64)()
ngpu = ctypes.c_int32()
query_interface(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu))
enum_fn = query_interface(FUNC["EnumPhysicalGPUs"])
if enum_fn is None:
raise RuntimeError("NvAPI function EnumPhysicalGPUs not available")
enum_fn(ctypes.byref(gpus), ctypes.byref(ngpu))
if ngpu.value == 0:
print("No NVIDIA GPUs found")
sys.exit(1)
@@ -29,7 +37,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
def get_gpu_name(gpu) -> str:
"""Return the full name string for a GPU handle."""
name_buf = ctypes.create_string_buffer(256)
query_interface(FUNC["GetFullName"])(gpu, name_buf)
fn = query_interface(FUNC["GetFullName"])
if fn is None:
raise RuntimeError("NvAPI function GetFullName not available")
fn(gpu, name_buf)
return name_buf.value.decode(errors="replace")
@@ -40,38 +51,45 @@ def discover_gpus() -> list[GpuInfo]:
infos = []
try:
import pynvml
pynvml.nvmlInit()
has_nvml = True
import pynvml as _pynvml
_pynvml.nvmlInit()
except Exception:
has_nvml = False
_pynvml = None
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml
for i in range(count):
name = get_gpu_name(gpus[i])
uuid = None
pci_bus_id = None
if has_nvml:
if pynvml is not None:
try:
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
uuid = pynvml.nvmlDeviceGetUUID(handle)
raw_uuid = pynvml.nvmlDeviceGetUUID(handle)
# NVML might return bytes
if isinstance(uuid, bytes):
uuid = uuid.decode('utf-8', errors='ignore')
if isinstance(raw_uuid, bytes):
uuid = raw_uuid.decode("utf-8", errors="ignore")
elif raw_uuid is not None:
uuid = str(raw_uuid)
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
# Parse something like "00000000:01:00.0" -> bus is 1
if isinstance(pci_info.bus, bytes):
pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16)
# Parse something like "00000000:01:00.0" -> bus is 1.
# PCI bus numbers are hex by convention (pynvml's field is an
# int; the str/bytes branches are defensive).
bus = pci_info.bus
if isinstance(bus, bytes):
pci_bus_id = int(bus.decode("utf-8", errors="ignore"), 16)
elif isinstance(bus, str):
pci_bus_id = int(bus, 16)
else:
pci_bus_id = pci_info.bus
except Exception:
pass
pci_bus_id = int(bus)
except Exception as exc:
log.debug("NVML query for GPU %d failed: %s", i, exc)
infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id))
if has_nvml:
try:
if pynvml is not None:
with contextlib.suppress(Exception):
pynvml.nvmlShutdown()
except Exception:
pass
return infos
+125 -43
View File
@@ -11,21 +11,26 @@ that are explicitly specified, leaving others unchanged on hardware.
"""
import ctypes
import subprocess
import logging
from typing import Optional
import subprocess
from typing import Any
try:
import pynvml
import pynvml as _pynvml_import
_NVML_AVAILABLE = True
except ImportError:
_pynvml_import = None
_NVML_AVAILABLE = False
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml_import
log = logging.getLogger("nvcurve.hal.limits")
# ── NVML library / handle helpers ─────────────────────────────────────────────
_nvml_lib: Optional[ctypes.CDLL] = None
_nvml_lib: ctypes.CDLL | None = None
def _nvml_cdll() -> ctypes.CDLL:
@@ -34,12 +39,12 @@ def _nvml_cdll() -> ctypes.CDLL:
if _nvml_lib is not None:
return _nvml_lib
# Prefer to reuse the library already loaded by pynvml to avoid dlopen races.
for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version
for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version
mod = getattr(pynvml, attr, None)
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
if lib is not None:
_nvml_lib = lib
return _nvml_lib
return lib
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
return _nvml_lib
@@ -53,33 +58,79 @@ def _get_handle(gpu_index: int):
# ── Power limit ───────────────────────────────────────────────────────────────
def get_power_limit(gpu_index: int = 0) -> dict:
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
out = {
def get_power_limit(gpu_index: int = 0, mode: str = "nvml") -> dict:
"""Return dict with power limit info.
Keys: power_limit_w, default_power_limit_w, min_power_limit_w,
min_power_limit_w_native, max_power_limit_w, rm_power_supported,
power_cap_mode.
mode: "nvml" (default) or "ioctl" (experimental RM power control).
min_power_limit_w is the effective minimum: in ioctl mode it is
extended to the experimental floor (30 W) when the RM interface is
present and validated; min_power_limit_w_native is always the VBIOS
minimum. The RM probe is GET-only (no writes) and safe to run on
every call.
"""
out: dict[str, int | bool | str | None] = {
"power_limit_w": None,
"default_power_limit_w": None,
"min_power_limit_w": None,
"min_power_limit_w_native": None,
"max_power_limit_w": None,
"rm_power_supported": False,
"power_cap_mode": mode,
}
try:
handle = _get_handle(gpu_index)
limit = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
constrs = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle)
out["power_limit_w"] = limit // 1000
out["min_power_limit_w"] = constrs[0] // 1000
native_min = constrs[0] // 1000
out["min_power_limit_w"] = native_min
out["min_power_limit_w_native"] = native_min
out["max_power_limit_w"] = constrs[1] // 1000
try:
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
out["default_power_limit_w"] = default // 1000
except Exception:
pass
except Exception as exc:
log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
except Exception as exc:
log.warning("get_power_limit: %s", exc)
return out
# GET-only RM discovery — reported so the UI can offer the experimental
# mode; the effective minimum only changes in ioctl mode.
try:
from . import rm_power
bounds = rm_power.probe_gpu(gpu_index)
out["rm_power_supported"] = bounds is not None
if bounds is not None and mode == "ioctl":
out["min_power_limit_w"] = bounds.lower_min_mw() // 1000
except Exception as exc:
log.debug("RM power probe failed: %s", exc)
return out
def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
"""Set the board power limit (Watts)."""
def set_power_limit(
limit_w: int, gpu_index: int = 0, mode: str = "nvml"
) -> tuple[bool, str]:
"""Set the board power limit (Watts).
mode "ioctl" (experimental) applies the limit through the undocumented
RM interface, which permits values below the VBIOS minimum. It has no
fallback: failures are reported, never silently switched to NVML.
"""
if mode == "ioctl":
from . import rm_power
try:
rm_power.set_power_limit_w(gpu_index, limit_w)
return True, "OK"
except rm_power.RmPowerError as exc:
return False, str(exc)
try:
handle = _get_handle(gpu_index)
pynvml.nvmlDeviceSetPowerManagementLimit(handle, limit_w * 1000)
@@ -89,7 +140,8 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
capture_output=True, text=True,
capture_output=True,
text=True,
)
if ret.returncode == 0:
return True, "OK"
@@ -111,34 +163,38 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1
# as ctypes objects; we use them when available and fall back to our own definition.
class _ClockOffset(ctypes.Structure):
_fields_ = [
("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t
("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t
("clockOffsetMHz", ctypes.c_int),
]
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
# NVML clock-type constants (same values as pynvml).
_NVML_CLOCK_GRAPHICS = 0
_NVML_CLOCK_MEM = 2
_NVML_CLOCK_MEM = 2
def _make_clock_offset(clock_type: int, pstate: int = 0, offset_mhz: int = 0) -> ctypes.Structure:
def _make_clock_offset(
clock_type: int, pstate: int = 0, offset_mhz: int = 0
) -> ctypes.Structure:
"""Return a populated nvmlClockOffset_t struct, using pynvml's type when available."""
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
info = pynvml.c_nvmlClockOffset_t()
info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type
info.pstate = pstate
info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type
info.pstate = pstate
info.clockOffsetMHz = offset_mhz
return info
info = _ClockOffset()
info.version = _CLOCK_OFFSET_VER
info.type = clock_type
info.pstate = pstate
info.version = _CLOCK_OFFSET_VER
info.type = clock_type
info.pstate = pstate
info.clockOffsetMHz = offset_mhz
return info
@@ -158,7 +214,7 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure).
Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM).
"""
out = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
out: dict[str, int | None] = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
if not _NVML_AVAILABLE:
return out
try:
@@ -167,11 +223,15 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
# Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly).
# Fall back to ctypes-direct if pynvml doesn't have it.
_pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None)
fn_get = _try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
fn_get = (
_try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
)
used_new_api = False
for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz")):
for clock_type, key in (
(_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz"),
):
info = _make_clock_offset(clock_type, pstate=0)
try:
if _pynvml_get is not None:
@@ -184,7 +244,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
out[key] = int(info.clockOffsetMHz)
used_new_api = True
else:
log.debug("nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc)
log.debug(
"nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc
)
except Exception as exc:
log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc)
@@ -200,7 +262,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"):
try:
res = pynvml.nvmlDeviceGetMemClkVfOffset(handle)
out["mem_offset_mhz"] = int(res[0] if isinstance(res, (list, tuple)) else res)
out["mem_offset_mhz"] = int(
res[0] if isinstance(res, (list, tuple)) else res
)
except Exception as exc:
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
@@ -210,8 +274,8 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
def set_clock_offsets(
gpc_offset_mhz: Optional[int] = None,
mem_offset_mhz: Optional[int] = None,
gpc_offset_mhz: int | None = None,
mem_offset_mhz: int | None = None,
gpu_index: int = 0,
) -> tuple[bool, str]:
"""Set clock offsets (MHz) for the specified domains only.
@@ -235,20 +299,35 @@ def set_clock_offsets(
domains.append((_NVML_CLOCK_MEM, mem_offset_mhz))
_pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None)
fn_set = _try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
fn_set = (
_try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
)
if _pynvml_set is not None or fn_set is not None:
all_ok = True
for clock_type, offset in domains:
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
try:
rc = _pynvml_set(handle, ctypes.byref(info)) if _pynvml_set else fn_set(handle, ctypes.byref(info))
if _pynvml_set is not None:
rc = _pynvml_set(handle, ctypes.byref(info))
elif fn_set is not None:
rc = fn_set(handle, ctypes.byref(info))
else:
break
if rc != 0:
log.debug("nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback", clock_type, rc)
log.debug(
"nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback",
clock_type,
rc,
)
all_ok = False
break
except Exception as exc:
log.debug("nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback", clock_type, exc)
log.debug(
"nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback",
clock_type,
exc,
)
all_ok = False
break
if all_ok:
@@ -257,12 +336,16 @@ def set_clock_offsets(
# Deprecated per-domain fallback (works on Blackwell/driver 590.x).
errs = []
if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"):
if gpc_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetGpcClkVfOffset"
):
try:
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
except Exception as exc:
errs.append(f"GPC: {exc}")
if mem_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetMemClkVfOffset"):
if mem_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetMemClkVfOffset"
):
try:
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
except Exception as exc:
@@ -278,6 +361,7 @@ def set_clock_offsets(
# ── Range queries ─────────────────────────────────────────────────────────────
def get_mem_offset_range(gpu_index: int = 0) -> dict:
"""Return the min/max allowed memory clock offset (MHz).
@@ -285,7 +369,7 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values.
"""
# Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000).
out = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
out: dict[str, int] = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
if not _NVML_AVAILABLE:
return out
try:
@@ -317,5 +401,3 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
except Exception as exc:
log.debug("get_mem_offset_range: %s", exc)
return out
+14
View File
@@ -162,6 +162,20 @@ def _nvml_read(gpu_index: int) -> dict:
with contextlib.suppress(_pynvml.NVMLError):
out["fan_pct"] = float(_pynvml.nvmlDeviceGetFanSpeed(handle))
# Per-fan speeds via the v2 API (fan_pct above stays fan 0 for legacy clients).
try:
num_fans = int(_pynvml.nvmlDeviceGetNumFans(handle))
fan_list: list[float | None] = []
for i in range(num_fans):
try:
fan_list.append(float(_pynvml.nvmlDeviceGetFanSpeed_v2(handle, i)))
except _pynvml.NVMLError:
fan_list.append(None)
if fan_list:
out["fans"] = fan_list
except _pynvml.NVMLError:
pass
with contextlib.suppress(_pynvml.NVMLError):
out["throttle_reasons"] = int(
_pynvml.nvmlDeviceGetCurrentClocksThrottleReasons(handle)
+627
View File
@@ -0,0 +1,627 @@
"""Undocumented NVIDIA RM power-limit interface (EXPERIMENTAL).
Port of the approach from LACT PR #1205 (ilya-zlobintsev/LACT): applies board
power limits through the private NV2080 power-limit "ordinary client"
interface on /dev/nvidiactl, which permits caps below the VBIOS minimum
(down to 30 W). The native maximum still applies.
EXPERIMENTAL — uses an undocumented driver interface. It may break after
driver updates. Discovery is GET-only and validates the RM payload against
NVML before any write is issued; a failed write restores the previous
request (even if it was below the VBIOS minimum).
"""
from __future__ import annotations
import contextlib
import ctypes
import fcntl
import logging
import os
import struct
import sys
from collections.abc import Callable
from dataclasses import dataclass
log = logging.getLogger("nvcurve.hal.rm_power")
# ── ioctl constants (nv-ioctl.h / nv-ioctl-numbers.h) ─────────────────────────
NV_IOCTL_MAGIC = ord("N") # 0x4E — user-space RM interface
NV_ESC_RM_ALLOC = 0x2B
NV_ESC_RM_CONTROL = 0x2A
# 'F' magic interface (kernel-open/common/inc/nv-ioctl-numbers.h) —
# NV_ESC_REGISTER_FD lives here, not in the 'N' RM interface.
NV_IOCTL_MAGIC_F = ord("F") # 0x46
NV_IOCTL_BASE_F = 200
NV_ESC_REGISTER_FD = NV_IOCTL_BASE_F + 1 # 201
# RM class IDs (nv0080.h / nv2080.h)
NV01_DEVICE_0 = 0x0080
NV20_SUBDEVICE_0 = 0x2080
# NV01_ROOT GPU queries (ctrl0000gpu.h) — resolve PCI identity to the RM
# device/subdevice instance numbers used by NV0080 and NV2080 allocations;
# neither number is a Linux device minor.
_CTRL_GPU_GET_ATTACHED_IDS = 0x201
_CTRL_GPU_GET_ID_INFO_V2 = 0x205
_CTRL_GPU_GET_PCI_INFO = 0x21B
_MAX_GPUS = 32
_INVALID_GPU_ID = 0xFFFFFFFF
# Private NV2080 power-limit client commands. Payloads compared against
# NvAPI and GSP from R595, R610 and R615 (native RM payloads, without
# NvAPI's 0x10-byte transport prefix).
_PWR_GET_INFO = 0x2080_A630
_PWR_GET_CONTROL = 0x2080_A632
_PWR_SET_CONTROL = 0x2080_E633
_ORDINARY_CLIENT = 0xFE
_LOWER_LIMIT_MW = 30_000 # experimental floor: 30 W
def _ioctl_rw(size: int, nr: int, magic: int = NV_IOCTL_MAGIC) -> int:
"""Linux ioctl request code: dir=RW, given size/type/nr."""
return (2 << 30) | (size << 16) | (magic << 8) | nr
def _ioctl_call(fd: int, code: int, arg) -> None:
"""Issue an ioctl, converting errno failures to RmPowerError.
The driver normally reports failures as an RM status in the parameter
struct, but an experimental interface can also fail at the kernel level
(ENOTTY/EBADF/EPERM across driver versions). Converting to RmPowerError
keeps the module's error contract uniform and lets callers clean up fds.
"""
try:
fcntl.ioctl(fd, code, arg)
except OSError as exc:
raise RmPowerError(f"ioctl 0x{code:x} failed: {exc}") from exc
# ── NVOS parameter structs (nvos.h) ──────────────────────────────────────────
class _NVOS21(ctypes.Structure):
_fields_ = [
("hRoot", ctypes.c_uint32),
("hObjectParent", ctypes.c_uint32),
("hObjectNew", ctypes.c_uint32),
("hClass", ctypes.c_uint32),
("pAllocParms", ctypes.c_uint64),
("paramsSize", ctypes.c_uint32),
("status", ctypes.c_uint32),
]
class _NVOS64(ctypes.Structure):
_fields_ = [
("hRoot", ctypes.c_uint32),
("hObjectParent", ctypes.c_uint32),
("hObjectNew", ctypes.c_uint32),
("hClass", ctypes.c_uint32),
("pAllocParms", ctypes.c_uint64),
("pRightsRequested", ctypes.c_uint64),
("paramsSize", ctypes.c_uint32),
("flags", ctypes.c_uint32),
("status", ctypes.c_uint32),
]
class _NVOS54(ctypes.Structure):
_fields_ = [
("hClient", ctypes.c_uint32),
("hObject", ctypes.c_uint32),
("cmd", ctypes.c_uint32),
("flags", ctypes.c_uint32),
("params", ctypes.c_uint64),
("paramsSize", ctypes.c_uint32),
("status", ctypes.c_uint32),
]
class _NV0080_ALLOC(ctypes.Structure):
_fields_ = [
("deviceId", ctypes.c_uint32),
("deviceFlags", ctypes.c_uint32),
("vgpuInstance", ctypes.c_uint32),
("pad", ctypes.c_uint32),
]
class _NV2080_ALLOC(ctypes.Structure):
_fields_ = [
("subDeviceId", ctypes.c_uint32),
("clientShare", ctypes.c_uint32),
("flags", ctypes.c_uint32),
("pad", ctypes.c_uint32),
]
# ── Errors ───────────────────────────────────────────────────────────────────
class RmPowerError(RuntimeError):
"""Raised when the RM power-limit interface is unavailable or fails."""
# ── Power-limit layouts and bounds ───────────────────────────────────────────
@dataclass(frozen=True)
class PowerLimitLayout:
"""Byte offsets of the private power-limit payloads for one wire format."""
name: str
info_size: int
control_size: int
info_min_at: int
request_at: int
client_at: int
mask_end: int
EXTENDED_LAYOUT = PowerLimitLayout(
name="extended",
info_size=0x924,
control_size=0x328,
info_min_at=0x28,
request_at=0x2C,
client_at=0x30,
mask_end=0x24,
)
LEGACY_LAYOUT = PowerLimitLayout(
name="legacy",
info_size=0x488,
control_size=0x188,
info_min_at=0xC,
request_at=0xC,
client_at=0x10,
mask_end=0x8,
)
@dataclass(frozen=True)
class PowerLimitBounds:
"""Power limit bounds in milliwatts (NVML/RM units)."""
min_mw: int
default_mw: int
max_mw: int
def lower_min_mw(self) -> int:
"""Effective minimum when the experimental route is active."""
return min(self.min_mw, _LOWER_LIMIT_MW)
@dataclass(frozen=True)
class LowerPowerLimit:
"""A validated RM power-limit layout that can be written."""
bounds: PowerLimitBounds
layout: PowerLimitLayout
def lower_min_mw(self) -> int:
return self.bounds.lower_min_mw()
# ── PCI identity → RM instance resolution ────────────────────────────────────
@dataclass(frozen=True)
class PciLocation:
domain: int
bus: int
dev: int
func: int = 0
def resolve_gpu_instance(
pci: PciLocation,
query: Callable[[int, bytearray], None],
) -> tuple[int, int]:
"""Resolve (device_instance, subdevice_instance) by PCI identity.
/dev/nvidiaN minors and RM device instances can have different orders;
the RM object must be matched by PCI domain/bus/slot, not by index.
The RM query exposes domain/bus/slot but no PCI function, so only
function-zero devices can be matched (never another function of a
multifunction device).
"""
if pci.func != 0:
raise RmPowerError("RM GPU lookup requires PCI function zero")
attached = bytearray(_MAX_GPUS * 4)
query(_CTRL_GPU_GET_ATTACHED_IDS, attached)
for i in range(_MAX_GPUS):
gpu_id = struct.unpack_from("<I", attached, i * 4)[0]
if gpu_id == _INVALID_GPU_ID:
continue
# NV0000_CTRL_GPU_GET_PCI_INFO_PARAMS: u32 gpuId, u32 domain,
# u16 bus, u16 slot.
location = bytearray(12)
location[0:4] = struct.pack("<I", gpu_id)
query(_CTRL_GPU_GET_PCI_INFO, location)
domain, bus, slot = struct.unpack_from("<IHH", location, 4)
if (domain, bus, slot) != (pci.domain, pci.bus, pci.dev):
continue
# NV0000_CTRL_GPU_GET_ID_INFO_V2_PARAMS: eight u32 fields, with
# deviceInstance/subDeviceInstance at +8/+12.
info = bytearray(32)
info[0:4] = struct.pack("<I", gpu_id)
query(_CTRL_GPU_GET_ID_INFO_V2, info)
device, subdevice = struct.unpack_from("<II", info, 8)
return device, subdevice
raise RmPowerError(f"no RM GPU matches PCI location {pci}")
# ── RM handle ────────────────────────────────────────────────────────────────
def _rm_control(fd: int, client: int, obj: int, cmd: int, buf: bytearray) -> None:
"""Issue an NVOS54 RM control whose parameter block is a byte buffer."""
arr = (ctypes.c_uint8 * len(buf)).from_buffer(buf)
req = _NVOS54(
hClient=client,
hObject=obj,
cmd=cmd,
flags=0,
params=ctypes.addressof(arr),
paramsSize=len(buf),
status=0,
)
_ioctl_call(fd, _ioctl_rw(ctypes.sizeof(_NVOS54), NV_ESC_RM_CONTROL), req)
if req.status != 0:
raise RmPowerError(
f"RM control 0x{cmd:08x} failed with status 0x{req.status:x}"
)
def _alloc_client(fd: int) -> int:
"""Allocate an RM client (NVOS21, all-zero parameters)."""
req = _NVOS21()
_ioctl_call(fd, _ioctl_rw(ctypes.sizeof(_NVOS21), NV_ESC_RM_ALLOC), req)
if req.status != 0:
raise RmPowerError(f"could not allocate RM client (status 0x{req.status:x})")
return req.hObjectNew
def _alloc_object(
fd: int, client: int, parent: int, class_id: int, alloc_params: ctypes.Structure
) -> int:
"""Allocate an RM object (NVOS64) and return its handle."""
req = _NVOS64(
hRoot=client,
hObjectParent=parent,
hObjectNew=0,
hClass=class_id,
pAllocParms=ctypes.addressof(alloc_params),
pRightsRequested=0,
paramsSize=ctypes.sizeof(alloc_params),
flags=0,
status=0,
)
_ioctl_call(fd, _ioctl_rw(ctypes.sizeof(_NVOS64), NV_ESC_RM_ALLOC), req)
if req.status != 0:
raise RmPowerError(
f"RM class 0x{class_id:x} allocation failed (status 0x{req.status:x})"
)
return req.hObjectNew
def _register_fd(device_fd: int, nvidiactl_fd: int) -> None:
"""Register the nvidiactl client with the device fd (NV_ESC_REGISTER_FD).
The ioctl is issued on the /dev/nvidiaN fd; the argument is the
nvidiactl fd to associate with it.
"""
_ioctl_call(
device_fd,
_ioctl_rw(4, NV_ESC_REGISTER_FD, NV_IOCTL_MAGIC_F),
struct.pack("i", nvidiactl_fd),
)
class RmHandle:
"""An NVIDIA RM client with device + subdevice objects for one GPU."""
def __init__(
self,
nvidiactl_fd: int,
device_fd: int,
client_handle: int,
device_handle: int,
subdevice_handle: int,
) -> None:
self._nvidiactl_fd = nvidiactl_fd
self._device_fd = device_fd
self.client_handle = client_handle
self.device_handle = device_handle
self.subdevice_handle = subdevice_handle
@classmethod
def open(cls, gpu_index: int) -> RmHandle:
"""Open an RM handle for the GPU at the given NVML index.
The RM device/subdevice instances are resolved by PCI identity
(minors and RM instances can have different orders).
"""
pynvml = _ensure_nvml()
try:
handle = pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
minor = int(pynvml.nvmlDeviceGetMinorNumber(handle))
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
pci = PciLocation(
domain=int(pci_info.domain),
bus=int(pci_info.bus),
dev=int(pci_info.device),
)
except Exception as exc:
raise RmPowerError(f"NVML query for GPU {gpu_index} failed: {exc}") from exc
try:
nvidiactl_fd = os.open("/dev/nvidiactl", os.O_RDWR)
except OSError as exc:
raise RmPowerError(f"could not open /dev/nvidiactl: {exc}") from exc
try:
client_handle = _alloc_client(nvidiactl_fd)
device_instance, subdevice_instance = resolve_gpu_instance(
pci,
lambda cmd, buf: _rm_control(
nvidiactl_fd, client_handle, client_handle, cmd, buf
),
)
except RmPowerError:
os.close(nvidiactl_fd)
raise
try:
device_fd = os.open(f"/dev/nvidia{minor}", os.O_RDWR)
except OSError as exc:
os.close(nvidiactl_fd)
raise RmPowerError(f"could not open /dev/nvidia{minor}: {exc}") from exc
try:
_register_fd(device_fd, nvidiactl_fd)
device_handle = _alloc_object(
nvidiactl_fd,
client_handle,
client_handle,
NV01_DEVICE_0,
_NV0080_ALLOC(deviceId=device_instance),
)
subdevice_handle = _alloc_object(
nvidiactl_fd,
client_handle,
device_handle,
NV20_SUBDEVICE_0,
_NV2080_ALLOC(subDeviceId=subdevice_instance),
)
except RmPowerError:
os.close(device_fd)
os.close(nvidiactl_fd)
raise
return cls(
nvidiactl_fd, device_fd, client_handle, device_handle, subdevice_handle
)
def control(self, cmd: int, buf: bytearray) -> None:
"""Issue an NVOS54 RM control on the subdevice with a byte buffer."""
_rm_control(
self._nvidiactl_fd, self.client_handle, self.subdevice_handle, cmd, buf
)
def close(self) -> None:
"""Close the fds; the driver reclaims the RM client objects."""
with contextlib.suppress(OSError):
os.close(self._device_fd)
with contextlib.suppress(OSError):
os.close(self._nvidiactl_fd)
# ── Power-limit probe / set (pure logic, testable with a fake query) ─────────
def _u32(data: bytearray | bytes, offset: int) -> int:
return struct.unpack_from("<I", data, offset)[0]
def _validate_header(layout: PowerLimitLayout, data: bytearray) -> None:
if _u32(data, 0) != 0xFF or _u32(data, 4) != 1:
raise RmPowerError("unrecognized RM power client layout")
# The extended layout has additional mask words; accepting only its low
# word would allow an unexpected client to be included in a later SET.
if any(byte != 0 for byte in data[8 : layout.mask_end]):
raise RmPowerError("unrecognized RM power client layout")
def _read_bounds(
layout: PowerLimitLayout, query: Callable[[int, bytearray], None]
) -> PowerLimitBounds:
info = bytearray(layout.info_size)
query(_PWR_GET_INFO, info)
_validate_header(layout, info)
bounds = PowerLimitBounds(
min_mw=_u32(info, layout.info_min_at),
default_mw=_u32(info, layout.info_min_at + 4),
max_mw=_u32(info, layout.info_min_at + 8),
)
if not (
bounds.min_mw > 0
and bounds.min_mw <= bounds.default_mw
and bounds.default_mw <= bounds.max_mw
):
raise RmPowerError("invalid RM power limit bounds")
return bounds
def _read_control(
layout: PowerLimitLayout, query: Callable[[int, bytearray], None]
) -> bytearray:
control = bytearray(layout.control_size)
control[4:8] = struct.pack("<I", 1)
control[layout.client_at] = _ORDINARY_CLIENT
query(_PWR_GET_CONTROL, control)
_validate_header(layout, control)
if control[layout.client_at] != _ORDINARY_CLIENT:
raise RmPowerError("unexpected power client")
if _u32(control, layout.request_at) in (0, 0xFFFFFFFF):
raise RmPowerError("no ordinary power request available")
return control
def probe(
nvml_bounds: PowerLimitBounds,
nvml_current_mw: int,
query: Callable[[int, bytearray], None],
) -> LowerPowerLimit:
"""GET-only discovery of the RM power-limit layout.
Probes the two known wire formats using GETs only. A driver version
number is not evidence that the payload still has the same layout or
units, so the bounds and the current request are validated against
NVML. Discovery never issues a SET.
"""
if sys.byteorder != "little":
raise RmPowerError("little-endian host required")
errors: list[str] = []
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
try:
bounds = _read_bounds(layout, query)
if bounds != nvml_bounds:
raise RmPowerError("RM power bounds differ from NVML")
control = _read_control(layout, query)
if _u32(control, layout.request_at) != nvml_current_mw:
raise RmPowerError("RM ordinary power request differs from NVML")
return LowerPowerLimit(bounds=bounds, layout=layout)
except RmPowerError as exc:
errors.append(f"{layout.name}: {exc}")
raise RmPowerError("no compatible RM power layout: " + "; ".join(errors))
def set_limit(
limit_mw: int,
support: LowerPowerLimit,
query: Callable[[int, bytearray], None],
) -> None:
"""Set the ordinary-client power request with readback verification.
Keeps the entire current payload, changing only entry 0's request.
Mask 1 and selector 0xFE prevent modifying any other entry or the
additional F8 client. A failed SET can have side effects, so the
previous request is restored even on transport failure — and the
restore uses 0xFE so a previous limit below the VBIOS minimum can
also be restored.
"""
layout = support.layout
bounds = _read_bounds(layout, query)
if bounds != support.bounds:
raise RmPowerError("RM power bounds changed since discovery")
lower = bounds.lower_min_mw()
if not (lower <= limit_mw <= bounds.max_mw):
raise RmPowerError(
f"power limit {limit_mw} mW outside supported range "
f"{lower}..{bounds.max_mw} mW"
)
before = _read_control(layout, query)
if _u32(before, layout.request_at) == limit_mw:
return
expected = bytearray(before)
expected[layout.request_at : layout.request_at + 4] = struct.pack("<I", limit_mw)
try:
request = bytearray(expected)
query(_PWR_SET_CONTROL, request)
if _read_control(layout, query) != expected:
raise RmPowerError("power request readback differs")
except RmPowerError as apply_error:
try:
restore = bytearray(before)
query(_PWR_SET_CONTROL, restore)
if _read_control(layout, query) != before:
raise RmPowerError("restored power request differs")
except RmPowerError as restore_error:
raise RmPowerError(
f"power request failed: {apply_error}; "
f"restoration also failed: {restore_error}"
) from None
raise RmPowerError(f"{apply_error} (previous power request restored)") from None
# ── High-level API (wires NVML state + RmHandle to the pure logic) ───────────
def _ensure_nvml():
"""Return pynvml with NVML initialized (nvmlInit is refcounted)."""
import pynvml
pynvml.nvmlInit()
return pynvml
def _nvml_power_state(gpu_index: int) -> tuple[PowerLimitBounds, int]:
"""Return (bounds, current_mw) from NVML for the given GPU."""
pynvml = _ensure_nvml()
try:
handle = pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
min_mw, max_mw = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle)
default_mw = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
current_mw = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
bounds = PowerLimitBounds(int(min_mw), int(default_mw), int(max_mw))
current = int(current_mw)
except Exception as exc:
raise RmPowerError(
f"NVML power state for GPU {gpu_index} unavailable: {exc}"
) from exc
return bounds, current
def probe_gpu(gpu_index: int = 0) -> PowerLimitBounds | None:
"""GET-only discovery of the RM power-limit interface for a GPU.
Returns the validated power bounds (milliwatts) when a compatible RM
layout is present, else None. Never issues a write.
"""
try:
bounds, current = _nvml_power_state(gpu_index)
except Exception as exc:
log.debug("RM probe: NVML state unavailable: %s", exc)
return None
try:
handle = RmHandle.open(gpu_index)
except RmPowerError as exc:
log.debug("RM probe: handle open failed: %s", exc)
return None
try:
probe(bounds, current, handle.control)
return bounds
except RmPowerError as exc:
log.debug("RM probe: %s", exc)
return None
finally:
handle.close()
def set_power_limit_w(gpu_index: int, limit_w: int) -> None:
"""Set the board power limit (watts) via the RM interface.
Raises RmPowerError on any failure (probe, range, write, readback).
A failed write restores the previous request.
"""
bounds, current = _nvml_power_state(gpu_index)
handle = RmHandle.open(gpu_index)
try:
support = probe(bounds, current, handle.control)
set_limit(int(limit_w) * 1000, support, handle.control)
finally:
handle.close()
+70 -30
View File
@@ -2,18 +2,20 @@
import ctypes
import json
import logging
import os
import struct
from datetime import datetime
from typing import Optional
from ..nvapi.bootstrap import nvcall_raw
from ..nvapi.constants import FUNC, CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS
from ..nvapi.constants import CT_BASE, CT_DELTA_OFF, CT_SIZE, CT_STRIDE, FUNC
from ..nvapi.types import SnapshotInfo
from .vfcurve import read_clock_table_raw, get_boost_mask
from .vfcurve import get_boost_mask, read_clock_table_raw
log = logging.getLogger("nvcurve.hal.snapshot")
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optional[str]:
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> str | None:
"""Save the current ClockBoostTable to disk.
Writes both a binary .bin file and a human-readable .json metadata file.
@@ -25,13 +27,21 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
print(f"Failed to read ClockBoostTable: {err}")
return None
os.makedirs(snapshot_dir, exist_ok=True)
try:
os.makedirs(snapshot_dir, exist_ok=True)
except OSError as exc:
print(f"Failed to create snapshot dir {snapshot_dir}: {exc}")
return None
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin")
meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json")
with open(bin_path, "wb") as f:
f.write(raw)
try:
with open(bin_path, "wb") as f:
f.write(raw)
except OSError as exc:
print(f"Failed to write snapshot {bin_path}: {exc}")
return None
offsets = []
max_entries = (len(raw) - CT_BASE) // CT_STRIDE
@@ -48,10 +58,14 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
"offsets_kHz": offsets,
"nonzero_offsets": sum(1 for o in offsets if o != 0),
}
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
try:
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
except OSError as exc:
print(f"Failed to write snapshot metadata {meta_path}: {exc}")
return None
print(f"Snapshot saved:")
print("Snapshot saved:")
print(f" Binary: {bin_path}")
print(f" Metadata: {meta_path}")
print(f" Size: {len(raw)} bytes")
@@ -65,20 +79,22 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None:
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
bins = sorted(
f for f in os.listdir(snapshot_dir) if f.endswith(".bin")
) # oldest first (lexicographic = chronological for our timestamp format)
# Oldest first (lexicographic = chronological for our timestamp format).
try:
bins = sorted(f for f in os.listdir(snapshot_dir) if f.endswith(".bin"))
except OSError:
return
excess = len(bins) - max_snapshots
for fname in bins[:excess]:
stem = fname[:-4] # strip .bin
for ext in (".bin", ".json"):
try:
os.remove(os.path.join(snapshot_dir, stem + ext))
except OSError:
pass
except OSError as exc:
log.debug("Could not remove %s: %s", stem + ext, exc)
def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
def restore(gpu, snapshot_dir: str, filepath: str | None = None) -> bool:
"""Restore a ClockBoostTable snapshot from disk.
If no filepath is given, uses the most recent snapshot in snapshot_dir.
@@ -88,10 +104,14 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
if not os.path.isdir(snapshot_dir):
print(f"No snapshots found in {snapshot_dir}")
return False
bins = sorted(
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
reverse=True,
)
try:
bins = sorted(
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
reverse=True,
)
except OSError:
print(f"No snapshots found in {snapshot_dir}")
return False
if not bins:
print(f"No snapshot .bin files in {snapshot_dir}")
return False
@@ -101,8 +121,21 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
print(f"Snapshot file not found: {filepath}")
return False
with open(filepath, "rb") as f:
raw = f.read()
# Contain the path inside the snapshot directory — callers (in
# particular the HTTP API) must not be able to point the restore at
# arbitrary files on the filesystem.
snap_dir = os.path.realpath(snapshot_dir)
resolved = os.path.realpath(filepath)
if not resolved.startswith(snap_dir + os.sep):
print(f"Snapshot path outside snapshot directory: {filepath}")
return False
try:
with open(filepath, "rb") as f:
raw = f.read()
except OSError as exc:
print(f"Failed to read snapshot {filepath}: {exc}")
return False
if len(raw) != CT_SIZE:
print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}")
@@ -134,8 +167,13 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
if not os.path.isdir(snapshot_dir):
return []
try:
fnames = sorted(os.listdir(snapshot_dir), reverse=True)
except OSError:
return []
results = []
for fname in sorted(os.listdir(snapshot_dir), reverse=True):
for fname in fnames:
if not fname.endswith(".json"):
continue
meta_path = os.path.join(snapshot_dir, fname)
@@ -143,13 +181,15 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
with open(meta_path) as f:
meta = json.load(f)
bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
results.append(SnapshotInfo(
filepath=bin_path,
timestamp=meta.get("timestamp", ""),
gpu=meta.get("gpu", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0),
size=meta.get("size", 0),
))
results.append(
SnapshotInfo(
filepath=bin_path,
timestamp=meta.get("timestamp", ""),
gpu=meta.get("gpu", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0),
size=meta.get("size", 0),
)
)
except (json.JSONDecodeError, KeyError):
continue
+1 -1
View File
@@ -9,7 +9,7 @@ from .errors import NVAPI_ERRORS
def load_nvapi() -> ctypes.CDLL:
"""Load libnvidia-api.so from the NVIDIA driver."""
for name in ("libnvidia-api.so", "libnvidia-api.so.1"):
for name in ("libnvidia-api.so", "libnvidia-api.so.1"): # gitleaks:allow
try:
return ctypes.CDLL(name)
except OSError:
+1
View File
@@ -61,6 +61,7 @@ class MonitoringSample:
pcie_link_width: int | None = None # Current PCIe link width (x1..x16)
pcie_link_generation: int | None = None # Current PCIe link generation (1..5)
mem_temp_c: float | None = None # VRAM temperature (if the GPU exposes it)
fans: list[float | None] | None = None # Per-fan speed % (index-aligned)
@dataclass
+91 -39
View File
@@ -21,12 +21,12 @@ def _gpu_stable_key(info) -> str:
def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
"""Apply a named profile to the given GPU. Returns a list of error strings."""
from .native import load_profile
from ..hal.gpu import get_gpu
from ..hal.limits import set_clock_offsets, set_power_limit
from ..hal.vfcurve import write_offsets, reset_offsets
from ..hal.snapshot import save as snapshot_save
from ..hal.vfcurve import reset_offsets, write_offsets
from ..safety import validate_write
from .native import load_profile
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
@@ -43,24 +43,31 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
errs.append(f"Mem offset: {msg}")
if profile.power_limit_w is not None:
ok, msg = set_power_limit(profile.power_limit_w, gpu_index)
mode = profile.power_cap_mode or "nvml"
ok, msg = set_power_limit(profile.power_limit_w, gpu_index, mode)
if not ok:
errs.append(f"Power limit: {msg}")
if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
errors = validate_write(deltas, cfg.max_delta_khz)
if errors:
errs.append("Curve: " + "; ".join(errors))
try:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
except ValueError:
errs.append("Curve: invalid point keys in profile")
else:
if cfg.auto_snapshot:
try:
snapshot_save(gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots)
except Exception as exc:
log.warning("Auto-snapshot failed: %s", exc)
ret, desc = write_offsets(gpu, deltas)
if ret != 0:
errs.append(f"Curve write failed ({ret}): {desc}")
errors = validate_write(deltas, cfg.max_delta_khz)
if errors:
errs.append("Curve: " + "; ".join(errors))
else:
if cfg.auto_snapshot:
try:
snapshot_save(
gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots
)
except Exception as exc:
log.warning("Auto-snapshot failed: %s", exc)
ret, desc = write_offsets(gpu, deltas)
if ret != 0:
errs.append(f"Curve write failed ({ret}): {desc}")
else:
reset_offsets(gpu)
@@ -69,9 +76,9 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool:
"""Apply a named profile with read-back verification, retrying on mismatch."""
from .native import load_profile
from ..hal.gpu import get_gpu
from ..hal.vfcurve import read_clock_offsets
from .native import load_profile
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
@@ -82,51 +89,88 @@ def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bo
log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index)
return False
expected: dict[int, int] = (
{int(k): v for k, v in profile.curve_deltas.items()}
if profile.curve_deltas else {}
)
try:
expected: dict[int, int] = (
{int(k): v for k, v in profile.curve_deltas.items()}
if profile.curve_deltas
else {}
)
except ValueError:
log.warning(
"Profile %r has invalid curve point keys — skipping GPU %d",
name,
gpu_index,
)
return False
for attempt in range(max_retries):
try:
errs = apply_profile(gpu_index, name, cfg)
except Exception as exc:
log.warning("Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc)
log.warning(
"Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc
)
errs = [str(exc)]
if errs:
log.warning("Auto-load attempt %d/%d errors: %s",
attempt + 1, max_retries, "; ".join(errs))
log.warning(
"Auto-load attempt %d/%d errors: %s",
attempt + 1,
max_retries,
"; ".join(errs),
)
elif expected:
gpu, _ = get_gpu(index=gpu_index)
offsets, err = read_clock_offsets(gpu)
if offsets is None:
log.warning("Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1, max_retries, err)
log.warning(
"Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1,
max_retries,
err,
)
else:
mismatches = [
f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz"
f"pt{idx}: expected {val / 1000:+.0f}MHz got {offsets[idx] / 1000:+.0f}MHz"
for idx, val in expected.items()
if idx < len(offsets) and offsets[idx] != val
]
if not mismatches:
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name, gpu_index, attempt + 1, max_retries)
log.info(
"Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1, max_retries, "; ".join(mismatches))
log.warning(
"Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1,
max_retries,
"; ".join(mismatches),
)
else:
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name, gpu_index, attempt + 1, max_retries)
log.info(
"Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True
if attempt < max_retries - 1:
delay = 2 ** attempt # 1s, 2s, 4s
delay = 2**attempt # 1s, 2s, 4s
log.info("Retrying auto-load in %ds…", delay)
time.sleep(delay)
log.warning("Auto-load profile %r failed after %d attempts on GPU %d",
name, max_retries, gpu_index)
log.warning(
"Auto-load profile %r failed after %d attempts on GPU %d",
name,
max_retries,
gpu_index,
)
return False
@@ -153,13 +197,19 @@ def run_autoload() -> None:
return
from ..config import Config
cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots",
"snapshot_dir", "profile_dir"):
for key in (
"max_delta_khz",
"auto_snapshot",
"max_snapshots",
"snapshot_dir",
"profile_dir",
):
if key in cfg_data:
setattr(cfg, key, cfg_data[key])
from ..hal.gpu import init_nvapi, discover_gpus
from ..hal.gpu import discover_gpus, init_nvapi
from ..hal.monitoring import init_nvml, shutdown_nvml
# Retry NvAPI init — the driver may not be fully ready at early boot.
@@ -189,7 +239,9 @@ def run_autoload() -> None:
if gpu_idx is None:
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
continue
log.info("Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key)
log.info(
"Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key
)
apply_with_retry(gpu_idx, profile_name, cfg)
shutdown_nvml()
+38 -18
View File
@@ -1,49 +1,70 @@
"""Native profile storage and schema."""
import json
import os
import glob
from dataclasses import dataclass, asdict
from typing import Dict, Optional, List
import json
import logging
import os
from dataclasses import asdict, dataclass
log = logging.getLogger("nvcurve.profiles.native")
@dataclass
class ProfileData:
name: str
gpu_name: str
curve_deltas: Dict[str, int] # { "index": delta_khz }
mem_offset_mhz: Optional[int] = None
power_limit_w: Optional[int] = None
fan_curve: Optional[List[Dict[str, int]]] = None
curve_deltas: dict[str, int] # { "index": delta_khz }
mem_offset_mhz: int | None = None
power_limit_w: int | None = None
# How power_limit_w is applied: "nvml" (default) or "ioctl" (experimental
# RM power control, permits values below the VBIOS minimum).
power_cap_mode: str | None = None
fan_curve: list[dict[str, int]] | None = None
# Fan indices controlled by fan_curve (0-based); None = all fans.
fan_targets: list[int] | None = None
def save_profile(profile_dir: str, data: ProfileData) -> str:
"""Save profile to JSON, sanitising the filename."""
os.makedirs(profile_dir, exist_ok=True)
try:
os.makedirs(profile_dir, exist_ok=True)
except OSError as exc:
raise RuntimeError(f"Cannot create profile dir {profile_dir}: {exc}") from exc
safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip()
if not safe_name:
safe_name = "Unnamed"
filepath = os.path.join(profile_dir, f"{safe_name}.json")
with open(filepath, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, indent=2)
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write profile {filepath}: {exc}") from exc
return filepath
def load_profile(filepath: str) -> ProfileData:
"""Load profile from JSON."""
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
try:
with open(filepath, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
raise
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Cannot read profile {filepath}: {exc}") from exc
# Migrate old field names.
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
# Drop removed fields so old profiles don't cause TypeError.
for obsolete in ("gpu_locked_min_mhz", "gpu_locked_max_mhz", "vram_p0_offset_mhz"):
data.pop(obsolete, None)
# Normalize the experimental power-cap mode; unknown values fall back to NVML.
if data.get("power_cap_mode") not in (None, "nvml", "ioctl"):
data["power_cap_mode"] = None
return ProfileData(**data)
def list_profiles(profile_dir: str) -> List[ProfileData]:
def list_profiles(profile_dir: str) -> list[ProfileData]:
"""Return a list of all safely readable profiles."""
if not os.path.exists(profile_dir):
return []
@@ -51,9 +72,8 @@ def list_profiles(profile_dir: str) -> List[ProfileData]:
for fp in glob.glob(os.path.join(profile_dir, "*.json")):
try:
profiles.append(load_profile(fp))
except Exception as e:
# log warning ideally, but swallowing for robustness
pass
except Exception as exc:
log.debug("Skipping unreadable profile %s: %s", fp, exc)
# Sort alphabetically by name
profiles.sort(key=lambda p: p.name.lower())
return profiles
+251 -43
View File
@@ -11,7 +11,7 @@ import logging
import os
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any
from typing import Any, Protocol
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -20,10 +20,11 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth
from .config import Config, default_config
from .config import Config, default_config, tls_enabled
from .hal.dashboard import get_dashboard_info
from .hal.fans import (
get_fan_info,
get_num_fans,
get_temp,
interpolate_fan_speed,
reset_fan,
@@ -90,8 +91,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
except Exception as exc:
log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser
webbrowser.open(url)
@@ -149,6 +150,7 @@ def _sample_dict(s) -> dict:
"temp_c": s.temp_c,
"power_w": s.power_w,
"fan_pct": s.fan_pct,
"fans": s.fans,
"pstate": s.pstate,
"pstate_label": f"P{s.pstate}" if s.pstate is not None else None,
"mem_used_bytes": s.mem_used_bytes,
@@ -219,7 +221,8 @@ async def _monitor_poller(gpu_index: int) -> None:
async def _fan_poller(gpu_index: int) -> None:
"""Continuously read GPU temp, interpolate fan speed from active curve, and apply."""
"""Continuously read GPU temp, interpolate fan speed from active curve, and apply it to the target fans."""
last_write_error: str | None = None
while True:
try:
g_state = _state["gpus"].get(gpu_index)
@@ -229,7 +232,25 @@ async def _fan_poller(gpu_index: int) -> None:
curve = g_state["fan_curve"]
target = interpolate_fan_speed(curve, temp)
if target is not None:
await _run(set_fan_speed, gpu_index, target)
ok, msg = await _run(
set_fan_speed,
gpu_index,
target,
g_state.get("fan_targets"),
)
if not ok:
# Log once per distinct failure so a stuck target
# list doesn't spam a warning every 2 s tick.
if msg != last_write_error:
log.warning(
"Fan write failed for GPU %d (targets=%s): %s",
gpu_index,
g_state.get("fan_targets"),
msg,
)
last_write_error = msg
else:
last_write_error = None
except asyncio.CancelledError:
return
except Exception as exc:
@@ -237,15 +258,34 @@ async def _fan_poller(gpu_index: int) -> None:
await asyncio.sleep(2.0)
async def _activate_fan_curve(gpu_index: int, curve: list) -> None:
async def _activate_fan_curve(
gpu_index: int, curve: list, fans: list[int] | None = None
) -> None:
"""Set the active fan curve, (re)start the poller, and persist it.
Persistence (config.json) is what makes the curve survive server restarts:
fan control is volatile, so the driver reverts to automatic mode on reboot
and the saved curve is re-applied at the next server start.
fans=None targets all fans on the device; a list targets the given
fan indices. Persistence (config.json) is what makes the curve survive
server restarts: fan control is volatile, so the driver reverts to
automatic mode on reboot and the saved curve is re-applied at the next
server start.
"""
g_state = _get_gpu_state(gpu_index)
# Validate explicit fan targets against the hardware. Stale indices
# (e.g. a profile saved on a 2-fan GPU applied to a 1-fan GPU, or a
# persisted entry restored after a hardware change) would otherwise
# make the poller fail silently on every tick.
if fans is not None:
num_fans = await _run(get_num_fans, gpu_index)
if not fans or (num_fans > 0 and any(f < 0 or f >= num_fans for f in fans)):
log.warning(
"Fan targets %s invalid for GPU %d (%d fan(s)); falling back to all fans",
fans,
gpu_index,
num_fans,
)
fans = None
# Stop existing poller if running
if g_state.get("fan_poller_task"):
g_state["fan_poller_task"].cancel()
@@ -254,10 +294,11 @@ async def _activate_fan_curve(gpu_index: int, curve: list) -> None:
g_state["fan_curve"] = curve
g_state["fan_curve_active"] = True
g_state["fan_targets"] = fans
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
cfg: Config = _state["config"]
cfg.fan_curves[_gpu_stable_key(gpu_index)] = curve
cfg.fan_curves[_gpu_stable_key(gpu_index)] = {"curve": curve, "fans": fans}
_persist_fan_curves(cfg.fan_curves)
@@ -276,6 +317,7 @@ async def _deactivate_fan_curve(gpu_index: int, reset_hardware: bool = True) ->
g_state["fan_curve"] = None
g_state["fan_curve_active"] = False
g_state["fan_targets"] = None
if reset_hardware:
ok, msg = await _run(reset_fan, gpu_index)
@@ -389,7 +431,15 @@ async def lifespan(app: FastAPI):
if g_state.get("fan_curve_active"):
continue # already activated by the auto-load profile path
key = _gpu_stable_key(gpu_index)
curve = cfg.fan_curves.get(key)
entry = cfg.fan_curves.get(key)
if not entry:
continue
# Migrate the legacy format (bare curve list) to the current
# {"curve": ..., "fans": ...} shape; legacy entries targeted all fans.
if isinstance(entry, list):
entry = {"curve": entry, "fans": None}
curve = entry.get("curve") if isinstance(entry, dict) else None
fans = entry.get("fans") if isinstance(entry, dict) else None
if not curve:
continue
ok, msg = validate_curve(curve)
@@ -398,7 +448,7 @@ async def lifespan(app: FastAPI):
continue
log.info("Restoring persisted fan curve on GPU %d (%s)", gpu_index, key)
try:
await _activate_fan_curve(gpu_index, curve)
await _activate_fan_curve(gpu_index, curve, fans)
except Exception as exc:
log.warning(
"Failed to restore persisted fan curve on GPU %d: %s",
@@ -502,12 +552,10 @@ app.add_middleware(AuthMiddleware)
class WriteRequest(BaseModel):
deltas: dict[int, int] # {point_index: delta_kHz}
max_delta_khz: int | None = None # per-request safety limit override
class GlobalOffsetRequest(BaseModel):
delta_khz: int
max_delta_khz: int | None = None # per-request safety limit override
class VerifyRequest(BaseModel):
@@ -521,6 +569,8 @@ class SnapshotRestoreRequest(BaseModel):
class LimitsRequest(BaseModel):
power_limit_w: int | None = None
mem_offset_mhz: int | None = None
# "nvml" (default) or "ioctl" (experimental RM power control).
power_cap_mode: str | None = None
class ProfileSaveRequest(BaseModel):
@@ -543,10 +593,14 @@ class FanCurvePoint(BaseModel):
class FanCurveRequest(BaseModel):
curve: list[FanCurvePoint]
# Fan indices to control (0-based); None = all fans on the device.
fans: list[int] | None = None
class FanSpeedRequest(BaseModel):
fan_pct: int
# Specific fan index to set (0-based); None = all fans on the device.
fan: int | None = None
class LoginRequest(BaseModel):
@@ -600,7 +654,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
if not users:
raise HTTPException(status_code=404, detail="Authentication is not enabled")
client_ip = request.client.host if request.client else "unknown"
client_ip = _client_ip(request, cfg.trusted_proxies)
if auth.is_locked_out(client_ip):
raise HTTPException(
status_code=429, detail="Too many failed attempts. Try again later."
@@ -623,6 +677,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
max_age=auth.SESSION_TTL_S,
httponly=True,
samesite="lax",
secure=tls_enabled(cfg),
path="/",
)
return response
@@ -656,6 +711,36 @@ def _require_gpu(gpu_index: int = 0):
return gpu, g_state
class _ClientIpSource(Protocol):
"""Structural type for the request objects _client_ip accepts.
Both Starlette's Request and WebSocket expose these; tests may pass
lightweight duck types.
"""
client: Any
headers: Any
def _client_ip(request: _ClientIpSource, trusted_proxies: list[str]) -> str:
"""Best-effort client IP for the login lockout.
When the direct peer is a configured trusted proxy (e.g. a TLS
reverse proxy), use the rightmost X-Forwarded-For entry that is not
itself a trusted proxy. Otherwise use the direct peer address —
X-Forwarded-For is spoofable, so it is only honoured for peers the
operator explicitly listed in ``trusted_proxies``.
"""
peer = request.client.host if request.client else "unknown"
if not trusted_proxies or peer not in trusted_proxies:
return peer
hops = [h.strip() for h in request.headers.get("x-forwarded-for", "").split(",")]
for hop in reversed(hops):
if hop and hop not in trusted_proxies:
return hop
return peer
# ── REST endpoints ────────────────────────────────────────────────────────────
@@ -831,6 +916,17 @@ def _persist_fan_curves(fan_curves: dict) -> None:
_persist_config_field("fan_curves", fan_curves if fan_curves else None)
def _persist_power_cap_modes(modes: dict[str, str]) -> None:
"""Persist the per-GPU experimental power-cap mode dict to config.json."""
_persist_config_field("power_cap_modes", modes if modes else None)
def _power_cap_mode(cfg: Config, gpu_index: int) -> str:
"""Return the effective power-cap mode for a GPU ("nvml" or "ioctl")."""
mode = cfg.power_cap_modes.get(_gpu_stable_key(gpu_index), "nvml")
return mode if mode in ("nvml", "ioctl") else "nvml"
@app.get("/api/profiles")
async def api_profiles(gpu_index: int = 0):
"""List saved native profiles, the active profile name, and the auto-load profile name."""
@@ -858,13 +954,15 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
curve_deltas = {str(p.index): p.delta_khz for p in state.points if p.delta_khz != 0}
try:
power_info = await _run(get_power_limit, gpu_index)
mode = _power_cap_mode(cfg, gpu_index)
power_info = await _run(get_power_limit, gpu_index, mode)
offsets = await _run(get_clock_offsets, gpu_index)
power_limit_w = power_info.get("power_limit_w")
mem_offset_mhz = offsets.get("mem_offset_mhz")
except Exception:
power_limit_w = None
mem_offset_mhz = None
mode = "nvml"
data = ProfileData(
name=req.name,
@@ -872,7 +970,11 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
curve_deltas=curve_deltas,
mem_offset_mhz=mem_offset_mhz,
power_limit_w=power_limit_w,
power_cap_mode=mode,
fan_curve=g_state.get("fan_curve") if g_state.get("fan_curve_active") else None,
fan_targets=g_state.get("fan_targets")
if g_state.get("fan_curve_active")
else None,
)
filepath = await _run(save_profile, cfg.profile_dir, data)
g_state["active_profile"] = req.name
@@ -989,7 +1091,8 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
errs.append(f"Mem offset: {msg}")
if profile.power_limit_w is not None:
ok, msg = await _run(set_power_limit, profile.power_limit_w, gpu_index)
mode = profile.power_cap_mode or "nvml"
ok, msg = await _run(set_power_limit, profile.power_limit_w, gpu_index, mode)
if not ok:
errs.append(f"Power limit: {msg}")
@@ -1024,7 +1127,7 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
if not ok:
errs.append(f"Fan curve: {msg}")
else:
await _activate_fan_curve(gpu_index, profile.fan_curve)
await _activate_fan_curve(gpu_index, profile.fan_curve, profile.fan_targets)
elif g_state.get("fan_curve_active"):
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
@@ -1118,7 +1221,9 @@ async def api_config_update(req: ConfigUpdateRequest):
@app.get("/api/limits")
async def api_limits(gpu_index: int = 0):
"""Current performance limits: power and clock offsets."""
power = await _run(get_power_limit, gpu_index)
cfg: Config = _state["config"]
mode = _power_cap_mode(cfg, gpu_index)
power = await _run(get_power_limit, gpu_index, mode)
offsets = await _run(get_clock_offsets, gpu_index)
mem_off_range = await _run(get_mem_offset_range, gpu_index)
return {
@@ -1132,10 +1237,36 @@ async def api_limits(gpu_index: int = 0):
async def api_limits_update(req: LimitsRequest, gpu_index: int = 0):
"""Update performance limits."""
g_state = _get_gpu_state(gpu_index)
cfg: Config = _state["config"]
errs = []
if req.power_cap_mode is not None:
if req.power_cap_mode not in ("nvml", "ioctl"):
raise HTTPException(
status_code=400, detail="power_cap_mode must be 'nvml' or 'ioctl'"
)
if req.power_cap_mode == "ioctl":
# Verify the GPU actually exposes the RM interface before enabling,
# so a client can't lock a GPU into a mode where every power
# operation fails (ioctl mode has no NVML fallback by design).
info = await _run(get_power_limit, gpu_index, "ioctl")
if not info.get("rm_power_supported"):
raise HTTPException(
status_code=409,
detail="Experimental RM power control is not supported "
"on this GPU/driver",
)
key = _gpu_stable_key(gpu_index)
if req.power_cap_mode == "nvml":
cfg.power_cap_modes.pop(key, None)
else:
cfg.power_cap_modes[key] = "ioctl"
_persist_power_cap_modes(cfg.power_cap_modes)
mode = _power_cap_mode(cfg, gpu_index)
if req.power_limit_w is not None:
ok, msg = await _run(set_power_limit, req.power_limit_w, gpu_index)
ok, msg = await _run(set_power_limit, req.power_limit_w, gpu_index, mode)
if not ok:
errs.append(f"Power Limit: {msg}")
@@ -1198,12 +1329,17 @@ async def _update_offsets_and_broadcast(gpu_index: int) -> None:
async def api_limits_reset(gpu_index: int = 0):
"""Reset power limit to hardware default and memory clock offset to 0."""
g_state = _get_gpu_state(gpu_index)
cfg: Config = _state["config"]
errs = []
power = await _run(get_power_limit, gpu_index)
# Reset uses the GPU's current mode: in ioctl mode the default is
# restored through the RM route (which can also restore a previous
# below-VBIOS-minimum cap).
mode = _power_cap_mode(cfg, gpu_index)
power = await _run(get_power_limit, gpu_index, mode)
default_w = power.get("default_power_limit_w")
if default_w is not None:
ok, msg = await _run(set_power_limit, default_w, gpu_index)
ok, msg = await _run(set_power_limit, default_w, gpu_index, mode)
if not ok:
errs.append(f"Power Limit: {msg}")
@@ -1226,7 +1362,7 @@ async def api_limits_reset(gpu_index: int = 0):
@app.get("/api/fans")
async def api_fans(gpu_index: int = 0):
"""Current fan state: fan %, curve, and whether curve control is active."""
"""Current fan state: per-fan %, curve, and whether curve control is active."""
_get_gpu_state(gpu_index)
g_state = _state["gpus"][gpu_index]
info = await _run(get_fan_info, gpu_index)
@@ -1236,12 +1372,16 @@ async def api_fans(gpu_index: int = 0):
"fan_mode": "curve" if curve_active else "auto",
"curve": g_state.get("fan_curve"),
"curve_active": curve_active,
"fan_targets": g_state.get("fan_targets"),
}
@app.post("/api/fans")
async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
"""Set or update the fan curve. Starts the fan control poller."""
"""Set or update the fan curve. Starts the fan control poller.
req.fans selects which fans the curve drives (None = all fans).
"""
g_state = _get_gpu_state(gpu_index)
curve_data = [{"temp_c": p.temp_c, "fan_pct": p.fan_pct} for p in req.curve]
@@ -1249,6 +1389,8 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
if not ok:
raise HTTPException(status_code=400, detail=msg)
fans = sorted(set(req.fans)) if req.fans is not None else None
# Test that fan control is available on this GPU, probing with the
# target for the *current* temperature so the fan is never briefly
# set to an inappropriate speed.
@@ -1261,14 +1403,14 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
)
target = interpolate_fan_speed(curve_data, test_temp)
if target is not None:
fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target)
fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target, fans)
if not fan_ok:
raise HTTPException(
status_code=500, detail=f"Fan control not available: {fan_msg}"
)
# Activate the curve (starts the poller) and persist it so it survives restarts.
await _activate_fan_curve(gpu_index, curve_data)
await _activate_fan_curve(gpu_index, curve_data, fans)
return {"ok": True}
@@ -1287,10 +1429,14 @@ async def api_fans_reset(gpu_index: int = 0):
@app.post("/api/fans/speed")
async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
"""One-shot set fan to an exact percentage (bypasses curve)."""
"""One-shot set fan(s) to an exact percentage (bypasses curve).
req.fan selects a single fan index; None sets all fans.
"""
_get_gpu_state(gpu_index)
pct = max(0, min(100, req.fan_pct))
ok, msg = await _run(set_fan_speed, gpu_index, pct)
fans = [req.fan] if req.fan is not None else None
ok, msg = await _run(set_fan_speed, gpu_index, pct, fans)
if not ok:
raise HTTPException(status_code=500, detail=msg)
return {"ok": True}
@@ -1340,10 +1486,10 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"])
effective_limit = (
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
)
errors = validate_write(req.deltas, effective_limit)
# The safety cap is always the server-side config value — clients cannot
# raise it per request (shared systems must not let one user override the
# hardware safety limit). Raise it in /etc/nvcurve/config.json if needed.
errors = validate_write(req.deltas, cfg.max_delta_khz)
if errors:
raise HTTPException(status_code=400, detail={"errors": errors})
@@ -1394,10 +1540,8 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
raise HTTPException(status_code=500, detail="Failed to read curve")
all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"}
effective_limit = (
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
)
errors = validate_write(all_deltas, effective_limit)
# Safety cap is the server-side config value only (see api_curve_write).
errors = validate_write(all_deltas, cfg.max_delta_khz)
if errors:
raise HTTPException(status_code=400, detail={"errors": errors})
@@ -1538,10 +1682,21 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
@app.post("/api/shutdown")
async def api_shutdown():
"""Gracefully shut down the server process."""
"""Gracefully shut down the server process.
Disabled when ``allow_api_shutdown`` is false in the config — on shared
systems stop the service via systemd instead.
"""
import os
import signal
cfg: Config = _state["config"]
if not cfg.allow_api_shutdown:
raise HTTPException(
status_code=403,
detail="API shutdown is disabled (allow_api_shutdown: false)",
)
loop = asyncio.get_running_loop()
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
return {"ok": True}
@@ -1692,7 +1847,10 @@ def _resolve_dist_dir() -> str:
the project-root layout used during local development.
"""
try:
from importlib.resources import files as _resource_files
# Project requires Python >= 3.12, so the 3.7-compat finding is a false positive.
from importlib.resources import ( # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2
files as _resource_files,
)
candidate = _resource_files("nvcurve") / "frontend" / "dist"
if candidate.is_dir():
@@ -1725,7 +1883,15 @@ async def serve_spa(catchall: str):
if not os.path.isdir(_dist_dir):
return {"error": "Frontend not built. Run pnpm build in frontend/."}
path = os.path.join(_dist_dir, catchall)
# Contain the resolved path inside the dist directory. The raw URL path
# can carry encoded ".." segments (e.g. /%2e%2e/etc/passwd) that would
# otherwise escape the dist dir via os.path.join — an unauthenticated
# arbitrary-file-read since the server runs as root.
base = os.path.realpath(_dist_dir)
path = os.path.realpath(os.path.join(_dist_dir, catchall))
if path != base and not path.startswith(base + os.sep):
raise HTTPException(status_code=404, detail="Not Found")
if os.path.isfile(path) and catchall:
return FileResponse(path)
@@ -1751,8 +1917,14 @@ def run(
gpu_index: int = 0,
config: Config = default_config,
open_browser: bool = False,
ssl_certfile: str | None = None,
ssl_keyfile: str | None = None,
) -> None:
"""Start the uvicorn server. Blocking."""
"""Start the uvicorn server. Blocking.
When both ssl_certfile and ssl_keyfile are given (either here or in the
config), the server serves HTTPS and the session cookie is Secure.
"""
import socket
import threading
@@ -1760,6 +1932,34 @@ def run(
_state["config"] = config
# CLI flags take precedence over config values.
certfile = ssl_certfile or config.ssl_certfile
keyfile = ssl_keyfile or config.ssl_keyfile
if certfile:
config.ssl_certfile = certfile
if keyfile:
config.ssl_keyfile = keyfile
tls = bool(certfile and keyfile)
# Fail fast on a bad TLS configuration — otherwise uvicorn dies at
# startup and (in daemon mode) the error is only visible in the server
# log while `serve status` reports "not running".
tls = False
if certfile and keyfile:
missing = [
f"{label} ({path})"
for label, path in (("certificate", certfile), ("key", keyfile))
if not os.path.isfile(path)
]
if missing:
print(f"Error: TLS file(s) not found: {', '.join(missing)}")
print(
"Fix the path (nvcurve service configure --ssl-certfile/--ssl-keyfile) "
"or disable TLS (--no-ssl)."
)
return
tls = True
# Suppress noisy websockets keepalive ping-timeout tracebacks — these are
# normal disconnection events (browser tab closed, network hiccup) and
# logging them at ERROR level creates false alarm noise.
@@ -1777,7 +1977,7 @@ def run(
)
return
url = f"http://{host}:{port}"
url = f"{'https' if tls else 'http'}://{host}:{port}"
# Print banner *before* uvicorn starts so it appears above uvicorn's own output.
# GPU name is populated by the lifespan; we omit it here since the server
@@ -1792,4 +1992,12 @@ def run(
if open_browser and not _DEV_PORT:
threading.Timer(1.2, lambda: _open_browser_as_user(url)).start()
uvicorn.run(app, host=host, port=port, log_level="warning", access_log=False)
uvicorn.run(
app,
host=host,
port=port,
log_level="warning",
access_log=False,
ssl_certfile=certfile if tls else None,
ssl_keyfile=keyfile if tls else None,
)
+11
View File
@@ -19,9 +19,20 @@ dependencies = [
[project.scripts]
nvcurve = "nvcurve.cli:main"
[dependency-groups]
dev = [
"hatchling", # enables local `hatch build` and resolves hatch_build.py imports
]
[tool.hatch.build.hooks.custom]
[tool.hatch.build.targets.wheel]
packages = ["nvcurve"]
# The custom build hook (hatch_build.py) compiles the React frontend when
# frontend/dist is missing or stale, so `uv tool install git+<repo-url>` works
# as a single command. It runs for both wheel and sdist builds.
[tool.hatch.build.targets.wheel.force-include]
"frontend/dist" = "nvcurve/frontend/dist"
+267 -166
View File
@@ -55,20 +55,20 @@ Key findings:
See NvAPI_VF_Curve_Documentation.md for full technical details.
"""
import argparse
import ctypes
import struct
import sys
import json
import os
import struct
import sys
import time
import argparse
from datetime import datetime
from typing import Optional, List, Tuple, Set, Dict
# ═══════════════════════════════════════════════════════════════════════════
# NvAPI bootstrap
# ═══════════════════════════════════════════════════════════════════════════
def load_nvapi():
"""Load libnvidia-api.so from the NVIDIA driver."""
for name in ("libnvidia-api.so", "libnvidia-api.so.1"):
@@ -145,23 +145,20 @@ def nvcall_raw(fid: int, gpu, buf: ctypes.Array):
FUNC = {
# Bootstrap
"Initialize": 0x0150E828,
"EnumPhysicalGPUs": 0xE5AC921F,
"GetFullName": 0xCEEE8E9F,
"Initialize": 0x0150E828,
"EnumPhysicalGPUs": 0xE5AC921F,
"GetFullName": 0xCEEE8E9F,
# V/F curve (read)
"GetVFPCurve": 0x21537AD4, # ClkVfPointsGetStatus
"GetClockBoostMask": 0x507B4B59, # ClkVfPointsGetInfo
"GetClockBoostTable": 0x23F1B133, # ClkVfPointsGetControl
"GetCurrentVoltage": 0x465F9BCF, # ClientVoltRailsGetStatus
"GetVFPCurve": 0x21537AD4, # ClkVfPointsGetStatus
"GetClockBoostMask": 0x507B4B59, # ClkVfPointsGetInfo
"GetClockBoostTable": 0x23F1B133, # ClkVfPointsGetControl
"GetCurrentVoltage": 0x465F9BCF, # ClientVoltRailsGetStatus
"GetClockBoostRanges": 0x64B43A6A, # ClkDomainsGetInfo
# Additional read
"GetPerfLimits": 0xE440B867, # PerfClientLimitsGetStatus
"GetPerfLimits": 0xE440B867, # PerfClientLimitsGetStatus
"GetVoltBoostPercent": 0x9DF23CA1, # ClientVoltRailsGetControl
# Write
"SetClockBoostTable": 0x0733E009, # ClkVfPointsSetControl
"SetClockBoostTable": 0x0733E009, # ClkVfPointsSetControl
}
# ═══════════════════════════════════════════════════════════════════════════
@@ -170,33 +167,33 @@ FUNC = {
# ═══════════════════════════════════════════════════════════════════════════
# GetVFPCurve (0x21537AD4)
VFP_SIZE = 0x1C28
VFP_BASE = 0x48
VFP_STRIDE = 0x1C # 28 bytes
VFP_SIZE = 0x1C28
VFP_BASE = 0x48
VFP_STRIDE = 0x1C # 28 bytes
VFP_MAX_ENTRIES = (VFP_SIZE - VFP_BASE) // VFP_STRIDE # 255
# Get/SetClockBoostTable (0x23F1B133 / 0x0733E009)
CT_SIZE = 0x2420
CT_BASE = 0x44
CT_STRIDE = 0x24 # 36 bytes
CT_SIZE = 0x2420
CT_BASE = 0x44
CT_STRIDE = 0x24 # 36 bytes
CT_DELTA_OFF = 0x14 # freqDelta offset within entry
CT_MAX_ENTRIES = (CT_SIZE - CT_BASE) // CT_STRIDE # 255
# GetClockBoostMask (0x507B4B59)
MASK_SIZE = 0x182C
MASK_SIZE = 0x182C
# Other structs
VOLT_SIZE = 0x004C
RANGES_SIZE = 0x0928
PERF_SIZE = 0x030C
VBOOST_SIZE = 0x0028
VOLT_SIZE = 0x004C
RANGES_SIZE = 0x0928
PERF_SIZE = 0x030C
VBOOST_SIZE = 0x0028
# Mask location within VFP/CT structs
MASK_OFFSET = 0x04
MASK_BYTES = 32 # 256 bits — covers up to 256 points
MASK_OFFSET = 0x04
MASK_BYTES = 32 # 256 bits — covers up to 256 points
# Safety constants
MAX_DELTA_KHZ = 300_000 # ±300 MHz hard cap for safety
MAX_DELTA_KHZ = 300_000 # ±300 MHz hard cap for safety
SNAPSHOT_DIR = os.path.expanduser("~/.cache/nv_vfcurve")
@@ -205,6 +202,7 @@ SNAPSHOT_DIR = os.path.expanduser("~/.cache/nv_vfcurve")
# GPU initialization
# ═══════════════════════════════════════════════════════════════════════════
def init_gpu() -> tuple:
"""Initialize NvAPI, enumerate GPUs, return (handle, name)."""
init_fn = nvfunc(FUNC["Initialize"], 0)
@@ -236,18 +234,20 @@ def init_gpu() -> tuple:
# also distinguishes GPU core vs memory clock domains.
# ═══════════════════════════════════════════════════════════════════════════
class BoostMask:
"""Parsed GetClockBoostMask data.
Provides the raw mask bytes for copying into other calls, plus
parsed per-entry enabled info for filtering.
"""
def __init__(self, raw: bytes):
self.raw = raw
self.size = len(raw)
# The mask field at offset 0x04, 16 bytes — same position as in VFP/CT structs
self.mask_bytes = raw[MASK_OFFSET:MASK_OFFSET + MASK_BYTES]
self.mask_bytes = raw[MASK_OFFSET : MASK_OFFSET + MASK_BYTES]
self.entries = []
self._parse_entries()
@@ -260,7 +260,7 @@ class BoostMask:
enabled = bool(self.mask_bytes[byte_idx] & (1 << bit_idx))
self.entries.append({"index": i, "enabled": enabled})
def get_enabled_indices(self) -> List[int]:
def get_enabled_indices(self) -> list[int]:
"""Return list of point indices that are enabled in the mask."""
return [e["index"] for e in self.entries if e["enabled"]]
@@ -273,12 +273,13 @@ class BoostMask:
buf[offset + i] = self.mask_bytes[i]
def read_boost_mask(gpu) -> Tuple[Optional[BoostMask], str]:
def read_boost_mask(gpu) -> tuple[BoostMask | None, str]:
"""Read the clock boost mask — the canonical source of active point info.
Per nvapioc, this mask must be copied into VFP and ClockBoostTable calls.
Using all-0xFF works on some GPUs (Blackwell) but fails on others (Pascal).
"""
def fill(buf):
for i in range(MASK_OFFSET, MASK_OFFSET + MASK_BYTES):
buf[i] = 0xFF
@@ -294,20 +295,22 @@ def read_boost_mask(gpu) -> Tuple[Optional[BoostMask], str]:
# Point classification — GPU core vs memory
# ═══════════════════════════════════════════════════════════════════════════
class CurveInfo:
"""Holds classified point information for the GPU's V/F curve.
Combines data from GetClockBoostMask, GetVFPCurve, and GetClockBoostTable
to determine which points are GPU core and which are memory.
"""
def __init__(self):
self.gpu_points: List[int] = [] # GPU core V/F point indices
self.mem_points: List[int] = [] # Memory V/F point indices
self.total_points: int = 0 # Total populated entries
self.mask: Optional[BoostMask] = None
self.gpu_points: list[int] = [] # GPU core V/F point indices
self.mem_points: list[int] = [] # Memory V/F point indices
self.total_points: int = 0 # Total populated entries
self.mask: BoostMask | None = None
@staticmethod
def build(gpu, mask: Optional[BoostMask] = None) -> 'CurveInfo':
def build(gpu, mask: BoostMask | None = None) -> "CurveInfo":
"""Classify all points by reading CT field_00 and VFP data.
field_00 == 0: GPU core data point
@@ -340,7 +343,7 @@ class CurveInfo:
has_vfp_data = False
if vfp_points and i < len(vfp_points):
f, v = vfp_points[i]
has_vfp_data = (f > 0 or v > 0)
has_vfp_data = f > 0 or v > 0
has_ct_data = False
for j in range(9):
@@ -378,13 +381,15 @@ class CurveInfo:
# Data readers (mask-aware)
# ═══════════════════════════════════════════════════════════════════════════
def _fill_mask_from_boost(buf, mask: BoostMask):
"""Copy boost mask into buffer."""
mask.copy_mask_into(buf)
def _read_vfp_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[List[Tuple[int, int]]]:
def _read_vfp_with_mask(gpu, mask: BoostMask | None) -> list[tuple[int, int]] | None:
"""Read VFP curve using the canonical boost mask."""
def fill(buf):
_fill_mask_from_boost(buf, mask)
@@ -403,8 +408,9 @@ def _read_vfp_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[List[Tuple[i
return points
def _read_clock_table_raw_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[bytes]:
def _read_clock_table_raw_with_mask(gpu, mask: BoostMask | None) -> bytes | None:
"""Read raw ClockBoostTable using the canonical boost mask."""
def fill(buf):
_fill_mask_from_boost(buf, mask)
@@ -412,13 +418,14 @@ def _read_clock_table_raw_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[
return d if d else None
def read_vfp_curve(gpu, mask: Optional[BoostMask] = None,
curve_info: Optional[CurveInfo] = None
) -> Tuple[Optional[List[Tuple[int, int]]], str]:
def read_vfp_curve(
gpu, mask: BoostMask | None = None, curve_info: CurveInfo | None = None
) -> tuple[list[tuple[int, int]] | None, str]:
"""Read V/F curve (frequency + voltage pairs).
Returns up to 255 entries. Use curve_info to determine which are GPU/mem.
"""
def fill(buf):
_fill_mask_from_boost(buf, mask)
@@ -442,18 +449,20 @@ def read_vfp_curve(gpu, mask: Optional[BoostMask] = None,
return points, "OK"
def read_clock_table_raw(gpu, mask: Optional[BoostMask] = None
) -> Tuple[Optional[bytes], str]:
def read_clock_table_raw(
gpu, mask: BoostMask | None = None
) -> tuple[bytes | None, str]:
"""Read the raw ClockBoostTable buffer."""
def fill(buf):
_fill_mask_from_boost(buf, mask)
return nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill)
def read_clock_offsets(gpu, mask: Optional[BoostMask] = None,
curve_info: Optional[CurveInfo] = None
) -> Tuple[Optional[List[int]], str]:
def read_clock_offsets(
gpu, mask: BoostMask | None = None, curve_info: CurveInfo | None = None
) -> tuple[list[int] | None, str]:
"""Read per-point frequency offsets from the ClockBoostTable."""
d, err = read_clock_table_raw(gpu, mask)
if not d:
@@ -482,14 +491,18 @@ def read_clock_entry_full(data: bytes, point: int) -> dict:
for j in range(9):
off = base + j * 4
if j == 5:
fields[f"field_{j:02d}_0x{j*4:02X}"] = struct.unpack_from("<i", data, off)[0]
fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from(
"<i", data, off
)[0]
else:
fields[f"field_{j:02d}_0x{j*4:02X}"] = struct.unpack_from("<I", data, off)[0]
fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from(
"<I", data, off
)[0]
fields["freqDelta_kHz"] = fields["field_05_0x14"]
return fields
def read_voltage(gpu) -> Tuple[Optional[int], str]:
def read_voltage(gpu) -> tuple[int | None, str]:
"""Read current GPU core voltage in µV."""
d, err = nvcall(FUNC["GetCurrentVoltage"], gpu, VOLT_SIZE, ver=1)
if not d:
@@ -497,7 +510,7 @@ def read_voltage(gpu) -> Tuple[Optional[int], str]:
return struct.unpack_from("<I", d, 0x28)[0], "OK"
def read_clock_ranges(gpu) -> Tuple[Optional[dict], str]:
def read_clock_ranges(gpu) -> tuple[dict | None, str]:
"""Read clock domain min/max offset ranges."""
d, err = nvcall(FUNC["GetClockBoostRanges"], gpu, RANGES_SIZE, ver=1)
if not d:
@@ -508,8 +521,7 @@ def read_clock_ranges(gpu) -> Tuple[Optional[dict], str]:
base = 0x08 + i * 0x48
if base + 0x48 > len(d):
break
words = [struct.unpack_from("<i", d, base + j)[0]
for j in range(0, 0x48, 4)]
words = [struct.unpack_from("<i", d, base + j)[0] for j in range(0, 0x48, 4)]
domains.append(words)
return {"num_domains": num, "domains": domains}, "OK"
@@ -518,14 +530,17 @@ def read_clock_ranges(gpu) -> Tuple[Optional[dict], str]:
# Mask bit helpers
# ═══════════════════════════════════════════════════════════════════════════
def set_mask_bit(buf, point: int, offset=MASK_OFFSET):
"""Set a single bit in the mask field."""
byte_idx = offset + (point // 8)
bit_idx = point % 8
buf[byte_idx] = int.from_bytes(buf[byte_idx:byte_idx+1], 'little') | (1 << bit_idx)
buf[byte_idx] = int.from_bytes(buf[byte_idx : byte_idx + 1], "little") | (
1 << bit_idx
)
def set_mask_bits(buf, points: Set[int], offset=MASK_OFFSET):
def set_mask_bits(buf, points: set[int], offset=MASK_OFFSET):
"""Set mask bits for a set of points."""
for p in points:
set_mask_bit(buf, p, offset)
@@ -535,11 +550,12 @@ def set_mask_bits(buf, points: Set[int], offset=MASK_OFFSET):
# Write operations
# ═══════════════════════════════════════════════════════════════════════════
def build_write_buffer(
gpu,
point_deltas: dict,
mask: Optional[BoostMask] = None,
) -> Tuple[Optional[ctypes.Array], str]:
mask: BoostMask | None = None,
) -> tuple[ctypes.Array | None, str]:
"""Build a SetClockBoostTable buffer with specified per-point deltas.
Strategy: read the current ClockBoostTable (using canonical mask),
@@ -576,9 +592,9 @@ def build_write_buffer(
def write_clock_offsets(
gpu,
point_deltas: dict,
mask: Optional[BoostMask] = None,
mask: BoostMask | None = None,
dry_run: bool = False,
) -> Tuple[int, str]:
) -> tuple[int, str]:
"""Write per-point frequency offsets via SetClockBoostTable."""
buf, err = build_write_buffer(gpu, point_deltas, mask)
if buf is None:
@@ -595,9 +611,10 @@ def write_clock_offsets(
# Safety checks
# ═══════════════════════════════════════════════════════════════════════════
def validate_write_request(point_deltas: dict,
curve_info: Optional[CurveInfo] = None
) -> Optional[str]:
def validate_write_request(
point_deltas: dict, curve_info: CurveInfo | None = None
) -> str | None:
"""Return an error message if the write request is unsafe, else None."""
mem_points = set()
if curve_info:
@@ -608,14 +625,18 @@ def validate_write_request(point_deltas: dict,
return f"Point {point} out of range (0–{CT_MAX_ENTRIES - 1})"
if point in mem_points:
return (f"Point {point} is a memory clock entry. "
"Memory offsets use a different mechanism (NVML). "
"Use --force if you really mean it.")
return (
f"Point {point} is a memory clock entry. "
"Memory offsets use a different mechanism (NVML). "
"Use --force if you really mean it."
)
if abs(delta_khz) > MAX_DELTA_KHZ:
return (f"Delta {delta_khz/1000:+.0f} MHz for point {point} exceeds "
f"safety limit of ±{MAX_DELTA_KHZ/1000:.0f} MHz. "
"Use --max-delta to raise the limit if needed.")
return (
f"Delta {delta_khz / 1000:+.0f} MHz for point {point} exceeds "
f"safety limit of ±{MAX_DELTA_KHZ / 1000:.0f} MHz. "
"Use --max-delta to raise the limit if needed."
)
return None
@@ -624,11 +645,12 @@ def validate_write_request(point_deltas: dict,
# Hex dump utility
# ═══════════════════════════════════════════════════════════════════════════
def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str:
lines = []
end = min(start + length, len(data))
for off in range(start, end, cols):
chunk = data[off:off + cols]
chunk = data[off : off + cols]
hx = " ".join(f"{b:02x}" for b in chunk)
asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
lines.append(f" {off:04x}: {hx:<{cols * 3}} {asc}")
@@ -639,7 +661,8 @@ def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str:
# Snapshot save/restore
# ═══════════════════════════════════════════════════════════════════════════
def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None):
def snapshot_save(gpu, gpu_name: str, mask: BoostMask | None = None):
"""Save the current ClockBoostTable to disk."""
raw, err = read_clock_table_raw(gpu, mask)
if not raw:
@@ -674,7 +697,7 @@ def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None):
with open(meta_fname, "w") as f:
json.dump(meta, f, indent=2)
print(f"Snapshot saved:")
print("Snapshot saved:")
print(f" Binary: {fname}")
print(f" Metadata: {meta_fname}")
print(f" Size: {len(raw)} bytes")
@@ -682,7 +705,7 @@ def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None):
return True
def snapshot_restore(gpu, mask: Optional[BoostMask] = None, filepath: str = None):
def snapshot_restore(gpu, mask: BoostMask | None = None, filepath: str = None):
"""Restore a ClockBoostTable snapshot from disk."""
if filepath is None:
if not os.path.isdir(SNAPSHOT_DIR):
@@ -730,7 +753,8 @@ def snapshot_restore(gpu, mask: Optional[BoostMask] = None, filepath: str = None
# Diagnostics
# ═══════════════════════════════════════════════════════════════════════════
def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
def run_diagnostics(gpu, gpu_name, mask: BoostMask | None = None):
"""Probe all known functions and report results."""
print(f"GPU: {gpu_name}")
print()
@@ -739,14 +763,14 @@ def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
print("=== Function probe ===")
print()
probes = [
("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1),
("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1),
("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1),
("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1),
("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1),
("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1),
("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1),
("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1),
("GetClockBoostRanges", FUNC["GetClockBoostRanges"], RANGES_SIZE, 1),
("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2),
("GetVoltBoostPercent", FUNC["GetVoltBoostPercent"], VBOOST_SIZE, 1),
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1),
("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2),
("GetVoltBoostPercent", FUNC["GetVoltBoostPercent"], VBOOST_SIZE, 1),
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1),
]
for name, fid, size, ver in probes:
ptr = QI(fid)
@@ -768,7 +792,9 @@ def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
# Step 3: test reads with the proper mask
needs_mask_fns = {
FUNC["GetVFPCurve"], FUNC["GetClockBoostMask"], FUNC["GetClockBoostTable"]
FUNC["GetVFPCurve"],
FUNC["GetClockBoostMask"],
FUNC["GetClockBoostTable"],
}
print()
@@ -817,8 +843,10 @@ def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
# Output formatting
# ═══════════════════════════════════════════════════════════════════════════
def print_curve(points, offsets, voltage, curve_info: Optional[CurveInfo] = None,
full=False):
def print_curve(
points, offsets, voltage, curve_info: CurveInfo | None = None, full=False
):
"""Print formatted V/F curve table."""
if voltage:
print(f"Current voltage: {voltage / 1000:.1f} mV")
@@ -845,9 +873,7 @@ def print_curve(points, offsets, voltage, curve_info: Optional[CurveInfo] = None
for i, (f, v) in enumerate(points):
if f == 0 and v == 0:
continue
if i in mem_set:
show.append(i)
elif f != prev_freq or i == len(points) - 1:
if i in mem_set or f != prev_freq or i == len(points) - 1:
show.append(i)
prev_freq = f
@@ -882,52 +908,78 @@ def print_curve(points, offsets, voltage, curve_info: Optional[CurveInfo] = None
# Summary
if curve_info and curve_info.gpu_points:
gpu_data = [(points[i][0], points[i][1]) for i in curve_info.gpu_points
if i < len(points) and points[i][0] > 0]
gpu_data = [
(points[i][0], points[i][1])
for i in curve_info.gpu_points
if i < len(points) and points[i][0] > 0
]
if gpu_data:
freqs = [f for f, v in gpu_data]
volts = [v for f, v in gpu_data]
print()
print(f"GPU core: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, "
f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV "
f"({len(gpu_data)} points)")
print(
f"GPU core: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz, "
f"{min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV "
f"({len(gpu_data)} points)"
)
if curve_info and curve_info.mem_points:
mem_data = [(points[i][0], points[i][1]) for i in curve_info.mem_points
if i < len(points) and points[i][0] > 0]
mem_data = [
(points[i][0], points[i][1])
for i in curve_info.mem_points
if i < len(points) and points[i][0] > 0
]
if mem_data:
freqs = [f for f, v in mem_data]
volts = [v for f, v in mem_data]
print(f"Memory: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, "
f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV "
f"({len(mem_data)} points)")
print(
f"Memory: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz, "
f"{min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV "
f"({len(mem_data)} points)"
)
if offsets:
gpu_indices = set(curve_info.gpu_points) if curve_info else set(range(len(offsets)))
gpu_offsets = [offsets[i] for i in gpu_indices
if i < len(offsets) and offsets[i] != 0]
gpu_indices = (
set(curve_info.gpu_points) if curve_info else set(range(len(offsets)))
)
gpu_offsets = [
offsets[i] for i in gpu_indices if i < len(offsets) and offsets[i] != 0
]
if gpu_offsets:
vals = set(gpu_offsets)
if len(vals) == 1:
print(f"GPU offset: {next(iter(vals))/1000:+.0f} MHz "
f"(uniform across {len(gpu_offsets)} points)")
print(
f"GPU offset: {next(iter(vals)) / 1000:+.0f} MHz "
f"(uniform across {len(gpu_offsets)} points)"
)
else:
print(f"GPU offsets: {len(gpu_offsets)} points active "
f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)")
print(
f"GPU offsets: {len(gpu_offsets)} points active "
f"(range: {min(vals) / 1000:+.0f} to {max(vals) / 1000:+.0f} MHz)"
)
def output_json(gpu_name, points, offsets, voltage,
curve_info: Optional[CurveInfo] = None):
def output_json(
gpu_name, points, offsets, voltage, curve_info: CurveInfo | None = None
):
"""Output JSON format."""
data = {
"gpu": gpu_name,
"current_voltage_uV": voltage,
"layout": {
"vfp_curve": {"size": VFP_SIZE, "base": VFP_BASE,
"stride": VFP_STRIDE, "max_entries": VFP_MAX_ENTRIES},
"clock_table": {"size": CT_SIZE, "base": CT_BASE,
"stride": CT_STRIDE, "delta_offset": CT_DELTA_OFF,
"max_entries": CT_MAX_ENTRIES},
"vfp_curve": {
"size": VFP_SIZE,
"base": VFP_BASE,
"stride": VFP_STRIDE,
"max_entries": VFP_MAX_ENTRIES,
},
"clock_table": {
"size": CT_SIZE,
"base": CT_BASE,
"stride": CT_STRIDE,
"delta_offset": CT_DELTA_OFF,
"max_entries": CT_MAX_ENTRIES,
},
},
"curve_info": {
"gpu_points": curve_info.gpu_points if curve_info else [],
@@ -958,6 +1010,7 @@ def output_json(gpu_name, points, offsets, voltage,
# Write command handler
# ═══════════════════════════════════════════════════════════════════════════
def cmd_write(gpu, gpu_name, args, mask, curve_info):
"""Handle write subcommand."""
delta_khz = int(args.delta * 1000)
@@ -977,21 +1030,27 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
elif args.point is not None:
point_deltas[args.point] = delta_khz
print(f"Target: point {args.point}, delta {args.delta:+.0f} MHz "
f"({delta_khz:+d} kHz)")
print(
f"Target: point {args.point}, delta {args.delta:+.0f} MHz "
f"({delta_khz:+d} kHz)"
)
elif args.range:
start, end = args.range
for i in range(start, end + 1):
point_deltas[i] = delta_khz
print(f"Target: points {start}–{end} ({len(point_deltas)} points), "
f"delta {args.delta:+.0f} MHz")
print(
f"Target: points {start}–{end} ({len(point_deltas)} points), "
f"delta {args.delta:+.0f} MHz"
)
elif args.glob:
for i in gpu_points:
point_deltas[i] = delta_khz
print(f"Target: all {len(point_deltas)} GPU core points, "
f"delta {args.delta:+.0f} MHz")
print(
f"Target: all {len(point_deltas)} GPU core points, "
f"delta {args.delta:+.0f} MHz"
)
else:
print("Error: specify --point N, --range A-B, --global, or --reset")
@@ -1013,11 +1072,17 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
changed = 0
for point in sorted(point_deltas.keys()):
new = point_deltas[point]
old = current_offsets[point] if current_offsets and point < len(current_offsets) else 0
old = (
current_offsets[point]
if current_offsets and point < len(current_offsets)
else 0
)
if old != new:
changed += 1
if changed <= 20:
print(f" Point {point:3d}: {old/1000:+8.0f} MHz → {new/1000:+8.0f} MHz")
print(
f" Point {point:3d}: {old / 1000:+8.0f} MHz → {new / 1000:+8.0f} MHz"
)
if changed > 20:
print(f" ... and {changed - 20} more points")
if changed == 0:
@@ -1036,8 +1101,10 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
first_pt = min(point_deltas.keys())
entry_off = CT_BASE + first_pt * CT_STRIDE
print(f"\nEntry for point {first_pt} (offset 0x{entry_off:04X}, "
f"stride 0x{CT_STRIDE:02X}):")
print(
f"\nEntry for point {first_pt} (offset 0x{entry_off:04X}, "
f"stride 0x{CT_STRIDE:02X}):"
)
print(hexdump(bytes(buf), entry_off, CT_STRIDE))
return
@@ -1070,8 +1137,10 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
actual = new_offsets[point] if point < len(new_offsets) else 0
if actual != expected:
mismatches += 1
print(f" MISMATCH point {point}: expected {expected/1000:+.0f} MHz, "
f"got {actual/1000:+.0f} MHz")
print(
f" MISMATCH point {point}: expected {expected / 1000:+.0f} MHz, "
f"got {actual / 1000:+.0f} MHz"
)
if mismatches == 0:
print(f"Verified: all {len(point_deltas)} points match expected values.")
@@ -1083,6 +1152,7 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
# Verify command handler
# ═══════════════════════════════════════════════════════════════════════════
def cmd_verify(gpu, gpu_name, args, mask, curve_info):
"""Write-verify-read cycle for a single point or range."""
delta_khz = int(args.delta * 1000)
@@ -1095,14 +1165,14 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
print("Error: --point or --range required for verify mode")
return
point_deltas = {p: delta_khz for p in points}
point_deltas = dict.fromkeys(points, delta_khz)
err = validate_write_request(point_deltas, curve_info)
if err:
print(f"Safety check FAILED: {err}")
return
print(f"=== Write-Verify Cycle ===")
print("=== Write-Verify Cycle ===")
print(f"GPU: {gpu_name}")
if curve_info:
print(f"Curve: {curve_info.describe()}")
@@ -1121,7 +1191,7 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
for p in points[:5]:
entry = read_clock_entry_full(before_raw, p) if before_raw else {}
off_val = before_offsets[p] if p < len(before_offsets) else 0
print(f" Point {p:3d}: freqDelta = {off_val/1000:+8.0f} MHz")
print(f" Point {p:3d}: freqDelta = {off_val / 1000:+8.0f} MHz")
if entry:
print(f" All fields: {entry}")
@@ -1156,8 +1226,10 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
match = "OK" if actual == expected else "MISMATCH"
if actual != expected:
all_ok = False
print(f" Point {p:3d}: expected {expected/1000:+8.0f} MHz, "
f"got {actual/1000:+8.0f} MHz [{match}]")
print(
f" Point {p:3d}: expected {expected / 1000:+8.0f} MHz, "
f"got {actual / 1000:+8.0f} MHz [{match}]"
)
# Step 5: Check for collateral damage
print()
@@ -1169,8 +1241,10 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
continue
if before_offsets[i] != after_offsets[i]:
collateral += 1
print(f" WARNING: Point {i} changed unexpectedly: "
f"{before_offsets[i]/1000:+.0f} → {after_offsets[i]/1000:+.0f} MHz")
print(
f" WARNING: Point {i} changed unexpectedly: "
f"{before_offsets[i] / 1000:+.0f} → {after_offsets[i] / 1000:+.0f} MHz"
)
if collateral == 0:
print(" No unintended changes detected.")
@@ -1187,14 +1261,16 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
continue
if before_entry[key] != after_entry[key]:
field_changes += 1
print(f" Point {p}, {key}: {before_entry[key]} → {after_entry[key]}")
print(
f" Point {p}, {key}: {before_entry[key]} → {after_entry[key]}"
)
if field_changes == 0:
print(" No unknown fields changed.")
# Step 7: Read voltage
voltage, _ = read_voltage(gpu)
if voltage:
print(f"\nCurrent voltage after write: {voltage/1000:.1f} mV")
print(f"\nCurrent voltage after write: {voltage / 1000:.1f} mV")
# Summary
print()
@@ -1215,6 +1291,7 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
# Inspect command
# ═══════════════════════════════════════════════════════════════════════════
def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
"""Show detailed field-level data for specific points."""
raw, err = read_clock_table_raw(gpu, mask)
@@ -1246,8 +1323,9 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
print(f"GPU: {gpu_name}")
if curve_info:
print(f"Curve: {curve_info.describe()}")
print(f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, "
f"9 fields × 4 bytes)")
print(
f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, 9 fields × 4 bytes)"
)
print()
for p in indices:
@@ -1267,7 +1345,7 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
freq_str = ""
if vfp_points and p < len(vfp_points):
f, v = vfp_points[p]
freq_str = f" (VFP: {f/1000:.0f} MHz @ {v/1000:.0f} mV)"
freq_str = f" (VFP: {f / 1000:.0f} MHz @ {v / 1000:.0f} mV)"
print(f"Point {p:3d} — buffer offset 0x{off:04X}{domain}{freq_str}")
for key, val in entry.items():
@@ -1275,8 +1353,10 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
continue
marker = " ← freqDelta" if "0x14" in key else ""
if "0x14" in key:
print(f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})"
f" = {val/1000:+.0f} MHz{marker}")
print(
f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})"
f" = {val / 1000:+.0f} MHz{marker}"
)
else:
print(f" {key}: {val:12d} (0x{val:08X})")
print()
@@ -1286,6 +1366,7 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
# Read command handler
# ═══════════════════════════════════════════════════════════════════════════
def cmd_read(gpu, gpu_name, args, mask, curve_info):
"""Handle read subcommand."""
if args.diag:
@@ -1309,10 +1390,13 @@ def cmd_read(gpu, gpu_name, args, mask, curve_info):
print(f"GPU: {gpu_name}")
if args.raw:
def fill_vfp(buf):
_fill_mask_from_boost(buf, mask)
vfp_raw, _ = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE,
ver=1, pre_fill=fill_vfp)
vfp_raw, _ = nvcall(
FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill_vfp
)
ct_raw, _ = read_clock_table_raw(gpu, mask)
if vfp_raw:
@@ -1348,7 +1432,8 @@ def cmd_read(gpu, gpu_name, args, mask, curve_info):
# Argument parsing
# ═══════════════════════════════════════════════════════════════════════════
def parse_range(s: str) -> Tuple[int, int]:
def parse_range(s: str) -> tuple[int, int]:
"""Parse 'A-B' into (A, B) tuple."""
parts = s.split("-")
if len(parts) != 2:
@@ -1360,7 +1445,9 @@ def parse_range(s: str) -> Tuple[int, int]:
if a > b:
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
if a < 0 or b >= CT_MAX_ENTRIES:
raise argparse.ArgumentTypeError(f"Range {a}-{b} outside 0–{CT_MAX_ENTRIES - 1}")
raise argparse.ArgumentTypeError(
f"Range {a}-{b} outside 0–{CT_MAX_ENTRIES - 1}"
)
return (a, b)
@@ -1390,14 +1477,16 @@ Examples:
# --- read ---
p_read = sub.add_parser("read", help="Read V/F curve (default)")
p_read.add_argument("--full", action="store_true",
help="Show all points including empty slots")
p_read.add_argument("--json", action="store_true",
help="JSON output with domain classification")
p_read.add_argument("--raw", action="store_true",
help="Include hex dumps")
p_read.add_argument("--diag", action="store_true",
help="Probe all functions with mask comparison")
p_read.add_argument(
"--full", action="store_true", help="Show all points including empty slots"
)
p_read.add_argument(
"--json", action="store_true", help="JSON output with domain classification"
)
p_read.add_argument("--raw", action="store_true", help="Include hex dumps")
p_read.add_argument(
"--diag", action="store_true", help="Probe all functions with mask comparison"
)
# --- inspect ---
p_insp = sub.add_parser("inspect", help="Show detailed entry fields")
@@ -1409,30 +1498,42 @@ Examples:
tgt = p_write.add_mutually_exclusive_group()
tgt.add_argument("--point", type=int, help="Single point index")
tgt.add_argument("--range", type=parse_range, help="Point range A-B")
tgt.add_argument("--global", dest="glob", action="store_true",
help="All GPU core points")
tgt.add_argument("--reset", action="store_true",
help="Reset all GPU core offsets to 0")
p_write.add_argument("--delta", type=float, default=0.0,
help="Frequency offset in MHz (e.g. 15, -30)")
p_write.add_argument("--dry-run", action="store_true",
help="Preview changes without applying")
p_write.add_argument("--force", action="store_true",
help="Allow modifying memory points")
p_write.add_argument("--max-delta", type=float, default=300.0,
help="Override safety limit (MHz, default 300)")
tgt.add_argument(
"--global", dest="glob", action="store_true", help="All GPU core points"
)
tgt.add_argument(
"--reset", action="store_true", help="Reset all GPU core offsets to 0"
)
p_write.add_argument(
"--delta",
type=float,
default=0.0,
help="Frequency offset in MHz (e.g. 15, -30)",
)
p_write.add_argument(
"--dry-run", action="store_true", help="Preview changes without applying"
)
p_write.add_argument(
"--force", action="store_true", help="Allow modifying memory points"
)
p_write.add_argument(
"--max-delta",
type=float,
default=300.0,
help="Override safety limit (MHz, default 300)",
)
# --- verify ---
p_ver = sub.add_parser("verify", help="Write-verify-read cycle")
p_ver.add_argument("--point", type=int, help="Single point index")
p_ver.add_argument("--range", type=parse_range, help="Point range A-B")
p_ver.add_argument("--delta", type=float, required=True,
help="Frequency offset in MHz")
p_ver.add_argument(
"--delta", type=float, required=True, help="Frequency offset in MHz"
)
# --- snapshot ---
p_snap = sub.add_parser("snapshot", help="Save/restore ClockBoostTable")
p_snap.add_argument("action", choices=["save", "restore"],
help="save or restore")
p_snap.add_argument("action", choices=["save", "restore"], help="save or restore")
p_snap.add_argument("--file", help="Snapshot file path (for restore)")
args = parser.parse_args()
+355
View File
@@ -0,0 +1,355 @@
"""Unit tests for the RM power-limit interface (fake RM, no hardware).
Standalone (no pytest required):
python tests/test_rm_power.py
Also works under pytest if available. Ports the test battery from LACT PR
#1205 (ilya-zlobintsev/LACT): layout discovery, NVML cross-validation,
write minimality, readback verification, and failure restoration.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from nvcurve.hal.rm_power import ( # noqa: E402
_CTRL_GPU_GET_ATTACHED_IDS,
_CTRL_GPU_GET_ID_INFO_V2,
_CTRL_GPU_GET_PCI_INFO,
_PWR_GET_CONTROL,
_PWR_GET_INFO,
_PWR_SET_CONTROL,
EXTENDED_LAYOUT,
LEGACY_LAYOUT,
PciLocation,
PowerLimitBounds,
RmPowerError,
_u32,
probe,
resolve_gpu_instance,
set_limit,
)
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}")
BOUNDS = PowerLimitBounds(min_mw=250_000, default_mw=300_000, max_mw=325_000)
class FakeRm:
"""In-memory fake of the RM power-limit client (both wire layouts)."""
def __init__(self, layout, current: int) -> None:
self.layout = layout
self.control = bytearray(layout.control_size)
self.control[0:8] = bytes([0xFF, 0, 0, 0, 1, 0, 0, 0])
self.control[layout.request_at - 4 : layout.request_at] = bytes(
[0x67, 0x67, 0, 0]
)
self.control[layout.request_at : layout.request_at + 4] = current.to_bytes(
4, "little"
)
self.control[layout.client_at] = 0xFE
self.reads: list[tuple[int, int]] = []
self.writes: list[bytes] = []
self.fail_first_write = False
self.fail_readback = False
self.fail_restore = False
def query(self, cmd: int, data: bytearray) -> None:
if cmd == _PWR_GET_INFO:
self.reads.append((cmd, len(data)))
if len(data) != self.layout.info_size:
raise RmPowerError("Unsupported INFO size")
data[0:8] = bytes([0xFF, 0, 0, 0, 1, 0, 0, 0])
for index, value in enumerate([250_000, 300_000, 325_000]):
offset = self.layout.info_min_at + 4 * index
data[offset : offset + 4] = value.to_bytes(4, "little")
elif cmd == _PWR_GET_CONTROL:
self.reads.append((cmd, len(data)))
if len(data) != self.layout.control_size:
raise RmPowerError("Unsupported CONTROL size")
if data[self.layout.client_at] != 0xFE:
raise AssertionError("unexpected client selector on GET")
if self.fail_readback and len(self.writes) == 1:
raise RmPowerError("readback unavailable")
data[:] = self.control
elif cmd == _PWR_SET_CONTROL:
if len(data) != self.layout.control_size:
raise AssertionError("bad SET size")
if data[4:8] != (1).to_bytes(4, "little"):
raise AssertionError("bad SET mask")
if data[self.layout.client_at] != 0xFE:
raise AssertionError("bad SET client selector")
# Only the request field may differ from the current state.
for i, (a, b) in enumerate(zip(data, self.control, strict=True)):
if self.layout.request_at <= i < self.layout.request_at + 4:
continue
if a != b:
raise AssertionError(f"SET modified byte {i:#x}")
self.writes.append(bytes(data))
if self.fail_restore and len(self.writes) > 1:
raise RmPowerError("restore unavailable")
self.control[:] = data
if self.fail_first_write and len(self.writes) == 1:
raise RmPowerError("SET failed after modifying hardware")
else:
raise AssertionError(f"unexpected command {cmd:#x}")
def test_detects_both_layouts_with_gets_without_a_driver_version() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 250_000)
support = probe(BOUNDS, 250_000, rm.query)
check(
f"{layout.name}: detected",
support.bounds == BOUNDS and support.layout == layout,
)
expected = (
[(_PWR_GET_INFO, 0x924), (_PWR_GET_CONTROL, 0x328)]
if layout == EXTENDED_LAYOUT
else [
(_PWR_GET_INFO, 0x924),
(_PWR_GET_INFO, 0x488),
(_PWR_GET_CONTROL, 0x188),
]
)
check(f"{layout.name}: GETs only, expected sequence", rm.reads == expected)
check(f"{layout.name}: no writes during discovery", rm.writes == [])
def test_unknown_layout_and_nvml_mismatches_never_write() -> None:
calls: list[tuple[int, int]] = []
def failing(cmd: int, data: bytearray) -> None:
calls.append((cmd, len(data)))
raise RmPowerError("Unsupported payload")
try:
probe(BOUNDS, 250_000, failing)
check("unknown layout rejected", False)
except RmPowerError:
check("unknown layout rejected", True)
check(
"unknown layout: only GETs attempted",
calls == [(_PWR_GET_INFO, 0x924), (_PWR_GET_INFO, 0x488)],
)
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 250_000)
try:
probe(BOUNDS, 300_000, rm.query)
check(f"{layout.name}: current mismatch rejected", False)
except RmPowerError:
check(f"{layout.name}: current mismatch rejected", True)
other_bounds = PowerLimitBounds(
min_mw=BOUNDS.min_mw, default_mw=BOUNDS.default_mw, max_mw=350_000
)
try:
probe(other_bounds, 250_000, rm.query)
check(f"{layout.name}: bounds mismatch rejected", False)
except RmPowerError:
check(f"{layout.name}: bounds mismatch rejected", True)
check(f"{layout.name}: no writes on mismatch", rm.writes == [])
def test_rejects_unrecognized_headers_masks_and_client_values() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
for at, value in [(0, 0), (4, 3), (layout.client_at, 0xF8)]:
rm = FakeRm(layout, 250_000)
rm.control[at] = value
try:
probe(BOUNDS, 250_000, rm.query)
check(f"{layout.name}: bad header/client rejected", False)
except RmPowerError:
check(f"{layout.name}: bad header/client rejected", True)
check(f"{layout.name}: no writes on bad header", rm.writes == [])
for current in (0, 0xFFFFFFFF):
rm = FakeRm(layout, current)
try:
probe(BOUNDS, current, rm.query)
check(f"{layout.name}: empty request rejected", False)
except RmPowerError:
check(f"{layout.name}: empty request rejected", True)
# The extended layout has additional mask words. Accepting only its low
# word would allow an unexpected client to be included in a later SET.
rm = FakeRm(EXTENDED_LAYOUT, 250_000)
rm.control[8] = 1
try:
probe(BOUNDS, 250_000, rm.query)
check("extended: nonzero mask word rejected", False)
except RmPowerError:
check("extended: nonzero mask word rejected", True)
check("extended: no writes on mask violation", rm.writes == [])
def test_changes_only_fe_request_and_keeps_vbios_maximum() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 250_000)
support = probe(BOUNDS, 250_000, rm.query)
for cap in (150_000, 30_000, 250_000):
set_limit(cap, support, rm.query)
check(
f"{layout.name}: set {cap} mW",
_u32(rm.control, layout.request_at) == cap,
)
writes = len(rm.writes)
for cap in (0, 29_999, 325_001, 350_000, 0xFFFFFFFF):
try:
set_limit(cap, support, rm.query)
check(f"{layout.name}: out-of-range {cap} rejected", False)
except RmPowerError:
check(f"{layout.name}: out-of-range {cap} rejected", True)
check(
f"{layout.name}: no writes for out-of-range caps",
len(rm.writes) == writes,
)
def test_restores_previous_below_minimum_request_after_set_or_readback_failure() -> (
None
):
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
for fail_set in (False, True):
rm = FakeRm(layout, 100_000)
original = bytes(rm.control)
rm.fail_first_write = fail_set
rm.fail_readback = not fail_set
support = probe(BOUNDS, 100_000, rm.query)
try:
set_limit(150_000, support, rm.query)
check(f"{layout.name}: failure reported", False)
except RmPowerError:
check(f"{layout.name}: failure reported", True)
check(f"{layout.name}: restore issued", len(rm.writes) == 2)
check(
f"{layout.name}: previous request restored",
bytes(rm.control) == original,
)
def test_reports_restore_failure_and_rejects_wrong_client_before_writing() -> None:
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
rm = FakeRm(layout, 100_000)
rm.fail_first_write = True
rm.fail_restore = True
support = probe(BOUNDS, 100_000, rm.query)
try:
set_limit(150_000, support, rm.query)
check(f"{layout.name}: restore failure reported", False)
except RmPowerError as exc:
check(
f"{layout.name}: restore failure reported",
"restoration also failed" in str(exc),
)
rm = FakeRm(layout, 250_000)
rm.control[layout.client_at] = 0xF8
try:
set_limit(150_000, support, rm.query)
check(f"{layout.name}: wrong client rejected", False)
except RmPowerError:
check(f"{layout.name}: wrong client rejected", True)
check(f"{layout.name}: no writes for wrong client", rm.writes == [])
# ── PCI identity → RM instance resolution ────────────────────────────────────
def test_resolves_pci_identity_when_minor_and_rm_orders_differ() -> None:
# This host has Ada at minor 5/RM 4 and the 5090 at minor 4/RM 5.
# IDs are opaque and enumeration order must not select the device.
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=0)
instances = resolve_gpu_instance(pci, lambda cmd, data: _fake_root(cmd, data))
check("resolves by PCI identity", instances == (5, 2))
def _fake_root(cmd: int, data: bytearray) -> None:
if cmd == _CTRL_GPU_GET_ATTACHED_IDS:
data[0:4] = (0x2E00).to_bytes(4, "little")
data[4:8] = (0x0D00).to_bytes(4, "little")
elif cmd == _CTRL_GPU_GET_PCI_INFO:
gpu_id = _u32(data, 0)
bus = 0x2E if gpu_id == 0x2E00 else 0x0D
data[8:10] = bus.to_bytes(2, "little")
elif cmd == _CTRL_GPU_GET_ID_INFO_V2:
if _u32(data, 0) != 0x0D00:
raise AssertionError("unexpected gpu id in ID_INFO_V2")
data[8:12] = (5).to_bytes(4, "little")
data[12:16] = (2).to_bytes(4, "little")
else:
raise AssertionError(f"unexpected command {cmd:#x}")
def test_does_not_fall_back_to_another_gpu_when_pci_is_missing() -> None:
pci = PciLocation(domain=1, bus=0x0D, dev=0, func=0)
def query(cmd: int, data: bytearray) -> None:
if cmd == _CTRL_GPU_GET_ATTACHED_IDS:
data[0:4] = (0x0D00).to_bytes(4, "little")
elif cmd == _CTRL_GPU_GET_PCI_INFO:
data[8:10] = (0x0D).to_bytes(2, "little")
else:
raise AssertionError("must not allocate a GPU from another PCI domain")
try:
resolve_gpu_instance(pci, query)
check("foreign PCI domain rejected", False)
except RmPowerError:
check("foreign PCI domain rejected", True)
def test_rejects_nonzero_pci_function() -> None:
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=1)
try:
resolve_gpu_instance(pci, lambda cmd, data: None)
check("nonzero function rejected", False)
except RmPowerError:
check("nonzero function rejected", True)
def test_propagates_rm_query_failure() -> None:
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=0)
try:
resolve_gpu_instance(
pci, lambda cmd, data: (_ for _ in ()).throw(RmPowerError("RM unavailable"))
)
check("RM query failure propagated", False)
except RmPowerError as exc:
check("RM query failure propagated", "RM unavailable" in str(exc))
def main() -> int:
tests = [
test_detects_both_layouts_with_gets_without_a_driver_version,
test_unknown_layout_and_nvml_mismatches_never_write,
test_rejects_unrecognized_headers_masks_and_client_values,
test_changes_only_fe_request_and_keeps_vbios_maximum,
test_restores_previous_below_minimum_request_after_set_or_readback_failure,
test_reports_restore_failure_and_rejects_wrong_client_before_writing,
test_resolves_pci_identity_when_minor_and_rm_orders_differ,
test_does_not_fall_back_to_another_gpu_when_pci_is_missing,
test_rejects_nonzero_pci_function,
test_propagates_rm_query_failure,
]
for t in tests:
print(f"== {t.__name__} ==")
t()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
+263
View File
@@ -0,0 +1,263 @@
"""Security regression tests for nvcurve.
Standalone (no pytest required):
python tests/test_security.py
Also works under pytest if available. Covers the security-critical logic:
SPA path containment, snapshot restore containment, login lockout client-IP
derivation, TLS scheme detection, daemon socket hardening, and the
server-enforced safety cap.
"""
import asyncio
import builtins
import io
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from nvcurve import (
daemon, # noqa: E402
server, # noqa: E402
)
from nvcurve.cli import _discover_server_url # noqa: E402
from nvcurve.config import ( # noqa: E402
Config,
normalize_trusted_proxies,
tls_enabled,
)
from nvcurve.hal.snapshot import restore as snapshot_restore # 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 _encoded_traversal(target: str = "/etc/hostname") -> str:
"""Build an encoded '..'-based traversal path deep enough to escape any dist dir."""
dist = os.path.abspath(server._dist_dir)
depth = len(dist.rstrip("/").split("/"))
enc = lambda s: s.replace("/", "%2f") # noqa: E731
return "/" + enc("../*" + str(depth + 2) + target)
def test_spa_path_containment() -> None:
"""The SPA catch-all must not serve files outside the dist directory."""
from fastapi.testclient import TestClient
client = TestClient(server.app)
r = client.get(_encoded_traversal())
check("SPA traversal -> 404", r.status_code == 404)
r = client.get(_encoded_traversal("/etc/passwd"))
check("SPA traversal /etc/passwd -> 404", r.status_code == 404)
# Normal static files must still be served.
r = client.get("/index.html")
check("SPA /index.html -> 200", r.status_code == 200)
r = client.get("/api/nonexistent")
check("unknown /api/ path -> 404", r.status_code == 404)
def test_snapshot_restore_containment() -> None:
"""Snapshot restore must only read files inside the snapshot dir."""
tmp = tempfile.mkdtemp()
snap_dir = os.path.join(tmp, "snaps")
os.makedirs(snap_dir)
outside = os.path.join(tmp, "evil.bin")
with open(outside, "wb") as f:
f.write(b"\x00" * 9248)
check(
"restore(outside file) rejected",
snapshot_restore(None, snap_dir, outside) is False,
)
check(
"restore(nonexistent) rejected",
snapshot_restore(None, snap_dir, "/etc/hostname") is False,
)
link = os.path.join(snap_dir, "link.bin")
os.symlink(outside, link)
check(
"restore(symlink escape) rejected",
snapshot_restore(None, snap_dir, link) is False,
)
def test_safety_cap_not_client_overridable() -> None:
"""The API must not accept a per-request safety cap override."""
check(
"WriteRequest has no max_delta_khz field",
"max_delta_khz" not in server.WriteRequest.model_fields,
)
check(
"GlobalOffsetRequest has no max_delta_khz field",
"max_delta_khz" not in server.GlobalOffsetRequest.model_fields,
)
def test_client_ip_derivation() -> None:
"""X-Forwarded-For is only honoured for configured trusted proxies."""
class FakeReq:
def __init__(self, peer: str, headers: dict):
self.client = type("C", (), {"host": peer})()
self.headers = headers
check(
"trusted proxy -> XFF used",
server._client_ip(
FakeReq("127.0.0.1", {"x-forwarded-for": "9.9.9.9"}), ["127.0.0.1"]
)
== "9.9.9.9",
)
check(
"untrusted peer -> XFF ignored",
server._client_ip(
FakeReq("8.8.8.8", {"x-forwarded-for": "9.9.9.9"}), ["127.0.0.1"]
)
== "8.8.8.8",
)
check(
"rightmost untrusted hop",
server._client_ip(
FakeReq("127.0.0.1", {"x-forwarded-for": "127.0.0.1, 9.9.9.9"}),
["127.0.0.1"],
)
== "9.9.9.9",
)
check(
"no XFF -> peer",
server._client_ip(FakeReq("127.0.0.1", {}), ["127.0.0.1"]) == "127.0.0.1",
)
check(
"all-trusted chain -> peer",
server._client_ip(
FakeReq("127.0.0.1", {"x-forwarded-for": "10.0.0.1, 10.0.0.2"}),
["127.0.0.1", "10.0.0.1", "10.0.0.2"],
)
== "127.0.0.1",
)
def test_trusted_proxies_normalization() -> None:
"""String values must be normalized to lists (no substring matching)."""
check("list passthrough", normalize_trusted_proxies(["1.2.3.4"]) == ["1.2.3.4"])
check(
"comma string split",
normalize_trusted_proxies("1.2.3.4, 5.6.7.8") == ["1.2.3.4", "5.6.7.8"],
)
check("None -> []", normalize_trusted_proxies(None) == [])
check("junk -> []", normalize_trusted_proxies(42) == [])
# The original bug: substring membership. After normalization, "127.0.0.1"
# must NOT be trusted when only "127.0.0.10" is listed.
trusted = normalize_trusted_proxies(["127.0.0.10"])
check("no substring trust", "127.0.0.1" not in trusted)
def test_tls_scheme_detection() -> None:
"""_discover_server_url must pick https when TLS is configured."""
from nvcurve import cli as cli_mod
# Hermetic: hide any live server's runtime info file and any existing
# persistent config so the defaults level of the priority chain is
# exercised.
real_info_file = cli_mod._SERVER_INFO_FILE
real_persistent_cfg = cli_mod._PERSISTENT_CONFIG_FILE
hidden = tempfile.mkdtemp()
cli_mod._SERVER_INFO_FILE = os.path.join(hidden, "nvcurve.json")
cli_mod._PERSISTENT_CONFIG_FILE = os.path.join(hidden, "config.json")
try:
cfg = Config(
host="10.0.0.5", port=9000, ssl_certfile="/x/c.pem", ssl_keyfile="/x/k.pem"
)
check("tls_enabled true", tls_enabled(cfg) is True)
check("https url", _discover_server_url(cfg) == "https://10.0.0.5:9000")
cfg2 = Config(host="10.0.0.5", port=9000)
check("http url", _discover_server_url(cfg2) == "http://10.0.0.5:9000")
finally:
cli_mod._SERVER_INFO_FILE = real_info_file
cli_mod._PERSISTENT_CONFIG_FILE = real_persistent_cfg
def test_daemon_ignores_caller_host_port() -> None:
"""serve_start must bind the configured host/port, never the caller's."""
daemon._cfg = Config(host="10.1.1.1", port=9999)
captured: dict = {}
class FakePopen:
def __init__(self, cmd, **kw):
captured["cmd"] = cmd
self.pid = 4242
def poll(self):
return 0
def terminate(self):
pass
def wait(self, *a):
return 0
real_open = builtins.open
def fake_open(path, *a, **kw):
if str(path).endswith("nvcurve-server.log"):
return io.StringIO()
return real_open(path, *a, **kw)
orig_popen = daemon.subprocess.Popen
daemon.subprocess.Popen = FakePopen
builtins.open = fake_open
try:
loop = asyncio.new_event_loop()
resp = loop.run_until_complete(
# "0.0.0.0" is a test payload proving the daemon ignores caller
# host/port — no socket is bound here.
daemon._dispatch({"cmd": "serve_start", "host": "0.0.0.0", "port": 12345}) # noqa: S104
)
finally:
builtins.open = real_open
daemon.subprocess.Popen = orig_popen
cmd = " ".join(captured.get("cmd", []))
check("config host/port used", "10.1.1.1" in cmd and "9999" in cmd)
check("caller host/port ignored", "0.0.0.0" not in cmd and "12345" not in cmd) # noqa: S104
check(
"response reports configured values",
resp.get("ok") is True
and resp.get("host") == "10.1.1.1"
and resp.get("port") == 9999,
)
check("response includes tls flag", "tls" in resp)
def main() -> int:
tests = [
test_spa_path_containment,
test_snapshot_restore_containment,
test_safety_cap_not_client_overridable,
test_client_ip_derivation,
test_trusted_proxies_normalization,
test_tls_scheme_detection,
test_daemon_ignores_caller_host_port,
]
for t in tests:
print(f"== {t.__name__} ==")
t()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
Generated
+69
View File
@@ -154,6 +154,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "hatchling"
version = "1.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pathspec" },
{ name = "pluggy" },
{ name = "tomlkit" },
{ name = "trove-classifiers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/08/33331757185504aae48b8d9bd78cec03a76e3aecfb52e549d05a2347c0dd/hatchling-1.32.0.tar.gz", hash = "sha256:0bdbde4a52b06c37e3eca395f85a762bf0ef06fe374fd8ae429dc6be10230f5f", size = 57783, upload-time = "2026-08-11T05:03:44.114Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/84/1798b6d85ecde0e31546004efd25c5de1b1f49250644a60cce460e12593a/hatchling-1.32.0-py3-none-any.whl", hash = "sha256:0e17c9c3b9aa7c625acc8d0f5b622f107d5049af9ecf5ada4de1aada5be7cdbc", size = 78435, upload-time = "2026-08-11T05:03:42.644Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -233,6 +249,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "hatchling" },
]
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = ">=4.0" },
@@ -243,6 +264,9 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
]
[package.metadata.requires-dev]
dev = [{ name = "hatchling" }]
[[package]]
name = "nvidia-ml-py"
version = "13.590.48"
@@ -252,6 +276,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -406,6 +457,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "tomlkit"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
]
[[package]]
name = "trove-classifiers"
version = "2026.6.1.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"