IPMI Controller v3: All features implemented
This commit is contained in:
parent
2c91d6f100
commit
63af1e883b
160
README.md
160
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -3,6 +3,55 @@
|
||||||
"ipmi_username": "root",
|
"ipmi_username": "root",
|
||||||
"ipmi_password": "calvin",
|
"ipmi_password": "calvin",
|
||||||
"ipmi_port": 623,
|
"ipmi_port": 623,
|
||||||
|
"http_sensor_enabled": false,
|
||||||
|
"http_sensor_url": "",
|
||||||
|
"http_sensor_timeout": 10,
|
||||||
|
"enabled": true,
|
||||||
|
"poll_interval": 10,
|
||||||
|
"fan_update_interval": 10,
|
||||||
|
"min_speed": 10,
|
||||||
|
"max_speed": 100,
|
||||||
|
"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,
|
||||||
|
"speed": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"temp": 40,
|
||||||
|
"speed": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"temp": 50,
|
||||||
|
"speed": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"temp": 60,
|
||||||
|
"speed": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"temp": 70,
|
||||||
|
"speed": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"temp": 80,
|
||||||
|
"speed": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sensor_source": "cpu",
|
||||||
|
"applies_to": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"theme": "dark",
|
||||||
"ssh_enabled": false,
|
"ssh_enabled": false,
|
||||||
"ssh_host": null,
|
"ssh_host": null,
|
||||||
"ssh_username": null,
|
"ssh_username": null,
|
||||||
|
|
@ -10,10 +59,7 @@
|
||||||
"ssh_use_key": false,
|
"ssh_use_key": false,
|
||||||
"ssh_key_file": null,
|
"ssh_key_file": null,
|
||||||
"ssh_port": 22,
|
"ssh_port": 22,
|
||||||
"enabled": true,
|
|
||||||
"interval": 10,
|
"interval": 10,
|
||||||
"min_speed": 10,
|
|
||||||
"max_speed": 100,
|
|
||||||
"fan_curve": [
|
"fan_curve": [
|
||||||
{
|
{
|
||||||
"temp": 30,
|
"temp": 30,
|
||||||
|
|
@ -39,7 +85,5 @@
|
||||||
"temp": 80,
|
"temp": 80,
|
||||||
"speed": 100
|
"speed": 100
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"panic_temp": 85,
|
|
||||||
"panic_speed": 100
|
|
||||||
}
|
}
|
||||||
|
|
@ -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,33 +313,52 @@ 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": [
|
|
||||||
{"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_temp": 85,
|
||||||
"panic_speed": 100
|
"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": 40, "speed": 25},
|
||||||
|
{"temp": 50, "speed": 40},
|
||||||
|
{"temp": 60, "speed": 60},
|
||||||
|
{"temp": 70, "speed": 80},
|
||||||
|
{"temp": 80, "speed": 100},
|
||||||
|
],
|
||||||
|
"sensor_source": "cpu",
|
||||||
|
"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']):
|
||||||
if ipmi_changed:
|
self._init_http_client()
|
||||||
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:
|
def _init_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,188 +418,254 @@ 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
|
||||||
temps = self._get_temperatures()
|
poll_interval = self.config.get('poll_interval', 10)
|
||||||
fans = self.controller.get_fan_speeds() if self.controller else []
|
if poll_counter % poll_interval == 0:
|
||||||
|
temps = self._get_temperatures()
|
||||||
|
fans = self.controller.get_fan_speeds() if self.controller else []
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
self.last_temps = temps
|
||||||
|
self.last_fans = fans
|
||||||
|
|
||||||
|
if temps:
|
||||||
|
self._last_data_time = datetime.utcnow()
|
||||||
|
|
||||||
|
# Apply fan curves
|
||||||
|
if not self.in_identify_mode:
|
||||||
|
self._apply_fan_curves(temps)
|
||||||
|
|
||||||
with self.lock:
|
poll_counter += 1
|
||||||
self.last_temps = temps
|
time.sleep(1)
|
||||||
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
|
|
||||||
cpu_temps = [t for t in temps if t.location.startswith('cpu')]
|
|
||||||
max_temp = max((t.value for t in cpu_temps), 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:
|
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],
|
# Get primary sensor
|
||||||
"fans": [asdict(f) for f in self.last_fans],
|
primary_sensor = self.config.get('primary_sensor', 'cpu')
|
||||||
"config": {
|
sensor_temps = [t for t in temps if t.location == primary_sensor]
|
||||||
# IPMI
|
if not sensor_temps:
|
||||||
"ipmi_host": self.config.get('ipmi_host'),
|
sensor_temps = [t for t in temps if t.location.startswith(primary_sensor)]
|
||||||
"ipmi_port": self.config.get('ipmi_port'),
|
if not sensor_temps:
|
||||||
"ipmi_username": self.config.get('ipmi_username'),
|
sensor_temps = temps # Fallback to any temp
|
||||||
# SSH
|
|
||||||
"ssh_enabled": self.config.get('ssh_enabled'),
|
max_temp = max(t.value for t in sensor_temps)
|
||||||
"ssh_host": self.config.get('ssh_host'),
|
|
||||||
"ssh_port": self.config.get('ssh_port'),
|
# Check panic temperature
|
||||||
"ssh_username": self.config.get('ssh_username'),
|
if max_temp >= self.config.get('panic_temp', 85):
|
||||||
"ssh_use_key": self.config.get('ssh_use_key'),
|
self._set_all_fans(self.config.get('panic_speed', 100), f"PANIC: Temp {max_temp}°C")
|
||||||
# Settings
|
return
|
||||||
"min_speed": self.config.get('min_speed'),
|
|
||||||
"max_speed": self.config.get('max_speed'),
|
# Get fan curves
|
||||||
"panic_temp": self.config.get('panic_temp'),
|
curves = self.config.get('fan_curves', {})
|
||||||
"interval": self.config.get('interval'),
|
default_curve = curves.get('Default', {'points': [{'temp': 30, 'speed': 15}, {'temp': 80, 'speed': 100}]})
|
||||||
"fan_curve": self.config.get('fan_curve')
|
|
||||||
}
|
# Apply curves to fans
|
||||||
}
|
fans = self.config.get('fans', {})
|
||||||
return status
|
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 set_manual_speed(self, speed: int) -> bool:
|
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."""
|
||||||
|
|
@ -681,63 +676,61 @@ class FanControlService:
|
||||||
self.controller.enable_manual_fan_control()
|
self.controller.enable_manual_fan_control()
|
||||||
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)
|
|
||||||
|
|
|
||||||
300
server.log
300
server.log
|
|
@ -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
|
|
||||||
|
|
|
||||||
1986
web_server.py
1986
web_server.py
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue