696 lines
25 KiB
Python
696 lines
25 KiB
Python
"""
|
|
Web API for IPMI Fan Controller v2
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Optional, List, Dict
|
|
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import HTMLResponse, FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel, Field
|
|
|
|
from fan_controller import get_service, FanControlService, IPMIFanController
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Pydantic models
|
|
class ConfigUpdate(BaseModel):
|
|
host: Optional[str] = None
|
|
username: Optional[str] = None
|
|
password: Optional[str] = None
|
|
port: Optional[int] = 623
|
|
enabled: Optional[bool] = None
|
|
interval: Optional[int] = Field(None, ge=5, le=300)
|
|
min_speed: Optional[int] = Field(None, ge=0, le=100)
|
|
max_speed: Optional[int] = Field(None, ge=0, le=100)
|
|
panic_temp: Optional[float] = Field(None, ge=50, le=100)
|
|
panic_speed: Optional[int] = Field(None, ge=0, le=100)
|
|
|
|
class FanCurvePoint(BaseModel):
|
|
temp: float = Field(..., ge=0, le=100)
|
|
speed: int = Field(..., ge=0, le=100)
|
|
|
|
class FanCurveUpdate(BaseModel):
|
|
points: List[FanCurvePoint]
|
|
|
|
class ManualSpeedRequest(BaseModel):
|
|
speed: int = Field(..., ge=0, le=100)
|
|
|
|
class StatusResponse(BaseModel):
|
|
running: bool
|
|
enabled: bool
|
|
connected: bool
|
|
manual_mode: bool
|
|
current_speed: int
|
|
target_speed: int
|
|
temperatures: List[Dict]
|
|
fans: List[Dict]
|
|
|
|
|
|
# Create static directory and HTML
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
STATIC_DIR.mkdir(exist_ok=True)
|
|
|
|
# Create the HTML dashboard
|
|
DASHBOARD_HTML = '''<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>IPMI Fan Controller v2</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
min-height: 100vh;
|
|
color: #fff;
|
|
padding: 20px;
|
|
}
|
|
.container { max-width: 900px; margin: 0 auto; }
|
|
h1 { text-align: center; margin-bottom: 10px; font-size: 1.8rem; }
|
|
.subtitle { text-align: center; color: #888; margin-bottom: 30px; }
|
|
|
|
.card {
|
|
background: rgba(255,255,255,0.05);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
.card h2 { font-size: 1.2rem; margin-bottom: 15px; color: #64b5f6; }
|
|
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.status-item {
|
|
background: rgba(0,0,0,0.2);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}
|
|
.status-item .label { font-size: 0.85rem; color: #888; margin-bottom: 5px; }
|
|
.status-item .value { font-size: 1.5rem; font-weight: bold; }
|
|
.status-item .value.good { color: #4caf50; }
|
|
.status-item .value.warn { color: #ff9800; }
|
|
.status-item .value.bad { color: #f44336; }
|
|
|
|
.temp-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 10px;
|
|
}
|
|
.temp-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 10px 15px;
|
|
background: rgba(0,0,0,0.2);
|
|
border-radius: 6px;
|
|
}
|
|
.temp-item .temp-value { font-weight: bold; }
|
|
.temp-item .temp-value.high { color: #f44336; }
|
|
.temp-item .temp-value.med { color: #ff9800; }
|
|
.temp-item .temp-value.low { color: #4caf50; }
|
|
|
|
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px; }
|
|
button {
|
|
padding: 12px 24px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.2s;
|
|
}
|
|
button.primary { background: #2196f3; color: white; }
|
|
button.primary:hover { background: #1976d2; }
|
|
button.success { background: #4caf50; color: white; }
|
|
button.success:hover { background: #388e3c; }
|
|
button.danger { background: #f44336; color: white; }
|
|
button.danger:hover { background: #d32f2f; }
|
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.slider-container { margin: 15px 0; }
|
|
.slider-container label { display: block; margin-bottom: 10px; }
|
|
input[type="range"] {
|
|
width: 100%;
|
|
height: 8px;
|
|
-webkit-appearance: none;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 4px;
|
|
outline: none;
|
|
}
|
|
input[type="range"]::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 24px;
|
|
height: 24px;
|
|
background: #2196f3;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.config-form {
|
|
display: grid;
|
|
gap: 15px;
|
|
}
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 15px;
|
|
}
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-size: 0.9rem;
|
|
color: #aaa;
|
|
}
|
|
.form-group input, .form-group select {
|
|
width: 100%;
|
|
padding: 10px;
|
|
background: rgba(0,0,0,0.2);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.log-output {
|
|
background: rgba(0,0,0,0.3);
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
padding: 15px 20px;
|
|
border-radius: 8px;
|
|
color: white;
|
|
animation: slideIn 0.3s ease;
|
|
z-index: 1000;
|
|
}
|
|
.toast.success { background: #4caf50; }
|
|
.toast.error { background: #f44336; }
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.form-row { grid-template-columns: 1fr; }
|
|
.status-grid { grid-template-columns: repeat(2, 1fr); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🌬️ IPMI Fan Controller v2</h1>
|
|
<p class="subtitle">Dell T710 & Compatible Servers</p>
|
|
|
|
<div class="card">
|
|
<h2>📊 Current Status</h2>
|
|
<div class="status-grid">
|
|
<div class="status-item">
|
|
<div class="label">Connection</div>
|
|
<div class="value" id="conn-status">-</div>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="label">Control Mode</div>
|
|
<div class="value" id="mode-status">-</div>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="label">Current Speed</div>
|
|
<div class="value" id="current-speed">-</div>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="label">Max Temp</div>
|
|
<div class="value" id="max-temp">-</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="temp-section" style="display:none;">
|
|
<h3 style="margin:15px 0 10px;font-size:1rem;color:#aaa;">Temperatures</h3>
|
|
<div class="temp-grid" id="temp-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🎛️ Quick Controls</h2>
|
|
<div class="controls">
|
|
<button class="success" id="btn-auto" onclick="setAuto(true)">▶ Start Auto</button>
|
|
<button class="danger" id="btn-stop" onclick="setAuto(false)">⏹ Stop Auto</button>
|
|
<button class="primary" onclick="testConnection()">🔄 Test Connection</button>
|
|
</div>
|
|
|
|
<div class="slider-container">
|
|
<label>Manual Fan Speed: <strong id="manual-speed-val">50%</strong></label>
|
|
<input type="range" id="manual-speed" min="0" max="100" value="50">
|
|
<button class="primary" style="margin-top:10px;" onclick="setManualSpeed()">Apply Manual Speed</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>⚙️ Configuration</h2>
|
|
<div class="config-form">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>IPMI Host/IP</label>
|
|
<input type="text" id="cfg-host" placeholder="192.168.1.100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Port</label>
|
|
<input type="number" id="cfg-port" value="623">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="cfg-username" placeholder="root">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Password</label>
|
|
<input type="password" id="cfg-password" placeholder="••••••••">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Min Speed (%)</label>
|
|
<input type="number" id="cfg-min" value="10" min="0" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Max Speed (%)</label>
|
|
<input type="number" id="cfg-max" value="100" min="0" max="100">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Panic Temp (°C)</label>
|
|
<input type="number" id="cfg-panic-temp" value="85" min="50" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Check Interval (sec)</label>
|
|
<input type="number" id="cfg-interval" value="10" min="5" max="300">
|
|
</div>
|
|
</div>
|
|
<button class="primary" onclick="saveConfig()">💾 Save Configuration</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>📈 Fan Curve</h2>
|
|
<div id="curve-editor" style="margin-bottom:15px;">
|
|
<p style="color:#888;margin-bottom:10px;">Temp (°C) → Speed (%)</p>
|
|
<div id="curve-points"></div>
|
|
</div>
|
|
<button class="primary" onclick="saveCurve()">Save Fan Curve</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>📝 Logs</h2>
|
|
<div class="log-output" id="logs">Ready...</div>
|
|
<button class="primary" style="margin-top:10px;" onclick="clearLogs()">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let currentStatus = {};
|
|
|
|
// Update slider display
|
|
document.getElementById('manual-speed').addEventListener('input', (e) => {
|
|
document.getElementById('manual-speed-val').textContent = e.target.value + '%';
|
|
});
|
|
|
|
async function fetchStatus() {
|
|
try {
|
|
const res = await fetch('/api/status');
|
|
currentStatus = await res.json();
|
|
updateUI();
|
|
} catch (e) {
|
|
log('Failed to fetch status: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function updateUI() {
|
|
// Connection status
|
|
const connEl = document.getElementById('conn-status');
|
|
if (currentStatus.connected) {
|
|
connEl.textContent = '✓ Connected';
|
|
connEl.className = 'value good';
|
|
} else {
|
|
connEl.textContent = '✗ Disconnected';
|
|
connEl.className = 'value bad';
|
|
}
|
|
|
|
// Mode
|
|
const modeEl = document.getElementById('mode-status');
|
|
if (currentStatus.enabled) {
|
|
modeEl.textContent = 'AUTO';
|
|
modeEl.className = 'value good';
|
|
} else if (currentStatus.manual_mode) {
|
|
modeEl.textContent = 'MANUAL';
|
|
modeEl.className = 'value warn';
|
|
} else {
|
|
modeEl.textContent = 'AUTO (BIOS)';
|
|
modeEl.className = 'value';
|
|
}
|
|
|
|
// Speed
|
|
document.getElementById('current-speed').textContent = currentStatus.current_speed + '%';
|
|
|
|
// Temperatures
|
|
const temps = currentStatus.temperatures || [];
|
|
const cpuTemps = temps.filter(t => t.location.includes('cpu'));
|
|
if (cpuTemps.length > 0) {
|
|
const maxTemp = Math.max(...cpuTemps.map(t => t.value));
|
|
const tempEl = document.getElementById('max-temp');
|
|
tempEl.textContent = maxTemp.toFixed(1) + '°C';
|
|
tempEl.className = 'value ' + (maxTemp > 70 ? 'bad' : maxTemp > 50 ? 'warn' : 'good');
|
|
}
|
|
|
|
// Temp list
|
|
const tempList = document.getElementById('temp-list');
|
|
tempList.innerHTML = temps.map(t => {
|
|
const cls = t.value > 70 ? 'high' : t.value > 50 ? 'med' : 'low';
|
|
return `<div class="temp-item">
|
|
<span>${t.name}</span>
|
|
<span class="temp-value ${cls}">${t.value.toFixed(1)}°C</span>
|
|
</div>`;
|
|
}).join('');
|
|
document.getElementById('temp-section').style.display = temps.length ? 'block' : 'none';
|
|
|
|
// Update config fields if empty
|
|
if (currentStatus.config) {
|
|
const cfg = currentStatus.config;
|
|
if (!document.getElementById('cfg-host').value && cfg.host)
|
|
document.getElementById('cfg-host').value = cfg.host;
|
|
if (!document.getElementById('cfg-username').value && cfg.username)
|
|
document.getElementById('cfg-username').value = cfg.username;
|
|
if (cfg.port) document.getElementById('cfg-port').value = cfg.port;
|
|
if (cfg.min_speed !== undefined) document.getElementById('cfg-min').value = cfg.min_speed;
|
|
if (cfg.max_speed !== undefined) document.getElementById('cfg-max').value = cfg.max_speed;
|
|
if (cfg.panic_temp) document.getElementById('cfg-panic-temp').value = cfg.panic_temp;
|
|
if (cfg.interval) document.getElementById('cfg-interval').value = cfg.interval;
|
|
}
|
|
|
|
// Update curve editor
|
|
updateCurveEditor();
|
|
}
|
|
|
|
function updateCurveEditor() {
|
|
const curve = (currentStatus.config?.fan_curve) || [
|
|
{temp: 30, speed: 15}, {temp: 40, speed: 25}, {temp: 50, speed: 40},
|
|
{temp: 60, speed: 60}, {temp: 70, speed: 80}, {temp: 80, speed: 100}
|
|
];
|
|
const container = document.getElementById('curve-points');
|
|
container.innerHTML = curve.map((p, i) => `
|
|
<div class="form-row" style="margin-bottom:8px;">
|
|
<input type="number" class="curve-temp" data-idx="${i}" value="${p.temp}" min="0" max="100" placeholder="Temp">
|
|
<input type="number" class="curve-speed" data-idx="${i}" value="${p.speed}" min="0" max="100" placeholder="Speed %">
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function testConnection() {
|
|
log('Testing IPMI connection...');
|
|
try {
|
|
const res = await fetch('/api/test', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
log('✓ Connection successful', 'success');
|
|
} else {
|
|
log('✗ Connection failed: ' + data.error, 'error');
|
|
}
|
|
} catch (e) {
|
|
log('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function setAuto(enabled) {
|
|
try {
|
|
const res = await fetch('/api/control/auto', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({enabled})
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
log(enabled ? 'Auto control enabled' : 'Auto control disabled', 'success');
|
|
fetchStatus();
|
|
} else {
|
|
log('Failed: ' + data.error, 'error');
|
|
}
|
|
} catch (e) {
|
|
log('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function setManualSpeed() {
|
|
const speed = parseInt(document.getElementById('manual-speed').value);
|
|
try {
|
|
const res = await fetch('/api/control/manual', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({speed})
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
log(`Manual speed set to ${speed}%`, 'success');
|
|
fetchStatus();
|
|
} else {
|
|
log('Failed: ' + data.error, 'error');
|
|
}
|
|
} catch (e) {
|
|
log('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const config = {
|
|
host: document.getElementById('cfg-host').value,
|
|
port: parseInt(document.getElementById('cfg-port').value),
|
|
username: document.getElementById('cfg-username').value,
|
|
password: document.getElementById('cfg-password').value || undefined,
|
|
min_speed: parseInt(document.getElementById('cfg-min').value),
|
|
max_speed: parseInt(document.getElementById('cfg-max').value),
|
|
panic_temp: parseFloat(document.getElementById('cfg-panic-temp').value),
|
|
interval: parseInt(document.getElementById('cfg-interval').value)
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/config', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(config)
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
log('Configuration saved', 'success');
|
|
document.getElementById('cfg-password').value = ''; // Clear password
|
|
fetchStatus();
|
|
} else {
|
|
log('Failed: ' + data.error, 'error');
|
|
}
|
|
} catch (e) {
|
|
log('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveCurve() {
|
|
const points = [];
|
|
document.querySelectorAll('.curve-temp').forEach((el, i) => {
|
|
const temp = parseFloat(el.value);
|
|
const speedEl = document.querySelector(`.curve-speed[data-idx="${i}"]`);
|
|
const speed = parseInt(speedEl.value);
|
|
points.push({temp, speed});
|
|
});
|
|
|
|
try {
|
|
const res = await fetch('/api/config/curve', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({points})
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
log('Fan curve saved', 'success');
|
|
fetchStatus();
|
|
} else {
|
|
log('Failed: ' + data.error, 'error');
|
|
}
|
|
} catch (e) {
|
|
log('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function log(msg, type='info') {
|
|
const logs = document.getElementById('logs');
|
|
const time = new Date().toLocaleTimeString();
|
|
logs.textContent += `[${time}] ${msg}\\n`;
|
|
logs.scrollTop = logs.scrollHeight;
|
|
|
|
if (type === 'success' || type === 'error') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = msg;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
}
|
|
|
|
function clearLogs() {
|
|
document.getElementById('logs').textContent = '';
|
|
}
|
|
|
|
// Auto-refresh
|
|
setInterval(fetchStatus, 3000);
|
|
fetchStatus();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
# Write the HTML file
|
|
(STATIC_DIR / "index.html").write_text(DASHBOARD_HTML)
|
|
|
|
|
|
# FastAPI app
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Application lifespan handler."""
|
|
service = get_service()
|
|
# Auto-start if configured
|
|
if service.config.get('enabled') and service.config.get('host'):
|
|
service.start()
|
|
yield
|
|
service.stop()
|
|
|
|
|
|
app = FastAPI(
|
|
title="IPMI Fan Controller v2",
|
|
description="Simple, robust fan control for Dell servers",
|
|
version="2.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def root():
|
|
"""Serve the dashboard."""
|
|
return HTMLResponse(content=DASHBOARD_HTML)
|
|
|
|
|
|
@app.get("/api/status", response_model=StatusResponse)
|
|
async def get_status():
|
|
"""Get current controller status."""
|
|
service = get_service()
|
|
return service.get_status()
|
|
|
|
|
|
@app.post("/api/test")
|
|
async def test_connection():
|
|
"""Test IPMI connection."""
|
|
service = get_service()
|
|
if not service.controller:
|
|
if not service._init_controller():
|
|
return {"success": False, "error": "Failed to initialize controller"}
|
|
|
|
success = service.controller.test_connection()
|
|
return {"success": success, "error": None if success else "Connection failed"}
|
|
|
|
|
|
@app.post("/api/config")
|
|
async def update_config(update: ConfigUpdate):
|
|
"""Update configuration."""
|
|
service = get_service()
|
|
|
|
updates = {k: v for k, v in update.model_dump().items() if v is not None}
|
|
if not updates:
|
|
return {"success": False, "error": "No valid updates provided"}
|
|
|
|
# Require password if host/username changed and no new password provided
|
|
if ('host' in updates or 'username' in updates) and 'password' not in updates:
|
|
if not service.config.get('password'):
|
|
return {"success": False, "error": "Password required when setting host/username"}
|
|
|
|
try:
|
|
service.update_config(**updates)
|
|
return {"success": True}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
@app.post("/api/config/curve")
|
|
async def update_curve(curve: FanCurveUpdate):
|
|
"""Update fan curve."""
|
|
service = get_service()
|
|
points = [{"temp": p.temp, "speed": p.speed} for p in curve.points]
|
|
service.update_config(fan_curve=points)
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/control/auto")
|
|
async def set_auto_control(data: dict):
|
|
"""Enable/disable automatic control."""
|
|
service = get_service()
|
|
enabled = data.get('enabled', False)
|
|
|
|
if enabled and not service.config.get('host'):
|
|
return {"success": False, "error": "IPMI host not configured"}
|
|
|
|
service.set_auto_mode(enabled)
|
|
|
|
if enabled and not service.running:
|
|
if not service.start():
|
|
return {"success": False, "error": "Failed to start service"}
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/control/manual")
|
|
async def set_manual_control(req: ManualSpeedRequest):
|
|
"""Set manual fan speed."""
|
|
service = get_service()
|
|
|
|
if not service.controller:
|
|
if not service._init_controller():
|
|
return {"success": False, "error": "Failed to connect to server"}
|
|
|
|
if service.set_manual_speed(req.speed):
|
|
return {"success": True}
|
|
return {"success": False, "error": "Failed to set fan speed"}
|
|
|
|
|
|
@app.post("/api/shutdown")
|
|
async def shutdown():
|
|
"""Return fans to automatic control and stop service."""
|
|
service = get_service()
|
|
service.stop()
|
|
return {"success": True, "message": "Service stopped, fans returned to automatic control"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|