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:
Pakobbix committed 2026-08-01 14:07:13 +00:00
commit d2d05bbe6d
11 files changed
+1288 -10

No files matched your search

+271
View File
@@ -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
View File
@@ -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>
+9 -1
View File
@@ -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>
);
}
+15
View File
@@ -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;
}
+198
View File
@@ -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"
+1
View File
@@ -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:
+180
View File
@@ -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:
Generated
+1 -1
View File
@@ -156,7 +156,7 @@ wheels = [
[[package]]
name = "nvcurve"
version = "0.5.0"
version = "0.5.1"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },