Compare commits

..

No commits in common. "a100327769eb71c8a98156ef6b5bab266eae896a" and "5b9ec7b3515f900ac304a3040aa496026b319922" have entirely different histories.

16 changed files with 886 additions and 3009 deletions

View File

@ -1,99 +0,0 @@
# 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,156 +1,138 @@
# IPMI Controller
# IPMI Fan Controller v2
Advanced IPMI fan control for Dell servers with web interface, HTTP sensor support, multiple fan curves, and persistent configuration.
A simpler, more robust fan controller for Dell T710 and compatible servers using IPMI.
## Features
## What's Different from v1?
- 🌡️ **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
- **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
### Automated Install (Recommended)
### 1. Install
```bash
git clone https://github.com/yourusername/ipmi-controller.git
cd ipmi-controller
cd ~/projects/fan-controller-v2
chmod +x install.sh
sudo ./install.sh
```
This will:
- Install all dependencies
- Create systemd service for auto-start
- Set up persistent data directory
- Start the controller on boot
- Install Python dependencies
- Create systemd service
- Set up config in `/etc/ipmi-fan-controller/`
### Manual Install
### 2. Configure
Edit the configuration file:
```bash
# Install dependencies
sudo apt-get install -y ipmitool python3-pip
pip3 install -r requirements.txt
# Run
python3 web_server.py
sudo nano /etc/ipmi-fan-controller/config.json
```
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
Set your IPMI credentials:
```json
{
"host": "192.168.1.100",
"username": "root",
"password": "your-password",
"port": 623
}
```
**Auto-backup via cron:**
```bash
# Add to crontab (keeps 30 days of backups)
0 2 * * * /opt/ipmi-controller/backup.sh auto
```
## Updating
### 3. Start
```bash
cd ipmi-controller
git pull
# Settings are preserved automatically
sudo systemctl restart ipmi-controller
sudo systemctl start ipmi-fan-controller
```
## Fan Curves
Open the web UI at `http://your-server:8000`
**Balanced** (Default) - Best for most users:
```
30°C → 10% | 40°C → 15% | 50°C → 30% | 60°C → 55% | 70°C → 85% | 80°C → 100%
```
## CLI Testing
**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
Test the IPMI connection without the web server:
```bash
docker-compose up -d
python3 fan_controller.py 192.168.1.100 root password
```
Data persists in `./data` directory.
This will:
1. Test the connection
2. Show temperatures and fan speeds
3. Try manual fan control (30% → 50% → auto)
## Management Commands
## Features
```bash
# Status
sudo systemctl status ipmi-controller
### 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
# Logs
sudo journalctl -u ipmi-controller -f
### Manual Control
- Set any fan speed from 0-100%
- Override automatic control temporarily
# Restart
sudo systemctl restart ipmi-controller
### Safety Features
- Returns to automatic control on shutdown
- Reconnects automatically if IPMI connection drops
- Panic temperature protection
# Stop
sudo systemctl stop 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}
]
}
```
## Troubleshooting
**IPMI Connection Failed:**
- Verify IPMI IP, username, password
- Test: `ipmitool -I lanplus -H <ip> -U <user> -P <pass> mc info`
### 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
**No Temperature Data:**
- Check HTTP sensor: `curl http://proxmox-ip:8888`
- Verify `sensor_preference` is set to "auto" or "http"
### 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
**Settings Lost After Update:**
- Ensure `data/` directory is not deleted
- Check file permissions: `ls -la data/`
### Service Won't Start
```bash
# Check logs
sudo journalctl -u ipmi-fan-controller -f
# Check config is valid JSON
sudo python3 -c "import json; json.load(open('/etc/ipmi-fan-controller/config.json'))"
```
## Files
- `fan_controller.py` - Core IPMI control logic
- `web_server.py` - FastAPI web interface
- `install.sh` - Installation script
- `requirements.txt` - Python dependencies
## License
MIT License
MIT License - Feel free to modify and distribute.

183
SETUP.md
View File

@ -1,183 +0,0 @@
# 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
```

141
backup.sh
View File

@ -1,141 +0,0 @@
#!/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

View File

@ -1,190 +0,0 @@
{
"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"
}

View File

@ -1,242 +0,0 @@
#!/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

View File

@ -1,20 +0,0 @@
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 Controller - Advanced Fan Control for Dell Servers
Features: Fan groups, multiple curves, HTTP sensors, panic mode
IPMI Fan Controller v2 - Simpler, More Robust
For Dell T710 and compatible servers
"""
import subprocess
import re
@ -8,7 +8,6 @@ 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
@ -20,19 +19,24 @@ logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('/tmp/ipmi-controller.log')
logging.FileHandler('/tmp/ipmi-fan-controller.log')
]
)
logger = logging.getLogger(__name__)
@dataclass
class FanCurvePoint:
temp: float
speed: int
@dataclass
class TemperatureReading:
name: str
location: str
value: float
status: str
source: str = "ipmi" # ipmi, http, ssh
@dataclass
@ -41,116 +45,20 @@ 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:
"""IPMI fan controller with advanced features."""
"""Simplified IPMI fan controller with robust error handling."""
# Default fan curve (temp C -> speed %)
DEFAULT_CURVE = [
FanCurvePoint(30, 15),
FanCurvePoint(40, 25),
FanCurvePoint(50, 40),
FanCurvePoint(60, 60),
FanCurvePoint(70, 80),
FanCurvePoint(80, 100),
]
def __init__(self, host: str, username: str, password: str, port: int = 623):
self.host = host
@ -196,12 +104,13 @@ class IPMIFanController:
return False, str(e)
def test_connection(self) -> bool:
"""Test IPMI connection."""
"""Test if we can connect to the server."""
success, _ = self._run_ipmi(["mc", "info"], timeout=10)
return success
def enable_manual_fan_control(self) -> bool:
"""Enable manual fan control mode."""
# Dell: raw 0x30 0x30 0x01 0x00
success, _ = self._run_ipmi(["raw", "0x30", "0x30", "0x01", "0x00"])
if success:
self.manual_mode = True
@ -210,6 +119,7 @@ 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
@ -218,14 +128,18 @@ 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."""
speed_percent = max(0, min(100, speed_percent))
if speed_percent < 0:
speed_percent = 0
if speed_percent > 100:
speed_percent = 100
hex_speed = f"0x{speed_percent:02x}"
success, _ = self._run_ipmi([
"raw", "0x30", "0x30", "0x02", fan_id, hex_speed
])
if success:
logger.info(f"Fan {fan_id} speed set to {speed_percent}%")
logger.info(f"Fan speed set to {speed_percent}%")
return success
def get_temperatures(self) -> List[TemperatureReading]:
@ -236,6 +150,7 @@ 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]
@ -250,8 +165,7 @@ class IPMIFanController:
name=name,
location=location,
value=value,
status=status,
source="ipmi"
status=status
))
return temps
@ -269,10 +183,12 @@ 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
@ -301,144 +217,100 @@ 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 IPMIControllerService:
"""Main service for IPMI Controller with all advanced features."""
class FanControlService:
"""Background service for automatic fan control."""
def __init__(self, config_path: str = "/etc/ipmi-controller/config.json"):
def __init__(self, config_path: str = "/etc/ipmi-fan-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_speeds: Dict[str, int] = {} # fan_id -> speed
self.target_speeds: Dict[str, int] = {}
self.current_speed = 0
self.target_speed = 0
self.last_temps: List[TemperatureReading] = []
self.last_fans: List[FanReading] = []
self.lock = threading.Lock()
self.in_identify_mode = False
# Default config
self.config = {
# 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
"host": "",
"username": "",
"password": "",
"port": 623,
"enabled": False,
"poll_interval": 10,
"fan_update_interval": 10,
"interval": 10, # seconds
"min_speed": 10,
"max_speed": 100,
"fan_curve": [
{"temp": 30, "speed": 15},
{"temp": 40, "speed": 25},
{"temp": 50, "speed": 40},
{"temp": 60, "speed": 60},
{"temp": 70, "speed": 80},
{"temp": 80, "speed": 100},
],
"panic_temp": 85,
"panic_speed": 100,
"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
"panic_speed": 100
}
self._load_config()
self._last_data_time = datetime.utcnow()
def _load_config(self):
"""Load configuration from file."""
try:
config_file = Path(self.config_path)
if config_file.exists():
with open(config_file) as f:
if Path(self.config_path).exists():
with open(self.config_path, 'r') as f:
loaded = json.load(f)
self._deep_update(self.config, loaded)
self.config.update(loaded)
logger.info(f"Loaded config from {self.config_path}")
except Exception as e:
logger.error(f"Failed to load config: {e}")
def _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:
config_file = Path(self.config_path)
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w') as f:
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w') as f:
json.dump(self.config, f, indent=2)
logger.info(f"Saved config to {self.config_path}")
except Exception as e:
@ -446,280 +318,149 @@ class IPMIControllerService:
def update_config(self, **kwargs):
"""Update configuration values."""
self._deep_update(self.config, kwargs)
self.config.update(kwargs)
self._save_config()
# Reinitialize if needed
if any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']):
# Reinitialize controller if connection params changed
if any(k in kwargs for k in ['host', 'username', 'password', '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) -> bool:
def _init_controller(self):
"""Initialize the IPMI controller."""
if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]):
if not all([self.config.get('host'), self.config.get('username'), self.config.get('password')]):
logger.warning("Missing IPMI credentials")
return False
self.controller = IPMIFanController(
host=self.config['ipmi_host'],
username=self.config['ipmi_username'],
password=self.config.get('ipmi_password', ''),
port=self.config.get('ipmi_port', 623)
host=self.config['host'],
username=self.config['username'],
password=self.config['password'],
port=self.config.get('port', 623)
)
if self.controller.test_connection():
logger.info(f"Connected to IPMI at {self.config['ipmi_host']}")
logger.info(f"Connected to IPMI at {self.config['host']}")
return True
else:
logger.error(f"Failed to connect to IPMI")
logger.error(f"Failed to connect to IPMI at {self.config['host']}")
self.controller = None
return False
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."""
def start(self):
"""Start the fan control service."""
if self.running:
return True
return
if not self._init_controller():
logger.error("Cannot start - IPMI connection failed")
logger.error("Cannot start service - 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("IPMI Controller service started")
logger.info("Fan control service started")
return True
def stop(self):
"""Stop the controller service."""
"""Stop the fan control service."""
self.running = False
if self.thread:
self.thread.join(timeout=5)
# Return to automatic control
if self.controller:
self.controller.disable_manual_fan_control()
logger.info("IPMI Controller service stopped")
logger.info("Fan control service stopped")
def _control_loop(self):
"""Main control loop."""
"""Main control loop running in background thread."""
# Enable manual control on startup
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("IPMI unhealthy, reconnecting...")
logger.warning("Controller unhealthy, attempting reconnect...")
if not self._init_controller():
time.sleep(30)
continue
self.controller.enable_manual_fan_control()
# 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 []
# Get sensor data
temps = self.controller.get_temperatures()
fans = self.controller.get_fan_speeds()
with self.lock:
self.last_temps = temps
self.last_fans = fans
if temps:
self._last_data_time = datetime.utcnow()
if not temps:
logger.warning("No temperature readings received")
time.sleep(self.config.get('interval', 10))
continue
# Apply fan curves
if not self.in_identify_mode:
self._apply_fan_curves(temps)
# 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)
poll_counter += 1
time.sleep(1)
# Apply limits
self.target_speed = max(self.config.get('min_speed', 10),
min(self.config.get('max_speed', 100), self.target_speed))
# Apply fan speed if changed significantly (>= 5%)
if abs(self.target_speed - self.current_speed) >= 5:
if self.controller.set_fan_speed(self.target_speed):
self.current_speed = self.target_speed
logger.info(f"Fan speed adjusted to {self.target_speed}% (CPU temp: {max_temp:.1f}°C)")
time.sleep(self.config.get('interval', 10))
except Exception as e:
logger.error(f"Control loop error: {e}")
time.sleep(10)
def _get_temperatures(self) -> List[TemperatureReading]:
"""Get temperatures from all sources."""
temps = []
preference = self.config.get('sensor_preference', 'ipmi')
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
}
}
# 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:
def set_manual_speed(self, speed: int) -> bool:
"""Set manual fan speed."""
if not self.controller:
return False
self.config['enabled'] = False
self._save_config()
speed = max(0, min(100, speed))
return self.controller.set_fan_speed(speed, fan_id)
if self.controller.set_fan_speed(speed):
self.current_speed = speed
return True
return False
def set_auto_mode(self, enabled: bool):
"""Enable or disable automatic control."""
@ -731,60 +472,63 @@ class IPMIControllerService:
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
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 instance
_service: Optional[FanControlService] = None
# 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]
def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService:
"""Get or create the global service instance."""
global _service
if _service is None:
_service = FanControlService(config_path)
return _service
if __name__ == "__main__":
# CLI test
# Simple CLI test
import sys
if len(sys.argv) < 4:
print("Usage: fan_controller.py <host> <username> <password>")
print("Usage: python fan_controller.py <host> <username> <password> [port]")
sys.exit(1)
host, user, pwd = sys.argv[1:4]
host = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
port = int(sys.argv[4]) if len(sys.argv) > 4 else 623
ctrl = IPMIFanController(host, user, pwd, port)
controller = IPMIFanController(host, username, password, port)
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()])
print(f"Testing connection to {host}...")
if controller.test_connection():
print("✓ Connected successfully")
print("\nTemperatures:")
for temp in controller.get_temperatures():
print(f" {temp.name}: {temp.value}°C ({temp.location})")
print("\nFan speeds:")
for fan in controller.get_fan_speeds():
print(f" Fan {fan.fan_number}: {fan.speed_rpm} RPM")
print("\nEnabling manual control...")
if controller.enable_manual_fan_control():
print("✓ Manual control enabled")
print("\nSetting fans to 30%...")
if controller.set_fan_speed(30):
print("✓ Speed set to 30%")
time.sleep(3)
print("\nSetting fans to 50%...")
if controller.set_fan_speed(50):
print("✓ Speed set to 50%")
time.sleep(3)
print("\nReturning to automatic control...")
controller.disable_manual_fan_control()
print("✓ Done")
else:
print("✗ Failed")
print("✗ Connection failed")
sys.exit(1)

View File

@ -1,161 +1,111 @@
#!/bin/bash
# IPMI Controller - Install Script with Persistence
# This sets up auto-start and ensures settings persist
# Setup script for IPMI Fan Controller v2
set -e
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 ""
echo "🌬️ IPMI Fan Controller v2 - Setup"
echo "=================================="
# Check if running as root for system-wide install
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
if [ "$EUID" -eq 0 ]; then
INSTALL_SYSTEM=true
INSTALL_DIR="/opt/ipmi-fan-controller"
CONFIG_DIR="/etc/ipmi-fan-controller"
else
SYSTEM_INSTALL=true
INSTALL_SYSTEM=false
INSTALL_DIR="$HOME/.local/ipmi-fan-controller"
CONFIG_DIR="$HOME/.config/ipmi-fan-controller"
echo "⚠️ Running as user - installing to $INSTALL_DIR"
echo " (Run with sudo for system-wide install)"
echo ""
fi
# Check dependencies
echo "📦 Checking dependencies..."
if ! command -v python3 &> /dev/null; then
echo "❌ Python 3 is required but not installed"
exit 1
fi
if ! command -v ipmitool &> /dev/null; then
echo "⚠️ ipmitool not found. Installing..."
if [ "$INSTALL_SYSTEM" = true ]; then
apt-get update && apt-get install -y ipmitool
else
echo "❌ Please install ipmitool: sudo apt-get install ipmitool"
exit 1
fi
fi
echo "✓ Python 3: $(python3 --version)"
echo "✓ ipmitool: $(ipmitool -V)"
# Create directories
echo ""
echo "📁 Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$DATA_DIR"
mkdir -p "$CONFIG_DIR"
mkdir -p "$INSTALL_DIR/logs"
# Copy files
echo "📋 Copying files..."
cp -r . "$INSTALL_DIR/" 2>/dev/null || true
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/"
# Ensure data directory exists with proper files
if [ ! -f "$DATA_DIR/config.json" ]; then
echo "⚙️ Creating default config..."
cat > "$DATA_DIR/config.json" << 'EOF'
# 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'
{
"ipmi_host": "",
"ipmi_username": "",
"ipmi_password": "",
"ipmi_port": 623,
"http_sensor_enabled": false,
"http_sensor_url": "",
"http_sensor_timeout": 10,
"host": "",
"username": "root",
"password": "",
"port": 623,
"enabled": false,
"poll_interval": 10,
"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_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"
"panic_speed": 100
}
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 [ "$SYSTEM_INSTALL" = true ]; then
if [ "$INSTALL_SYSTEM" = true ]; then
echo ""
echo "🔧 Creating systemd service..."
cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
cat > /etc/systemd/system/ipmi-fan-controller.service << EOF
[Unit]
Description=IPMI Controller
Description=IPMI Fan Controller v2
After=network.target
[Service]
Type=simple
User=$USER
User=root
WorkingDirectory=$INSTALL_DIR
Environment="PYTHONUNBUFFERED=1"
Environment="DATA_DIR=$DATA_DIR"
Environment="CONFIG_PATH=$CONFIG_DIR/config.json"
ExecStart=/usr/bin/python3 $INSTALL_DIR/web_server.py
ExecStop=/bin/kill -TERM \$MAINPID
Restart=always
ExecStop=/usr/bin/python3 -c "import requests; requests.post('http://localhost:8000/api/shutdown')"
Restart=on-failure
RestartSec=10
[Install]
@ -163,53 +113,35 @@ WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
# Set proper ownership
chown -R "$USER:$USER" "$INSTALL_DIR"
systemctl enable ipmi-fan-controller.service
echo ""
echo "✅ Installation complete!"
echo ""
echo "Start the service:"
echo " sudo systemctl start $SERVICE_NAME"
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 "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"
echo "Or test from CLI:"
echo " python3 $INSTALL_DIR/fan_controller.py <host> <user> <pass>"
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 "Start manually:"
echo " cd $INSTALL_DIR && ./start.sh"
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 create a systemd service manually:"
echo " nano ~/.config/systemd/user/ipmi-controller.service"
echo ""
echo "Access: http://localhost:8000"
echo "Or test from CLI:"
echo " python3 $INSTALL_DIR/fan_controller.py <host> <user> <pass>"
fi
echo ""
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!"
echo "📖 Configuration file: $CONFIG_DIR/config.json"

View File

@ -2,5 +2,3 @@ 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

View File

@ -1,30 +0,0 @@
#!/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}")

View File

@ -1,9 +0,0 @@
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

View File

@ -1,117 +0,0 @@
#!/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