Compare commits

...

10 Commits

16 changed files with 3013 additions and 890 deletions

99
PERSISTENCE.md Normal file
View File

@ -0,0 +1,99 @@
# IPMI Controller - Persistence Setup
## Data Persistence
All configuration and user data is stored in the `data/` directory:
- `data/config.json` - All settings, fan curves, IPMI config
- `data/users.json` - User accounts and passwords
**IMPORTANT:** The `data/` directory is committed to git for version control of your settings.
## Backup Your Settings
```bash
# Create backup
cd ~/ipmi-controller
cp -r data data.backup.$(date +%Y%m%d)
# Or backup to external location
cp data/config.json /mnt/backup/ipmi-controller-config.json
```
## Auto-Start on Boot (systemd)
1. **Create service file:**
```bash
sudo tee /etc/systemd/system/ipmi-controller.service << 'EOF'
[Unit]
Description=IPMI Controller
After=network.target
[Service]
Type=simple
User=devmatrix
WorkingDirectory=/home/devmatrix/ipmi-controller
ExecStart=/usr/bin/python3 /home/devmatrix/ipmi-controller/web_server.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
```
2. **Enable and start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ipmi-controller
sudo systemctl start ipmi-controller
```
3. **Check status:**
```bash
sudo systemctl status ipmi-controller
sudo journalctl -u ipmi-controller -f
```
## Docker Deployment (Persistent)
```bash
# Using docker-compose
cd ~/ipmi-controller
docker-compose up -d
# Data is persisted in ./data directory
```
## Updating Without Losing Settings
```bash
cd ~/ipmi-controller
# Backup first
cp -r data data.backup
# Pull updates
git pull
# Restart
sudo systemctl restart ipmi-controller
# OR if using docker:
docker-compose restart
```
## What Gets Persisted
✅ IPMI connection settings
✅ HTTP sensor configuration
✅ Fan curves (Balanced, Silent, Performance, etc.)
✅ User accounts and passwords
✅ Theme preference (dark/light)
✅ Fan groups and custom names
✅ All control settings (poll interval, panic temps, etc.)
## Migration to New Server
1. Copy `data/config.json` and `data/users.json` to new server
2. Install ipmitool: `sudo apt-get install ipmitool`
3. Install Python deps: `pip install -r requirements.txt`
4. Start server: `python3 web_server.py`

206
README.md
View File

@ -1,138 +1,156 @@
# 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, multiple fan curves, and persistent configuration.
## What's Different from v1?
## Features
- **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
- 🌡️ **Dual Sensor Support** - IPMI + HTTP (lm-sensors from Proxmox/host)
- 🌬️ **Smart Fan Control** - Automatic curves, manual control, panic mode
- 📊 **3 Preset Curves** - Balanced (default), Silent, Performance
- 👥 **Fan Groups** - Organize and control fans individually or in groups
- 🔍 **Fan Identify** - Visual fan identification
- 🎨 **Themes** - Dark and Light mode
- 📱 **Responsive Web UI** - Works on desktop and mobile
- 🔌 **Public API** - For external integrations
- 💾 **Persistent Settings** - Survives restarts and updates
## Quick Start
### 1. Install
### Automated Install (Recommended)
```bash
cd ~/projects/fan-controller-v2
git clone https://github.com/yourusername/ipmi-controller.git
cd ipmi-controller
chmod +x install.sh
sudo ./install.sh
```
This will:
- Install Python dependencies
- Create systemd service
- Set up config in `/etc/ipmi-fan-controller/`
- Install all dependencies
- Create systemd service for auto-start
- Set up persistent data directory
- Start the controller on boot
### 2. Configure
Edit the configuration file:
### Manual Install
```bash
sudo nano /etc/ipmi-fan-controller/config.json
# Install dependencies
sudo apt-get install -y ipmitool python3-pip
pip3 install -r requirements.txt
# Run
python3 web_server.py
```
Set your IPMI credentials:
```json
{
"host": "192.168.1.100",
"username": "root",
"password": "your-password",
"port": 623
}
Access at `http://your-server:8000`
## Initial Setup
1. Complete the setup wizard (create admin + IPMI config)
2. Login with your admin credentials
3. (Optional) Set up HTTP sensor on your Proxmox host:
```bash
# On Proxmox server
curl -O https://raw.githubusercontent.com/yourusername/ipmi-controller/main/setup-sensors-server.sh
sudo ./setup-sensors-server.sh
```
4. Enable auto control and enjoy automatic fan management!
## Persistence
All your settings are automatically saved to `data/config.json`:
✅ IPMI configuration
✅ HTTP sensor settings
✅ Fan curves (Balanced, Silent, Performance)
✅ User accounts
✅ Theme preference
✅ All control settings
**Backups:**
```bash
./backup.sh backup # Create backup
./backup.sh list # List backups
./backup.sh restore [file] # Restore from backup
```
### 3. Start
**Auto-backup via cron:**
```bash
# Add to crontab (keeps 30 days of backups)
0 2 * * * /opt/ipmi-controller/backup.sh auto
```
## Updating
```bash
sudo systemctl start ipmi-fan-controller
cd ipmi-controller
git pull
# Settings are preserved automatically
sudo systemctl restart ipmi-controller
```
Open the web UI at `http://your-server:8000`
## Fan Curves
## CLI Testing
**Balanced** (Default) - Best for most users:
```
30°C → 10% | 40°C → 15% | 50°C → 30% | 60°C → 55% | 70°C → 85% | 80°C → 100%
```
Test the IPMI connection without the web server:
**Silent** - Noise-sensitive environments:
```
30°C → 5% | 40°C → 10% | 50°C → 15% | 60°C → 35% | 70°C → 70% | 80°C → 100%
```
**Performance** - Heavy workloads:
```
30°C → 20% | 40°C → 35% | 50°C → 55% | 60°C → 85% | 70°C → 100%
```
## Documentation
- [Setup Guide](SETUP.md) - Full installation instructions
- [Persistence Guide](PERSISTENCE.md) - Backup and restore
- [API Reference](API.md) - Public API documentation
## Docker
```bash
python3 fan_controller.py 192.168.1.100 root password
docker-compose up -d
```
This will:
1. Test the connection
2. Show temperatures and fan speeds
3. Try manual fan control (30% → 50% → auto)
Data persists in `./data` directory.
## Features
## Management Commands
### Automatic Control
- Adjusts fan speed based on CPU temperature
- Configurable fan curve (temp → speed mapping)
- Panic mode: sets fans to 100% if temp exceeds threshold
```bash
# Status
sudo systemctl status ipmi-controller
### Manual Control
- Set any fan speed from 0-100%
- Override automatic control temporarily
# Logs
sudo journalctl -u ipmi-controller -f
### Safety Features
- Returns to automatic control on shutdown
- Reconnects automatically if IPMI connection drops
- Panic temperature protection
# Restart
sudo systemctl restart ipmi-controller
## Configuration Options
```json
{
"host": "192.168.1.100", // IPMI IP address
"username": "root", // IPMI username
"password": "secret", // IPMI password
"port": 623, // IPMI port (default: 623)
"enabled": false, // Start automatic control on boot
"interval": 10, // Check interval in seconds
"min_speed": 10, // Minimum fan speed (%)
"max_speed": 100, // Maximum fan speed (%)
"panic_temp": 85, // Panic mode trigger (°C)
"panic_speed": 100, // Panic mode fan speed (%)
"fan_curve": [ // Temp (°C) → Speed (%) mapping
{"temp": 30, "speed": 15},
{"temp": 40, "speed": 25},
{"temp": 50, "speed": 40},
{"temp": 60, "speed": 60},
{"temp": 70, "speed": 80},
{"temp": 80, "speed": 100}
]
}
# Stop
sudo systemctl stop ipmi-controller
```
## 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
**IPMI Connection Failed:**
- Verify IPMI IP, username, password
- Test: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
### 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
**No Temperature Data:**
- Check HTTP sensor: `curl http://proxmox-ip:8888`
- Verify `sensor_preference` is set to "auto" or "http"
### Service Won't Start
```bash
# Check logs
sudo journalctl -u ipmi-fan-controller -f
# Check config is valid JSON
sudo python3 -c "import json; json.load(open('/etc/ipmi-fan-controller/config.json'))"
```
## Files
- `fan_controller.py` - Core IPMI control logic
- `web_server.py` - FastAPI web interface
- `install.sh` - Installation script
- `requirements.txt` - Python dependencies
**Settings Lost After Update:**
- Ensure `data/` directory is not deleted
- Check file permissions: `ls -la data/`
## License
MIT License - Feel free to modify and distribute.
MIT License

183
SETUP.md Normal file
View File

@ -0,0 +1,183 @@
# IPMI Controller - Setup Guide
## Prerequisites
- Dell server with IPMI (iDRAC) enabled
- Linux host (Ubuntu/Debian recommended)
- Python 3.10+
- ipmitool
## Installation
### 1. Install IPMI Controller
On your management server (where you run the controller):
```bash
git clone https://github.com/yourusername/ipmi-controller.git
cd ipmi-controller
pip install -r requirements.txt
```
### 2. Install ipmitool
```bash
sudo apt-get update
sudo apt-get install -y ipmitool
```
### 3. Run the Controller
```bash
python3 web_server.py
```
Open `http://your-server:8000` in browser.
## Initial Setup
1. **Complete the Setup Wizard:**
- Create admin account
- Enter IPMI credentials
- IP: Your Dell server's IPMI IP
- Username: Usually "root"
- Password: Your IPMI password
- Port: 623 (default)
2. **Login** with your new admin credentials
## lm-sensors HTTP Server (Optional but Recommended)
For better temperature monitoring (including PCIe cards), set up lm-sensors on your Dell server:
### Option A: Automated Setup
On your **Dell/Proxmox server** (not the controller):
```bash
# Download and run setup script
curl -O https://raw.githubusercontent.com/yourusername/ipmi-controller/main/setup-sensors-server.sh
chmod +x setup-sensors-server.sh
sudo ./setup-sensors-server.sh
```
### Option B: Manual Setup
1. **Install lm-sensors:**
```bash
sudo apt-get install -y lm-sensors netcat-openbsd
```
2. **Detect sensors:**
```bash
sudo sensors-detect --auto
```
3. **Test sensors:**
```bash
sensors
```
4. **Create HTTP server script** (`/usr/local/bin/sensors-http-server.sh`):
```bash
#!/bin/bash
PORT=${1:-8888}
while true; do
{
echo -e "HTTP/1.1 200 OK\r"
echo -e "Content-Type: text/plain\r"
echo -e "Access-Control-Allow-Origin: *\r"
echo -e "\r"
sensors -u 2>/dev/null || echo "Error"
} | nc -l -p "$PORT" -q 1
done
```
5. **Make executable:**
```bash
chmod +x /usr/local/bin/sensors-http-server.sh
```
6. **Create systemd service** (`/etc/systemd/system/sensors-http.service`):
```ini
[Unit]
Description=lm-sensors HTTP Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sensors-http-server.sh 8888
Restart=always
User=root
[Install]
WantedBy=multi-user.target
```
7. **Enable and start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable sensors-http
sudo systemctl start sensors-http
```
8. **Test:**
```bash
curl http://$(hostname -I | awk '{print $1}'):8888
```
### Configure IPMI Controller
1. Go to **Settings****HTTP** tab
2. Enable "HTTP Sensor"
3. Enter URL: `http://your-dell-server-ip:8888`
4. Save
## Docker Deployment
```bash
docker build -t ipmi-controller .
docker run -d \
-p 8000:8000 \
-v $(pwd)/data:/app/data \
--name ipmi-controller \
ipmi-controller
```
## Troubleshooting
### IPMI Connection Failed
- Verify IPMI IP is correct
- Check IPMI username/password
- Ensure IPMI is enabled in BIOS/iDRAC
- Test manually: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
### No Temperature Data
- Check if lm-sensors is installed on Dell server
- Run `sensors` to verify it works
- Check HTTP endpoint: `curl http://dell-ip:8888`
### Service Won't Start
```bash
# Check logs
sudo journalctl -u sensors-http -f
# Check if port is in use
sudo netstat -tlnp | grep 8888
```
## Security Notes
- Change default password after first login
- Use HTTPS/reverse proxy for production
- Firewall port 8000 to internal network only
- HTTP sensor endpoint is read-only
## Updating
```bash
cd ipmi-controller
git pull
pip install -r requirements.txt
# Restart the service
```

Binary file not shown.

Binary file not shown.

141
backup.sh Executable file
View File

@ -0,0 +1,141 @@
#!/bin/bash
# IPMI Controller - Backup and Restore
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATA_DIR="${DATA_DIR:-$SCRIPT_DIR/data}"
BACKUP_DIR="${BACKUP_DIR:-$SCRIPT_DIR/backups}"
show_help() {
echo "IPMI Controller - Backup/Restore Tool"
echo ""
echo "Usage:"
echo " $0 backup - Create backup"
echo " $0 restore [filename] - Restore from backup"
echo " $0 list - List available backups"
echo " $0 auto - Auto-backup (cron-friendly)"
echo ""
}
create_backup() {
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/ipmi-controller-backup-$TIMESTAMP.tar.gz"
echo "📦 Creating backup..."
tar -czf "$BACKUP_FILE" -C "$SCRIPT_DIR" data/ 2>/dev/null
if [ $? -eq 0 ]; then
echo "✅ Backup created: $BACKUP_FILE"
echo ""
ls -lh "$BACKUP_FILE"
else
echo "❌ Backup failed"
exit 1
fi
}
restore_backup() {
if [ -z "$1" ]; then
echo "❌ Please specify backup file"
list_backups
exit 1
fi
BACKUP_FILE="$BACKUP_DIR/$1"
if [ ! -f "$BACKUP_FILE" ]; then
BACKUP_FILE="$1"
fi
if [ ! -f "$BACKUP_FILE" ]; then
echo "❌ Backup file not found: $1"
exit 1
fi
echo "⚠️ This will overwrite current settings!"
read -p "Are you sure? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted"
exit 0
fi
# Create safety backup first
echo "📦 Creating safety backup..."
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
tar -czf "$BACKUP_DIR/safety-backup-before-restore-$TIMESTAMP.tar.gz" -C "$SCRIPT_DIR" data/ 2>/dev/null
# Stop service if running
if systemctl is-active --quiet ipmi-controller 2>/dev/null; then
echo "🛑 Stopping service..."
sudo systemctl stop ipmi-controller
WAS_RUNNING=true
else
WAS_RUNNING=false
fi
# Restore
echo "📥 Restoring from backup..."
tar -xzf "$BACKUP_FILE" -C "$SCRIPT_DIR"
if [ $? -eq 0 ]; then
echo "✅ Restore complete"
# Restart service if it was running
if [ "$WAS_RUNNING" = true ]; then
echo "🚀 Starting service..."
sudo systemctl start ipmi-controller
fi
else
echo "❌ Restore failed"
exit 1
fi
}
list_backups() {
echo "📂 Available backups:"
echo ""
if [ -d "$BACKUP_DIR" ] && [ "$(ls -A "$BACKUP_DIR")" ]; then
ls -lh "$BACKUP_DIR"/*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
else
echo " No backups found"
fi
}
auto_backup() {
# This is cron-friendly - keeps only last 30 days
mkdir -p "$BACKUP_DIR"
# Create backup
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/auto-backup-$TIMESTAMP.tar.gz"
tar -czf "$BACKUP_FILE" -C "$SCRIPT_DIR" data/ 2>/dev/null
# Clean old backups (keep last 30 days)
find "$BACKUP_DIR" -name "auto-backup-*.tar.gz" -mtime +30 -delete 2>/dev/null
echo "Auto-backup complete: $BACKUP_FILE"
}
case "${1:-}" in
backup)
create_backup
;;
restore)
restore_backup "$2"
;;
list)
list_backups
;;
auto)
auto_backup
;;
help|--help|-h)
show_help
;;
*)
show_help
exit 1
;;
esac

190
data/config.json Normal file
View File

@ -0,0 +1,190 @@
{
"ipmi_host": "192.168.5.191",
"ipmi_username": "root",
"ipmi_password": "calvin",
"ipmi_port": 623,
"http_sensor_enabled": true,
"http_sensor_url": "http://192.168.5.200:8888",
"http_sensor_timeout": 10,
"enabled": true,
"poll_interval": 10,
"fan_update_interval": 10,
"min_speed": 10,
"max_speed": 100,
"panic_temp": 85.0,
"panic_speed": 100,
"panic_on_no_data": true,
"no_data_timeout": 60,
"primary_sensor": "cpu",
"sensor_preference": "auto",
"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"
},
"Balanced": {
"points": [
{
"temp": 30,
"speed": 10
},
{
"temp": 35,
"speed": 12
},
{
"temp": 40,
"speed": 15
},
{
"temp": 45,
"speed": 20
},
{
"temp": 50,
"speed": 30
},
{
"temp": 55,
"speed": 40
},
{
"temp": 60,
"speed": 55
},
{
"temp": 65,
"speed": 70
},
{
"temp": 70,
"speed": 85
},
{
"temp": 75,
"speed": 95
},
{
"temp": 80,
"speed": 100
}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{
"temp": 30,
"speed": 5
},
{
"temp": 40,
"speed": 10
},
{
"temp": 50,
"speed": 15
},
{
"temp": 55,
"speed": 25
},
{
"temp": 60,
"speed": 35
},
{
"temp": 65,
"speed": 50
},
{
"temp": 70,
"speed": 70
},
{
"temp": 75,
"speed": 85
},
{
"temp": 80,
"speed": 100
}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{
"temp": 30,
"speed": 20
},
{
"temp": 35,
"speed": 25
},
{
"temp": 40,
"speed": 35
},
{
"temp": 45,
"speed": 45
},
{
"temp": 50,
"speed": 55
},
{
"temp": 55,
"speed": 70
},
{
"temp": 60,
"speed": 85
},
{
"temp": 65,
"speed": 95
},
{
"temp": 70,
"speed": 100
}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
"theme": "dark",
"active_curve": "Performance"
}

242
deploy-prod.sh Executable file
View File

@ -0,0 +1,242 @@
#!/bin/bash
# IPMI Controller - Production Deployment Script
# Run this on the production server to set up auto-start and persistence
set -e
INSTALL_DIR="/opt/ipmi-controller"
DATA_DIR="$INSTALL_DIR/data"
SERVICE_NAME="ipmi-controller"
USER="devmatrix"
echo "🌡️ IPMI Controller - Production Deployment"
echo "============================================="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "❌ Please run as root: sudo ./deploy-prod.sh"
exit 1
fi
# Get the source directory
SOURCE_DIR="${1:-$(pwd)}"
if [ ! -f "$SOURCE_DIR/web_server.py" ]; then
echo "❌ Cannot find web_server.py in $SOURCE_DIR"
echo "Usage: sudo ./deploy-prod.sh [source-directory]"
exit 1
fi
echo "📁 Source: $SOURCE_DIR"
echo "📁 Install: $INSTALL_DIR"
echo ""
# Create directories
echo "📂 Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$DATA_DIR"
mkdir -p /var/log/ipmi-controller
# Copy application files
echo "📋 Copying application files..."
cp "$SOURCE_DIR/web_server.py" "$INSTALL_DIR/"
cp "$SOURCE_DIR/fan_controller.py" "$INSTALL_DIR/"
cp "$SOURCE_DIR/requirements.txt" "$INSTALL_DIR/"
# Install dependencies
echo "🐍 Installing Python dependencies..."
pip3 install -q -r "$SOURCE_DIR/requirements.txt" || pip install -q -r "$SOURCE_DIR/requirements.txt"
# Install ipmitool if not present
if ! command -v ipmitool &> /dev/null; then
echo "📦 Installing ipmitool..."
apt-get update -qq
apt-get install -y -qq ipmitool
fi
# Preserve existing config if it exists
if [ -f "$DATA_DIR/config.json" ]; then
echo "💾 Preserving existing configuration..."
cp "$DATA_DIR/config.json" "$DATA_DIR/config.json.backup.$(date +%Y%m%d%H%M%S)"
else
echo "⚙️ Creating default configuration..."
cat > "$DATA_DIR/config.json" << 'EOF'
{
"ipmi_host": "",
"ipmi_username": "",
"ipmi_password": "",
"ipmi_port": 623,
"http_sensor_enabled": false,
"http_sensor_url": "",
"http_sensor_timeout": 10,
"enabled": false,
"poll_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": "auto",
"fans": {},
"fan_groups": {},
"fan_curves": {
"Balanced": {
"points": [
{"temp": 30, "speed": 10},
{"temp": 35, "speed": 12},
{"temp": 40, "speed": 15},
{"temp": 45, "speed": 20},
{"temp": 50, "speed": 30},
{"temp": 55, "speed": 40},
{"temp": 60, "speed": 55},
{"temp": 65, "speed": 70},
{"temp": 70, "speed": 85},
{"temp": 75, "speed": 95},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{"temp": 30, "speed": 5},
{"temp": 40, "speed": 10},
{"temp": 50, "speed": 15},
{"temp": 55, "speed": 25},
{"temp": 60, "speed": 35},
{"temp": 65, "speed": 50},
{"temp": 70, "speed": 70},
{"temp": 75, "speed": 85},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{"temp": 30, "speed": 20},
{"temp": 35, "speed": 25},
{"temp": 40, "speed": 35},
{"temp": 45, "speed": 45},
{"temp": 50, "speed": 55},
{"temp": 55, "speed": 70},
{"temp": 60, "speed": 85},
{"temp": 65, "speed": 95},
{"temp": 70, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
"active_curve": "Balanced",
"theme": "dark"
}
EOF
fi
# Create users file if not exists
if [ ! -f "$DATA_DIR/users.json" ]; then
echo '{"users": {}}' > "$DATA_DIR/users.json"
fi
# Set ownership
chown -R "$USER:$USER" "$INSTALL_DIR"
chown -R "$USER:$USER" /var/log/ipmi-controller
# Create systemd service
echo "🔧 Creating systemd service..."
cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
[Unit]
Description=IPMI Controller - Fan Control for Dell Servers
After=network.target
Wants=network.target
[Service]
Type=simple
User=$USER
Group=$USER
WorkingDirectory=$INSTALL_DIR
Environment="PYTHONUNBUFFERED=1"
Environment="DATA_DIR=$DATA_DIR"
Environment="LOG_DIR=/var/log/ipmi-controller"
ExecStart=/usr/bin/python3 $INSTALL_DIR/web_server.py
ExecStop=/bin/kill -TERM \$MAINPID
ExecReload=/bin/kill -HUP \$MAINPID
# Restart policy
Restart=always
RestartSec=10
StartLimitInterval=60s
StartLimitBurst=3
# Logging
StandardOutput=append:/var/log/ipmi-controller/server.log
StandardError=append:/var/log/ipmi-controller/error.log
# Security
NoNewPrivileges=false
ProtectSystem=no
ProtectHome=no
[Install]
WantedBy=multi-user.target
EOF
# Create logrotate config
echo "📝 Creating logrotate config..."
cat > "/etc/logrotate.d/ipmi-controller" << EOF
/var/log/ipmi-controller/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 644 $USER $USER
sharedscripts
postrotate
systemctl reload ipmi-controller
endscript
}
EOF
# Reload systemd
echo "🔄 Reloading systemd..."
systemctl daemon-reload
# Enable service
echo "✅ Enabling service to start on boot..."
systemctl enable "$SERVICE_NAME"
echo ""
echo "🚀 Starting IPMI Controller..."
systemctl start "$SERVICE_NAME"
sleep 2
# Check status
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo ""
echo "✅ IPMI Controller is running!"
echo ""
echo "🌐 Access: http://$(hostname -I | awk '{print $1}'):8000"
echo ""
echo "📋 Management Commands:"
echo " Status: sudo systemctl status $SERVICE_NAME"
echo " Logs: sudo journalctl -u $SERVICE_NAME -f"
echo " Restart: sudo systemctl restart $SERVICE_NAME"
echo " Stop: sudo systemctl stop $SERVICE_NAME"
echo ""
echo "💾 Settings: $DATA_DIR/config.json"
echo "📜 Logs: /var/log/ipmi-controller/"
echo ""
echo "✅ Production deployment complete!"
echo " Service will auto-start on boot."
else
echo ""
echo "⚠️ Service failed to start. Check logs:"
echo " sudo journalctl -u $SERVICE_NAME -f"
exit 1
fi

20
docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
version: '3.8'
services:
ipmi-controller:
build: .
container_name: ipmi-controller
restart: unless-stopped
ports:
- "8000:8000"
volumes:
# Persist data directory
- ./data:/app/data
# Optional: mount ipmitool from host if needed
- /usr/bin/ipmitool:/usr/bin/ipmitool:ro
environment:
- PYTHONUNBUFFERED=1
- DATA_DIR=/app/data
# Required for ipmitool to work in container
privileged: true
network_mode: host

View File

@ -1,6 +1,6 @@
"""
IPMI Fan Controller v2 - Simpler, More Robust
For Dell T710 and compatible servers
IPMI Controller - Advanced Fan Control for Dell Servers
Features: Fan groups, multiple curves, HTTP sensors, panic mode
"""
import subprocess
import re
@ -8,6 +8,7 @@ import time
import json
import logging
import threading
import requests
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from datetime import datetime
@ -19,24 +20,19 @@ logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('/tmp/ipmi-fan-controller.log')
logging.FileHandler('/tmp/ipmi-controller.log')
]
)
logger = logging.getLogger(__name__)
@dataclass
class FanCurvePoint:
temp: float
speed: int
@dataclass
class TemperatureReading:
name: str
location: str
value: float
status: str
source: str = "ipmi" # ipmi, http, ssh
@dataclass
@ -45,20 +41,116 @@ class FanReading:
fan_number: int
speed_rpm: 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 - chip names typically don't have spaces or colons at start
if line and not line.startswith("_") and ":" not in line and not line[0].isdigit():
if "Adapter:" not in line and "ERROR" not in line.upper():
current_chip = line
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().split()[0]) # Handle "34.000" or "34.000 (high ="
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, IndexError):
pass
return temps
def _classify_sensor_name(self, name: str, chip: str) -> str:
"""Classify sensor location from name."""
import re
name_lower = name.lower()
chip_lower = chip.lower()
# Check chip name first for CPU identification
if "coretemp" in chip_lower:
# Extract CPU number from chip (coretemp-isa-0000 = cpu1, coretemp-isa-0001 = cpu2)
if "0001" in chip or "isa-0001" in chip_lower:
return "cpu2"
return "cpu1"
# Check sensor name for core temps
if "core" in name_lower:
# Try to determine which CPU based on core number
core_match = re.search(r'core\s*(\d+)', name_lower)
if core_match:
core_num = int(core_match.group(1))
if core_num >= 6:
return "cpu2"
return "cpu1"
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 "composite" in name_lower:
return "pcie"
elif "loc1" in name_lower or "loc2" in name_lower:
return "chipset"
return "other"
def is_healthy(self) -> bool:
return self.consecutive_failures < 3
class IPMIFanController:
"""Simplified IPMI fan controller with robust error handling."""
# Default fan curve (temp C -> speed %)
DEFAULT_CURVE = [
FanCurvePoint(30, 15),
FanCurvePoint(40, 25),
FanCurvePoint(50, 40),
FanCurvePoint(60, 60),
FanCurvePoint(70, 80),
FanCurvePoint(80, 100),
]
"""IPMI fan controller with advanced features."""
def __init__(self, host: str, username: str, password: str, port: int = 623):
self.host = host
@ -104,13 +196,12 @@ class IPMIFanController:
return False, str(e)
def test_connection(self) -> bool:
"""Test if we can connect to the server."""
"""Test IPMI connection."""
success, _ = self._run_ipmi(["mc", "info"], timeout=10)
return success
def enable_manual_fan_control(self) -> bool:
"""Enable manual fan control mode."""
# Dell: raw 0x30 0x30 0x01 0x00
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
if success:
self.manual_mode = True
@ -119,7 +210,6 @@ class IPMIFanController:
def disable_manual_fan_control(self) -> bool:
"""Return to automatic fan control."""
# Dell: raw 0x30 0x30 0x01 0x01
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x01"])
if success:
self.manual_mode = False
@ -128,18 +218,14 @@ class IPMIFanController:
def set_fan_speed(self, speed_percent: int, fan_id: str = "0xff") -> bool:
"""Set fan speed (0-100%). fan_id 0xff = all fans."""
if speed_percent < 0:
speed_percent = 0
if speed_percent > 100:
speed_percent = 100
speed_percent = max(0, min(100, speed_percent))
hex_speed = f"0x{speed_percent:02x}"
success, _ = self._run_ipmi([
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed
])
if success:
logger.info(f"Fan speed set to {speed_percent}%")
logger.info(f"Fan {fan_id} speed set to {speed_percent}%")
return success
def get_temperatures(self) -> List[TemperatureReading]:
@ -150,7 +236,6 @@ class IPMIFanController:
temps = []
for line in output.splitlines():
# Parse: Sensor Name | 01h | ok | 3.1 | 45 degrees C
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 5:
name = parts[0]
@ -165,7 +250,8 @@ class IPMIFanController:
name=name,
location=location,
value=value,
status=status
status=status,
source="ipmi"
))
return temps
@ -183,12 +269,10 @@ class IPMIFanController:
name = parts[0]
reading = parts[4]
# Extract fan number
match = re.search(r'fan\s*(\d+)', name, re.IGNORECASE)
fan_number = int(match.group(1)) if match else 0
fan_id = f"0x{fan_number-1:02x}" if fan_number > 0 else "0x00"
# Extract RPM
rpm_match = re.search(r'(\d+)\s*RPM', reading, re.IGNORECASE)
rpm = int(rpm_match.group(1)) if rpm_match else None
@ -217,100 +301,144 @@ class IPMIFanController:
return "memory"
return "other"
def calculate_fan_speed(self, temps: List[TemperatureReading],
curve: Optional[List[FanCurvePoint]] = None) -> int:
"""Calculate target fan speed based on temperatures."""
if not temps:
return 50 # Default if no temps
if curve is None:
curve = self.DEFAULT_CURVE
# Find max CPU temperature
cpu_temps = [t for t in temps if t.location.startswith("cpu")]
if cpu_temps:
max_temp = max(t.value for t in cpu_temps)
else:
max_temp = max(t.value for t in temps)
# Apply fan curve with linear interpolation
sorted_curve = sorted(curve, key=lambda p: p.temp)
if max_temp <= sorted_curve[0].temp:
return sorted_curve[0].speed
if max_temp >= sorted_curve[-1].temp:
return sorted_curve[-1].speed
for i in range(len(sorted_curve) - 1):
p1, p2 = sorted_curve[i], sorted_curve[i + 1]
if p1.temp <= max_temp <= p2.temp:
if p2.temp == p1.temp:
return p1.speed
ratio = (max_temp - p1.temp) / (p2.temp - p1.temp)
speed = p1.speed + ratio * (p2.speed - p1.speed)
return int(round(speed))
return sorted_curve[-1].speed
def is_healthy(self) -> bool:
"""Check if controller is working properly."""
return self.consecutive_failures < self.max_failures
class FanControlService:
"""Background service for automatic fan control."""
class IPMIControllerService:
"""Main service for IPMI Controller with all advanced features."""
def __init__(self, config_path: str = "/etc/ipmi-fan-controller/config.json"):
def __init__(self, config_path: str = "/etc/ipmi-controller/config.json"):
self.config_path = config_path
self.controller: Optional[IPMIFanController] = None
self.http_client: Optional[HTTPSensorClient] = None
self.running = False
self.thread: Optional[threading.Thread] = None
self.current_speed = 0
self.target_speed = 0
self.current_speeds: Dict[str, int] = {} # fan_id -> speed
self.target_speeds: Dict[str, int] = {}
self.last_temps: List[TemperatureReading] = []
self.last_fans: List[FanReading] = []
self.lock = threading.Lock()
self.in_identify_mode = False
# Default config
self.config = {
"host": "",
"username": "",
"password": "",
"port": 623,
# IPMI Settings
"ipmi_host": "",
"ipmi_username": "",
"ipmi_password": "",
"ipmi_port": 623,
# HTTP Sensor Settings
"http_sensor_enabled": False,
"http_sensor_url": "",
"http_sensor_timeout": 10,
# Fan Control Settings
"enabled": False,
"interval": 10, # seconds
"poll_interval": 10,
"fan_update_interval": 10,
"min_speed": 10,
"max_speed": 100,
"fan_curve": [
{"temp": 30, "speed": 15},
{"temp": 40, "speed": 25},
{"temp": 50, "speed": 40},
{"temp": 60, "speed": 60},
{"temp": 70, "speed": 80},
{"temp": 80, "speed": 100},
],
"panic_temp": 85,
"panic_speed": 100
"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": "auto", # ipmi, http, auto
# Fan Configuration
"fans": {}, # fan_id -> {"name": "Custom Name", "group": "group1"}
"fan_groups": {}, # group_name -> {"fans": ["0x00", "0x01"], "curve": "Default"}
# Fan Curves
"active_curve": "Balanced",
"fan_curves": {
"Balanced": {
"points": [
{"temp": 30, "speed": 10},
{"temp": 35, "speed": 12},
{"temp": 40, "speed": 15},
{"temp": 45, "speed": 20},
{"temp": 50, "speed": 30},
{"temp": 55, "speed": 40},
{"temp": 60, "speed": 55},
{"temp": 65, "speed": 70},
{"temp": 70, "speed": 85},
{"temp": 75, "speed": 95},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{"temp": 30, "speed": 5},
{"temp": 40, "speed": 10},
{"temp": 50, "speed": 15},
{"temp": 55, "speed": 25},
{"temp": 60, "speed": 35},
{"temp": 65, "speed": 50},
{"temp": 70, "speed": 70},
{"temp": 75, "speed": 85},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{"temp": 30, "speed": 20},
{"temp": 35, "speed": 25},
{"temp": 40, "speed": 35},
{"temp": 45, "speed": 45},
{"temp": 50, "speed": 55},
{"temp": 55, "speed": 70},
{"temp": 60, "speed": 85},
{"temp": 65, "speed": 95},
{"temp": 70, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
# UI Settings
"theme": "dark", # dark, light, auto
}
self._load_config()
self._last_data_time = datetime.utcnow()
def _load_config(self):
"""Load configuration from file."""
try:
if Path(self.config_path).exists():
with open(self.config_path, 'r') as f:
config_file = Path(self.config_path)
if config_file.exists():
with open(config_file) as f:
loaded = json.load(f)
self.config.update(loaded)
self._deep_update(self.config, loaded)
logger.info(f"Loaded config from {self.config_path}")
except Exception as 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):
"""Save configuration to file."""
try:
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w') as f:
config_file = Path(self.config_path)
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w') as f:
json.dump(self.config, f, indent=2)
logger.info(f"Saved config to {self.config_path}")
except Exception as e:
@ -318,149 +446,280 @@ class FanControlService:
def update_config(self, **kwargs):
"""Update configuration values."""
self.config.update(kwargs)
self._deep_update(self.config, kwargs)
self._save_config()
# Reinitialize controller if connection params changed
if any(k in kwargs for k in ['host', 'username', 'password', 'port']):
# Reinitialize if needed
if any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']):
self._init_controller()
if any(k in kwargs for k in ['http_sensor_enabled', 'http_sensor_url']):
self._init_http_client()
def _init_controller(self):
def _init_controller(self) -> bool:
"""Initialize the IPMI controller."""
if not all([self.config.get('host'), self.config.get('username'), self.config.get('password')]):
logger.warning("Missing IPMI credentials")
if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]):
return False
self.controller = IPMIFanController(
host=self.config['host'],
username=self.config['username'],
password=self.config['password'],
port=self.config.get('port', 623)
host=self.config['ipmi_host'],
username=self.config['ipmi_username'],
password=self.config.get('ipmi_password', ''),
port=self.config.get('ipmi_port', 623)
)
if self.controller.test_connection():
logger.info(f"Connected to IPMI at {self.config['host']}")
logger.info(f"Connected to IPMI at {self.config['ipmi_host']}")
return True
else:
logger.error(f"Failed to connect to IPMI at {self.config['host']}")
logger.error(f"Failed to connect to IPMI")
self.controller = None
return False
def start(self):
"""Start the fan control service."""
def _init_http_client(self) -> bool:
"""Initialize HTTP sensor client."""
if not self.config.get('http_sensor_enabled'):
return False
url = self.config.get('http_sensor_url')
if not url:
return False
self.http_client = HTTPSensorClient(
url=url,
timeout=self.config.get('http_sensor_timeout', 10)
)
logger.info(f"HTTP sensor client initialized for {url}")
return True
def start(self) -> bool:
"""Start the controller service."""
if self.running:
return
return True
if not self._init_controller():
logger.error("Cannot start service - IPMI connection failed")
logger.error("Cannot start - IPMI connection failed")
return False
if self.config.get('http_sensor_enabled'):
self._init_http_client()
self.running = True
self.thread = threading.Thread(target=self._control_loop, daemon=True)
self.thread.start()
logger.info("Fan control service started")
logger.info("IPMI Controller service started")
return True
def stop(self):
"""Stop the fan control service."""
"""Stop the controller service."""
self.running = False
if self.thread:
self.thread.join(timeout=5)
# Return to automatic control
if self.controller:
self.controller.disable_manual_fan_control()
logger.info("Fan control service stopped")
logger.info("IPMI Controller service stopped")
def _control_loop(self):
"""Main control loop running in background thread."""
# Enable manual control on startup
"""Main control loop."""
if self.controller:
self.controller.enable_manual_fan_control()
poll_counter = 0
while self.running:
try:
if not self.config.get('enabled', False):
time.sleep(1)
continue
# Ensure controller is healthy
if not self.controller or not self.controller.is_healthy():
logger.warning("Controller unhealthy, attempting reconnect...")
logger.warning("IPMI unhealthy, reconnecting...")
if not self._init_controller():
time.sleep(30)
continue
self.controller.enable_manual_fan_control()
# Get sensor data
temps = self.controller.get_temperatures()
fans = self.controller.get_fan_speeds()
# Poll temperatures at configured interval
poll_interval = self.config.get('poll_interval', 10)
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
with self.lock:
self.last_temps = temps
self.last_fans = fans
if not temps:
logger.warning("No temperature readings received")
time.sleep(self.config.get('interval', 10))
continue
if temps:
self._last_data_time = datetime.utcnow()
# Check for panic temperature
max_temp = max((t.value for t in temps if t.location.startswith('cpu')), default=0)
if max_temp >= self.config.get('panic_temp', 85):
self.target_speed = self.config.get('panic_speed', 100)
logger.warning(f"PANIC MODE: CPU temp {max_temp}°C, setting fans to {self.target_speed}%")
else:
# Calculate target speed from curve
curve = [FanCurvePoint(p['temp'], p['speed']) for p in self.config.get('fan_curve', [])]
self.target_speed = self.controller.calculate_fan_speed(temps, curve)
# Apply fan curves
if not self.in_identify_mode:
self._apply_fan_curves(temps)
# 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))
poll_counter += 1
time.sleep(1)
except Exception as e:
logger.error(f"Control loop error: {e}")
time.sleep(10)
def get_status(self) -> Dict:
"""Get current status."""
with self.lock:
return {
"running": self.running,
"enabled": self.config.get('enabled', False),
"connected": self.controller is not None and self.controller.is_healthy(),
"manual_mode": self.controller.manual_mode if self.controller else False,
"current_speed": self.current_speed,
"target_speed": self.target_speed,
"temperatures": [asdict(t) for t in self.last_temps],
"fans": [asdict(f) for f in self.last_fans],
"config": {
k: v for k, v in self.config.items()
if k != 'password' # Don't expose password
}
}
def _get_temperatures(self) -> List[TemperatureReading]:
"""Get temperatures from all sources."""
temps = []
preference = self.config.get('sensor_preference', 'ipmi')
def set_manual_speed(self, speed: int) -> bool:
# Try IPMI
if self.controller and preference in ['ipmi', 'auto']:
temps = self.controller.get_temperatures()
# Try HTTP sensor
if self.http_client and preference in ['http', 'auto']:
http_temps = self.http_client.fetch_sensors()
if http_temps:
if preference == 'http' or not 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
def _apply_fan_curves(self, temps: List[TemperatureReading]):
"""Apply fan curves based on temperatures."""
if not temps:
# Check for panic mode on no data
if self.config.get('panic_on_no_data', True):
time_since_data = (datetime.utcnow() - self._last_data_time).total_seconds()
if time_since_data > self.config.get('no_data_timeout', 60):
self._set_all_fans(self.config.get('panic_speed', 100), "PANIC: No data")
return
# 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', {})
active_curve_name = self.config.get('active_curve', 'Balanced')
default_curve = curves.get(active_curve_name, curves.get('Balanced', {'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."""
if not self.controller:
return False
self.config['enabled'] = False
self._save_config()
speed = max(0, min(100, speed))
if self.controller.set_fan_speed(speed):
self.current_speed = speed
return True
return False
return self.controller.set_fan_speed(speed, fan_id)
def set_auto_mode(self, enabled: bool):
"""Enable or disable automatic control."""
@ -472,63 +731,60 @@ class FanControlService:
elif not enabled and self.controller:
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
# Global service instance
_service: Optional[FanControlService] = None
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
def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService:
"""Get or create the global service instance."""
global _service
if _service is None:
_service = FanControlService(config_path)
return _service
# Global service instances
_service_instances: Dict[str, IPMIControllerService] = {}
def get_service(config_path: str = "/etc/ipmi-controller/config.json") -> IPMIControllerService:
"""Get or create the service instance."""
if config_path not in _service_instances:
_service_instances[config_path] = IPMIControllerService(config_path)
return _service_instances[config_path]
if __name__ == "__main__":
# Simple CLI test
# CLI test
import sys
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)
host = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
host, user, pwd = sys.argv[1:4]
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}...")
if controller.test_connection():
print("✓ Connected successfully")
print("\nTemperatures:")
for temp in controller.get_temperatures():
print(f" {temp.name}: {temp.value}°C ({temp.location})")
print("\nFan speeds:")
for fan in controller.get_fan_speeds():
print(f" Fan {fan.fan_number}: {fan.speed_rpm} RPM")
print("\nEnabling manual control...")
if controller.enable_manual_fan_control():
print("✓ Manual control enabled")
print("\nSetting fans to 30%...")
if controller.set_fan_speed(30):
print("✓ Speed set to 30%")
time.sleep(3)
print("\nSetting fans to 50%...")
if controller.set_fan_speed(50):
print("✓ Speed set to 50%")
time.sleep(3)
print("\nReturning to automatic control...")
controller.disable_manual_fan_control()
print("✓ Done")
print(f"Testing {host}...")
if ctrl.test_connection():
print("✓ Connected")
print("\nTemps:", [(t.name, t.value) for t in ctrl.get_temperatures()])
print("\nFans:", [(f.fan_number, f.speed_rpm) for f in ctrl.get_fan_speeds()])
else:
print("✗ Connection failed")
sys.exit(1)
print("✗ Failed")

View File

@ -1,147 +1,215 @@
#!/bin/bash
# Setup script for IPMI Fan Controller v2
# IPMI Controller - Install Script with Persistence
# This sets up auto-start and ensures settings persist
set -e
echo "🌬️ IPMI Fan Controller v2 - Setup"
echo "=================================="
INSTALL_DIR="${1:-/opt/ipmi-controller}"
DATA_DIR="$INSTALL_DIR/data"
SERVICE_NAME="ipmi-controller"
USER="${SUDO_USER:-$USER}"
echo "🌡️ IPMI Controller Installation"
echo "================================"
echo "Install dir: $INSTALL_DIR"
echo "Data dir: $DATA_DIR"
echo "Service user: $USER"
echo ""
# Check if running as root for system-wide install
if [ "$EUID" -eq 0 ]; then
INSTALL_SYSTEM=true
INSTALL_DIR="/opt/ipmi-fan-controller"
CONFIG_DIR="/etc/ipmi-fan-controller"
if [ "$EUID" -ne 0 ]; then
echo "⚠️ Not running as root. Installing to $HOME/ipmi-controller instead."
INSTALL_DIR="$HOME/ipmi-controller"
DATA_DIR="$INSTALL_DIR/data"
SYSTEM_INSTALL=false
else
INSTALL_SYSTEM=false
INSTALL_DIR="$HOME/.local/ipmi-fan-controller"
CONFIG_DIR="$HOME/.config/ipmi-fan-controller"
echo "⚠️ Running as user - installing to $INSTALL_DIR"
echo " (Run with sudo for system-wide install)"
echo ""
SYSTEM_INSTALL=true
fi
# Check dependencies
echo "📦 Checking dependencies..."
if ! command -v python3 &> /dev/null; then
echo "❌ Python 3 is required but not installed"
exit 1
fi
if ! command -v ipmitool &> /dev/null; then
echo "⚠️ ipmitool not found. Installing..."
if [ "$INSTALL_SYSTEM" = true ]; then
apt-get update && apt-get install -y ipmitool
else
echo "❌ Please install ipmitool: sudo apt-get install ipmitool"
exit 1
fi
fi
echo "✓ Python 3: $(python3 --version)"
echo "✓ ipmitool: $(ipmitool -V)"
# Create directories
echo ""
echo "📁 Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$CONFIG_DIR"
mkdir -p "$INSTALL_DIR/logs"
mkdir -p "$DATA_DIR"
# Copy files
echo ""
echo "📋 Installing files..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cp "$SCRIPT_DIR/fan_controller.py" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/web_server.py" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
echo "📋 Copying files..."
cp -r . "$INSTALL_DIR/" 2>/dev/null || true
# Install Python dependencies
echo ""
echo "🐍 Installing Python dependencies..."
python3 -m pip install -q -r "$INSTALL_DIR/requirements.txt"
# Create default config if not exists
if [ ! -f "$CONFIG_DIR/config.json" ]; then
echo ""
echo "⚙️ Creating default configuration..."
cat > "$CONFIG_DIR/config.json" << 'EOF'
# Ensure data directory exists with proper files
if [ ! -f "$DATA_DIR/config.json" ]; then
echo "⚙️ Creating default config..."
cat > "$DATA_DIR/config.json" << 'EOF'
{
"host": "",
"username": "root",
"password": "",
"port": 623,
"ipmi_host": "",
"ipmi_username": "",
"ipmi_password": "",
"ipmi_port": 623,
"http_sensor_enabled": false,
"http_sensor_url": "",
"http_sensor_timeout": 10,
"enabled": false,
"interval": 10,
"poll_interval": 10,
"min_speed": 10,
"max_speed": 100,
"fan_curve": [
{"temp": 30, "speed": 15},
{"temp": 40, "speed": 25},
{"temp": 50, "speed": 40},
{"temp": 60, "speed": 60},
{"temp": 70, "speed": 80},
{"temp": 80, "speed": 100}
],
"panic_temp": 85,
"panic_speed": 100
"panic_speed": 100,
"panic_on_no_data": true,
"no_data_timeout": 60,
"primary_sensor": "cpu",
"sensor_preference": "auto",
"fans": {},
"fan_groups": {},
"fan_curves": {
"Balanced": {
"points": [
{"temp": 30, "speed": 10},
{"temp": 35, "speed": 12},
{"temp": 40, "speed": 15},
{"temp": 45, "speed": 20},
{"temp": 50, "speed": 30},
{"temp": 55, "speed": 40},
{"temp": 60, "speed": 55},
{"temp": 65, "speed": 70},
{"temp": 70, "speed": 85},
{"temp": 75, "speed": 95},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Silent": {
"points": [
{"temp": 30, "speed": 5},
{"temp": 40, "speed": 10},
{"temp": 50, "speed": 15},
{"temp": 55, "speed": 25},
{"temp": 60, "speed": 35},
{"temp": 65, "speed": 50},
{"temp": 70, "speed": 70},
{"temp": 75, "speed": 85},
{"temp": 80, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
},
"Performance": {
"points": [
{"temp": 30, "speed": 20},
{"temp": 35, "speed": 25},
{"temp": 40, "speed": 35},
{"temp": 45, "speed": 45},
{"temp": 50, "speed": 55},
{"temp": 55, "speed": 70},
{"temp": 60, "speed": 85},
{"temp": 65, "speed": 95},
{"temp": 70, "speed": 100}
],
"sensor_source": "cpu",
"applies_to": "all"
}
},
"active_curve": "Balanced",
"theme": "dark"
}
EOF
fi
if [ ! -f "$DATA_DIR/users.json" ]; then
echo "👤 Creating users file..."
echo '{"users": {}}' > "$DATA_DIR/users.json"
fi
# Install Python dependencies
echo "🐍 Installing dependencies..."
if [ "$SYSTEM_INSTALL" = true ]; then
pip3 install -q -r "$INSTALL_DIR/requirements.txt" || pip install -q -r "$INSTALL_DIR/requirements.txt"
else
pip3 install --user -q -r "$INSTALL_DIR/requirements.txt" 2>/dev/null || true
fi
# Install ipmitool if not present
if ! command -v ipmitool &> /dev/null; then
echo "📦 Installing ipmitool..."
if [ "$SYSTEM_INSTALL" = true ]; then
apt-get update -qq && apt-get install -y -qq ipmitool
else
echo "⚠️ Please install ipmitool manually: sudo apt-get install ipmitool"
fi
else
echo "✓ ipmitool already installed"
fi
# Create systemd service (system-wide only)
if [ "$INSTALL_SYSTEM" = true ]; then
echo ""
echo "🔧 Creating systemd service..."
cat > /etc/systemd/system/ipmi-fan-controller.service << EOF
if [ "$SYSTEM_INSTALL" = true ]; then
echo "🔧 Creating systemd service..."
cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
[Unit]
Description=IPMI Fan Controller v2
Description=IPMI Controller
After=network.target
[Service]
Type=simple
User=root
User=$USER
WorkingDirectory=$INSTALL_DIR
Environment="CONFIG_PATH=$CONFIG_DIR/config.json"
Environment="PYTHONUNBUFFERED=1"
Environment="DATA_DIR=$DATA_DIR"
ExecStart=/usr/bin/python3 $INSTALL_DIR/web_server.py
ExecStop=/usr/bin/python3 -c "import requests; requests.post('http://localhost:8000/api/shutdown')"
Restart=on-failure
ExecStop=/bin/kill -TERM \$MAINPID
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable ipmi-fan-controller.service
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
echo ""
echo "✅ Installation complete!"
echo ""
echo "Next steps:"
echo " 1. Edit config: sudo nano $CONFIG_DIR/config.json"
echo " 2. Start service: sudo systemctl start ipmi-fan-controller"
echo " 3. View status: sudo systemctl status ipmi-fan-controller"
echo " 4. Open web UI: http://$(hostname -I | awk '{print $1}'):8000"
echo ""
echo "Or test from CLI:"
echo " python3 $INSTALL_DIR/fan_controller.py <host> <user> <pass>"
# Set proper ownership
chown -R "$USER:$USER" "$INSTALL_DIR"
echo ""
echo "✅ Installation complete!"
echo ""
echo "Start the service:"
echo " sudo systemctl start $SERVICE_NAME"
echo ""
echo "Check status:"
echo " sudo systemctl status $SERVICE_NAME"
echo ""
echo "View logs:"
echo " sudo journalctl -u $SERVICE_NAME -f"
echo ""
echo "Access: http://$(hostname -I | awk '{print $1}'):8000"
else
# User install - create a simple start script
cat > "$INSTALL_DIR/start.sh" << 'EOF'
#!/bin/bash
cd "$(dirname "$0")"
export DATA_DIR="./data"
export PYTHONUNBUFFERED=1
echo "Starting IPMI Controller..."
echo "Data directory: $DATA_DIR"
python3 web_server.py
EOF
chmod +x "$INSTALL_DIR/start.sh"
echo ""
echo "✅ User installation complete!"
echo ""
echo "Next steps:"
echo " 1. Edit config: nano $CONFIG_DIR/config.json"
echo " 2. Start manually:"
echo " CONFIG_PATH=$CONFIG_DIR/config.json python3 $INSTALL_DIR/web_server.py"
echo " 3. Open web UI: http://localhost:8000"
echo ""
echo "Or test from CLI:"
echo " python3 $INSTALL_DIR/fan_controller.py <host> <user> <pass>"
echo ""
echo "✅ User installation complete!"
echo ""
echo "Start manually:"
echo " cd $INSTALL_DIR && ./start.sh"
echo ""
echo "Or create a systemd service manually:"
echo " nano ~/.config/systemd/user/ipmi-controller.service"
echo ""
echo "Access: http://localhost:8000"
fi
echo ""
echo "📖 Configuration file: $CONFIG_DIR/config.json"
echo "📁 Your settings are stored in: $DATA_DIR"
echo " - config.json: All configuration"
echo " - users.json: User accounts"
echo ""
echo "💾 These files persist across restarts and updates!"

View File

@ -2,3 +2,5 @@ fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0
python-multipart==0.0.6
paramiko==3.4.0

30
reset_password.py Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Reset password for fan controller"""
import json
import hashlib
import sys
USERS_FILE = "/home/devmatrix/projects/fan-controller-v2/data/users.json"
def hash_password(password):
return hashlib.sha256(password.encode()).hexdigest()
if len(sys.argv) != 3:
print("Usage: reset_password.py <username> <new_password>")
sys.exit(1)
username = sys.argv[1]
password = sys.argv[2]
try:
with open(USERS_FILE) as f:
data = json.load(f)
data["users"][username] = hash_password(password)
with open(USERS_FILE, 'w') as f:
json.dump(data, f)
print(f"Password reset for user: {username}")
except Exception as e:
print(f"Error: {e}")

9
server.log Normal file
View File

@ -0,0 +1,9 @@
INFO: Started server process [33302]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:44708 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.5.30:64440 - "GET /login HTTP/1.1" 200 OK
INFO: 192.168.5.30:56562 - "GET /favicon.ico HTTP/1.1" 200 OK
INFO: 192.168.5.30:49401 - "POST /api/setup/test-ipmi HTTP/1.1" 200 OK
INFO: 192.168.5.30:49401 - "GET /favicon.ico HTTP/1.1" 200 OK

117
setup-sensors-server.sh Executable file
View File

@ -0,0 +1,117 @@
#!/bin/bash
# IPMI Controller - lm-sensors HTTP Server Setup
# Run this on your Proxmox/Dell server to expose sensors over HTTP
set -e
echo "🌡️ IPMI Controller - lm-sensors HTTP Setup"
echo "============================================"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "❌ Please run as root (sudo)"
exit 1
fi
# Install lm-sensors if not present
echo ""
echo "📦 Checking lm-sensors..."
if ! command -v sensors &> /dev/null; then
echo "Installing lm-sensors..."
apt-get update
apt-get install -y lm-sensors
echo ""
echo "🔧 Running sensors-detect..."
echo "Answer YES to all questions or use default values"
sensors-detect --auto
else
echo "✓ lm-sensors already installed"
fi
# Install netcat if not present
if ! command -v nc &> /dev/null; then
echo "Installing netcat..."
apt-get install -y netcat-openbsd
fi
# Create the HTTP sensors server script
SERVER_SCRIPT="/usr/local/bin/sensors-http-server.sh"
echo ""
echo "📝 Creating HTTP server script..."
cat > "$SERVER_SCRIPT" << 'EOF'
#!/bin/bash
# lm-sensors HTTP Server for IPMI Controller
# Serves sensor data on port 8888
PORT=${1:-8888}
echo "Starting lm-sensors HTTP server on port $PORT..."
echo "Access via: http://$(hostname -I | awk '{print $1}'):$PORT"
while true; do
{
echo -e "HTTP/1.1 200 OK\r"
echo -e "Content-Type: text/plain\r"
echo -e "Access-Control-Allow-Origin: *\r"
echo -e "\r"
sensors -u 2>/dev/null || echo "Error reading sensors"
} | nc -l -p "$PORT" -q 1
done
EOF
chmod +x "$SERVER_SCRIPT"
echo "✓ Created $SERVER_SCRIPT"
# Create systemd service
SERVICE_FILE="/etc/systemd/system/sensors-http.service"
echo ""
echo "🔧 Creating systemd service..."
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=lm-sensors HTTP Server for IPMI Controller
After=network.target
[Service]
Type=simple
ExecStart=$SERVER_SCRIPT 8888
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Created $SERVICE_FILE"
# Reload systemd and enable service
echo ""
echo "🚀 Enabling and starting service..."
systemctl daemon-reload
systemctl enable sensors-http.service
systemctl start sensors-http.service
# Check status
sleep 2
if systemctl is-active --quiet sensors-http.service; then
echo "✓ Service is running!"
echo ""
echo "🌐 HTTP Endpoint: http://$(hostname -I | awk '{print $1}'):8888"
echo ""
echo "Test with: curl http://$(hostname -I | awk '{print $1}'):8888"
else
echo "⚠️ Service failed to start. Check logs:"
echo " journalctl -u sensors-http.service -f"
fi
echo ""
echo "📋 Management Commands:"
echo " Start: sudo systemctl start sensors-http"
echo " Stop: sudo systemctl stop sensors-http"
echo " Status: sudo systemctl status sensors-http"
echo " Logs: sudo journalctl -u sensors-http -f"
echo ""
echo "✅ Setup complete!"

File diff suppressed because it is too large Load Diff