feat: add fan curve control via temperature-based fan speed curves #1
No files matched your search
+271
@@ -0,0 +1,271 @@
|
|||||||
|
# Fan Tab Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a third **Fans** tab to the WebUI alongside the existing `Curve` and `Performance` tabs. The tab presents a fan speed curve editor (temperature → target fan %) with a Live Monitor sidebar, and the ability to apply, save in profiles, and reset fan settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decision: Fan Control via NVML
|
||||||
|
|
||||||
|
Fan control will use **NVML (pynvml)**, not NvAPI. Rationale:
|
||||||
|
- `nvmlDeviceSetFanSpeed(handle, speed)` is well-documented and widely supported
|
||||||
|
- `nvmlDeviceGetFanSpeed(handle)` is already used in `hal/monitoring.py:108` for reading
|
||||||
|
- `nvmlDeviceGetFanSpeedInfo(handle)` returns current mode (0=auto, 1=manual) and current speed
|
||||||
|
- No need to reverse-engineer NvAPI fan functions — NVML provides a clean, stable API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Backend — HAL Layer
|
||||||
|
|
||||||
|
#### 1.1 New file: `nvcurve/hal/fans.py`
|
||||||
|
|
||||||
|
Fan curve model: a list of **temperature → fan %** target points, similar to the existing V/F curve concept but simpler (no NvAPI table, just user-defined targets).
|
||||||
|
|
||||||
|
```
|
||||||
|
FanPoint:
|
||||||
|
temp_c: int # temperature threshold in °C (e.g. 30, 40, 50, 60, 70, 80)
|
||||||
|
fan_pct: int # target fan speed at that temp (0-100 %)
|
||||||
|
```
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
- `get_fan_state(gpu_index) -> dict` — returns current fan %, fan mode (auto/manual), min/max fan speeds
|
||||||
|
- `set_fan_speed(gpu_index, pct) -> tuple[bool, str]` — sets fan to a specific % via `nvmlDeviceSetFanSpeed`
|
||||||
|
- `reset_fan(gpu_index) -> tuple[bool, str]` — restores automatic fan control
|
||||||
|
- `get_fan_curve(gpu_index) -> list[dict]` — returns currently stored fan curve points (from config/profile)
|
||||||
|
- `apply_fan_curve(gpu_index, curve) -> None` — background thread that reads temp, interpolates fan % from curve, and calls `set_fan_speed` periodically
|
||||||
|
|
||||||
|
Key detail: Unlike V/F curve or power limits (one-shot writes), a fan curve needs a **continuous feedback loop**. The daemon/server needs a background task that:
|
||||||
|
1. Reads current GPU temp (already available via monitoring poller)
|
||||||
|
2. Interpolates the target fan % from the active fan curve
|
||||||
|
3. Calls `set_fan_speed` with the interpolated value
|
||||||
|
4. Runs at a configurable interval (e.g. every 2-5 seconds)
|
||||||
|
|
||||||
|
**Two approaches for the feedback loop:**
|
||||||
|
|
||||||
|
**A) Server-side poller (Recommended)** — Add a new asyncio task in `server.py` lifespan, similar to `_monitor_poller`. When a fan curve is active, the poller reads temp, interpolates, and sets fan speed each cycle.
|
||||||
|
|
||||||
|
**B) Daemon-side poller** — Run the loop in `daemon.py`. More complex, requires IPC coordination.
|
||||||
|
|
||||||
|
I recommend **approach A** for simplicity and consistency with the existing architecture.
|
||||||
|
|
||||||
|
#### 1.2 Modify: `nvcurve/server.py`
|
||||||
|
|
||||||
|
New REST endpoints:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/api/fans` | Current fan state: `{fan_pct, fan_mode, min_fan_pct, max_fan_pct, curve}` |
|
||||||
|
| `POST` | `/api/fans` | Set fan curve: `{curve: [{temp_c, fan_pct}]}` — starts/updates the feedback loop |
|
||||||
|
| `POST` | `/api/fans/reset` | Reset to automatic fan control, stops feedback loop |
|
||||||
|
| `POST` | `/api/fans/speed` | One-shot set fan to exact %: `{fan_pct: 50}` |
|
||||||
|
|
||||||
|
New server state:
|
||||||
|
- Per-GPU: `fan_curve: list[dict] | None`, `fan_active: bool`, `fan_poller_task: asyncio.Task | None`
|
||||||
|
- New `_fan_poller(gpu_index)` async task, similar pattern to `_monitor_poller`
|
||||||
|
|
||||||
|
The fan poller reads temp from NVML, interpolates fan % from the stored curve using linear interpolation between nearest points (clamp at min/max), and calls `set_fan_speed`.
|
||||||
|
|
||||||
|
#### 1.3 Modify: `nvcurve/nvapi/types.py`
|
||||||
|
|
||||||
|
Add to `MonitoringSample` (optional — fan_pct already exists):
|
||||||
|
- No change needed; `fan_pct` is already present.
|
||||||
|
|
||||||
|
#### 1.4 Modify: `nvcurve/profiles/native.py`
|
||||||
|
|
||||||
|
Extend `ProfileData`:
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ProfileData:
|
||||||
|
name: str
|
||||||
|
gpu_name: str
|
||||||
|
curve_deltas: Dict[str, int]
|
||||||
|
mem_offset_mhz: Optional[int] = None
|
||||||
|
power_limit_w: Optional[int] = None
|
||||||
|
fan_curve: Optional[List[Dict[str, int]]] = None # NEW: [{temp_c, fan_pct}, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add migration in `load_profile` to handle old profiles without `fan_curve`.
|
||||||
|
|
||||||
|
#### 1.5 Modify: `nvcurve/profiles/apply.py`
|
||||||
|
|
||||||
|
When applying a profile, if `fan_curve` is present, call the new `/api/fans` endpoint (or the HAL function directly) to activate the fan curve.
|
||||||
|
|
||||||
|
#### 1.6 Modify: `nvcurve/server.py` — Profile endpoints
|
||||||
|
|
||||||
|
In the profile save endpoint, include the current active fan curve in the saved profile data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Frontend — Types & API
|
||||||
|
|
||||||
|
#### 2.1 Modify: `frontend/src/types.ts`
|
||||||
|
|
||||||
|
New types:
|
||||||
|
```typescript
|
||||||
|
export interface FanPoint {
|
||||||
|
temp_c: number;
|
||||||
|
fan_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanState {
|
||||||
|
fan_pct: number | null;
|
||||||
|
fan_mode: number | null; // 0 = auto, 1 = manual
|
||||||
|
min_fan_pct: number | null;
|
||||||
|
max_fan_pct: number | null;
|
||||||
|
curve: FanPoint[];
|
||||||
|
curve_active: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend `ProfileData`:
|
||||||
|
```typescript
|
||||||
|
export interface ProfileData {
|
||||||
|
// ... existing fields
|
||||||
|
fan_curve: FanPoint[] | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Modify: `frontend/src/api/client.ts`
|
||||||
|
|
||||||
|
New API methods:
|
||||||
|
```typescript
|
||||||
|
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
|
||||||
|
updateFans: (updates: { curve?: FanPoint[] }, gpuIndex: number) => post('/fans', updates, gpuIndex),
|
||||||
|
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
|
||||||
|
setFanSpeed: (fanPct: number, gpuIndex: number) => post('/fans/speed', { fan_pct: fanPct }, gpuIndex),
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Frontend — Components
|
||||||
|
|
||||||
|
#### 3.1 New file: `frontend/src/components/Fans/FanCurveEditor.tsx`
|
||||||
|
|
||||||
|
Main content area for the Fans tab. Similar visual style to `PerformancePanel` but with a curve visualization:
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- SVG chart: X-axis = temperature (°C, range ~20-100), Y-axis = fan speed (%)
|
||||||
|
- Interactive points on the curve that can be dragged vertically (adjust fan %) and horizontally (adjust temp threshold)
|
||||||
|
- Minimum 2 points, maximum ~10 points
|
||||||
|
- Click to add a new point, drag to adjust, double-click or delete button to remove
|
||||||
|
- Visual style matches `CurveEditor` but simpler (no domain toggle, no zoom/pan needed — the range is small)
|
||||||
|
|
||||||
|
**Controls (header bar, same pattern as PerformancePanel):**
|
||||||
|
- "pending" badge when curve has unsaved changes
|
||||||
|
- Apply / Discard / Reset buttons
|
||||||
|
- ConfirmDialog on apply and reset
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
- On mount: `GET /api/fans` to load current state
|
||||||
|
- User edits → local `pending` state
|
||||||
|
- Apply → `POST /api/fans` with new curve
|
||||||
|
- Reset → `POST /api/fans/reset` to restore auto fan control
|
||||||
|
|
||||||
|
Color scheme: Use `orange-400` / `amber-400` for the fan curve line and points (heat-themed), consistent with the existing zinc/pink/cyan palette.
|
||||||
|
|
||||||
|
#### 3.2 New file: `frontend/src/components/Monitor/FanMonitor.tsx`
|
||||||
|
|
||||||
|
Sidebar component matching `LiveMonitor` / `PerformanceMonitor` style:
|
||||||
|
|
||||||
|
```
|
||||||
|
Live Monitor (header)
|
||||||
|
├─ GaugeCard: Fan Speed (current %, sparkline from history)
|
||||||
|
├─ GaugeCard: GPU Temp (current °C, sparkline from history)
|
||||||
|
├─ GaugeCard: Target Fan (interpolated target %, sparkline)
|
||||||
|
└─ GaugeCard: Fan Mode ("Auto" / "Curve Active", no sparkline)
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuses existing `GaugeCard` component. Data comes from the existing `monitor` and `monitorHistory` from `useMonitor()` hook, plus `fanState` from the new fan API.
|
||||||
|
|
||||||
|
No new WebSocket needed — the existing monitor poller already pushes `fan_pct` and `temp_c`. The "Target Fan" gauge can be computed client-side from the active curve + current temp.
|
||||||
|
|
||||||
|
#### 3.3 Modify: `frontend/src/App.tsx`
|
||||||
|
|
||||||
|
Add `fans` to the tab union type and rendering:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve');
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a third tab button between the existing buttons:
|
||||||
|
```tsx
|
||||||
|
<button onClick={() => setActiveTab('fans')}
|
||||||
|
className={`... ${activeTab === 'fans' ? 'border-pink-500 text-zinc-100' : '...'}`}>
|
||||||
|
Fans
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the fans tab content rendering:
|
||||||
|
```tsx
|
||||||
|
{activeTab === 'fans' && (
|
||||||
|
<div className="flex gap-4 items-start w-full">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<FanCurveEditor />
|
||||||
|
</div>
|
||||||
|
<div className="w-80 shrink-0 flex flex-col">
|
||||||
|
<FanMonitor monitor={monitor} history={monitorHistory} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the new components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Integration & Polish
|
||||||
|
|
||||||
|
#### 4.1 Profile Integration
|
||||||
|
- When saving a profile, include the active fan curve
|
||||||
|
- When applying a profile with a fan curve, activate it
|
||||||
|
- In `ProfilePanel`, display a small indicator if a profile contains a fan curve
|
||||||
|
|
||||||
|
#### 4.2 Safety Considerations
|
||||||
|
- Validate fan % values: clamp to 0-100
|
||||||
|
- Validate temp values: reasonable range (0-120°C)
|
||||||
|
- Ensure curve points are sorted by temp_c
|
||||||
|
- Warn user before resetting to auto (fan control was manual)
|
||||||
|
- On server disconnect, log a warning that fan curve control is lost
|
||||||
|
|
||||||
|
#### 4.3 Edge Cases
|
||||||
|
- GPU with no controllable fan (e.g., SFF passively cooled) — `nvmlDeviceSetFanSpeed` returns error; show "Fan control not available" message
|
||||||
|
- Multiple GPUs — each GPU has its own fan curve state
|
||||||
|
- Driver doesn't support `nvmlDeviceSetFanSpeed` — graceful degradation, show read-only fan info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `nvcurve/hal/fans.py` | NVML fan control HAL (read, set, reset, fan info) |
|
||||||
|
| `frontend/src/components/Fans/FanCurveEditor.tsx` | Fan curve editor with SVG chart |
|
||||||
|
| `frontend/src/components/Monitor/FanMonitor.tsx` | Fan Live Monitor sidebar |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `nvcurve/server.py` | New `/api/fans` endpoints, fan poller task, per-GPU fan state, profile save/apply includes fan curve |
|
||||||
|
| `nvcurve/nvapi/types.py` | No change (fan_pct already exists) |
|
||||||
|
| `nvcurve/profiles/native.py` | `ProfileData` + `fan_curve` field, migration in `load_profile` |
|
||||||
|
| `nvcurve/profiles/apply.py` | Apply fan curve when loading profile |
|
||||||
|
| `frontend/src/types.ts` | `FanPoint`, `FanState` types; extend `ProfileData` |
|
||||||
|
| `frontend/src/api/client.ts` | `fans`, `updateFans`, `resetFans`, `setFanSpeed` methods |
|
||||||
|
| `frontend/src/App.tsx` | Third tab button + fan tab content rendering |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Backend HAL** — `hal/fans.py` (read fan, set fan, get fan info)
|
||||||
|
2. **Backend Server** — `/api/fans` endpoints + fan poller in `server.py`
|
||||||
|
3. **Backend Profiles** — extend `ProfileData`, save/apply integration
|
||||||
|
4. **Frontend Types & API** — `types.ts`, `client.ts`
|
||||||
|
5. **Frontend FanMonitor** — sidebar component (reuses existing data)
|
||||||
|
6. **Frontend FanCurveEditor** — main chart component
|
||||||
|
7. **Frontend App.tsx** — wire up the tab
|
||||||
|
8. **Testing** — manual verification of fan control, profile save/apply, reset
|
||||||
+38
-4
@@ -7,12 +7,15 @@ import { CurveEditor } from './components/CurveEditor/CurveEditor';
|
|||||||
import { PointTable } from './components/PointTable/PointTable';
|
import { PointTable } from './components/PointTable/PointTable';
|
||||||
import { PerformancePanel } from './components/Limits/PerformancePanel';
|
import { PerformancePanel } from './components/Limits/PerformancePanel';
|
||||||
import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor';
|
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 { ProfilePanel } from './components/Profiles/ProfilePanel';
|
||||||
import { api } from './api/client';
|
import { api } from './api/client';
|
||||||
import { useCurveStore } from './store/curveStore';
|
import { useCurveStore } from './store/curveStore';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { Loader, ChevronDown } from 'lucide-react';
|
import { Loader, ChevronDown } from 'lucide-react';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { FanState } from './types';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const gpuInfo = useGpu();
|
const gpuInfo = useGpu();
|
||||||
@@ -20,7 +23,8 @@ export default function App() {
|
|||||||
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
|
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
|
||||||
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore();
|
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 [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu');
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
const profileRef = useRef<HTMLDivElement>(null);
|
const profileRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,6 +49,16 @@ export default function App() {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [selectedGpuIndex, setActiveProfile]);
|
}, [selectedGpuIndex, setActiveProfile]);
|
||||||
|
|
||||||
|
function refreshFans() {
|
||||||
|
api.fans(selectedGpuIndex).then(setFanState).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'fans') {
|
||||||
|
refreshFans();
|
||||||
|
}
|
||||||
|
}, [activeTab, selectedGpuIndex]);
|
||||||
|
|
||||||
// Worst connection status wins
|
// Worst connection status wins
|
||||||
const wsStatus =
|
const wsStatus =
|
||||||
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
|
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
|
||||||
@@ -81,6 +95,12 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
Performance
|
Performance
|
||||||
</button>
|
</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>
|
||||||
<div className="relative" ref={profileRef}>
|
<div className="relative" ref={profileRef}>
|
||||||
<button
|
<button
|
||||||
@@ -126,12 +146,12 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-80 shrink-0 flex flex-col">
|
<div className="w-80 shrink-0 flex flex-col">
|
||||||
<LiveMonitor monitor={monitor} history={monitorHistory} />
|
<LiveMonitor monitor={monitor} history={monitorHistory} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{curve && (
|
{curve && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<PointTable
|
<PointTable
|
||||||
@@ -142,7 +162,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : activeTab === 'performance' ? (
|
||||||
<div className="flex gap-4 items-start w-full">
|
<div className="flex gap-4 items-start w-full">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<PerformancePanel />
|
<PerformancePanel />
|
||||||
@@ -151,6 +171,20 @@ export default function App() {
|
|||||||
<PerformanceMonitor monitor={monitor} history={monitorHistory} />
|
<PerformanceMonitor monitor={monitor} history={monitorHistory} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData } from '../types';
|
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData, FanState, FanPoint } from '../types';
|
||||||
|
|
||||||
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
|
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
|
||||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
||||||
@@ -76,4 +76,12 @@ export const api = {
|
|||||||
/** Server config */
|
/** Server config */
|
||||||
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
|
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
|
||||||
post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }),
|
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 {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
history: number[];
|
history?: number[];
|
||||||
unit?: string;
|
unit?: string;
|
||||||
color?: string; // tailwind color class for the sparkline stroke
|
color?: string; // tailwind color class for the sparkline stroke
|
||||||
max?: number;
|
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="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-xs text-zinc-500 uppercase tracking-wider">{label}</div>
|
||||||
<div className="text-xl font-mono font-semibold text-zinc-100">{value}</div>
|
<div className="text-xl font-mono font-semibold text-zinc-100">{value}</div>
|
||||||
<div className="mt-1 w-full">
|
{history && (
|
||||||
<Sparkline data={history} color={color} max={max} />
|
<div className="mt-1 w-full">
|
||||||
</div>
|
<Sparkline data={history} color={color} max={max} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,10 +66,25 @@ export interface LimitsState {
|
|||||||
max_mem_offset_mhz: number | null;
|
max_mem_offset_mhz: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FanPoint {
|
||||||
|
temp_c: number;
|
||||||
|
fan_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanState {
|
||||||
|
fan_pct: number | null;
|
||||||
|
fan_mode: number | null;
|
||||||
|
min_fan_pct: number | null;
|
||||||
|
max_fan_pct: number | null;
|
||||||
|
curve: FanPoint[] | null;
|
||||||
|
curve_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
name: string;
|
name: string;
|
||||||
gpu_name: string;
|
gpu_name: string;
|
||||||
curve_deltas: Record<string, number>;
|
curve_deltas: Record<string, number>;
|
||||||
mem_offset_mhz: number | null;
|
mem_offset_mhz: number | null;
|
||||||
power_limit_w: number | null;
|
power_limit_w: number | null;
|
||||||
|
fan_curve: FanPoint[] | null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Hardware Abstraction Layer for Fan Control.
|
||||||
|
|
||||||
|
Uses NVML (via pynvml) for all operations:
|
||||||
|
- nvmlDeviceGetFanSpeed_v2 : read current fan speed % for a fan index
|
||||||
|
- nvmlDeviceSetFanSpeed_v2 : set fan speed % for a fan index
|
||||||
|
- nvmlDeviceGetMinMaxFanSpeed: get min/max fan speed constraints
|
||||||
|
- nvmlDeviceGetTemperature : read GPU temp for curve interpolation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pynvml
|
||||||
|
_NVML_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_NVML_AVAILABLE = False
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.hal.fans")
|
||||||
|
|
||||||
|
# We use fan index 0 (first/primary fan) for all operations.
|
||||||
|
_FAN_INDEX = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_handle(gpu_index: int):
|
||||||
|
"""Return an NVML device handle."""
|
||||||
|
if not _NVML_AVAILABLE:
|
||||||
|
raise RuntimeError("NVML not available (install nvidia-ml-py)")
|
||||||
|
return pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
|
||||||
|
|
||||||
|
|
||||||
|
def get_fan_info(gpu_index: int = 0) -> dict:
|
||||||
|
"""Return current fan state: fan_pct, fan_mode, min_fan_pct, max_fan_pct.
|
||||||
|
|
||||||
|
Returns None values on failure.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"fan_pct": None,
|
||||||
|
"fan_mode": None,
|
||||||
|
"min_fan_pct": None,
|
||||||
|
"max_fan_pct": None,
|
||||||
|
}
|
||||||
|
if not _NVML_AVAILABLE:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
handle = _get_handle(gpu_index)
|
||||||
|
|
||||||
|
# Get current fan speed using v2 API (fan index 0)
|
||||||
|
try:
|
||||||
|
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed_v2(handle, _FAN_INDEX))
|
||||||
|
except pynvml.NVMLError:
|
||||||
|
# Fallback to legacy v1 API
|
||||||
|
try:
|
||||||
|
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed(handle))
|
||||||
|
except pynvml.NVMLError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get min/max fan speed constraints
|
||||||
|
try:
|
||||||
|
min_s = ctypes.c_uint(0)
|
||||||
|
max_s = ctypes.c_uint(0)
|
||||||
|
pynvml.nvmlDeviceGetMinMaxFanSpeed(handle, min_s, max_s)
|
||||||
|
out["min_fan_pct"] = int(min_s.value)
|
||||||
|
out["max_fan_pct"] = int(max_s.value)
|
||||||
|
except pynvml.NVMLError:
|
||||||
|
out["min_fan_pct"] = 0
|
||||||
|
out["max_fan_pct"] = 100
|
||||||
|
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
log.warning("get_fan_info: %s", exc)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
|
||||||
|
"""Set fan speed to a percentage (0-100) on the primary fan."""
|
||||||
|
pct = max(0, min(100, int(pct)))
|
||||||
|
if not _NVML_AVAILABLE:
|
||||||
|
return False, "NVML not available"
|
||||||
|
try:
|
||||||
|
handle = _get_handle(gpu_index)
|
||||||
|
pynvml.nvmlDeviceSetFanSpeed_v2(handle, _FAN_INDEX, pct)
|
||||||
|
return True, "OK"
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
log.warning("set_fan_speed(%d, %d): %s", gpu_index, pct, exc)
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
|
||||||
|
"""Restore automatic fan control.
|
||||||
|
|
||||||
|
Tries nvidia-smi --fan=default first (most reliable), then falls back to
|
||||||
|
NVML nvmlDeviceSetDefaultFanSpeed_v2.
|
||||||
|
"""
|
||||||
|
if not _NVML_AVAILABLE:
|
||||||
|
return False, "NVML not available"
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Try nvidia-smi approach first (most reliable for restoring auto)
|
||||||
|
try:
|
||||||
|
ret = subprocess.run(
|
||||||
|
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if ret.returncode == 0:
|
||||||
|
return True, "OK"
|
||||||
|
log.debug("nvidia-smi -fan default failed: %s", ret.stderr.strip())
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.debug("nvidia-smi not found, falling back to NVML")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log.warning("nvidia-smi -fan default timed out")
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("nvidia-smi -fan default error: %s", exc)
|
||||||
|
|
||||||
|
# Fallback: use NVML to reset to default fan speed
|
||||||
|
try:
|
||||||
|
handle = _get_handle(gpu_index)
|
||||||
|
pynvml.nvmlDeviceSetDefaultFanSpeed_v2(handle, _FAN_INDEX)
|
||||||
|
return True, "OK"
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
return False, f"Failed to reset fan: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_temp(gpu_index: int = 0) -> Optional[float]:
|
||||||
|
"""Read current GPU temperature in °C."""
|
||||||
|
if not _NVML_AVAILABLE:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
handle = _get_handle(gpu_index)
|
||||||
|
return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU))
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
log.debug("get_temp: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
|
||||||
|
"""Interpolate target fan speed from a curve at a given temperature.
|
||||||
|
|
||||||
|
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
|
||||||
|
Returns fan_pct clamped to 0-100, or None if curve is empty.
|
||||||
|
"""
|
||||||
|
if not curve or len(curve) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
temp = float(temp_c)
|
||||||
|
|
||||||
|
# Find the two surrounding points
|
||||||
|
for i in range(len(curve) - 1):
|
||||||
|
t0, f0 = curve[i]["temp_c"], curve[i]["fan_pct"]
|
||||||
|
t1, f1 = curve[i + 1]["temp_c"], curve[i + 1]["fan_pct"]
|
||||||
|
|
||||||
|
if t0 == t1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if t0 <= temp <= t1:
|
||||||
|
fraction = (temp - t0) / (t1 - t0)
|
||||||
|
result = f0 + fraction * (f1 - f0)
|
||||||
|
return max(0, min(100, int(round(result))))
|
||||||
|
|
||||||
|
# Outside range: clamp to first or last point
|
||||||
|
if temp <= curve[0]["temp_c"]:
|
||||||
|
return max(0, min(100, curve[0]["fan_pct"]))
|
||||||
|
return max(0, min(100, curve[-1]["fan_pct"]))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_curve(curve: List[dict]) -> tuple[bool, str]:
|
||||||
|
"""Validate a fan curve.
|
||||||
|
|
||||||
|
Returns (True, "OK") or (False, error_message).
|
||||||
|
"""
|
||||||
|
if not curve or len(curve) < 2:
|
||||||
|
return False, "Fan curve requires at least 2 points"
|
||||||
|
|
||||||
|
temps = [p["temp_c"] for p in curve]
|
||||||
|
speeds = [p["fan_pct"] for p in curve]
|
||||||
|
|
||||||
|
# Check for duplicate temperatures
|
||||||
|
if len(temps) != len(set(temps)):
|
||||||
|
return False, "Fan curve has duplicate temperature values"
|
||||||
|
|
||||||
|
# Check temperature range
|
||||||
|
for t in temps:
|
||||||
|
if t < 0 or t > 120:
|
||||||
|
return False, f"Temperature {t}°C out of range (0-120)"
|
||||||
|
|
||||||
|
# Check fan speed range
|
||||||
|
for s in speeds:
|
||||||
|
if s < 0 or s > 100:
|
||||||
|
return False, f"Fan speed {s}% out of range (0-100)"
|
||||||
|
|
||||||
|
# Check sorted by temperature
|
||||||
|
for i in range(len(temps) - 1):
|
||||||
|
if temps[i] >= temps[i + 1]:
|
||||||
|
return False, "Fan curve points must be sorted by ascending temperature"
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
@@ -14,6 +14,7 @@ class ProfileData:
|
|||||||
curve_deltas: Dict[str, int] # { "index": delta_khz }
|
curve_deltas: Dict[str, int] # { "index": delta_khz }
|
||||||
mem_offset_mhz: Optional[int] = None
|
mem_offset_mhz: Optional[int] = None
|
||||||
power_limit_w: 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:
|
def save_profile(profile_dir: str, data: ProfileData) -> str:
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ from .hal.limits import (
|
|||||||
set_clock_offsets,
|
set_clock_offsets,
|
||||||
get_mem_offset_range,
|
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 (
|
from .profiles.native import (
|
||||||
ProfileData,
|
ProfileData,
|
||||||
save_profile,
|
save_profile,
|
||||||
@@ -169,6 +177,25 @@ async def _monitor_poller(gpu_index: int) -> None:
|
|||||||
await asyncio.sleep(cfg.poll_interval_s)
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -196,6 +223,9 @@ async def lifespan(app: FastAPI):
|
|||||||
"active_profile": None,
|
"active_profile": None,
|
||||||
"monitor_clients": set(),
|
"monitor_clients": set(),
|
||||||
"curve_clients": set(),
|
"curve_clients": set(),
|
||||||
|
"fan_curve": None,
|
||||||
|
"fan_curve_active": False,
|
||||||
|
"fan_poller_task": None,
|
||||||
}
|
}
|
||||||
_state["gpus"][idx] = g_state
|
_state["gpus"][idx] = g_state
|
||||||
|
|
||||||
@@ -252,6 +282,23 @@ async def lifespan(app: FastAPI):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
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)
|
await loop.run_in_executor(None, shutdown_nvml)
|
||||||
|
|
||||||
|
|
||||||
@@ -305,6 +352,19 @@ class ConfigUpdateRequest(BaseModel):
|
|||||||
gpu_index: int = 0
|
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 ──────────────────────────────
|
# ── Helper: run blocking HAL call in thread pool ──────────────────────────────
|
||||||
|
|
||||||
async def _run(fn, *args):
|
async def _run(fn, *args):
|
||||||
@@ -513,6 +573,7 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
|||||||
curve_deltas=curve_deltas,
|
curve_deltas=curve_deltas,
|
||||||
mem_offset_mhz=mem_offset_mhz,
|
mem_offset_mhz=mem_offset_mhz,
|
||||||
power_limit_w=power_limit_w,
|
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)
|
filepath = await _run(save_profile, cfg.profile_dir, data)
|
||||||
g_state["active_profile"] = req.name
|
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)
|
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:
|
if not errs:
|
||||||
g_state["active_profile"] = name
|
g_state["active_profile"] = name
|
||||||
return errs
|
return errs
|
||||||
@@ -812,6 +903,95 @@ async def api_limits_reset(gpu_index: int = 0):
|
|||||||
return {"ok": True}
|
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 ────────────────────────────────────────────────────────────
|
# ── Write endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _reconcile_check(gpu_index: int) -> dict | None:
|
async def _reconcile_check(gpu_index: int) -> dict | None:
|
||||||
|
|||||||
Reference in new issue
Block a user