Merge pull request 'feat: add fan curve control via temperature-based fan speed curves' (#1) from feature/fan-curve into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
commit
d2d05bbe6d
11 files changed
+1288
-10
No files matched your search
+271
@@ -0,0 +1,271 @@
|
||||
# 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
|
||||
+38
-4
@@ -7,12 +7,15 @@ import { CurveEditor } from './components/CurveEditor/CurveEditor';
|
||||
import { PointTable } from './components/PointTable/PointTable';
|
||||
import { PerformancePanel } from './components/Limits/PerformancePanel';
|
||||
import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor';
|
||||
import { FanMonitor } from './components/Monitor/FanMonitor';
|
||||
import { FanCurveEditor } from './components/Fans/FanCurveEditor';
|
||||
import { ProfilePanel } from './components/Profiles/ProfilePanel';
|
||||
import { api } from './api/client';
|
||||
import { useCurveStore } from './store/curveStore';
|
||||
import { Toaster } from 'sonner';
|
||||
import { Loader, ChevronDown } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { FanState } from './types';
|
||||
|
||||
export default function App() {
|
||||
const gpuInfo = useGpu();
|
||||
@@ -20,7 +23,8 @@ export default function App() {
|
||||
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
|
||||
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'curve' | 'performance'>('curve');
|
||||
const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve');
|
||||
const [fanState, setFanState] = useState<FanState | null>(null);
|
||||
const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu');
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,6 +49,16 @@ export default function App() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [selectedGpuIndex, setActiveProfile]);
|
||||
|
||||
function refreshFans() {
|
||||
api.fans(selectedGpuIndex).then(setFanState).catch(console.error);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'fans') {
|
||||
refreshFans();
|
||||
}
|
||||
}, [activeTab, selectedGpuIndex]);
|
||||
|
||||
// Worst connection status wins
|
||||
const wsStatus =
|
||||
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
|
||||
@@ -81,6 +95,12 @@ export default function App() {
|
||||
>
|
||||
Performance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('fans')}
|
||||
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'fans' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Fans
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative" ref={profileRef}>
|
||||
<button
|
||||
@@ -126,12 +146,12 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-80 shrink-0 flex flex-col">
|
||||
<LiveMonitor monitor={monitor} history={monitorHistory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{curve && (
|
||||
<div className="w-full">
|
||||
<PointTable
|
||||
@@ -142,7 +162,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
) : activeTab === 'performance' ? (
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PerformancePanel />
|
||||
@@ -151,6 +171,20 @@ export default function App() {
|
||||
<PerformanceMonitor monitor={monitor} history={monitorHistory} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<FanCurveEditor onChanged={refreshFans} />
|
||||
</div>
|
||||
<div className="w-80 shrink-0 flex flex-col">
|
||||
<FanMonitor
|
||||
monitor={monitor}
|
||||
history={monitorHistory}
|
||||
fanCurve={fanState?.curve ?? null}
|
||||
fanCurveActive={fanState?.curve_active ?? false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData } from '../types';
|
||||
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData, FanState, FanPoint } from '../types';
|
||||
|
||||
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
|
||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
||||
@@ -76,4 +76,12 @@ export const api = {
|
||||
/** Server config */
|
||||
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
|
||||
post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }),
|
||||
|
||||
/** Fan control */
|
||||
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
|
||||
updateFans: (curve: FanPoint[], gpuIndex: number) =>
|
||||
post('/fans', { curve }, gpuIndex),
|
||||
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
|
||||
setFanSpeed: (fanPct: number, gpuIndex: number) =>
|
||||
post('/fans/speed', { fan_pct: fanPct }, gpuIndex),
|
||||
};
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Check, X, RotateCcw, Plus } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
import type { FanPoint, FanState } from '../../types';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog';
|
||||
|
||||
function defaultCurve(): FanPoint[] {
|
||||
return [
|
||||
{ temp_c: 30, fan_pct: 0 },
|
||||
{ temp_c: 45, fan_pct: 40 },
|
||||
{ temp_c: 60, fan_pct: 80 },
|
||||
{ temp_c: 70, fan_pct: 100 },
|
||||
];
|
||||
}
|
||||
|
||||
const CHART_W = 500;
|
||||
const CHART_H = 260;
|
||||
const PAD = { top: 10, right: 10, bottom: 30, left: 40 };
|
||||
const PLOT_W = CHART_W - PAD.left - PAD.right;
|
||||
const PLOT_H = CHART_H - PAD.top - PAD.bottom;
|
||||
|
||||
const TEMP_MIN = 20;
|
||||
const TEMP_MAX = 100;
|
||||
const FAN_MIN = 0;
|
||||
const FAN_MAX = 100;
|
||||
|
||||
function tempToX(t: number) {
|
||||
return PAD.left + ((t - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * PLOT_W;
|
||||
}
|
||||
|
||||
function fanToY(f: number) {
|
||||
return PAD.top + PLOT_H - ((f - FAN_MIN) / (FAN_MAX - FAN_MIN)) * PLOT_H;
|
||||
}
|
||||
|
||||
function xToTemp(x: number) {
|
||||
return Math.round(TEMP_MIN + ((x - PAD.left) / PLOT_W) * (TEMP_MAX - TEMP_MIN));
|
||||
}
|
||||
|
||||
function yToFan(y: number) {
|
||||
return Math.round(FAN_MAX - ((y - PAD.top) / PLOT_H) * (FAN_MAX - FAN_MIN));
|
||||
}
|
||||
|
||||
export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
const { selectedGpuIndex } = useCurveStore();
|
||||
const [fanState, setFanState] = useState<FanState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<FanPoint[] | null>(null);
|
||||
const [confirmApply, setConfirmApply] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
async function fetchFans() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.fans(selectedGpuIndex);
|
||||
setFanState(data);
|
||||
if (data.curve && data.curve.length > 0) {
|
||||
setPending(null);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load fan state');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchFans(); }, [selectedGpuIndex]);
|
||||
|
||||
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||
const hasPending = pending !== null;
|
||||
const curveActive = fanState?.curve_active ?? false;
|
||||
const isDefaults = !pending && !fanState?.curve;
|
||||
|
||||
async function handleApply() {
|
||||
const curveToApply = pending ?? fanState?.curve ?? defaultCurve();
|
||||
if (curveToApply.length < 2) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateFans(curveToApply, selectedGpuIndex);
|
||||
setPending(null);
|
||||
setConfirmApply(false);
|
||||
await fetchFans();
|
||||
onChanged?.();
|
||||
toast.success('Fan curve applied');
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? String(e));
|
||||
setConfirmApply(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.resetFans(selectedGpuIndex);
|
||||
setPending(null);
|
||||
setConfirmReset(false);
|
||||
await fetchFans();
|
||||
onChanged?.();
|
||||
toast.success('Fan control reset to automatic');
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? String(e));
|
||||
setConfirmReset(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanvasClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||
if (dragIdx !== null) return;
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scaleX = CHART_W / rect.width;
|
||||
const scaleY = CHART_H / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
|
||||
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
||||
|
||||
const baseCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||
const existing = baseCurve.findIndex(p => p.temp_c === temp);
|
||||
if (existing >= 0) return;
|
||||
|
||||
const updated = [...baseCurve, { temp_c: temp, fan_pct: fan }].sort((a, b) => a.temp_c - b.temp_c);
|
||||
setPending(updated);
|
||||
}
|
||||
|
||||
function removePoint(idx: number) {
|
||||
const curve = pending ?? fanState?.curve;
|
||||
if (!curve || curve.length <= 2) return;
|
||||
const updated = curve.filter((_, i) => i !== idx);
|
||||
setPending(updated);
|
||||
}
|
||||
|
||||
const handlePointerDown = useCallback((idx: number) => {
|
||||
setDragIdx(idx);
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (dragIdx === null) return;
|
||||
e.preventDefault();
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scaleX = CHART_W / rect.width;
|
||||
const scaleY = CHART_H / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
|
||||
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
||||
|
||||
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||
const updated = [...curve];
|
||||
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
|
||||
setPending(updated);
|
||||
}, [dragIdx, pending, fanState]);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setDragIdx(null);
|
||||
setPending((p) => p ? [...p].sort((a, b) => a.temp_c - b.temp_c) : p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragIdx === null) return;
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
return () => window.removeEventListener('pointerup', handlePointerUp);
|
||||
}, [dragIdx, handlePointerUp]);
|
||||
|
||||
// Build polyline path from curve points
|
||||
const curvePath = activeCurve && activeCurve.length >= 2
|
||||
? activeCurve.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`).join(' ')
|
||||
: null;
|
||||
|
||||
// Grid lines
|
||||
const tempTicks = [30, 40, 50, 60, 70, 80, 90];
|
||||
const fanTicks = [0, 25, 50, 75, 100];
|
||||
|
||||
if (loading && !fanState) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 h-9" />
|
||||
<div className="p-4 flex flex-col gap-6">
|
||||
<div className="h-64 bg-zinc-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Fan Curve</span>
|
||||
|
||||
{isDefaults && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-xs">
|
||||
Defaults
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasPending && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
||||
{activeCurve?.length ?? 0} pts
|
||||
</span>
|
||||
)}
|
||||
|
||||
{curveActive && !hasPending && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmApply(true)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPending(null); setError(null); }}
|
||||
disabled={!hasPending || busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X size={12} />
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={busy || !curveActive}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-500 hover:text-red-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="p-4 flex justify-center relative">
|
||||
{isDefaults && (
|
||||
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center">
|
||||
<span className="inline-block bg-zinc-900/90 backdrop-blur-sm border border-orange-500/30 rounded-lg px-4 py-2 text-xs text-orange-400/90 font-medium">
|
||||
Default curve — edit points or apply as-is to enable curve control
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||
className="max-w-full cursor-crosshair select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onClick={handleCanvasClick}
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
{/* Background */}
|
||||
<rect x={PAD.left} y={PAD.top} width={PLOT_W} height={PLOT_H} fill="#09090b" rx="4" />
|
||||
|
||||
{/* Grid lines - horizontal (fan %) */}
|
||||
{fanTicks.map(f => (
|
||||
<g key={`fy-${f}`}>
|
||||
<line
|
||||
x1={PAD.left} y1={fanToY(f)}
|
||||
x2={PAD.left + PLOT_W} y2={fanToY(f)}
|
||||
stroke="#27272a" strokeWidth="0.5"
|
||||
/>
|
||||
<text x={PAD.left - 6} y={fanToY(f) + 3} textAnchor="end" fill="#71717a" fontSize="9" fontFamily="monospace">
|
||||
{f}%
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Grid lines - vertical (temp) */}
|
||||
{tempTicks.map(t => (
|
||||
<g key={`tx-${t}`}>
|
||||
<line
|
||||
x1={tempToX(t)} y1={PAD.top}
|
||||
x2={tempToX(t)} y2={PAD.top + PLOT_H}
|
||||
stroke="#27272a" strokeWidth="0.5"
|
||||
/>
|
||||
<text x={tempToX(t)} y={PAD.top + PLOT_H + 16} textAnchor="middle" fill="#71717a" fontSize="9" fontFamily="monospace">
|
||||
{t}°
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Axis labels */}
|
||||
<text x={PAD.left + PLOT_W / 2} y={CHART_H - 2} textAnchor="middle" fill="#52525b" fontSize="9">Temperature (°C)</text>
|
||||
<text x={8} y={PAD.top + PLOT_H / 2} textAnchor="middle" fill="#52525b" fontSize="9" transform={`rotate(-90, 8, ${PAD.top + PLOT_H / 2})`}>Fan Speed (%)</text>
|
||||
|
||||
{/* Curve line */}
|
||||
{curvePath && (
|
||||
<polyline
|
||||
points={curvePath}
|
||||
fill="none"
|
||||
stroke={hasPending ? '#22d3ee' : '#fb923c'}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Curve fill area */}
|
||||
{curvePath && activeCurve && activeCurve.length >= 2 && (
|
||||
<polygon
|
||||
points={`${tempToX(activeCurve[0].temp_c)},${PAD.top + PLOT_H} ${curvePath} ${tempToX(activeCurve[activeCurve.length - 1].temp_c)},${PAD.top + PLOT_H}`}
|
||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
||||
opacity="0.07"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
{activeCurve?.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={tempToX(p.temp_c)}
|
||||
cy={fanToY(p.fan_pct)}
|
||||
r="6"
|
||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
||||
stroke="#09090b"
|
||||
strokeWidth="2"
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePointerDown(i);
|
||||
}}
|
||||
/>
|
||||
{/* Delete button on hover */}
|
||||
{activeCurve && activeCurve.length > 2 && (
|
||||
<circle
|
||||
cx={tempToX(p.temp_c) + 8}
|
||||
cy={fanToY(p.fan_pct) - 8}
|
||||
r="7"
|
||||
fill="#27272a"
|
||||
stroke="#3f3f46"
|
||||
strokeWidth="1"
|
||||
className="cursor-pointer opacity-0 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePoint(i);
|
||||
}}
|
||||
>
|
||||
<title>Remove point</title>
|
||||
</circle>
|
||||
)}
|
||||
{/* Value label */}
|
||||
<text
|
||||
x={tempToX(p.temp_c)}
|
||||
y={fanToY(p.fan_pct) - 12}
|
||||
textAnchor="middle"
|
||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fontWeight="600"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{p.fan_pct}%
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* No-curve hint (only when curve is truly empty) */}
|
||||
{!activeCurve && (
|
||||
<text x={CHART_W / 2} y={CHART_H / 2} textAnchor="middle" fill="#52525b" fontSize="11">
|
||||
Click to add points
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Point table below chart */}
|
||||
{activeCurve && activeCurve.length > 0 && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Curve Points</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||
const newCurve = [...curve, { temp_c: 50, fan_pct: 50 }];
|
||||
newCurve.sort((a, b) => a.temp_c - b.temp_c);
|
||||
setPending(newCurve);
|
||||
}}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] transition-colors"
|
||||
>
|
||||
<Plus size={10} />
|
||||
Add Point
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-zinc-500">
|
||||
<th className="text-left pb-1 font-normal">Temp (°C)</th>
|
||||
<th className="text-left pb-1 font-normal">Fan (%)</th>
|
||||
<th className="text-left pb-1 font-normal"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeCurve.map((p, i) => (
|
||||
<tr key={i} className="border-t border-zinc-800/50">
|
||||
<td className="py-1 font-mono text-zinc-300">{p.temp_c}</td>
|
||||
<td className="py-1 font-mono text-zinc-300">{p.fan_pct}</td>
|
||||
<td className="py-1">
|
||||
{activeCurve.length > 2 && (
|
||||
<button
|
||||
onClick={() => removePoint(i)}
|
||||
className="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-4 pb-3 text-[10px] text-zinc-600">
|
||||
{curveActive
|
||||
? 'Fan curve is active. Server adjusts fan speed based on GPU temperature.'
|
||||
: isDefaults
|
||||
? 'These are default values. Click Apply to enable curve control, or edit points first.'
|
||||
: 'Apply a curve to enable automatic fan control based on temperature.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmApply && (
|
||||
<ConfirmDialog
|
||||
message="Apply fan curve?"
|
||||
detail="Fan control will switch to curve-based mode. The server will adjust fan speed based on GPU temperature."
|
||||
confirmLabel="Apply"
|
||||
onConfirm={handleApply}
|
||||
onCancel={() => setConfirmApply(false)}
|
||||
/>
|
||||
)}
|
||||
{confirmReset && (
|
||||
<ConfirmDialog
|
||||
message="Reset fan control to automatic?"
|
||||
detail="This will deactivate the fan curve and restore the GPU driver's automatic fan control."
|
||||
confirmLabel="Reset"
|
||||
isDestructive
|
||||
onConfirm={handleReset}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { GaugeCard } from './GaugeCard';
|
||||
import { fmt } from '../../utils/units';
|
||||
import type { MonitoringSample, FanPoint } from '../../types';
|
||||
|
||||
interface Props {
|
||||
monitor: MonitoringSample | null;
|
||||
history: MonitoringSample[];
|
||||
fanCurve: FanPoint[] | null;
|
||||
fanCurveActive: boolean;
|
||||
}
|
||||
|
||||
function pluck<K extends keyof MonitoringSample>(
|
||||
history: MonitoringSample[],
|
||||
key: K,
|
||||
): number[] {
|
||||
return history.map((s) => (s[key] as number | null) ?? 0);
|
||||
}
|
||||
|
||||
function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): number | null {
|
||||
if (!curve || !tempC || curve.length < 2) return null;
|
||||
|
||||
for (let i = 0; i < curve.length - 1; i++) {
|
||||
const t0 = curve[i].temp_c;
|
||||
const f0 = curve[i].fan_pct;
|
||||
const t1 = curve[i + 1].temp_c;
|
||||
const f1 = curve[i + 1].fan_pct;
|
||||
|
||||
if (t0 === t1) continue;
|
||||
if (t0 <= tempC && tempC <= t1) {
|
||||
const fraction = (tempC - t0) / (t1 - t0);
|
||||
return Math.max(0, Math.min(100, Math.round(f0 + fraction * (f1 - f0))));
|
||||
}
|
||||
}
|
||||
|
||||
if (tempC <= curve[0].temp_c) return curve[0].fan_pct;
|
||||
return curve[curve.length - 1].fan_pct;
|
||||
}
|
||||
|
||||
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) {
|
||||
const fanHistory = pluck(history, 'fan_pct');
|
||||
const tempHistory = pluck(history, 'temp_c');
|
||||
|
||||
const currentTemp = monitor?.temp_c ?? null;
|
||||
const targetFan = computeTargetFan(fanCurve, currentTemp);
|
||||
const targetFanHistory = history.map((s) => computeTargetFan(fanCurve, s.temp_c) ?? 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full h-full">
|
||||
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
|
||||
{fanCurveActive && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
|
||||
Curve Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<GaugeCard
|
||||
label="Fan Speed"
|
||||
value={fmt.pct(monitor?.fan_pct)}
|
||||
history={fanHistory}
|
||||
color="#fb923c"
|
||||
max={100}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="GPU Temp"
|
||||
value={fmt.celsius(monitor?.temp_c)}
|
||||
history={tempHistory}
|
||||
color="#f87171"
|
||||
max={100}
|
||||
/>
|
||||
{fanCurveActive && (
|
||||
<GaugeCard
|
||||
label="Target Fan"
|
||||
value={targetFan !== null ? `${targetFan}%` : '—'}
|
||||
history={targetFanHistory}
|
||||
color="#fbbf24"
|
||||
max={100}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-auto">
|
||||
<GaugeCard
|
||||
label="Fan Mode"
|
||||
value={fanCurveActive ? 'Curve' : 'Auto'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
history: number[];
|
||||
history?: number[];
|
||||
unit?: string;
|
||||
color?: string; // tailwind color class for the sparkline stroke
|
||||
max?: number;
|
||||
@@ -44,9 +44,11 @@ export function GaugeCard({ label, value, history, color = '#a78bfa', max }: Pro
|
||||
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-1 min-w-0">
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider">{label}</div>
|
||||
<div className="text-xl font-mono font-semibold text-zinc-100">{value}</div>
|
||||
<div className="mt-1 w-full">
|
||||
<Sparkline data={history} color={color} max={max} />
|
||||
</div>
|
||||
{history && (
|
||||
<div className="mt-1 w-full">
|
||||
<Sparkline data={history} color={color} max={max} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,10 +66,25 @@ export interface LimitsState {
|
||||
max_mem_offset_mhz: number | null;
|
||||
}
|
||||
|
||||
export interface FanPoint {
|
||||
temp_c: number;
|
||||
fan_pct: number;
|
||||
}
|
||||
|
||||
export interface FanState {
|
||||
fan_pct: number | null;
|
||||
fan_mode: 'auto' | 'curve' | null;
|
||||
min_fan_pct: number | null;
|
||||
max_fan_pct: number | null;
|
||||
curve: FanPoint[] | null;
|
||||
curve_active: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileData {
|
||||
name: string;
|
||||
gpu_name: string;
|
||||
curve_deltas: Record<string, number>;
|
||||
mem_offset_mhz: number | null;
|
||||
power_limit_w: number | null;
|
||||
fan_curve: FanPoint[] | null;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Hardware Abstraction Layer for Fan Control.
|
||||
|
||||
Uses NVML (via pynvml) for all operations:
|
||||
- nvmlDeviceGetFanSpeed_v2 : read current fan speed % for a fan index
|
||||
- nvmlDeviceSetFanSpeed_v2 : set fan speed % for a fan index
|
||||
- nvmlDeviceGetMinMaxFanSpeed: get min/max fan speed constraints
|
||||
- nvmlDeviceGetTemperature : read GPU temp for curve interpolation
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
try:
|
||||
import pynvml
|
||||
_NVML_AVAILABLE = True
|
||||
except ImportError:
|
||||
_NVML_AVAILABLE = False
|
||||
|
||||
log = logging.getLogger("nvcurve.hal.fans")
|
||||
|
||||
# We use fan index 0 (first/primary fan) for all operations.
|
||||
_FAN_INDEX = 0
|
||||
|
||||
|
||||
def _get_handle(gpu_index: int):
|
||||
"""Return an NVML device handle."""
|
||||
if not _NVML_AVAILABLE:
|
||||
raise RuntimeError("NVML not available (install nvidia-ml-py)")
|
||||
return pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
|
||||
|
||||
|
||||
def get_fan_info(gpu_index: int = 0) -> dict:
|
||||
"""Return current fan state: fan_pct, fan_mode, min_fan_pct, max_fan_pct.
|
||||
|
||||
Returns None values on failure.
|
||||
"""
|
||||
out = {
|
||||
"fan_pct": None,
|
||||
"fan_mode": None,
|
||||
"min_fan_pct": None,
|
||||
"max_fan_pct": None,
|
||||
}
|
||||
if not _NVML_AVAILABLE:
|
||||
return out
|
||||
try:
|
||||
handle = _get_handle(gpu_index)
|
||||
|
||||
# Get current fan speed using v2 API (fan index 0)
|
||||
try:
|
||||
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed_v2(handle, _FAN_INDEX))
|
||||
except pynvml.NVMLError:
|
||||
# Fallback to legacy v1 API
|
||||
try:
|
||||
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed(handle))
|
||||
except pynvml.NVMLError:
|
||||
pass
|
||||
|
||||
# Get min/max fan speed constraints
|
||||
try:
|
||||
min_s = ctypes.c_uint(0)
|
||||
max_s = ctypes.c_uint(0)
|
||||
pynvml.nvmlDeviceGetMinMaxFanSpeed(handle, min_s, max_s)
|
||||
out["min_fan_pct"] = int(min_s.value)
|
||||
out["max_fan_pct"] = int(max_s.value)
|
||||
except pynvml.NVMLError:
|
||||
out["min_fan_pct"] = 0
|
||||
out["max_fan_pct"] = 100
|
||||
|
||||
except pynvml.NVMLError as exc:
|
||||
log.warning("get_fan_info: %s", exc)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
|
||||
"""Set fan speed to a percentage (0-100) on the primary fan."""
|
||||
pct = max(0, min(100, int(pct)))
|
||||
if not _NVML_AVAILABLE:
|
||||
return False, "NVML not available"
|
||||
try:
|
||||
handle = _get_handle(gpu_index)
|
||||
pynvml.nvmlDeviceSetFanSpeed_v2(handle, _FAN_INDEX, pct)
|
||||
return True, "OK"
|
||||
except pynvml.NVMLError as exc:
|
||||
log.warning("set_fan_speed(%d, %d): %s", gpu_index, pct, exc)
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
|
||||
"""Restore automatic fan control.
|
||||
|
||||
Tries nvidia-smi --fan=default first (most reliable), then falls back to
|
||||
NVML nvmlDeviceSetDefaultFanSpeed_v2.
|
||||
"""
|
||||
if not _NVML_AVAILABLE:
|
||||
return False, "NVML not available"
|
||||
|
||||
import subprocess
|
||||
|
||||
# Try nvidia-smi approach first (most reliable for restoring auto)
|
||||
try:
|
||||
ret = subprocess.run(
|
||||
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if ret.returncode == 0:
|
||||
return True, "OK"
|
||||
log.debug("nvidia-smi -fan default failed: %s", ret.stderr.strip())
|
||||
except FileNotFoundError:
|
||||
log.debug("nvidia-smi not found, falling back to NVML")
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("nvidia-smi -fan default timed out")
|
||||
except Exception as exc:
|
||||
log.debug("nvidia-smi -fan default error: %s", exc)
|
||||
|
||||
# Fallback: use NVML to reset to default fan speed
|
||||
try:
|
||||
handle = _get_handle(gpu_index)
|
||||
pynvml.nvmlDeviceSetDefaultFanSpeed_v2(handle, _FAN_INDEX)
|
||||
return True, "OK"
|
||||
except pynvml.NVMLError as exc:
|
||||
return False, f"Failed to reset fan: {exc}"
|
||||
|
||||
|
||||
def get_temp(gpu_index: int = 0) -> Optional[float]:
|
||||
"""Read current GPU temperature in °C."""
|
||||
if not _NVML_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
handle = _get_handle(gpu_index)
|
||||
return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU))
|
||||
except pynvml.NVMLError as exc:
|
||||
log.debug("get_temp: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
|
||||
"""Interpolate target fan speed from a curve at a given temperature.
|
||||
|
||||
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
|
||||
Returns fan_pct clamped to 0-100, or None if curve is empty.
|
||||
"""
|
||||
if not curve or len(curve) < 2:
|
||||
return None
|
||||
|
||||
temp = float(temp_c)
|
||||
|
||||
# Find the two surrounding points
|
||||
for i in range(len(curve) - 1):
|
||||
t0, f0 = curve[i]["temp_c"], curve[i]["fan_pct"]
|
||||
t1, f1 = curve[i + 1]["temp_c"], curve[i + 1]["fan_pct"]
|
||||
|
||||
if t0 == t1:
|
||||
continue
|
||||
|
||||
if t0 <= temp <= t1:
|
||||
fraction = (temp - t0) / (t1 - t0)
|
||||
result = f0 + fraction * (f1 - f0)
|
||||
return max(0, min(100, int(round(result))))
|
||||
|
||||
# Outside range: clamp to first or last point
|
||||
if temp <= curve[0]["temp_c"]:
|
||||
return max(0, min(100, curve[0]["fan_pct"]))
|
||||
return max(0, min(100, curve[-1]["fan_pct"]))
|
||||
|
||||
|
||||
def validate_curve(curve: List[dict]) -> tuple[bool, str]:
|
||||
"""Validate a fan curve.
|
||||
|
||||
Returns (True, "OK") or (False, error_message).
|
||||
"""
|
||||
if not curve or len(curve) < 2:
|
||||
return False, "Fan curve requires at least 2 points"
|
||||
|
||||
temps = [p["temp_c"] for p in curve]
|
||||
speeds = [p["fan_pct"] for p in curve]
|
||||
|
||||
# Check for duplicate temperatures
|
||||
if len(temps) != len(set(temps)):
|
||||
return False, "Fan curve has duplicate temperature values"
|
||||
|
||||
# Check temperature range
|
||||
for t in temps:
|
||||
if t < 0 or t > 120:
|
||||
return False, f"Temperature {t}°C out of range (0-120)"
|
||||
|
||||
# Check fan speed range
|
||||
for s in speeds:
|
||||
if s < 0 or s > 100:
|
||||
return False, f"Fan speed {s}% out of range (0-100)"
|
||||
|
||||
# Check sorted by temperature
|
||||
for i in range(len(temps) - 1):
|
||||
if temps[i] >= temps[i + 1]:
|
||||
return False, "Fan curve points must be sorted by ascending temperature"
|
||||
|
||||
return True, "OK"
|
||||
@@ -14,6 +14,7 @@ class ProfileData:
|
||||
curve_deltas: Dict[str, int] # { "index": delta_khz }
|
||||
mem_offset_mhz: Optional[int] = None
|
||||
power_limit_w: Optional[int] = None
|
||||
fan_curve: Optional[List[Dict[str, int]]] = None
|
||||
|
||||
|
||||
def save_profile(profile_dir: str, data: ProfileData) -> str:
|
||||
|
||||
@@ -38,6 +38,14 @@ from .hal.limits import (
|
||||
set_clock_offsets,
|
||||
get_mem_offset_range,
|
||||
)
|
||||
from .hal.fans import (
|
||||
get_fan_info,
|
||||
set_fan_speed,
|
||||
reset_fan,
|
||||
get_temp,
|
||||
interpolate_fan_speed,
|
||||
validate_curve,
|
||||
)
|
||||
from .profiles.native import (
|
||||
ProfileData,
|
||||
save_profile,
|
||||
@@ -169,6 +177,25 @@ async def _monitor_poller(gpu_index: int) -> None:
|
||||
await asyncio.sleep(cfg.poll_interval_s)
|
||||
|
||||
|
||||
async def _fan_poller(gpu_index: int) -> None:
|
||||
"""Continuously read GPU temp, interpolate fan speed from active curve, and apply."""
|
||||
while True:
|
||||
try:
|
||||
g_state = _state["gpus"].get(gpu_index)
|
||||
if g_state and g_state.get("fan_curve_active") and g_state.get("fan_curve"):
|
||||
temp = await _run(get_temp, gpu_index)
|
||||
if temp is not None:
|
||||
curve = g_state["fan_curve"]
|
||||
target = interpolate_fan_speed(curve, temp)
|
||||
if target is not None:
|
||||
await _run(set_fan_speed, gpu_index, target)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc:
|
||||
log.warning("Fan poller error for GPU %d: %s", gpu_index, exc)
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -196,6 +223,9 @@ async def lifespan(app: FastAPI):
|
||||
"active_profile": None,
|
||||
"monitor_clients": set(),
|
||||
"curve_clients": set(),
|
||||
"fan_curve": None,
|
||||
"fan_curve_active": False,
|
||||
"fan_poller_task": None,
|
||||
}
|
||||
_state["gpus"][idx] = g_state
|
||||
|
||||
@@ -252,6 +282,23 @@ async def lifespan(app: FastAPI):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
for gpu_index, g_state in _state["gpus"].items():
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
try:
|
||||
await g_state["fan_poller_task"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if g_state.get("fan_curve_active"):
|
||||
g_state["fan_curve_active"] = False
|
||||
g_state["fan_curve"] = None
|
||||
try:
|
||||
await loop.run_in_executor(None, reset_fan, gpu_index)
|
||||
log.info("GPU %d: restored automatic fan control on shutdown", gpu_index)
|
||||
except Exception as exc:
|
||||
log.warning("GPU %d: failed to restore automatic fan control on shutdown: %s",
|
||||
gpu_index, exc)
|
||||
|
||||
await loop.run_in_executor(None, shutdown_nvml)
|
||||
|
||||
|
||||
@@ -305,6 +352,19 @@ class ConfigUpdateRequest(BaseModel):
|
||||
gpu_index: int = 0
|
||||
|
||||
|
||||
class FanCurvePoint(BaseModel):
|
||||
temp_c: int
|
||||
fan_pct: int
|
||||
|
||||
|
||||
class FanCurveRequest(BaseModel):
|
||||
curve: list[FanCurvePoint]
|
||||
|
||||
|
||||
class FanSpeedRequest(BaseModel):
|
||||
fan_pct: int
|
||||
|
||||
|
||||
# ── Helper: run blocking HAL call in thread pool ──────────────────────────────
|
||||
|
||||
async def _run(fn, *args):
|
||||
@@ -513,6 +573,7 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
||||
curve_deltas=curve_deltas,
|
||||
mem_offset_mhz=mem_offset_mhz,
|
||||
power_limit_w=power_limit_w,
|
||||
fan_curve=g_state.get("fan_curve") if g_state.get("fan_curve_active") else None,
|
||||
)
|
||||
filepath = await _run(save_profile, cfg.profile_dir, data)
|
||||
g_state["active_profile"] = req.name
|
||||
@@ -625,6 +686,36 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
||||
|
||||
await _update_offsets_and_broadcast(gpu_index)
|
||||
|
||||
# Apply fan curve if present in profile
|
||||
if profile.fan_curve:
|
||||
ok, msg = validate_curve(profile.fan_curve)
|
||||
if not ok:
|
||||
errs.append(f"Fan curve: {msg}")
|
||||
else:
|
||||
# Stop existing fan poller if running
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
try:
|
||||
await g_state["fan_poller_task"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
g_state["fan_curve"] = profile.fan_curve
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
elif g_state.get("fan_curve_active"):
|
||||
# Profile has no fan curve, deactivate any active fan curve
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
try:
|
||||
await g_state["fan_poller_task"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
g_state["fan_poller_task"] = None
|
||||
g_state["fan_curve"] = None
|
||||
g_state["fan_curve_active"] = False
|
||||
await _run(reset_fan, gpu_index)
|
||||
|
||||
if not errs:
|
||||
g_state["active_profile"] = name
|
||||
return errs
|
||||
@@ -812,6 +903,95 @@ async def api_limits_reset(gpu_index: int = 0):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Fan endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/fans")
|
||||
async def api_fans(gpu_index: int = 0):
|
||||
"""Current fan state: fan %, curve, and whether curve control is active."""
|
||||
_get_gpu_state(gpu_index)
|
||||
g_state = _state["gpus"][gpu_index]
|
||||
info = await _run(get_fan_info, gpu_index)
|
||||
curve_active = g_state.get("fan_curve_active", False)
|
||||
return {
|
||||
**info,
|
||||
"fan_mode": "curve" if curve_active else "auto",
|
||||
"curve": g_state.get("fan_curve"),
|
||||
"curve_active": curve_active,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/fans")
|
||||
async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
||||
"""Set or update the fan curve. Starts the fan control poller."""
|
||||
g_state = _get_gpu_state(gpu_index)
|
||||
|
||||
curve_data = [{"temp_c": p.temp_c, "fan_pct": p.fan_pct} for p in req.curve]
|
||||
ok, msg = validate_curve(curve_data)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
|
||||
# Test that fan control is available on this GPU, probing with the
|
||||
# target for the *current* temperature so the fan is never briefly
|
||||
# set to an inappropriate speed.
|
||||
if curve_data:
|
||||
sample = await _run(poll, g_state["gpu"], gpu_index)
|
||||
test_temp = sample.temp_c if sample and sample.temp_c is not None else curve_data[0]["temp_c"]
|
||||
target = interpolate_fan_speed(curve_data, test_temp)
|
||||
if target is not None:
|
||||
fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target)
|
||||
if not fan_ok:
|
||||
raise HTTPException(status_code=500, detail=f"Fan control not available: {fan_msg}")
|
||||
|
||||
# Stop existing poller if running
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
try:
|
||||
await g_state["fan_poller_task"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
g_state["fan_curve"] = curve_data
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/fans/reset")
|
||||
async def api_fans_reset(gpu_index: int = 0):
|
||||
"""Deactivate fan curve control and restore automatic fan mode."""
|
||||
g_state = _get_gpu_state(gpu_index)
|
||||
|
||||
# Stop poller
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
try:
|
||||
await g_state["fan_poller_task"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
g_state["fan_poller_task"] = None
|
||||
|
||||
g_state["fan_curve"] = None
|
||||
g_state["fan_curve_active"] = False
|
||||
|
||||
ok, msg = await _run(reset_fan, gpu_index)
|
||||
if not ok:
|
||||
log.warning("Fan reset warning: %s", msg)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/fans/speed")
|
||||
async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
||||
"""One-shot set fan to an exact percentage (bypasses curve)."""
|
||||
_get_gpu_state(gpu_index)
|
||||
pct = max(0, min(100, req.fan_pct))
|
||||
ok, msg = await _run(set_fan_speed, gpu_index, pct)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Write endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _reconcile_check(gpu_index: int) -> dict | None:
|
||||
|
||||
Reference in new issue
Block a user