# 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`: ```python @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: ```typescript 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`: ```typescript export interface ProfileData { // ... existing fields fan_curve: FanPoint[] | null; } ``` #### 2.2 Modify: `frontend/src/api/client.ts` New API methods: ```typescript fans: (gpuIndex: number) => get('/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: ```tsx const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve'); ``` Add a third tab button between the existing buttons: ```tsx ``` Add the fans tab content rendering: ```tsx {activeTab === 'fans' && (
)} ``` 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