- Backend HAL (hal/fans.py): NVML v2 fan read/set/reset with min/max queries - Server endpoints: GET/POST /api/fans, POST /api/fans/reset, POST /api/fans/speed - Background fan poller: reads GPU temp every 2s, interpolates fan speed from curve - Profile integration: fan_curve field saved/applied, auto-restore on shutdown - Frontend: FanCurveEditor (SVG chart with drag/add/delete points), FanMonitor sidebar - App.tsx: three-tab layout (Curve, Performance, Fans) - GaugeCard: optional history sparkline, Fan Mode card without sparkline - fan_mode field populated as 'curve' or 'auto' in GET /api/fans
10 KiB
Fan Tab Implementation Plan
Overview
Add a third Fans tab to the WebUI alongside the existing Curve and Performance tabs. The tab presents a fan speed curve editor (temperature → target fan %) with a Live Monitor sidebar, and the ability to apply, save in profiles, and reset fan settings.
Architecture Decision: Fan Control via NVML
Fan control will use NVML (pynvml), not NvAPI. Rationale:
nvmlDeviceSetFanSpeed(handle, speed)is well-documented and widely supportednvmlDeviceGetFanSpeed(handle)is already used inhal/monitoring.py:108for readingnvmlDeviceGetFanSpeedInfo(handle)returns current mode (0=auto, 1=manual) and current speed- No need to reverse-engineer NvAPI fan functions — NVML provides a clean, stable API
Implementation Plan
Phase 1: Backend — HAL Layer
1.1 New file: nvcurve/hal/fans.py
Fan curve model: a list of temperature → fan % target points, similar to the existing V/F curve concept but simpler (no NvAPI table, just user-defined targets).
FanPoint:
temp_c: int # temperature threshold in °C (e.g. 30, 40, 50, 60, 70, 80)
fan_pct: int # target fan speed at that temp (0-100 %)
Functions:
get_fan_state(gpu_index) -> dict— returns current fan %, fan mode (auto/manual), min/max fan speedsset_fan_speed(gpu_index, pct) -> tuple[bool, str]— sets fan to a specific % vianvmlDeviceSetFanSpeedreset_fan(gpu_index) -> tuple[bool, str]— restores automatic fan controlget_fan_curve(gpu_index) -> list[dict]— returns currently stored fan curve points (from config/profile)apply_fan_curve(gpu_index, curve) -> None— background thread that reads temp, interpolates fan % from curve, and callsset_fan_speedperiodically
Key detail: Unlike V/F curve or power limits (one-shot writes), a fan curve needs a continuous feedback loop. The daemon/server needs a background task that:
- Reads current GPU temp (already available via monitoring poller)
- Interpolates the target fan % from the active fan curve
- Calls
set_fan_speedwith the interpolated value - Runs at a configurable interval (e.g. every 2-5 seconds)
Two approaches for the feedback loop:
A) Server-side poller (Recommended) — Add a new asyncio task in server.py lifespan, similar to _monitor_poller. When a fan curve is active, the poller reads temp, interpolates, and sets fan speed each cycle.
B) Daemon-side poller — Run the loop in daemon.py. More complex, requires IPC coordination.
I recommend approach A for simplicity and consistency with the existing architecture.
1.2 Modify: nvcurve/server.py
New REST endpoints:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/fans |
Current fan state: {fan_pct, fan_mode, min_fan_pct, max_fan_pct, curve} |
POST |
/api/fans |
Set fan curve: {curve: [{temp_c, fan_pct}]} — starts/updates the feedback loop |
POST |
/api/fans/reset |
Reset to automatic fan control, stops feedback loop |
POST |
/api/fans/speed |
One-shot set fan to exact %: {fan_pct: 50} |
New server state:
- Per-GPU:
fan_curve: list[dict] | None,fan_active: bool,fan_poller_task: asyncio.Task | None - New
_fan_poller(gpu_index)async task, similar pattern to_monitor_poller
The fan poller reads temp from NVML, interpolates fan % from the stored curve using linear interpolation between nearest points (clamp at min/max), and calls set_fan_speed.
1.3 Modify: nvcurve/nvapi/types.py
Add to MonitoringSample (optional — fan_pct already exists):
- No change needed;
fan_pctis already present.
1.4 Modify: nvcurve/profiles/native.py
Extend ProfileData:
@dataclass
class ProfileData:
name: str
gpu_name: str
curve_deltas: Dict[str, int]
mem_offset_mhz: Optional[int] = None
power_limit_w: Optional[int] = None
fan_curve: Optional[List[Dict[str, int]]] = None # NEW: [{temp_c, fan_pct}, ...]
Add migration in load_profile to handle old profiles without fan_curve.
1.5 Modify: nvcurve/profiles/apply.py
When applying a profile, if fan_curve is present, call the new /api/fans endpoint (or the HAL function directly) to activate the fan curve.
1.6 Modify: nvcurve/server.py — Profile endpoints
In the profile save endpoint, include the current active fan curve in the saved profile data.
Phase 2: Frontend — Types & API
2.1 Modify: frontend/src/types.ts
New types:
export interface FanPoint {
temp_c: number;
fan_pct: number;
}
export interface FanState {
fan_pct: number | null;
fan_mode: number | null; // 0 = auto, 1 = manual
min_fan_pct: number | null;
max_fan_pct: number | null;
curve: FanPoint[];
curve_active: boolean;
}
Extend ProfileData:
export interface ProfileData {
// ... existing fields
fan_curve: FanPoint[] | null;
}
2.2 Modify: frontend/src/api/client.ts
New API methods:
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
updateFans: (updates: { curve?: FanPoint[] }, gpuIndex: number) => post('/fans', updates, gpuIndex),
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number) => post('/fans/speed', { fan_pct: fanPct }, gpuIndex),
Phase 3: Frontend — Components
3.1 New file: frontend/src/components/Fans/FanCurveEditor.tsx
Main content area for the Fans tab. Similar visual style to PerformancePanel but with a curve visualization:
Layout:
- SVG chart: X-axis = temperature (°C, range ~20-100), Y-axis = fan speed (%)
- Interactive points on the curve that can be dragged vertically (adjust fan %) and horizontally (adjust temp threshold)
- Minimum 2 points, maximum ~10 points
- Click to add a new point, drag to adjust, double-click or delete button to remove
- Visual style matches
CurveEditorbut simpler (no domain toggle, no zoom/pan needed — the range is small)
Controls (header bar, same pattern as PerformancePanel):
- "pending" badge when curve has unsaved changes
- Apply / Discard / Reset buttons
- ConfirmDialog on apply and reset
Data flow:
- On mount:
GET /api/fansto load current state - User edits → local
pendingstate - Apply →
POST /api/fanswith new curve - Reset →
POST /api/fans/resetto restore auto fan control
Color scheme: Use orange-400 / amber-400 for the fan curve line and points (heat-themed), consistent with the existing zinc/pink/cyan palette.
3.2 New file: frontend/src/components/Monitor/FanMonitor.tsx
Sidebar component matching LiveMonitor / PerformanceMonitor style:
Live Monitor (header)
├─ GaugeCard: Fan Speed (current %, sparkline from history)
├─ GaugeCard: GPU Temp (current °C, sparkline from history)
├─ GaugeCard: Target Fan (interpolated target %, sparkline)
└─ GaugeCard: Fan Mode ("Auto" / "Curve Active", no sparkline)
Reuses existing GaugeCard component. Data comes from the existing monitor and monitorHistory from useMonitor() hook, plus fanState from the new fan API.
No new WebSocket needed — the existing monitor poller already pushes fan_pct and temp_c. The "Target Fan" gauge can be computed client-side from the active curve + current temp.
3.3 Modify: frontend/src/App.tsx
Add fans to the tab union type and rendering:
const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve');
Add a third tab button between the existing buttons:
<button onClick={() => setActiveTab('fans')}
className={`... ${activeTab === 'fans' ? 'border-pink-500 text-zinc-100' : '...'}`}>
Fans
</button>
Add the fans tab content rendering:
{activeTab === 'fans' && (
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0">
<FanCurveEditor />
</div>
<div className="w-80 shrink-0 flex flex-col">
<FanMonitor monitor={monitor} history={monitorHistory} />
</div>
</div>
)}
Import the new components.
Phase 4: Integration & Polish
4.1 Profile Integration
- When saving a profile, include the active fan curve
- When applying a profile with a fan curve, activate it
- In
ProfilePanel, display a small indicator if a profile contains a fan curve
4.2 Safety Considerations
- Validate fan % values: clamp to 0-100
- Validate temp values: reasonable range (0-120°C)
- Ensure curve points are sorted by temp_c
- Warn user before resetting to auto (fan control was manual)
- On server disconnect, log a warning that fan curve control is lost
4.3 Edge Cases
- GPU with no controllable fan (e.g., SFF passively cooled) —
nvmlDeviceSetFanSpeedreturns error; show "Fan control not available" message - Multiple GPUs — each GPU has its own fan curve state
- Driver doesn't support
nvmlDeviceSetFanSpeed— graceful degradation, show read-only fan info
File Summary
New Files
| File | Purpose |
|---|---|
nvcurve/hal/fans.py |
NVML fan control HAL (read, set, reset, fan info) |
frontend/src/components/Fans/FanCurveEditor.tsx |
Fan curve editor with SVG chart |
frontend/src/components/Monitor/FanMonitor.tsx |
Fan Live Monitor sidebar |
Modified Files
| File | Changes |
|---|---|
nvcurve/server.py |
New /api/fans endpoints, fan poller task, per-GPU fan state, profile save/apply includes fan curve |
nvcurve/nvapi/types.py |
No change (fan_pct already exists) |
nvcurve/profiles/native.py |
ProfileData + fan_curve field, migration in load_profile |
nvcurve/profiles/apply.py |
Apply fan curve when loading profile |
frontend/src/types.ts |
FanPoint, FanState types; extend ProfileData |
frontend/src/api/client.ts |
fans, updateFans, resetFans, setFanSpeed methods |
frontend/src/App.tsx |
Third tab button + fan tab content rendering |
Implementation Order
- Backend HAL —
hal/fans.py(read fan, set fan, get fan info) - Backend Server —
/api/fansendpoints + fan poller inserver.py - Backend Profiles — extend
ProfileData, save/apply integration - Frontend Types & API —
types.ts,client.ts - Frontend FanMonitor — sidebar component (reuses existing data)
- Frontend FanCurveEditor — main chart component
- Frontend App.tsx — wire up the tab
- Testing — manual verification of fan control, profile save/apply, reset