Files
nvcurve/fan_plan.md
T
ARIA 477c4becae feat: add fan curve control via temperature-based fan speed curves
- 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
2026-08-01 15:59:39 +02:00

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 supported
  • nvmlDeviceGetFanSpeed(handle) is already used in hal/monitoring.py:108 for reading
  • nvmlDeviceGetFanSpeedInfo(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 speeds
  • set_fan_speed(gpu_index, pct) -> tuple[bool, str] — sets fan to a specific % via nvmlDeviceSetFanSpeed
  • reset_fan(gpu_index) -> tuple[bool, str] — restores automatic fan control
  • get_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 calls set_fan_speed periodically

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:

  1. Reads current GPU temp (already available via monitoring poller)
  2. Interpolates the target fan % from the active fan curve
  3. Calls set_fan_speed with the interpolated value
  4. 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_pct is 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 CurveEditor but 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/fans to load current state
  • User edits → local pending state
  • Apply → POST /api/fans with new curve
  • Reset → POST /api/fans/reset to 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) — nvmlDeviceSetFanSpeed returns 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

  1. Backend HAL — hal/fans.py (read fan, set fan, get fan info)
  2. Backend Server — /api/fans endpoints + fan poller in server.py
  3. Backend Profiles — extend ProfileData, save/apply integration
  4. Frontend Types & API — types.ts, client.ts
  5. Frontend FanMonitor — sidebar component (reuses existing data)
  6. Frontend FanCurveEditor — main chart component
  7. Frontend App.tsx — wire up the tab
  8. Testing — manual verification of fan control, profile save/apply, reset