Initial v2: Simpler, more robust fan controller

This commit is contained in:
devmatrix 2026-02-20 15:01:39 +00:00
commit 5b9ec7b351
5 changed files with 1518 additions and 0 deletions

138
README.md Normal file
View File

@ -0,0 +1,138 @@
# IPMI Fan Controller v2
A simpler, more robust fan controller for Dell T710 and compatible servers using IPMI.
## What's Different from v1?
- **Direct host execution** - No Docker networking complications
- **Better error recovery** - Automatically reconnects on IPMI failures
- **Simpler codebase** - Easier to debug and modify
- **Working web UI** - Clean, responsive dashboard
- **CLI testing mode** - Test without starting the web server
## Quick Start
### 1. Install
```bash
cd ~/projects/fan-controller-v2
chmod +x install.sh
sudo ./install.sh
```
This will:
- Install Python dependencies
- Create systemd service
- Set up config in `/etc/ipmi-fan-controller/`
### 2. Configure
Edit the configuration file:
```bash
sudo nano /etc/ipmi-fan-controller/config.json
```
Set your IPMI credentials:
```json
{
"host": "192.168.1.100",
"username": "root",
"password": "your-password",
"port": 623
}
```
### 3. Start
```bash
sudo systemctl start ipmi-fan-controller
```
Open the web UI at `http://your-server:8000`
## CLI Testing
Test the IPMI connection without the web server:
```bash
python3 fan_controller.py 192.168.1.100 root password
```
This will:
1. Test the connection
2. Show temperatures and fan speeds
3. Try manual fan control (30% → 50% → auto)
## Features
### Automatic Control
- Adjusts fan speed based on CPU temperature
- Configurable fan curve (temp → speed mapping)
- Panic mode: sets fans to 100% if temp exceeds threshold
### Manual Control
- Set any fan speed from 0-100%
- Override automatic control temporarily
### Safety Features
- Returns to automatic control on shutdown
- Reconnects automatically if IPMI connection drops
- Panic temperature protection
## Configuration Options
```json
{
"host": "192.168.1.100", // IPMI IP address
"username": "root", // IPMI username
"password": "secret", // IPMI password
"port": 623, // IPMI port (default: 623)
"enabled": false, // Start automatic control on boot
"interval": 10, // Check interval in seconds
"min_speed": 10, // Minimum fan speed (%)
"max_speed": 100, // Maximum fan speed (%)
"panic_temp": 85, // Panic mode trigger (°C)
"panic_speed": 100, // Panic mode fan speed (%)
"fan_curve": [ // Temp (°C) → Speed (%) mapping
{"temp": 30, "speed": 15},
{"temp": 40, "speed": 25},
{"temp": 50, "speed": 40},
{"temp": 60, "speed": 60},
{"temp": 70, "speed": 80},
{"temp": 80, "speed": 100}
]
}
```
## Troubleshooting
### Connection Failed
1. Verify IPMI is enabled in BIOS/iDRAC
2. Test manually: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
3. Check firewall allows port 623
### Fans Not Responding
1. Some Dell servers need 3rd party PCIe response disabled
2. Try enabling manual mode first via web UI
3. Check IPMI user has admin privileges
### Service Won't Start
```bash
# Check logs
sudo journalctl -u ipmi-fan-controller -f
# Check config is valid JSON
sudo python3 -c "import json; json.load(open('/etc/ipmi-fan-controller/config.json'))"
```
## Files
- `fan_controller.py` - Core IPMI control logic
- `web_server.py` - FastAPI web interface
- `install.sh` - Installation script
- `requirements.txt` - Python dependencies
## License
MIT License - Feel free to modify and distribute.

534
fan_controller.py Normal file
View File

@ -0,0 +1,534 @@
"""
IPMI Fan Controller v2 - Simpler, More Robust
For Dell T710 and compatible servers
"""
import subprocess
import re
import time
import json
import logging
import threading
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from datetime import datetime
from pathlib import Path
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('/tmp/ipmi-fan-controller.log')
]
)
logger = logging.getLogger(__name__)
@dataclass
class FanCurvePoint:
temp: float
speed: int
@dataclass
class TemperatureReading:
name: str
location: str
value: float
status: str
@dataclass
class FanReading:
fan_id: str
fan_number: int
speed_rpm: Optional[int]
speed_percent: Optional[int]
class IPMIFanController:
"""Simplified IPMI fan controller with robust error handling."""
# Default fan curve (temp C -> speed %)
DEFAULT_CURVE = [
FanCurvePoint(30, 15),
FanCurvePoint(40, 25),
FanCurvePoint(50, 40),
FanCurvePoint(60, 60),
FanCurvePoint(70, 80),
FanCurvePoint(80, 100),
]
def __init__(self, host: str, username: str, password: str, port: int = 623):
self.host = host
self.username = username
self.password = password
self.port = port
self.manual_mode = False
self.last_successful_read = None
self.consecutive_failures = 0
self.max_failures = 5
def _run_ipmi(self, args: List[str], timeout: int = 15) -> Tuple[bool, str]:
"""Run IPMI command with error handling."""
cmd = [
"ipmitool", "-I", "lanplus",
"-H", self.host,
"-U", self.username,
"-P", self.password,
"-p", str(self.port)
] + args
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode == 0:
self.consecutive_failures = 0
return True, result.stdout
else:
self.consecutive_failures += 1
logger.warning(f"IPMI command failed: {result.stderr}")
return False, result.stderr
except subprocess.TimeoutExpired:
self.consecutive_failures += 1
logger.error(f"IPMI command timed out after {timeout}s")
return False, "Timeout"
except Exception as e:
self.consecutive_failures += 1
logger.error(f"IPMI command error: {e}")
return False, str(e)
def test_connection(self) -> bool:
"""Test if we can connect to the server."""
success, _ = self._run_ipmi(["mc", "info"], timeout=10)
return success
def enable_manual_fan_control(self) -> bool:
"""Enable manual fan control mode."""
# Dell: raw 0x30 0x30 0x01 0x00
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
if success:
self.manual_mode = True
logger.info("Manual fan control enabled")
return success
def disable_manual_fan_control(self) -> bool:
"""Return to automatic fan control."""
# Dell: raw 0x30 0x30 0x01 0x01
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"])
if success:
self.manual_mode = False
logger.info("Automatic fan control restored")
return success
def set_fan_speed(self, speed_percent: int, fan_id: str = "0xff") -> bool:
"""Set fan speed (0-100%). fan_id 0xff = all fans."""
if speed_percent < 0:
speed_percent = 0
if speed_percent > 100:
speed_percent = 100
hex_speed = f"0x{speed_percent:02x}"
success, _ = self._run_ipmi([
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed
])
if success:
logger.info(f"Fan speed set to {speed_percent}%")
return success
def get_temperatures(self) -> List[TemperatureReading]:
"""Get temperature readings from all sensors."""
success, output = self._run_ipmi(["sdr", "type", "temperature"])
if not success:
return []
temps = []
for line in output.splitlines():
# Parse: Sensor Name | 01h | ok | 3.1 | 45 degrees C
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 5:
name = parts[0]
status = parts[2] if len(parts) > 2 else "unknown"
reading = parts[4]
match = re.search(r'(\d+(?:\.\d+)?)\s+degrees\s+C', reading, re.IGNORECASE)
if match:
value = float(match.group(1))
location = self._classify_temp_location(name)
temps.append(TemperatureReading(
name=name,
location=location,
value=value,
status=status
))
return temps
def get_fan_speeds(self) -> List[FanReading]:
"""Get current fan speeds."""
success, output = self._run_ipmi(["sdr", "elist", "full"])
if not success:
return []
fans = []
for line in output.splitlines():
if "fan" in line.lower() and "rpm" in line.lower():
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 5:
name = parts[0]
reading = parts[4]
# Extract fan number
match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE)
fan_number = int(match.group(1)) if match else 0
fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00"
# Extract RPM
rpm_match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE)
rpm = int(rpm_match.group(1)) if rpm_match else None
fans.append(FanReading(
fan_id=fan_id,
fan_number=fan_number,
speed_rpm=rpm,
speed_percent=None
))
return fans
def _classify_temp_location(self, name: str) -> str:
"""Classify temperature sensor location."""
name_lower = name.lower()
if "cpu" in name_lower or "proc" in name_lower:
if "1" in name or "one" in name_lower:
return "cpu1"
elif "2" in name or "two" in name_lower:
return "cpu2"
return "cpu"
elif "inlet" in name_lower or "ambient" in name_lower:
return "inlet"
elif "exhaust" in name_lower:
return "exhaust"
elif "memory" in name_lower or "dimm" in name_lower:
return "memory"
return "other"
def calculate_fan_speed(self, temps: List[TemperatureReading],
curve: Optional[List[FanCurvePoint]] = None) -> int:
"""Calculate target fan speed based on temperatures."""
if not temps:
return 50 # Default if no temps
if curve is None:
curve = self.DEFAULT_CURVE
# Find max CPU temperature
cpu_temps = [t for t in temps if t.location.startswith("cpu")]
if cpu_temps:
max_temp = max(t.value for t in cpu_temps)
else:
max_temp = max(t.value for t in temps)
# Apply fan curve with linear interpolation
sorted_curve = sorted(curve, key=lambda p: p.temp)
if max_temp <= sorted_curve[0].temp:
return sorted_curve[0].speed
if max_temp >= sorted_curve[-1].temp:
return sorted_curve[-1].speed
for i in range(len(sorted_curve) - 1):
p1, p2 = sorted_curve[i], sorted_curve[i + 1]
if p1.temp <= max_temp <= p2.temp:
if p2.temp == p1.temp:
return p1.speed
ratio = (max_temp - p1.temp) / (p2.temp - p1.temp)
speed = p1.speed + ratio * (p2.speed - p1.speed)
return int(round(speed))
return sorted_curve[-1].speed
def is_healthy(self) -> bool:
"""Check if controller is working properly."""
return self.consecutive_failures < self.max_failures
class FanControlService:
"""Background service for automatic fan control."""
def __init__(self, config_path: str = "/etc/ipmi-fan-controller/config.json"):
self.config_path = config_path
self.controller: Optional[IPMIFanController] = None
self.running = False
self.thread: Optional[threading.Thread] = None
self.current_speed = 0
self.target_speed = 0
self.last_temps: List[TemperatureReading] = []
self.last_fans: List[FanReading] = []
self.lock = threading.Lock()
# Default config
self.config = {
"host": "",
"username": "",
"password": "",
"port": 623,
"enabled": False,
"interval": 10, # seconds
"min_speed": 10,
"max_speed": 100,
"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},
],
"panic_temp": 85,
"panic_speed": 100
}
self._load_config()
def _load_config(self):
"""Load configuration from file."""
try:
if Path(self.config_path).exists():
with open(self.config_path, 'r') as f:
loaded = json.load(f)
self.config.update(loaded)
logger.info(f"Loaded config from {self.config_path}")
except Exception as e:
logger.error(f"Failed to load config: {e}")
def _save_config(self):
"""Save configuration to file."""
try:
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w') as f:
json.dump(self.config, f, indent=2)
logger.info(f"Saved config to {self.config_path}")
except Exception as e:
logger.error(f"Failed to save config: {e}")
def update_config(self, **kwargs):
"""Update configuration values."""
self.config.update(kwargs)
self._save_config()
# Reinitialize controller if connection params changed
if any(k in kwargs for k in ['host', 'username', 'password', 'port']):
self._init_controller()
def _init_controller(self):
"""Initialize the IPMI controller."""
if not all([self.config.get('host'), self.config.get('username'), self.config.get('password')]):
logger.warning("Missing IPMI credentials")
return False
self.controller = IPMIFanController(
host=self.config['host'],
username=self.config['username'],
password=self.config['password'],
port=self.config.get('port', 623)
)
if self.controller.test_connection():
logger.info(f"Connected to IPMI at {self.config['host']}")
return True
else:
logger.error(f"Failed to connect to IPMI at {self.config['host']}")
self.controller = None
return False
def start(self):
"""Start the fan control service."""
if self.running:
return
if not self._init_controller():
logger.error("Cannot start service - IPMI connection failed")
return False
self.running = True
self.thread = threading.Thread(target=self._control_loop, daemon=True)
self.thread.start()
logger.info("Fan control service started")
return True
def stop(self):
"""Stop the fan control service."""
self.running = False
if self.thread:
self.thread.join(timeout=5)
# Return to automatic control
if self.controller:
self.controller.disable_manual_fan_control()
logger.info("Fan control service stopped")
def _control_loop(self):
"""Main control loop running in background thread."""
# Enable manual control on startup
if self.controller:
self.controller.enable_manual_fan_control()
while self.running:
try:
if not self.config.get('enabled', False):
time.sleep(1)
continue
if not self.controller or not self.controller.is_healthy():
logger.warning("Controller unhealthy, attempting reconnect...")
if not self._init_controller():
time.sleep(30)
continue
self.controller.enable_manual_fan_control()
# Get sensor data
temps = self.controller.get_temperatures()
fans = self.controller.get_fan_speeds()
with self.lock:
self.last_temps = temps
self.last_fans = fans
if not temps:
logger.warning("No temperature readings received")
time.sleep(self.config.get('interval', 10))
continue
# Check for panic temperature
max_temp = max((t.value for t in temps if t.location.startswith('cpu')), default=0)
if max_temp >= self.config.get('panic_temp', 85):
self.target_speed = self.config.get('panic_speed', 100)
logger.warning(f"PANIC MODE: CPU temp {max_temp}°C, setting fans to {self.target_speed}%")
else:
# Calculate target speed from curve
curve = [FanCurvePoint(p['temp'], p['speed']) for p in self.config.get('fan_curve', [])]
self.target_speed = self.controller.calculate_fan_speed(temps, curve)
# Apply limits
self.target_speed = max(self.config.get('min_speed', 10),
min(self.config.get('max_speed', 100), self.target_speed))
# Apply fan speed if changed significantly (>= 5%)
if abs(self.target_speed - self.current_speed) >= 5:
if self.controller.set_fan_speed(self.target_speed):
self.current_speed = self.target_speed
logger.info(f"Fan speed adjusted to {self.target_speed}% (CPU temp: {max_temp:.1f}°C)")
time.sleep(self.config.get('interval', 10))
except Exception as e:
logger.error(f"Control loop error: {e}")
time.sleep(10)
def get_status(self) -> Dict:
"""Get current status."""
with self.lock:
return {
"running": self.running,
"enabled": self.config.get('enabled', False),
"connected": self.controller is not None and self.controller.is_healthy(),
"manual_mode": self.controller.manual_mode if self.controller else False,
"current_speed": self.current_speed,
"target_speed": self.target_speed,
"temperatures": [asdict(t) for t in self.last_temps],
"fans": [asdict(f) for f in self.last_fans],
"config": {
k: v for k, v in self.config.items()
if k != 'password' # Don't expose password
}
}
def set_manual_speed(self, speed: int) -> bool:
"""Set manual fan speed."""
if not self.controller:
return False
self.config['enabled'] = False
speed = max(0, min(100, speed))
if self.controller.set_fan_speed(speed):
self.current_speed = speed
return True
return False
def set_auto_mode(self, enabled: bool):
"""Enable or disable automatic control."""
self.config['enabled'] = enabled
self._save_config()
if enabled and self.controller:
self.controller.enable_manual_fan_control()
elif not enabled and self.controller:
self.controller.disable_manual_fan_control()
# Global service instance
_service: Optional[FanControlService] = None
def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService:
"""Get or create the global service instance."""
global _service
if _service is None:
_service = FanControlService(config_path)
return _service
if __name__ == "__main__":
# Simple CLI test
import sys
if len(sys.argv) < 4:
print("Usage: python fan_controller.py <host> <username> <password> [port]")
sys.exit(1)
host = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
port = int(sys.argv[4]) if len(sys.argv) > 4 else 623
controller = IPMIFanController(host, username, password, port)
print(f"Testing connection to {host}...")
if controller.test_connection():
print("✓ Connected successfully")
print("\nTemperatures:")
for temp in controller.get_temperatures():
print(f" {temp.name}: {temp.value}°C ({temp.location})")
print("\nFan speeds:")
for fan in controller.get_fan_speeds():
print(f" Fan {fan.fan_number}: {fan.speed_rpm} RPM")
print("\nEnabling manual control...")
if controller.enable_manual_fan_control():
print("✓ Manual control enabled")
print("\nSetting fans to 30%...")
if controller.set_fan_speed(30):
print("✓ Speed set to 30%")
time.sleep(3)
print("\nSetting fans to 50%...")
if controller.set_fan_speed(50):
print("✓ Speed set to 50%")
time.sleep(3)
print("\nReturning to automatic control...")
controller.disable_manual_fan_control()
print("✓ Done")
else:
print("✗ Connection failed")
sys.exit(1)

147
install.sh Executable file
View File

@ -0,0 +1,147 @@
#!/bin/bash
# Setup script for IPMI Fan Controller v2
set -e
echo "🌬️ IPMI Fan Controller v2 - Setup"
echo "=================================="
# Check if running as root for system-wide install
if [ "$EUID" -eq 0 ]; then
INSTALL_SYSTEM=true
INSTALL_DIR="/opt/ipmi-fan-controller"
CONFIG_DIR="/etc/ipmi-fan-controller"
else
INSTALL_SYSTEM=false
INSTALL_DIR="$HOME/.local/ipmi-fan-controller"
CONFIG_DIR="$HOME/.config/ipmi-fan-controller"
echo "⚠️ Running as user - installing to $INSTALL_DIR"
echo " (Run with sudo for system-wide install)"
echo ""
fi
# Check dependencies
echo "📦 Checking dependencies..."
if ! command -v python3 &> /dev/null; then
echo "❌ Python 3 is required but not installed"
exit 1
fi
if ! command -v ipmitool &> /dev/null; then
echo "⚠️ ipmitool not found. Installing..."
if [ "$INSTALL_SYSTEM" = true ]; then
apt-get update && apt-get install -y ipmitool
else
echo "❌ Please install ipmitool: sudo apt-get install ipmitool"
exit 1
fi
fi
echo "✓ Python 3: $(python3 --version)"
echo "✓ ipmitool: $(ipmitool -V)"
# Create directories
echo ""
echo "📁 Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$CONFIG_DIR"
mkdir -p "$INSTALL_DIR/logs"
# Copy files
echo ""
echo "📋 Installing files..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cp "$SCRIPT_DIR/fan_controller.py" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/web_server.py" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
# Install Python dependencies
echo ""
echo "🐍 Installing Python dependencies..."
python3 -m pip install -q -r "$INSTALL_DIR/requirements.txt"
# Create default config if not exists
if [ ! -f "$CONFIG_DIR/config.json" ]; then
echo ""
echo "⚙️ Creating default configuration..."
cat > "$CONFIG_DIR/config.json" << 'EOF'
{
"host": "",
"username": "root",
"password": "",
"port": 623,
"enabled": false,
"interval": 10,
"min_speed": 10,
"max_speed": 100,
"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}
],
"panic_temp": 85,
"panic_speed": 100
}
EOF
fi
# Create systemd service (system-wide only)
if [ "$INSTALL_SYSTEM" = true ]; then
echo ""
echo "🔧 Creating systemd service..."
cat > /etc/systemd/system/ipmi-fan-controller.service << EOF
[Unit]
Description=IPMI Fan Controller v2
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
Environment="CONFIG_PATH=$CONFIG_DIR/config.json"
ExecStart=/usr/bin/python3 $INSTALL_DIR/web_server.py
ExecStop=/usr/bin/python3 -c "import requests; requests.post('http://localhost:8000/api/shutdown')"
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable ipmi-fan-controller.service
echo ""
echo "✅ Installation complete!"
echo ""
echo "Next steps:"
echo " 1. Edit config: sudo nano $CONFIG_DIR/config.json"
echo " 2. Start service: sudo systemctl start ipmi-fan-controller"
echo " 3. View status: sudo systemctl status ipmi-fan-controller"
echo " 4. Open web UI: http://$(hostname -I | awk '{print $1}'):8000"
echo ""
echo "Or test from CLI:"
echo " python3 $INSTALL_DIR/fan_controller.py <host> <user> <pass>"
else
echo ""
echo "✅ User installation complete!"
echo ""
echo "Next steps:"
echo " 1. Edit config: nano $CONFIG_DIR/config.json"
echo " 2. Start manually:"
echo " CONFIG_PATH=$CONFIG_DIR/config.json python3 $INSTALL_DIR/web_server.py"
echo " 3. Open web UI: http://localhost:8000"
echo ""
echo "Or test from CLI:"
echo " python3 $INSTALL_DIR/fan_controller.py <host> <user> <pass>"
fi
echo ""
echo "📖 Configuration file: $CONFIG_DIR/config.json"

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0

695
web_server.py Normal file
View File

@ -0,0 +1,695 @@
"""
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)