- 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
272 lines
10 KiB
Markdown
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
|