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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user