feat: add fan curve control via temperature-based fan speed curves

- Backend HAL (hal/fans.py): NVML v2 fan read/set/reset with min/max queries
- Server endpoints: GET/POST /api/fans, POST /api/fans/reset, POST /api/fans/speed
- Background fan poller: reads GPU temp every 2s, interpolates fan speed from curve
- Profile integration: fan_curve field saved/applied, auto-restore on shutdown
- Frontend: FanCurveEditor (SVG chart with drag/add/delete points), FanMonitor sidebar
- App.tsx: three-tab layout (Curve, Performance, Fans)
- GaugeCard: optional history sparkline, Fan Mode card without sparkline
- fan_mode field populated as 'curve' or 'auto' in GET /api/fans
This commit is contained in:
ARIA committed 2026-08-01 15:59:39 +02:00
1 parent 7605d21e27
commit 477c4becae
11 files changed
+1288 -10

No files matched your search

@@ -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>
);
}