IPMI Controller v3: All features implemented

This commit is contained in:
devmatrix 2026-02-20 15:50:59 +00:00
parent 2c91d6f100
commit 63af1e883b
6 changed files with 1504 additions and 1763 deletions

160
README.md
View File

@ -1,138 +1,50 @@
# IPMI Fan Controller v2 # IPMI Controller
A simpler, more robust fan controller for Dell T710 and compatible servers using IPMI. Advanced IPMI fan control for Dell servers with web interface, HTTP sensor support, fan groups, and multiple fan curves.
## 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 ## Features
### Automatic Control - 🌡️ **Temperature Monitoring** - IPMI and HTTP (lm-sensors) sensor support
- Adjusts fan speed based on CPU temperature - 🌬️ **Fan Control** - Automatic curves, manual control, panic mode
- Configurable fan curve (temp → speed mapping) - 👥 **Fan Groups** - Group fans with different curves
- Panic mode: sets fans to 100% if temp exceeds threshold - 🔍 **Fan Identify** - Visual fan identification
- 🎨 **Dark/Light Mode** - Theme switching
- 📊 **Public API** - For external integrations
- 🔒 **Secure** - Password protected with JWT auth
### Manual Control ## Quick Start
- Set any fan speed from 0-100%
- Override automatic control temporarily
### Safety Features ### Requirements
- Returns to automatic control on shutdown - Python 3.10+
- Reconnects automatically if IPMI connection drops - ipmitool
- Panic temperature protection - Linux server (tested on Dell T710)
## Configuration Options ### Install
```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 ```bash
# Check logs git clone https://github.com/yourusername/ipmi-controller.git
sudo journalctl -u ipmi-fan-controller -f cd ipmi-controller
pip install -r requirements.txt
# Check config is valid JSON
sudo python3 -c "import json; json.load(open('/etc/ipmi-fan-controller/config.json'))"
``` ```
## Files ### Run
```bash
python3 web_server.py
```
- `fan_controller.py` - Core IPMI control logic Open `http://your-server:8000` and complete the setup wizard.
- `web_server.py` - FastAPI web interface
- `install.sh` - Installation script ## Docker
- `requirements.txt` - Python dependencies
```bash
docker build -t ipmi-controller .
docker run -d -p 8000:8000 -v ./data:/app/data ipmi-controller
```
## Documentation
- [Setup Guide](SETUP.md) - Full installation and configuration
- [API Reference](API.md) - Public API documentation
## License ## License
MIT License - Feel free to modify and distribute. MIT License

View File

@ -3,18 +3,25 @@
"ipmi_username": "root", "ipmi_username": "root",
"ipmi_password": "calvin", "ipmi_password": "calvin",
"ipmi_port": 623, "ipmi_port": 623,
"ssh_enabled": false, "http_sensor_enabled": false,
"ssh_host": null, "http_sensor_url": "",
"ssh_username": null, "http_sensor_timeout": 10,
"ssh_password": null,
"ssh_use_key": false,
"ssh_key_file": null,
"ssh_port": 22,
"enabled": true, "enabled": true,
"interval": 10, "poll_interval": 10,
"fan_update_interval": 10,
"min_speed": 10, "min_speed": 10,
"max_speed": 100, "max_speed": 100,
"fan_curve": [ "panic_temp": 85,
"panic_speed": 100,
"panic_on_no_data": true,
"no_data_timeout": 60,
"primary_sensor": "cpu",
"sensor_preference": "ipmi",
"fans": {},
"fan_groups": {},
"fan_curves": {
"Default": {
"points": [
{ {
"temp": 30, "temp": 30,
"speed": 15 "speed": 15
@ -40,6 +47,43 @@
"speed": 100 "speed": 100
} }
], ],
"panic_temp": 85, "sensor_source": "cpu",
"panic_speed": 100 "applies_to": "all"
}
},
"theme": "dark",
"ssh_enabled": false,
"ssh_host": null,
"ssh_username": null,
"ssh_password": null,
"ssh_use_key": false,
"ssh_key_file": null,
"ssh_port": 22,
"interval": 10,
"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
}
]
} }

View File

@ -1,6 +1,6 @@
""" """
IPMI Fan Controller v2 - Simpler, More Robust IPMI Controller - Advanced Fan Control for Dell Servers
For Dell T710 and compatible servers Features: Fan groups, multiple curves, HTTP sensors, panic mode
""" """
import subprocess import subprocess
import re import re
@ -8,7 +8,7 @@ import time
import json import json
import logging import logging
import threading import threading
import paramiko import requests
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from datetime import datetime from datetime import datetime
@ -20,24 +20,19 @@ logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[ handlers=[
logging.StreamHandler(), logging.StreamHandler(),
logging.FileHandler('/tmp/ipmi-fan-controller.log') logging.FileHandler('/tmp/ipmi-controller.log')
] ]
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass
class FanCurvePoint:
temp: float
speed: int
@dataclass @dataclass
class TemperatureReading: class TemperatureReading:
name: str name: str
location: str location: str
value: float value: float
status: str status: str
source: str = "ipmi" # ipmi, http, ssh
@dataclass @dataclass
@ -46,20 +41,99 @@ class FanReading:
fan_number: int fan_number: int
speed_rpm: Optional[int] speed_rpm: Optional[int]
speed_percent: Optional[int] speed_percent: Optional[int]
name: Optional[str] = None # Custom name
group: Optional[str] = None # Fan group
@dataclass
class FanCurve:
name: str
points: List[Dict[str, float]] # [{"temp": 30, "speed": 15}, ...]
sensor_source: str = "cpu" # Which sensor to use
applies_to: str = "all" # "all", group name, or fan_id
class HTTPSensorClient:
"""Client for fetching sensor data from HTTP endpoint (lm-sensors over HTTP)."""
def __init__(self, url: str, timeout: int = 10):
self.url = url
self.timeout = timeout
self.last_reading = None
self.consecutive_failures = 0
def fetch_sensors(self) -> List[TemperatureReading]:
"""Fetch sensor data from HTTP endpoint."""
try:
response = requests.get(self.url, timeout=self.timeout)
response.raise_for_status()
# Parse lm-sensors style output
temps = self._parse_sensors_output(response.text)
self.consecutive_failures = 0
return temps
except Exception as e:
logger.error(f"Failed to fetch HTTP sensors from {self.url}: {e}")
self.consecutive_failures += 1
return []
def _parse_sensors_output(self, output: str) -> List[TemperatureReading]:
"""Parse lm-sensors -u style output."""
temps = []
current_chip = ""
for line in output.splitlines():
line = line.strip()
# New chip section
if line.endswith(":") and not line.startswith(" "):
current_chip = line.rstrip(":")
continue
# Temperature reading
if "_input:" in line and "temp" in line.lower():
parts = line.split(":")
if len(parts) == 2:
name = parts[0].strip()
try:
value = float(parts[1].strip())
location = self._classify_sensor_name(name, current_chip)
temps.append(TemperatureReading(
name=f"{current_chip}/{name}",
location=location,
value=value,
status="ok",
source="http"
))
except ValueError:
pass
return temps
def _classify_sensor_name(self, name: str, chip: str) -> str:
"""Classify sensor location from name."""
name_lower = name.lower()
if "core" in name_lower:
if "0" in name or "1" in name:
return "cpu1"
elif "2" in name or "3" in name:
return "cpu2"
return "cpu"
elif "package" in name_lower:
return "cpu"
elif "tdie" in name_lower or "tctl" in name_lower:
return "cpu"
elif "pcie" in name_lower or "nvme" in name_lower or "gpu" in name_lower:
return "pcie"
return "other"
def is_healthy(self) -> bool:
return self.consecutive_failures < 3
class IPMIFanController: class IPMIFanController:
"""Simplified IPMI fan controller with robust error handling.""" """IPMI fan controller with advanced features."""
# 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): def __init__(self, host: str, username: str, password: str, port: int = 623):
self.host = host self.host = host
@ -111,7 +185,6 @@ class IPMIFanController:
def enable_manual_fan_control(self) -> bool: def enable_manual_fan_control(self) -> bool:
"""Enable manual fan control mode.""" """Enable manual fan control mode."""
# Dell: raw 0x30 0x30 0x01 0x00
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"]) success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
if success: if success:
self.manual_mode = True self.manual_mode = True
@ -120,7 +193,6 @@ class IPMIFanController:
def disable_manual_fan_control(self) -> bool: def disable_manual_fan_control(self) -> bool:
"""Return to automatic fan control.""" """Return to automatic fan control."""
# Dell: raw 0x30 0x30 0x01 0x01
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"]) success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"])
if success: if success:
self.manual_mode = False self.manual_mode = False
@ -129,18 +201,14 @@ class IPMIFanController:
def set_fan_speed(self, speed_percent: int, fan_id: str = "0xff") -> bool: def set_fan_speed(self, speed_percent: int, fan_id: str = "0xff") -> bool:
"""Set fan speed (0-100%). fan_id 0xff = all fans.""" """Set fan speed (0-100%). fan_id 0xff = all fans."""
if speed_percent < 0: speed_percent = max(0, min(100, speed_percent))
speed_percent = 0
if speed_percent > 100:
speed_percent = 100
hex_speed = f"0x{speed_percent:02x}" hex_speed = f"0x{speed_percent:02x}"
success, _ = self._run_ipmi([ success, _ = self._run_ipmi([
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed "raw", "0x30", "0x30", "0x02", fan_id, hex_speed
]) ])
if success: if success:
logger.info(f"Fan speed set to {speed_percent}%") logger.info(f"Fan {fan_id} speed set to {speed_percent}%")
return success return success
def get_temperatures(self) -> List[TemperatureReading]: def get_temperatures(self) -> List[TemperatureReading]:
@ -151,7 +219,6 @@ class IPMIFanController:
temps = [] temps = []
for line in output.splitlines(): for line in output.splitlines():
# Parse: Sensor Name | 01h | ok | 3.1 | 45 degrees C
parts = [p.strip() for p in line.split("|")] parts = [p.strip() for p in line.split("|")]
if len(parts) >= 5: if len(parts) >= 5:
name = parts[0] name = parts[0]
@ -166,7 +233,8 @@ class IPMIFanController:
name=name, name=name,
location=location, location=location,
value=value, value=value,
status=status status=status,
source="ipmi"
)) ))
return temps return temps
@ -184,12 +252,10 @@ class IPMIFanController:
name = parts[0] name = parts[0]
reading = parts[4] reading = parts[4]
# Extract fan number
match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE) match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE)
fan_number = int(match.group(1)) if match else 0 fan_number = int(match.group(1)) if match else 0
fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00" 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_match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE)
rpm = int(rpm_match.group(1)) if rpm_match else None rpm = int(rpm_match.group(1)) if rpm_match else None
@ -218,188 +284,28 @@ class IPMIFanController:
return "memory" return "memory"
return "other" 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: def is_healthy(self) -> bool:
"""Check if controller is working properly.""" """Check if controller is working properly."""
return self.consecutive_failures < self.max_failures return self.consecutive_failures < self.max_failures
class SSHSensorClient: class IPMIControllerService:
"""SSH client for lm-sensors data collection.""" """Main service for IPMI Controller with all advanced features."""
def __init__(self, host: str, username: str, password: Optional[str] = None, def __init__(self, config_path: str = "/etc/ipmi-controller/config.json"):
key_file: Optional[str] = None, port: int = 22):
self.host = host
self.username = username
self.password = password
self.key_file = key_file
self.port = port
self.client: Optional[paramiko.SSHClient] = None
self.consecutive_failures = 0
def connect(self) -> bool:
"""Connect to SSH server."""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
"hostname": self.host,
"port": self.port,
"username": self.username,
"timeout": 10
}
if self.key_file and Path(self.key_file).exists():
connect_kwargs["key_filename"] = self.key_file
elif self.password:
connect_kwargs["password"] = self.password
else:
logger.error("No authentication method available for SSH")
return False
self.client.connect(**connect_kwargs)
logger.info(f"SSH connected to {self.host}")
return True
except Exception as e:
logger.error(f"SSH connection failed: {e}")
self.consecutive_failures += 1
return False
def disconnect(self):
"""Close SSH connection."""
if self.client:
self.client.close()
self.client = None
def get_lm_sensors_data(self) -> List[TemperatureReading]:
"""Get temperature data from lm-sensors."""
if not self.client:
if not self.connect():
return []
try:
stdin, stdout, stderr = self.client.exec_command("sensors -u", timeout=15)
output = stdout.read().decode()
error = stderr.read().decode()
if error:
logger.warning(f"sensors command stderr: {error}")
temps = self._parse_sensors_output(output)
self.consecutive_failures = 0
return temps
except Exception as e:
logger.error(f"Failed to get sensors data: {e}")
self.consecutive_failures += 1
self.disconnect() # Force reconnect on next attempt
return []
def _parse_sensors_output(self, output: str) -> List[TemperatureReading]:
"""Parse lm-sensors -u output."""
temps = []
current_chip = ""
for line in output.splitlines():
line = line.strip()
# New chip section
if line.endswith(":") and not line.startswith(" "):
current_chip = line.rstrip(":")
continue
# Temperature reading
if "_input:" in line and "temp" in line.lower():
parts = line.split(":")
if len(parts) == 2:
name = parts[0].strip()
try:
value = float(parts[1].strip())
location = self._classify_sensor_name(name, current_chip)
temps.append(TemperatureReading(
name=f"{current_chip}/{name}",
location=location,
value=value,
status="ok"
))
except ValueError:
pass
return temps
def _classify_sensor_name(self, name: str, chip: str) -> str:
"""Classify sensor location from name."""
name_lower = name.lower()
chip_lower = chip.lower()
if "core" in name_lower:
if "0" in name or "1" in name:
return "cpu1"
elif "2" in name or "3" in name:
return "cpu2"
return "cpu"
elif "package" in name_lower:
return "cpu"
elif "tdie" in name_lower or "tctl" in name_lower:
return "cpu"
return "other"
def is_healthy(self) -> bool:
return self.consecutive_failures < 3
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.config_path = config_path
self.controller: Optional[IPMIFanController] = None self.controller: Optional[IPMIFanController] = None
self.ssh_client: Optional[SSHSensorClient] = None self.http_client: Optional[HTTPSensorClient] = None
self.running = False self.running = False
self.thread: Optional[threading.Thread] = None self.thread: Optional[threading.Thread] = None
self.current_speed = 0 self.current_speeds: Dict[str, int] = {} # fan_id -> speed
self.target_speed = 0 self.target_speeds: Dict[str, int] = {}
self.last_temps: List[TemperatureReading] = [] self.last_temps: List[TemperatureReading] = []
self.last_fans: List[FanReading] = [] self.last_fans: List[FanReading] = []
self.lock = threading.Lock() self.lock = threading.Lock()
self.in_identify_mode = False
# Default config with new structure # Default config
self.config = { self.config = {
# IPMI Settings # IPMI Settings
"ipmi_host": "", "ipmi_host": "",
@ -407,21 +313,34 @@ class FanControlService:
"ipmi_password": "", "ipmi_password": "",
"ipmi_port": 623, "ipmi_port": 623,
# SSH Settings # HTTP Sensor Settings
"ssh_enabled": False, "http_sensor_enabled": False,
"ssh_host": None, "http_sensor_url": "",
"ssh_username": None, "http_sensor_timeout": 10,
"ssh_password": None,
"ssh_use_key": False,
"ssh_key_file": None,
"ssh_port": 22,
# Fan Control Settings # Fan Control Settings
"enabled": False, "enabled": False,
"interval": 10, "poll_interval": 10,
"fan_update_interval": 10,
"min_speed": 10, "min_speed": 10,
"max_speed": 100, "max_speed": 100,
"fan_curve": [ "panic_temp": 85,
"panic_speed": 100,
"panic_on_no_data": True,
"no_data_timeout": 60,
# Sensor Selection
"primary_sensor": "cpu", # cpu, cpu1, cpu2, inlet, exhaust, pcie, etc.
"sensor_preference": "ipmi", # ipmi, http, auto
# Fan Configuration
"fans": {}, # fan_id -> {"name": "Custom Name", "group": "group1"}
"fan_groups": {}, # group_name -> {"fans": ["0x00", "0x01"], "curve": "Default"}
# Fan Curves
"fan_curves": {
"Default": {
"points": [
{"temp": 30, "speed": 15}, {"temp": 30, "speed": 15},
{"temp": 40, "speed": 25}, {"temp": 40, "speed": 25},
{"temp": 50, "speed": 40}, {"temp": 50, "speed": 40},
@ -429,11 +348,17 @@ class FanControlService:
{"temp": 70, "speed": 80}, {"temp": 70, "speed": 80},
{"temp": 80, "speed": 100}, {"temp": 80, "speed": 100},
], ],
"panic_temp": 85, "sensor_source": "cpu",
"panic_speed": 100 "applies_to": "all"
}
},
# UI Settings
"theme": "dark", # dark, light, auto
} }
self._load_config() self._load_config()
self._last_data_time = datetime.utcnow()
def _load_config(self): def _load_config(self):
"""Load configuration from file.""" """Load configuration from file."""
@ -442,11 +367,19 @@ class FanControlService:
if config_file.exists(): if config_file.exists():
with open(config_file) as f: with open(config_file) as f:
loaded = json.load(f) loaded = json.load(f)
self.config.update(loaded) self._deep_update(self.config, loaded)
logger.info(f"Loaded config from {self.config_path}") logger.info(f"Loaded config from {self.config_path}")
except Exception as e: except Exception as e:
logger.error(f"Failed to load config: {e}") logger.error(f"Failed to load config: {e}")
def _deep_update(self, d: dict, u: dict):
"""Deep update dictionary."""
for k, v in u.items():
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
self._deep_update(d[k], v)
else:
d[k] = v
def _save_config(self): def _save_config(self):
"""Save configuration to file.""" """Save configuration to file."""
try: try:
@ -460,22 +393,18 @@ class FanControlService:
def update_config(self, **kwargs): def update_config(self, **kwargs):
"""Update configuration values.""" """Update configuration values."""
self.config.update(kwargs) self._deep_update(self.config, kwargs)
self._save_config() self._save_config()
# Reinitialize controllers if connection params changed # Reinitialize if needed
ipmi_changed = any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']) if any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']):
ssh_changed = any(k in kwargs for k in ['ssh_host', 'ssh_username', 'ssh_password', 'ssh_key_file', 'ssh_port']) self._init_controller()
if any(k in kwargs for k in ['http_sensor_enabled', 'http_sensor_url']):
self._init_http_client()
if ipmi_changed: def _init_controller(self) -> bool:
self._init_ipmi_controller()
if ssh_changed or (kwargs.get('ssh_enabled') and not self.ssh_client):
self._init_ssh_client()
def _init_ipmi_controller(self) -> bool:
"""Initialize the IPMI controller.""" """Initialize the IPMI controller."""
if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]): if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]):
logger.warning("Missing IPMI credentials")
return False return False
self.controller = IPMIFanController( self.controller = IPMIFanController(
@ -489,86 +418,79 @@ class FanControlService:
logger.info(f"Connected to IPMI at {self.config['ipmi_host']}") logger.info(f"Connected to IPMI at {self.config['ipmi_host']}")
return True return True
else: else:
logger.error(f"Failed to connect to IPMI at {self.config['ipmi_host']}") logger.error(f"Failed to connect to IPMI")
self.controller = None self.controller = None
return False return False
def _init_ssh_client(self) -> bool: def _init_http_client(self) -> bool:
"""Initialize SSH client for lm-sensors.""" """Initialize HTTP sensor client."""
if not self.config.get('ssh_enabled'): if not self.config.get('http_sensor_enabled'):
return False return False
host = self.config.get('ssh_host') or self.config.get('ipmi_host') url = self.config.get('http_sensor_url')
username = self.config.get('ssh_username') or self.config.get('ipmi_username') if not url:
if not all([host, username]):
logger.warning("Missing SSH credentials")
return False return False
self.ssh_client = SSHSensorClient( self.http_client = HTTPSensorClient(
host=host, url=url,
username=username, timeout=self.config.get('http_sensor_timeout', 10)
password=self.config.get('ssh_password') or self.config.get('ipmi_password'),
key_file=self.config.get('ssh_key_file'),
port=self.config.get('ssh_port', 22)
) )
logger.info(f"HTTP sensor client initialized for {url}")
return True return True
def start(self) -> bool: def start(self) -> bool:
"""Start the fan control service.""" """Start the controller service."""
if self.running: if self.running:
return True return True
if not self._init_ipmi_controller(): if not self._init_controller():
logger.error("Cannot start service - IPMI connection failed") logger.error("Cannot start - IPMI connection failed")
return False return False
if self.config.get('ssh_enabled'): if self.config.get('http_sensor_enabled'):
self._init_ssh_client() self._init_http_client()
self.running = True self.running = True
self.thread = threading.Thread(target=self._control_loop, daemon=True) self.thread = threading.Thread(target=self._control_loop, daemon=True)
self.thread.start() self.thread.start()
logger.info("Fan control service started") logger.info("IPMI Controller service started")
return True return True
def stop(self): def stop(self):
"""Stop the fan control service.""" """Stop the controller service."""
self.running = False self.running = False
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=5)
# Return to automatic control
if self.controller: if self.controller:
self.controller.disable_manual_fan_control() self.controller.disable_manual_fan_control()
if self.ssh_client: logger.info("IPMI Controller service stopped")
self.ssh_client.disconnect()
logger.info("Fan control service stopped")
def _control_loop(self): def _control_loop(self):
"""Main control loop running in background thread.""" """Main control loop."""
# Enable manual control on startup
if self.controller: if self.controller:
self.controller.enable_manual_fan_control() self.controller.enable_manual_fan_control()
poll_counter = 0
while self.running: while self.running:
try: try:
if not self.config.get('enabled', False): if not self.config.get('enabled', False):
time.sleep(1) time.sleep(1)
continue continue
# Ensure controllers are healthy # Ensure controller is healthy
if not self.controller or not self.controller.is_healthy(): if not self.controller or not self.controller.is_healthy():
logger.warning("IPMI controller unhealthy, attempting reconnect...") logger.warning("IPMI unhealthy, reconnecting...")
if not self._init_ipmi_controller(): if not self._init_controller():
time.sleep(30) time.sleep(30)
continue continue
self.controller.enable_manual_fan_control() self.controller.enable_manual_fan_control()
# Get temperature data # Poll temperatures at configured interval
poll_interval = self.config.get('poll_interval', 10)
if poll_counter % poll_interval == 0:
temps = self._get_temperatures() temps = self._get_temperatures()
fans = self.controller.get_fan_speeds() if self.controller else [] fans = self.controller.get_fan_speeds() if self.controller else []
@ -576,101 +498,174 @@ class FanControlService:
self.last_temps = temps self.last_temps = temps
self.last_fans = fans self.last_fans = fans
if not temps: if temps:
logger.warning("No temperature readings received") self._last_data_time = datetime.utcnow()
time.sleep(self.config.get('interval', 10))
continue
# Check for panic temperature # Apply fan curves
cpu_temps = [t for t in temps if t.location.startswith('cpu')] if not self.in_identify_mode:
max_temp = max((t.value for t in cpu_temps), default=0) self._apply_fan_curves(temps)
if max_temp >= self.config.get('panic_temp', 85): poll_counter += 1
self.target_speed = self.config.get('panic_speed', 100) time.sleep(1)
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: except Exception as e:
logger.error(f"Control loop error: {e}") logger.error(f"Control loop error: {e}")
time.sleep(10) time.sleep(10)
def _get_temperatures(self) -> List[TemperatureReading]: def _get_temperatures(self) -> List[TemperatureReading]:
"""Get temperatures from IPMI and/or SSH lm-sensors.""" """Get temperatures from all sources."""
temps = [] temps = []
preference = self.config.get('sensor_preference', 'ipmi')
# Try IPMI first # Try IPMI
if self.controller: if self.controller and preference in ['ipmi', 'auto']:
temps = self.controller.get_temperatures() temps = self.controller.get_temperatures()
# Try SSH lm-sensors if enabled and IPMI failed or has no data # Try HTTP sensor
if self.config.get('ssh_enabled') and self.ssh_client: if self.http_client and preference in ['http', 'auto']:
if not temps or self.config.get('prefer_ssh_temps', False): http_temps = self.http_client.fetch_sensors()
ssh_temps = self.ssh_client.get_lm_sensors_data() if http_temps:
if ssh_temps: if preference == 'http' or not temps:
temps = ssh_temps temps = http_temps
else:
# Merge, preferring HTTP for PCIe sensors
temp_dict = {t.name: t for t in temps}
for ht in http_temps:
if ht.location == 'pcie' or ht.name not in temp_dict:
temps.append(ht)
return temps return temps
def get_status(self) -> Dict: def _apply_fan_curves(self, temps: List[TemperatureReading]):
"""Get current status.""" """Apply fan curves based on temperatures."""
with self.lock: if not temps:
status = { # Check for panic mode on no data
"running": self.running, if self.config.get('panic_on_no_data', True):
"enabled": self.config.get('enabled', False), time_since_data = (datetime.utcnow() - self._last_data_time).total_seconds()
"connected": self.controller is not None and self.controller.is_healthy(), if time_since_data > self.config.get('no_data_timeout', 60):
"manual_mode": self.controller.manual_mode if self.controller else False, self._set_all_fans(self.config.get('panic_speed', 100), "PANIC: No data")
"current_speed": self.current_speed, return
"target_speed": self.target_speed,
"temperatures": [asdict(t) for t in self.last_temps],
"fans": [asdict(f) for f in self.last_fans],
"config": {
# IPMI
"ipmi_host": self.config.get('ipmi_host'),
"ipmi_port": self.config.get('ipmi_port'),
"ipmi_username": self.config.get('ipmi_username'),
# SSH
"ssh_enabled": self.config.get('ssh_enabled'),
"ssh_host": self.config.get('ssh_host'),
"ssh_port": self.config.get('ssh_port'),
"ssh_username": self.config.get('ssh_username'),
"ssh_use_key": self.config.get('ssh_use_key'),
# Settings
"min_speed": self.config.get('min_speed'),
"max_speed": self.config.get('max_speed'),
"panic_temp": self.config.get('panic_temp'),
"interval": self.config.get('interval'),
"fan_curve": self.config.get('fan_curve')
}
}
return status
def set_manual_speed(self, speed: int) -> bool: # Get primary sensor
primary_sensor = self.config.get('primary_sensor', 'cpu')
sensor_temps = [t for t in temps if t.location == primary_sensor]
if not sensor_temps:
sensor_temps = [t for t in temps if t.location.startswith(primary_sensor)]
if not sensor_temps:
sensor_temps = temps # Fallback to any temp
max_temp = max(t.value for t in sensor_temps)
# Check panic temperature
if max_temp >= self.config.get('panic_temp', 85):
self._set_all_fans(self.config.get('panic_speed', 100), f"PANIC: Temp {max_temp}°C")
return
# Get fan curves
curves = self.config.get('fan_curves', {})
default_curve = curves.get('Default', {'points': [{'temp': 30, 'speed': 15}, {'temp': 80, 'speed': 100}]})
# Apply curves to fans
fans = self.config.get('fans', {})
groups = self.config.get('fan_groups', {})
# Calculate target speeds per group/individual
fan_speeds = {}
for fan_id, fan_info in fans.items():
group = fan_info.get('group')
curve_name = fan_info.get('curve', 'Default')
if group and group in groups:
curve_name = groups[group].get('curve', 'Default')
curve = curves.get(curve_name, default_curve)
speed = self._calculate_curve_speed(max_temp, curve['points'])
# Apply limits
speed = max(self.config.get('min_speed', 10),
min(self.config.get('max_speed', 100), speed))
fan_speeds[fan_id] = speed
# If no individual fan configs, apply to all
if not fan_speeds:
speed = self._calculate_curve_speed(max_temp, default_curve['points'])
speed = max(self.config.get('min_speed', 10),
min(self.config.get('max_speed', 100), speed))
self._set_all_fans(speed, f"Temp {max_temp}°C")
else:
# Set individual fan speeds
for fan_id, speed in fan_speeds.items():
self._set_fan_speed(fan_id, speed, f"Temp {max_temp}°C")
def _calculate_curve_speed(self, temp: float, points: List[Dict]) -> int:
"""Calculate fan speed from curve points."""
if not points:
return 50
sorted_points = sorted(points, key=lambda p: p['temp'])
if temp <= sorted_points[0]['temp']:
return sorted_points[0]['speed']
if temp >= sorted_points[-1]['temp']:
return sorted_points[-1]['speed']
for i in range(len(sorted_points) - 1):
p1, p2 = sorted_points[i], sorted_points[i + 1]
if p1['temp'] <= temp <= p2['temp']:
if p2['temp'] == p1['temp']:
return p1['speed']
ratio = (temp - p1['temp']) / (p2['temp'] - p1['temp'])
speed = p1['speed'] + ratio * (p2['speed'] - p1['speed'])
return int(round(speed))
return sorted_points[-1]['speed']
def _set_all_fans(self, speed: int, reason: str):
"""Set all fans to a speed."""
if self.controller and speed != self.current_speeds.get('all'):
if self.controller.set_fan_speed(speed, "0xff"):
self.current_speeds['all'] = speed
logger.info(f"All fans set to {speed}% ({reason})")
def _set_fan_speed(self, fan_id: str, speed: int, reason: str):
"""Set specific fan speed."""
if self.controller and speed != self.current_speeds.get(fan_id):
if self.controller.set_fan_speed(speed, fan_id):
self.current_speeds[fan_id] = speed
logger.info(f"Fan {fan_id} set to {speed}% ({reason})")
def identify_fan(self, fan_id: str):
"""Identify a fan by setting it to 100% and others to 0%."""
if not self.controller:
return False
self.in_identify_mode = True
# Set all fans to 0%
self.controller.set_fan_speed(0, "0xff")
time.sleep(0.5)
# Set target fan to 100%
self.controller.set_fan_speed(100, fan_id)
return True
def stop_identify(self):
"""Stop identify mode and resume normal control."""
self.in_identify_mode = False
def set_manual_speed(self, speed: int, fan_id: str = "0xff") -> bool:
"""Set manual fan speed.""" """Set manual fan speed."""
if not self.controller: if not self.controller:
return False return False
self.config['enabled'] = False self.config['enabled'] = False
self._save_config()
speed = max(0, min(100, speed)) speed = max(0, min(100, speed))
if self.controller.set_fan_speed(speed): return self.controller.set_fan_speed(speed, fan_id)
self.current_speed = speed
return True
return False
def set_auto_mode(self, enabled: bool): def set_auto_mode(self, enabled: bool):
"""Enable or disable automatic control.""" """Enable or disable automatic control."""
@ -682,62 +677,60 @@ class FanControlService:
elif not enabled and self.controller: elif not enabled and self.controller:
self.controller.disable_manual_fan_control() self.controller.disable_manual_fan_control()
def get_status(self) -> Dict:
"""Get current controller status."""
with self.lock:
status = {
"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,
"in_identify_mode": self.in_identify_mode,
"current_speeds": self.current_speeds,
"target_speeds": self.target_speeds,
"temperatures": [asdict(t) for t in self.last_temps],
"fans": [asdict(f) for f in self.last_fans],
"config": self._get_safe_config()
}
return status
def _get_safe_config(self) -> Dict:
"""Get config without sensitive data."""
safe = json.loads(json.dumps(self.config))
# Remove passwords
safe.pop('ipmi_password', None)
safe.pop('http_sensor_password', None)
return safe
# Global service instances # Global service instances
_service_instances: Dict[str, FanControlService] = {} _service_instances: Dict[str, IPMIControllerService] = {}
def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService: def get_service(config_path: str = "/etc/ipmi-controller/config.json") -> IPMIControllerService:
"""Get or create the service instance for a config path.""" """Get or create the service instance."""
if config_path not in _service_instances: if config_path not in _service_instances:
_service_instances[config_path] = FanControlService(config_path) _service_instances[config_path] = IPMIControllerService(config_path)
return _service_instances[config_path] return _service_instances[config_path]
if __name__ == "__main__": if __name__ == "__main__":
# Simple CLI test # CLI test
import sys import sys
if len(sys.argv) < 4: if len(sys.argv) < 4:
print("Usage: python fan_controller.py <host> <username> <password> [port]") print("Usage: fan_controller.py <host> <username> <password>")
sys.exit(1) sys.exit(1)
host = sys.argv[1] host, user, pwd = sys.argv[1:4]
username = sys.argv[2]
password = sys.argv[3]
port = int(sys.argv[4]) if len(sys.argv) > 4 else 623 port = int(sys.argv[4]) if len(sys.argv) > 4 else 623
controller = IPMIFanController(host, username, password, port) ctrl = IPMIFanController(host, user, pwd, port)
print(f"Testing connection to {host}...") print(f"Testing {host}...")
if controller.test_connection(): if ctrl.test_connection():
print("✓ Connected successfully") print("✓ Connected")
print("\nTemps:", [(t.name, t.value) for t in ctrl.get_temperatures()])
print("\nTemperatures:") print("\nFans:", [(f.fan_number, f.speed_rpm) for f in ctrl.get_fan_speeds()])
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: else:
print("✗ Connection failed") print("✗ Failed")
sys.exit(1)

View File

@ -1,255 +1,61 @@
/home/devmatrix/projects/fan-controller-v2/web_server.py:49: PydanticDeprecatedSince20: Pydantic V1 style `@validator` validators are deprecated. You should migrate to Pydantic V2 style `@field_validator` validators, see the migration guide for more details. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.5/migration/ INFO: Started server process [896609]
@validator('new_password')
INFO: Started server process [893663]
INFO: Waiting for application startup. INFO: Waiting for application startup.
INFO: Application startup complete. INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 192.168.5.30:56770 - "GET /api/status HTTP/1.1" 401 Unauthorized INFO: 192.168.5.30:63112 - "GET /api/status HTTP/1.1" 401 Unauthorized
INFO: 192.168.5.30:56770 - "GET /login HTTP/1.1" 200 OK INFO: 192.168.5.30:63112 - "GET /login HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:34494 - "GET /api/status HTTP/1.1" 401 Unauthorized
/home/devmatrix/projects/fan-controller-v2/web_server.py:141: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
expiry = datetime.utcnow() + timedelta(days=7)
INFO: 192.168.5.30:49736 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET / HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). /home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
self._sessions[token] = (username, datetime.utcnow() + timedelta(days=7))
INFO: 192.168.5.30:49451 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET / HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
if datetime.utcnow() > expiry: if datetime.utcnow() > expiry:
2026-02-20 15:37:16,554 - fan_controller - INFO - Loaded config from /home/devmatrix/projects/fan-controller-v2/data/config.json 2026-02-20 15:49:53,771 - fan_controller - INFO - Loaded config from /home/devmatrix/projects/fan-controller-v2/data/config.json
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET /favicon.ico HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:34498 - "POST /api/auth/login HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:34506 - "GET /api/status HTTP/1.1" 200 OK 2026-02-20 15:50:02,664 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK 2026-02-20 15:50:02,666 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool'
2026-02-20 15:37:24,066 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json 2026-02-20 15:50:02,666 - fan_controller - ERROR - Failed to connect to IPMI
2026-02-20 15:37:24,068 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' INFO: 192.168.5.30:49451 - "POST /api/config/ipmi HTTP/1.1" 200 OK
2026-02-20 15:37:24,069 - fan_controller - ERROR - Failed to connect to IPMI at 192.168.5.191 /home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
INFO: 192.168.5.30:49736 - "POST /api/config/ipmi HTTP/1.1" 200 OK if datetime.utcnow() > expiry:
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:39328 - "GET /api/status HTTP/1.1" 401 Unauthorized
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). /home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
self._sessions[token] = (username, datetime.utcnow() + timedelta(days=7))
INFO: 127.0.0.1:39340 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
2026-02-20 15:50:24,002 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool'
2026-02-20 15:50:24,002 - fan_controller - ERROR - Failed to connect to IPMI
INFO: 192.168.5.30:49451 - "POST /api/test HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
if datetime.utcnow() > expiry: if datetime.utcnow() > expiry:
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK 2026-02-20 15:50:25,685 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool'
INFO: 192.168.5.30:49736 - "GET /favicon.ico HTTP/1.1" 200 OK 2026-02-20 15:50:25,686 - fan_controller - ERROR - Failed to connect to IPMI
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 127.0.0.1:59364 - "POST /api/test HTTP/1.1" 200 OK
2026-02-20 15:37:27,369 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json /home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
2026-02-20 15:37:27,370 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool'
2026-02-20 15:37:27,371 - fan_controller - ERROR - Failed to connect to IPMI at 192.168.5.191
2026-02-20 15:37:27,371 - fan_controller - ERROR - Cannot start service - IPMI connection failed
INFO: 192.168.5.30:49736 - "POST /api/control/auto HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
if datetime.utcnow() > expiry: if datetime.utcnow() > expiry:
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:37104 - "GET / HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:37118 - "GET /login HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:141: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). INFO: 192.168.5.30:49451 - "GET / HTTP/1.1" 200 OK
expiry = datetime.utcnow() + timedelta(days=7) INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:37130 - "POST /api/auth/login HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
2026-02-20 15:37:33,643 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "POST /api/control/auto HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:49736 - "POST /api/control/manual HTTP/1.1" 500 Internal Server Error INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
ERROR: Exception in ASGI application INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
Traceback (most recent call last): INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1352, in api_control_manual
if not service._init_controller():
^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'FanControlService' object has no attribute '_init_controller'. Did you mean: '_init_ipmi_controller'?
2026-02-20 15:37:36,531 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json
2026-02-20 15:37:36,533 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool'
2026-02-20 15:37:36,533 - fan_controller - ERROR - Failed to connect to IPMI at 192.168.5.191
2026-02-20 15:37:36,533 - fan_controller - ERROR - Cannot start service - IPMI connection failed
INFO: 192.168.5.30:62376 - "POST /api/control/auto HTTP/1.1" 200 OK
/home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
if datetime.utcnow() > expiry:
INFO: 192.168.5.30:62376 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:62376 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:62376 - "POST /api/control/manual HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1352, in api_control_manual
if not service._init_controller():
^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'FanControlService' object has no attribute '_init_controller'. Did you mean: '_init_ipmi_controller'?
INFO: 192.168.5.30:55640 - "POST /api/test HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1323, in api_test
if not service._init_controller():
^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'FanControlService' object has no attribute '_init_controller'. Did you mean: '_init_ipmi_controller'?
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK
INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK

File diff suppressed because it is too large Load Diff