Initial v2: Simpler, more robust fan controller
This commit is contained in:
commit
5b9ec7b351
|
|
@ -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.
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue