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

272 lines
10 KiB
Markdown

# 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<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:
```tsx
const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve');
```
Add a third tab button between the existing buttons:
```tsx
<button onClick={() => setActiveTab('fans')}
className={`... ${activeTab === 'fans' ? 'border-pink-500 text-zinc-100' : '...'}`}>
Fans
</button>
```
Add the fans tab content rendering:
```tsx
{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