diff --git a/fan_plan.md b/fan_plan.md new file mode 100644 index 0000000..d6dbc25 --- /dev/null +++ b/fan_plan.md @@ -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('/fans', gpuIndex), +updateFans: (updates: { curve?: FanPoint[] }, gpuIndex: number) => post('/fans', updates, gpuIndex), +resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex), +setFanSpeed: (fanPct: number, gpuIndex: number) => post('/fans/speed', { fan_pct: fanPct }, gpuIndex), +``` + +--- + +### Phase 3: Frontend — Components + +#### 3.1 New file: `frontend/src/components/Fans/FanCurveEditor.tsx` + +Main content area for the Fans tab. Similar visual style to `PerformancePanel` but with a curve visualization: + +**Layout:** +- SVG chart: X-axis = temperature (°C, range ~20-100), Y-axis = fan speed (%) +- Interactive points on the curve that can be dragged vertically (adjust fan %) and horizontally (adjust temp threshold) +- Minimum 2 points, maximum ~10 points +- Click to add a new point, drag to adjust, double-click or delete button to remove +- Visual style matches `CurveEditor` but simpler (no domain toggle, no zoom/pan needed — the range is small) + +**Controls (header bar, same pattern as PerformancePanel):** +- "pending" badge when curve has unsaved changes +- Apply / Discard / Reset buttons +- ConfirmDialog on apply and reset + +**Data flow:** +- On mount: `GET /api/fans` to load current state +- User edits → local `pending` state +- Apply → `POST /api/fans` with new curve +- Reset → `POST /api/fans/reset` to restore auto fan control + +Color scheme: Use `orange-400` / `amber-400` for the fan curve line and points (heat-themed), consistent with the existing zinc/pink/cyan palette. + +#### 3.2 New file: `frontend/src/components/Monitor/FanMonitor.tsx` + +Sidebar component matching `LiveMonitor` / `PerformanceMonitor` style: + +``` +Live Monitor (header) +├─ GaugeCard: Fan Speed (current %, sparkline from history) +├─ GaugeCard: GPU Temp (current °C, sparkline from history) +├─ GaugeCard: Target Fan (interpolated target %, sparkline) +└─ GaugeCard: Fan Mode ("Auto" / "Curve Active", no sparkline) +``` + +Reuses existing `GaugeCard` component. Data comes from the existing `monitor` and `monitorHistory` from `useMonitor()` hook, plus `fanState` from the new fan API. + +No new WebSocket needed — the existing monitor poller already pushes `fan_pct` and `temp_c`. The "Target Fan" gauge can be computed client-side from the active curve + current temp. + +#### 3.3 Modify: `frontend/src/App.tsx` + +Add `fans` to the tab union type and rendering: + +```tsx +const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve'); +``` + +Add a third tab button between the existing buttons: +```tsx + +``` + +Add the fans tab content rendering: +```tsx +{activeTab === 'fans' && ( +
+
+ +
+
+ +
+
+)} +``` + +Import the new components. + +--- + +### Phase 4: Integration & Polish + +#### 4.1 Profile Integration +- When saving a profile, include the active fan curve +- When applying a profile with a fan curve, activate it +- In `ProfilePanel`, display a small indicator if a profile contains a fan curve + +#### 4.2 Safety Considerations +- Validate fan % values: clamp to 0-100 +- Validate temp values: reasonable range (0-120°C) +- Ensure curve points are sorted by temp_c +- Warn user before resetting to auto (fan control was manual) +- On server disconnect, log a warning that fan curve control is lost + +#### 4.3 Edge Cases +- GPU with no controllable fan (e.g., SFF passively cooled) — `nvmlDeviceSetFanSpeed` returns error; show "Fan control not available" message +- Multiple GPUs — each GPU has its own fan curve state +- Driver doesn't support `nvmlDeviceSetFanSpeed` — graceful degradation, show read-only fan info + +--- + +## File Summary + +### New Files +| File | Purpose | +|------|---------| +| `nvcurve/hal/fans.py` | NVML fan control HAL (read, set, reset, fan info) | +| `frontend/src/components/Fans/FanCurveEditor.tsx` | Fan curve editor with SVG chart | +| `frontend/src/components/Monitor/FanMonitor.tsx` | Fan Live Monitor sidebar | + +### Modified Files +| File | Changes | +|------|---------| +| `nvcurve/server.py` | New `/api/fans` endpoints, fan poller task, per-GPU fan state, profile save/apply includes fan curve | +| `nvcurve/nvapi/types.py` | No change (fan_pct already exists) | +| `nvcurve/profiles/native.py` | `ProfileData` + `fan_curve` field, migration in `load_profile` | +| `nvcurve/profiles/apply.py` | Apply fan curve when loading profile | +| `frontend/src/types.ts` | `FanPoint`, `FanState` types; extend `ProfileData` | +| `frontend/src/api/client.ts` | `fans`, `updateFans`, `resetFans`, `setFanSpeed` methods | +| `frontend/src/App.tsx` | Third tab button + fan tab content rendering | + +--- + +## Implementation Order + +1. **Backend HAL** — `hal/fans.py` (read fan, set fan, get fan info) +2. **Backend Server** — `/api/fans` endpoints + fan poller in `server.py` +3. **Backend Profiles** — extend `ProfileData`, save/apply integration +4. **Frontend Types & API** — `types.ts`, `client.ts` +5. **Frontend FanMonitor** — sidebar component (reuses existing data) +6. **Frontend FanCurveEditor** — main chart component +7. **Frontend App.tsx** — wire up the tab +8. **Testing** — manual verification of fan control, profile save/apply, reset diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 034a20a..59dc31c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu'); const [isProfileOpen, setIsProfileOpen] = useState(false); const profileRef = useRef(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 +
- +
- + {curve && (
)} - ) : ( + ) : activeTab === 'performance' ? (
@@ -151,6 +171,20 @@ export default function App() {
+ ) : ( +
+
+ +
+
+ +
+
)}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b63f020..43c6a98 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(path: string, gpuIndex?: number): Promise { 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('/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), }; diff --git a/frontend/src/components/Fans/FanCurveEditor.tsx b/frontend/src/components/Fans/FanCurveEditor.tsx new file mode 100644 index 0000000..1ddcf5b --- /dev/null +++ b/frontend/src/components/Fans/FanCurveEditor.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + const [confirmApply, setConfirmApply] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + const [dragIdx, setDragIdx] = useState(null); + const svgRef = useRef(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) { + 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) => { + 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 ( +
+
+
+
+
+
+ ); + } + + return ( + <> +
+ {/* Header */} +
+ Fan Curve + + {isDefaults && ( + + Defaults + + )} + + {hasPending && ( + + {activeCurve?.length ?? 0} pts + + )} + + {curveActive && !hasPending && ( + + Active + + )} + +
+ + + +
+
+ + {/* Error banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Chart area */} +
+ {isDefaults && ( +
+ + Default curve — edit points or apply as-is to enable curve control + +
+ )} + + {/* Background */} + + + {/* Grid lines - horizontal (fan %) */} + {fanTicks.map(f => ( + + + + {f}% + + + ))} + + {/* Grid lines - vertical (temp) */} + {tempTicks.map(t => ( + + + + {t}° + + + ))} + + {/* Axis labels */} + Temperature (°C) + Fan Speed (%) + + {/* Curve line */} + {curvePath && ( + + )} + + {/* Curve fill area */} + {curvePath && activeCurve && activeCurve.length >= 2 && ( + + )} + + {/* Points */} + {activeCurve?.map((p, i) => ( + + { + e.stopPropagation(); + handlePointerDown(i); + }} + /> + {/* Delete button on hover */} + {activeCurve && activeCurve.length > 2 && ( + { + e.stopPropagation(); + removePoint(i); + }} + > + Remove point + + )} + {/* Value label */} + + {p.fan_pct}% + + + ))} + + {/* No-curve hint (only when curve is truly empty) */} + {!activeCurve && ( + + Click to add points + + )} + +
+ + {/* Point table below chart */} + {activeCurve && activeCurve.length > 0 && ( +
+
+ Curve Points + +
+
+ + + + + + + + + + {activeCurve.map((p, i) => ( + + + + + + ))} + +
Temp (°C)Fan (%)
{p.temp_c}{p.fan_pct} + {activeCurve.length > 2 && ( + + )} +
+
+
+ )} + + {/* Info */} +
+ {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.'} +
+
+ + {confirmApply && ( + setConfirmApply(false)} + /> + )} + {confirmReset && ( + setConfirmReset(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/Monitor/FanMonitor.tsx b/frontend/src/components/Monitor/FanMonitor.tsx new file mode 100644 index 0000000..da3d8bc --- /dev/null +++ b/frontend/src/components/Monitor/FanMonitor.tsx @@ -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( + 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 ( +
+
+
+ Live Monitor + {fanCurveActive && ( + + Curve Active + + )} +
+
+ + + {fanCurveActive && ( + + )} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/Monitor/GaugeCard.tsx b/frontend/src/components/Monitor/GaugeCard.tsx index 595b234..0b5c81b 100644 --- a/frontend/src/components/Monitor/GaugeCard.tsx +++ b/frontend/src/components/Monitor/GaugeCard.tsx @@ -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
{label}
{value}
-
- -
+ {history && ( +
+ +
+ )}
); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f6188d5..4a40f45 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; mem_offset_mhz: number | null; power_limit_w: number | null; + fan_curve: FanPoint[] | null; } diff --git a/nvcurve/hal/fans.py b/nvcurve/hal/fans.py new file mode 100644 index 0000000..c32793f --- /dev/null +++ b/nvcurve/hal/fans.py @@ -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" diff --git a/nvcurve/profiles/native.py b/nvcurve/profiles/native.py index 534f54a..4f11c31 100644 --- a/nvcurve/profiles/native.py +++ b/nvcurve/profiles/native.py @@ -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: diff --git a/nvcurve/server.py b/nvcurve/server.py index 1284d79..81edbc1 100644 --- a/nvcurve/server.py +++ b/nvcurve/server.py @@ -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: diff --git a/uv.lock b/uv.lock index eec27f7..de8ec54 100644 --- a/uv.lock +++ b/uv.lock @@ -156,7 +156,7 @@ wheels = [ [[package]] name = "nvcurve" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "fastapi" },