diff --git a/README.md b/README.md index dfaae10..0d6a7e3 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,50 @@ -# IPMI Fan Controller v2 +# IPMI Controller -A simpler, more robust fan controller for Dell T710 and compatible servers using IPMI. - -## What's Different from v1? - -- **Direct host execution** - No Docker networking complications -- **Better error recovery** - Automatically reconnects on IPMI failures -- **Simpler codebase** - Easier to debug and modify -- **Working web UI** - Clean, responsive dashboard -- **CLI testing mode** - Test without starting the web server - -## Quick Start - -### 1. Install - -```bash -cd ~/projects/fan-controller-v2 -chmod +x install.sh -sudo ./install.sh -``` - -This will: -- Install Python dependencies -- Create systemd service -- Set up config in `/etc/ipmi-fan-controller/` - -### 2. Configure - -Edit the configuration file: - -```bash -sudo nano /etc/ipmi-fan-controller/config.json -``` - -Set your IPMI credentials: -```json -{ - "host": "192.168.1.100", - "username": "root", - "password": "your-password", - "port": 623 -} -``` - -### 3. Start - -```bash -sudo systemctl start ipmi-fan-controller -``` - -Open the web UI at `http://your-server:8000` - -## CLI Testing - -Test the IPMI connection without the web server: - -```bash -python3 fan_controller.py 192.168.1.100 root password -``` - -This will: -1. Test the connection -2. Show temperatures and fan speeds -3. Try manual fan control (30% → 50% → auto) +Advanced IPMI fan control for Dell servers with web interface, HTTP sensor support, fan groups, and multiple fan curves. ## Features -### Automatic Control -- Adjusts fan speed based on CPU temperature -- Configurable fan curve (temp → speed mapping) -- Panic mode: sets fans to 100% if temp exceeds threshold +- 🌡️ **Temperature Monitoring** - IPMI and HTTP (lm-sensors) sensor support +- 🌬️ **Fan Control** - Automatic curves, manual control, panic mode +- 👥 **Fan Groups** - Group fans with different curves +- 🔍 **Fan Identify** - Visual fan identification +- 🎨 **Dark/Light Mode** - Theme switching +- 📊 **Public API** - For external integrations +- 🔒 **Secure** - Password protected with JWT auth -### Manual Control -- Set any fan speed from 0-100% -- Override automatic control temporarily +## Quick Start -### Safety Features -- Returns to automatic control on shutdown -- Reconnects automatically if IPMI connection drops -- Panic temperature protection +### Requirements +- Python 3.10+ +- ipmitool +- Linux server (tested on Dell T710) -## Configuration Options - -```json -{ - "host": "192.168.1.100", // IPMI IP address - "username": "root", // IPMI username - "password": "secret", // IPMI password - "port": 623, // IPMI port (default: 623) - "enabled": false, // Start automatic control on boot - "interval": 10, // Check interval in seconds - "min_speed": 10, // Minimum fan speed (%) - "max_speed": 100, // Maximum fan speed (%) - "panic_temp": 85, // Panic mode trigger (°C) - "panic_speed": 100, // Panic mode fan speed (%) - "fan_curve": [ // Temp (°C) → Speed (%) mapping - {"temp": 30, "speed": 15}, - {"temp": 40, "speed": 25}, - {"temp": 50, "speed": 40}, - {"temp": 60, "speed": 60}, - {"temp": 70, "speed": 80}, - {"temp": 80, "speed": 100} - ] -} -``` - -## Troubleshooting - -### Connection Failed -1. Verify IPMI is enabled in BIOS/iDRAC -2. Test manually: `ipmitool -I lanplus -H -U -P mc info` -3. Check firewall allows port 623 - -### Fans Not Responding -1. Some Dell servers need 3rd party PCIe response disabled -2. Try enabling manual mode first via web UI -3. Check IPMI user has admin privileges - -### Service Won't Start +### Install ```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'))" +git clone https://github.com/yourusername/ipmi-controller.git +cd ipmi-controller +pip install -r requirements.txt ``` -## Files +### Run +```bash +python3 web_server.py +``` -- `fan_controller.py` - Core IPMI control logic -- `web_server.py` - FastAPI web interface -- `install.sh` - Installation script -- `requirements.txt` - Python dependencies +Open `http://your-server:8000` and complete the setup wizard. + +## Docker + +```bash +docker build -t ipmi-controller . +docker run -d -p 8000:8000 -v ./data:/app/data ipmi-controller +``` + +## Documentation + +- [Setup Guide](SETUP.md) - Full installation and configuration +- [API Reference](API.md) - Public API documentation ## License -MIT License - Feel free to modify and distribute. +MIT License diff --git a/__pycache__/fan_controller.cpython-312.pyc b/__pycache__/fan_controller.cpython-312.pyc index ae16cad..20555c4 100644 Binary files a/__pycache__/fan_controller.cpython-312.pyc and b/__pycache__/fan_controller.cpython-312.pyc differ diff --git a/data/config.json b/data/config.json index 59135c9..bc78da6 100644 --- a/data/config.json +++ b/data/config.json @@ -3,6 +3,55 @@ "ipmi_username": "root", "ipmi_password": "calvin", "ipmi_port": 623, + "http_sensor_enabled": false, + "http_sensor_url": "", + "http_sensor_timeout": 10, + "enabled": true, + "poll_interval": 10, + "fan_update_interval": 10, + "min_speed": 10, + "max_speed": 100, + "panic_temp": 85, + "panic_speed": 100, + "panic_on_no_data": true, + "no_data_timeout": 60, + "primary_sensor": "cpu", + "sensor_preference": "ipmi", + "fans": {}, + "fan_groups": {}, + "fan_curves": { + "Default": { + "points": [ + { + "temp": 30, + "speed": 15 + }, + { + "temp": 40, + "speed": 25 + }, + { + "temp": 50, + "speed": 40 + }, + { + "temp": 60, + "speed": 60 + }, + { + "temp": 70, + "speed": 80 + }, + { + "temp": 80, + "speed": 100 + } + ], + "sensor_source": "cpu", + "applies_to": "all" + } + }, + "theme": "dark", "ssh_enabled": false, "ssh_host": null, "ssh_username": null, @@ -10,10 +59,7 @@ "ssh_use_key": false, "ssh_key_file": null, "ssh_port": 22, - "enabled": true, "interval": 10, - "min_speed": 10, - "max_speed": 100, "fan_curve": [ { "temp": 30, @@ -39,7 +85,5 @@ "temp": 80, "speed": 100 } - ], - "panic_temp": 85, - "panic_speed": 100 + ] } \ No newline at end of file diff --git a/fan_controller.py b/fan_controller.py index 6849d00..d09728e 100644 --- a/fan_controller.py +++ b/fan_controller.py @@ -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,7 +8,7 @@ import time import json import logging import threading -import paramiko +import requests from dataclasses import dataclass, asdict from typing import List, Dict, Optional, Tuple from datetime import datetime @@ -20,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 @@ -46,20 +41,99 @@ 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 + if line.endswith(":") and not line.startswith(" "): + current_chip = line.rstrip(":") + continue + + # Temperature reading + if "_input:" in line and "temp" in line.lower(): + parts = line.split(":") + if len(parts) == 2: + name = parts[0].strip() + try: + value = float(parts[1].strip()) + location = self._classify_sensor_name(name, current_chip) + temps.append(TemperatureReading( + name=f"{current_chip}/{name}", + location=location, + value=value, + status="ok", + source="http" + )) + except ValueError: + pass + + return temps + + def _classify_sensor_name(self, name: str, chip: str) -> str: + """Classify sensor location from name.""" + name_lower = name.lower() + if "core" in name_lower: + if "0" in name or "1" in name: + return "cpu1" + elif "2" in name or "3" in name: + return "cpu2" + return "cpu" + elif "package" in name_lower: + return "cpu" + elif "tdie" in name_lower or "tctl" in name_lower: + return "cpu" + elif "pcie" in name_lower or "nvme" in name_lower or "gpu" in name_lower: + return "pcie" + return "other" + + def is_healthy(self) -> bool: + return self.consecutive_failures < 3 class IPMIFanController: - """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 @@ -111,7 +185,6 @@ class IPMIFanController: 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 @@ -120,7 +193,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 @@ -129,18 +201,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]: @@ -151,7 +219,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] @@ -166,7 +233,8 @@ class IPMIFanController: name=name, location=location, value=value, - status=status + status=status, + source="ipmi" )) return temps @@ -184,12 +252,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 @@ -218,188 +284,28 @@ 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 SSHSensorClient: - """SSH client for lm-sensors data collection.""" +class IPMIControllerService: + """Main service for IPMI Controller with all advanced features.""" - def __init__(self, host: str, username: str, password: Optional[str] = None, - key_file: Optional[str] = None, port: int = 22): - self.host = host - self.username = username - self.password = password - self.key_file = key_file - self.port = port - self.client: Optional[paramiko.SSHClient] = None - self.consecutive_failures = 0 - - def connect(self) -> bool: - """Connect to SSH server.""" - try: - self.client = paramiko.SSHClient() - self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - connect_kwargs = { - "hostname": self.host, - "port": self.port, - "username": self.username, - "timeout": 10 - } - - if self.key_file and Path(self.key_file).exists(): - connect_kwargs["key_filename"] = self.key_file - elif self.password: - connect_kwargs["password"] = self.password - else: - logger.error("No authentication method available for SSH") - return False - - self.client.connect(**connect_kwargs) - logger.info(f"SSH connected to {self.host}") - return True - - except Exception as e: - logger.error(f"SSH connection failed: {e}") - self.consecutive_failures += 1 - return False - - def disconnect(self): - """Close SSH connection.""" - if self.client: - self.client.close() - self.client = None - - def get_lm_sensors_data(self) -> List[TemperatureReading]: - """Get temperature data from lm-sensors.""" - if not self.client: - if not self.connect(): - return [] - - try: - stdin, stdout, stderr = self.client.exec_command("sensors -u", timeout=15) - output = stdout.read().decode() - error = stderr.read().decode() - - if error: - logger.warning(f"sensors command stderr: {error}") - - temps = self._parse_sensors_output(output) - self.consecutive_failures = 0 - return temps - - except Exception as e: - logger.error(f"Failed to get sensors data: {e}") - self.consecutive_failures += 1 - self.disconnect() # Force reconnect on next attempt - return [] - - def _parse_sensors_output(self, output: str) -> List[TemperatureReading]: - """Parse lm-sensors -u output.""" - temps = [] - current_chip = "" - - for line in output.splitlines(): - line = line.strip() - - # New chip section - if line.endswith(":") and not line.startswith(" "): - current_chip = line.rstrip(":") - continue - - # Temperature reading - if "_input:" in line and "temp" in line.lower(): - parts = line.split(":") - if len(parts) == 2: - name = parts[0].strip() - try: - value = float(parts[1].strip()) - location = self._classify_sensor_name(name, current_chip) - temps.append(TemperatureReading( - name=f"{current_chip}/{name}", - location=location, - value=value, - status="ok" - )) - except ValueError: - pass - - return temps - - def _classify_sensor_name(self, name: str, chip: str) -> str: - """Classify sensor location from name.""" - name_lower = name.lower() - chip_lower = chip.lower() - - if "core" in name_lower: - if "0" in name or "1" in name: - return "cpu1" - elif "2" in name or "3" in name: - return "cpu2" - return "cpu" - elif "package" in name_lower: - return "cpu" - elif "tdie" in name_lower or "tctl" in name_lower: - return "cpu" - return "other" - - def is_healthy(self) -> bool: - return self.consecutive_failures < 3 - - -class FanControlService: - """Background service for automatic fan control.""" - - def __init__(self, config_path: str = "/etc/ipmi-fan-controller/config.json"): + def __init__(self, config_path: str = "/etc/ipmi-controller/config.json"): self.config_path = config_path self.controller: Optional[IPMIFanController] = None - self.ssh_client: Optional[SSHSensorClient] = 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 with new structure + # Default config self.config = { # IPMI Settings "ipmi_host": "", @@ -407,33 +313,52 @@ class FanControlService: "ipmi_password": "", "ipmi_port": 623, - # SSH Settings - "ssh_enabled": False, - "ssh_host": None, - "ssh_username": None, - "ssh_password": None, - "ssh_use_key": False, - "ssh_key_file": None, - "ssh_port": 22, + # HTTP Sensor Settings + "http_sensor_enabled": False, + "http_sensor_url": "", + "http_sensor_timeout": 10, # Fan Control Settings "enabled": False, - "interval": 10, + "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": "ipmi", # ipmi, http, auto + + # Fan Configuration + "fans": {}, # fan_id -> {"name": "Custom Name", "group": "group1"} + "fan_groups": {}, # group_name -> {"fans": ["0x00", "0x01"], "curve": "Default"} + + # Fan Curves + "fan_curves": { + "Default": { + "points": [ + {"temp": 30, "speed": 15}, + {"temp": 40, "speed": 25}, + {"temp": 50, "speed": 40}, + {"temp": 60, "speed": 60}, + {"temp": 70, "speed": 80}, + {"temp": 80, "speed": 100}, + ], + "sensor_source": "cpu", + "applies_to": "all" + } + }, + + # UI Settings + "theme": "dark", # dark, light, auto } self._load_config() + self._last_data_time = datetime.utcnow() def _load_config(self): """Load configuration from file.""" @@ -442,11 +367,19 @@ class FanControlService: 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: @@ -460,22 +393,18 @@ class FanControlService: def update_config(self, **kwargs): """Update configuration values.""" - self.config.update(kwargs) + self._deep_update(self.config, kwargs) self._save_config() - # Reinitialize controllers if connection params changed - ipmi_changed = any(k in kwargs for k in ['ipmi_host', 'ipmi_username', 'ipmi_password', 'ipmi_port']) - ssh_changed = any(k in kwargs for k in ['ssh_host', 'ssh_username', 'ssh_password', 'ssh_key_file', 'ssh_port']) - - if ipmi_changed: - self._init_ipmi_controller() - if ssh_changed or (kwargs.get('ssh_enabled') and not self.ssh_client): - self._init_ssh_client() + # 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_ipmi_controller(self) -> bool: + def _init_controller(self) -> bool: """Initialize the IPMI controller.""" if not all([self.config.get('ipmi_host'), self.config.get('ipmi_username')]): - logger.warning("Missing IPMI credentials") return False self.controller = IPMIFanController( @@ -489,188 +418,254 @@ class FanControlService: 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['ipmi_host']}") + logger.error(f"Failed to connect to IPMI") self.controller = None return False - def _init_ssh_client(self) -> bool: - """Initialize SSH client for lm-sensors.""" - if not self.config.get('ssh_enabled'): + def _init_http_client(self) -> bool: + """Initialize HTTP sensor client.""" + if not self.config.get('http_sensor_enabled'): return False - host = self.config.get('ssh_host') or self.config.get('ipmi_host') - username = self.config.get('ssh_username') or self.config.get('ipmi_username') - - if not all([host, username]): - logger.warning("Missing SSH credentials") + url = self.config.get('http_sensor_url') + if not url: return False - self.ssh_client = SSHSensorClient( - host=host, - username=username, - password=self.config.get('ssh_password') or self.config.get('ipmi_password'), - key_file=self.config.get('ssh_key_file'), - port=self.config.get('ssh_port', 22) + 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 fan control service.""" + """Start the controller service.""" if self.running: return True - if not self._init_ipmi_controller(): - logger.error("Cannot start service - IPMI connection failed") + if not self._init_controller(): + logger.error("Cannot start - IPMI connection failed") return False - if self.config.get('ssh_enabled'): - self._init_ssh_client() + 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() - if self.ssh_client: - self.ssh_client.disconnect() - - 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 controllers are healthy + # Ensure controller is healthy if not self.controller or not self.controller.is_healthy(): - logger.warning("IPMI controller unhealthy, attempting reconnect...") - if not self._init_ipmi_controller(): + logger.warning("IPMI unhealthy, reconnecting...") + if not self._init_controller(): time.sleep(30) continue self.controller.enable_manual_fan_control() - # Get temperature data - temps = self._get_temperatures() - fans = self.controller.get_fan_speeds() if self.controller else [] + # 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 + + if temps: + self._last_data_time = datetime.utcnow() + + # Apply fan curves + if not self.in_identify_mode: + self._apply_fan_curves(temps) - with self.lock: - self.last_temps = temps - self.last_fans = fans - - if not temps: - logger.warning("No temperature readings received") - time.sleep(self.config.get('interval', 10)) - continue - - # Check for panic temperature - cpu_temps = [t for t in temps if t.location.startswith('cpu')] - max_temp = max((t.value for t in cpu_temps), default=0) - - if max_temp >= self.config.get('panic_temp', 85): - self.target_speed = self.config.get('panic_speed', 100) - logger.warning(f"PANIC MODE: CPU temp {max_temp}°C, setting fans to {self.target_speed}%") - else: - # Calculate target speed from curve - curve = [FanCurvePoint(p['temp'], p['speed']) for p in self.config.get('fan_curve', [])] - self.target_speed = self.controller.calculate_fan_speed(temps, curve) - - # Apply limits - self.target_speed = max(self.config.get('min_speed', 10), - min(self.config.get('max_speed', 100), self.target_speed)) - - # Apply fan speed if changed significantly (>= 5%) - if abs(self.target_speed - self.current_speed) >= 5: - if self.controller.set_fan_speed(self.target_speed): - self.current_speed = self.target_speed - logger.info(f"Fan speed adjusted to {self.target_speed}% (CPU temp: {max_temp:.1f}°C)") - - time.sleep(self.config.get('interval', 10)) + poll_counter += 1 + time.sleep(1) except Exception as e: logger.error(f"Control loop error: {e}") time.sleep(10) def _get_temperatures(self) -> List[TemperatureReading]: - """Get temperatures from IPMI and/or SSH lm-sensors.""" + """Get temperatures from all sources.""" temps = [] + preference = self.config.get('sensor_preference', 'ipmi') - # Try IPMI first - if self.controller: + # Try IPMI + if self.controller and preference in ['ipmi', 'auto']: temps = self.controller.get_temperatures() - # Try SSH lm-sensors if enabled and IPMI failed or has no data - if self.config.get('ssh_enabled') and self.ssh_client: - if not temps or self.config.get('prefer_ssh_temps', False): - ssh_temps = self.ssh_client.get_lm_sensors_data() - if ssh_temps: - temps = ssh_temps + # 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 get_status(self) -> Dict: - """Get current 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, - "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": { - # IPMI - "ipmi_host": self.config.get('ipmi_host'), - "ipmi_port": self.config.get('ipmi_port'), - "ipmi_username": self.config.get('ipmi_username'), - # SSH - "ssh_enabled": self.config.get('ssh_enabled'), - "ssh_host": self.config.get('ssh_host'), - "ssh_port": self.config.get('ssh_port'), - "ssh_username": self.config.get('ssh_username'), - "ssh_use_key": self.config.get('ssh_use_key'), - # Settings - "min_speed": self.config.get('min_speed'), - "max_speed": self.config.get('max_speed'), - "panic_temp": self.config.get('panic_temp'), - "interval": self.config.get('interval'), - "fan_curve": self.config.get('fan_curve') - } - } - return status + def _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', {}) + default_curve = curves.get('Default', {'points': [{'temp': 30, 'speed': 15}, {'temp': 80, 'speed': 100}]}) + + # Apply curves to fans + fans = self.config.get('fans', {}) + groups = self.config.get('fan_groups', {}) + + # Calculate target speeds per group/individual + fan_speeds = {} + + for fan_id, fan_info in fans.items(): + group = fan_info.get('group') + curve_name = fan_info.get('curve', 'Default') + + if group and group in groups: + curve_name = groups[group].get('curve', 'Default') + + curve = curves.get(curve_name, default_curve) + speed = self._calculate_curve_speed(max_temp, curve['points']) + + # Apply limits + speed = max(self.config.get('min_speed', 10), + min(self.config.get('max_speed', 100), speed)) + + fan_speeds[fan_id] = speed + + # If no individual fan configs, apply to all + if not fan_speeds: + speed = self._calculate_curve_speed(max_temp, default_curve['points']) + speed = max(self.config.get('min_speed', 10), + min(self.config.get('max_speed', 100), speed)) + self._set_all_fans(speed, f"Temp {max_temp}°C") + else: + # Set individual fan speeds + for fan_id, speed in fan_speeds.items(): + self._set_fan_speed(fan_id, speed, f"Temp {max_temp}°C") - def set_manual_speed(self, speed: int) -> bool: + def _calculate_curve_speed(self, temp: float, points: List[Dict]) -> int: + """Calculate fan speed from curve points.""" + if not points: + return 50 + + sorted_points = sorted(points, key=lambda p: p['temp']) + + if temp <= sorted_points[0]['temp']: + return sorted_points[0]['speed'] + if temp >= sorted_points[-1]['temp']: + return sorted_points[-1]['speed'] + + for i in range(len(sorted_points) - 1): + p1, p2 = sorted_points[i], sorted_points[i + 1] + if p1['temp'] <= temp <= p2['temp']: + if p2['temp'] == p1['temp']: + return p1['speed'] + ratio = (temp - p1['temp']) / (p2['temp'] - p1['temp']) + speed = p1['speed'] + ratio * (p2['speed'] - p1['speed']) + return int(round(speed)) + + return sorted_points[-1]['speed'] + + def _set_all_fans(self, speed: int, reason: str): + """Set all fans to a speed.""" + if self.controller and speed != self.current_speeds.get('all'): + if self.controller.set_fan_speed(speed, "0xff"): + self.current_speeds['all'] = speed + logger.info(f"All fans set to {speed}% ({reason})") + + def _set_fan_speed(self, fan_id: str, speed: int, reason: str): + """Set specific fan speed.""" + if self.controller and speed != self.current_speeds.get(fan_id): + if self.controller.set_fan_speed(speed, fan_id): + self.current_speeds[fan_id] = speed + logger.info(f"Fan {fan_id} set to {speed}% ({reason})") + + def identify_fan(self, fan_id: str): + """Identify a fan by setting it to 100% and others to 0%.""" + if not self.controller: + return False + + self.in_identify_mode = True + + # Set all fans to 0% + self.controller.set_fan_speed(0, "0xff") + time.sleep(0.5) + + # Set target fan to 100% + self.controller.set_fan_speed(100, fan_id) + + return True + + def stop_identify(self): + """Stop identify mode and resume normal control.""" + self.in_identify_mode = False + + def set_manual_speed(self, speed: int, fan_id: str = "0xff") -> bool: """Set manual fan speed.""" 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.""" @@ -681,63 +676,61 @@ class FanControlService: self.controller.enable_manual_fan_control() 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 instances -_service_instances: Dict[str, FanControlService] = {} +_service_instances: Dict[str, IPMIControllerService] = {} -def get_service(config_path: str = "/etc/ipmi-fan-controller/config.json") -> FanControlService: - """Get or create the service instance for a config path.""" +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] = FanControlService(config_path) + _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 [port]") + print("Usage: fan_controller.py ") 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") diff --git a/server.log b/server.log index d2d448a..cfdc567 100644 --- a/server.log +++ b/server.log @@ -1,255 +1,61 @@ -/home/devmatrix/projects/fan-controller-v2/web_server.py:49: PydanticDeprecatedSince20: Pydantic V1 style `@validator` validators are deprecated. You should migrate to Pydantic V2 style `@field_validator` validators, see the migration guide for more details. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.5/migration/ - @validator('new_password') -INFO: Started server process [893663] +INFO: Started server process [896609] 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: 192.168.5.30:56770 - "GET /api/status HTTP/1.1" 401 Unauthorized -INFO: 192.168.5.30:56770 - "GET /login HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 127.0.0.1:34494 - "GET /api/status HTTP/1.1" 401 Unauthorized -/home/devmatrix/projects/fan-controller-v2/web_server.py:141: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). - expiry = datetime.utcnow() + timedelta(days=7) -INFO: 192.168.5.30:49736 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET / HTTP/1.1" 200 OK +INFO: 192.168.5.30:63112 - "GET /api/status HTTP/1.1" 401 Unauthorized +INFO: 192.168.5.30:63112 - "GET /login HTTP/1.1" 200 OK /home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + self._sessions[token] = (username, datetime.utcnow() + timedelta(days=7)) +INFO: 192.168.5.30:49451 - "POST /api/auth/login HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET / HTTP/1.1" 200 OK +/home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). if datetime.utcnow() > expiry: -2026-02-20 15:37:16,554 - fan_controller - INFO - Loaded config from /home/devmatrix/projects/fan-controller-v2/data/config.json -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /favicon.ico HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:34498 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 127.0.0.1:34506 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -2026-02-20 15:37:24,066 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json -2026-02-20 15:37:24,068 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' -2026-02-20 15:37:24,069 - fan_controller - ERROR - Failed to connect to IPMI at 192.168.5.191 -INFO: 192.168.5.30:49736 - "POST /api/config/ipmi HTTP/1.1" 200 OK +2026-02-20 15:49:53,771 - fan_controller - INFO - Loaded config from /home/devmatrix/projects/fan-controller-v2/data/config.json +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /favicon.ico HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +2026-02-20 15:50:02,664 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json +2026-02-20 15:50:02,666 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' +2026-02-20 15:50:02,666 - fan_controller - ERROR - Failed to connect to IPMI +INFO: 192.168.5.30:49451 - "POST /api/config/ipmi HTTP/1.1" 200 OK +/home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + if datetime.utcnow() > expiry: +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /favicon.ico HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:39328 - "GET /api/status HTTP/1.1" 401 Unauthorized +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK /home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + self._sessions[token] = (username, datetime.utcnow() + timedelta(days=7)) +INFO: 127.0.0.1:39340 - "POST /api/auth/login HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +2026-02-20 15:50:24,002 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' +2026-02-20 15:50:24,002 - fan_controller - ERROR - Failed to connect to IPMI +INFO: 192.168.5.30:49451 - "POST /api/test HTTP/1.1" 200 OK +/home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). if datetime.utcnow() > expiry: -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /favicon.ico HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -2026-02-20 15:37:27,369 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json -2026-02-20 15:37:27,370 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' -2026-02-20 15:37:27,371 - fan_controller - ERROR - Failed to connect to IPMI at 192.168.5.191 -2026-02-20 15:37:27,371 - fan_controller - ERROR - Cannot start service - IPMI connection failed -INFO: 192.168.5.30:49736 - "POST /api/control/auto HTTP/1.1" 200 OK -/home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). +2026-02-20 15:50:25,685 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' +2026-02-20 15:50:25,686 - fan_controller - ERROR - Failed to connect to IPMI +INFO: 127.0.0.1:59364 - "POST /api/test HTTP/1.1" 200 OK +/home/devmatrix/projects/fan-controller-v2/web_server.py:156: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). if datetime.utcnow() > expiry: -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:37104 - "GET / HTTP/1.1" 200 OK -INFO: 127.0.0.1:37118 - "GET /login HTTP/1.1" 200 OK -/home/devmatrix/projects/fan-controller-v2/web_server.py:141: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). - expiry = datetime.utcnow() + timedelta(days=7) -INFO: 127.0.0.1:37130 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -2026-02-20 15:37:33,643 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json -INFO: 192.168.5.30:49736 - "POST /api/control/auto HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:49736 - "POST /api/control/manual HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__ - await super().__call__(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__ - await self.middleware_stack(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__ - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 91, in __call__ - await self.simple_response(scope, receive, send, request_headers=headers) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 146, in simple_response - await self.app(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - await app(scope, receive, sender) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__ - await self.middleware_stack(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app - await route.handle(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle - await self.app(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - await app(scope, receive, sender) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app - response = await func(request) - ^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app - raise e - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app - raw_response = await run_endpoint_function( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function - return await dependant.call(**values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1352, in api_control_manual - if not service._init_controller(): - ^^^^^^^^^^^^^^^^^^^^^^^^ -AttributeError: 'FanControlService' object has no attribute '_init_controller'. Did you mean: '_init_ipmi_controller'? -2026-02-20 15:37:36,531 - fan_controller - INFO - Saved config to /home/devmatrix/projects/fan-controller-v2/data/config.json -2026-02-20 15:37:36,533 - fan_controller - ERROR - IPMI command error: [Errno 2] No such file or directory: 'ipmitool' -2026-02-20 15:37:36,533 - fan_controller - ERROR - Failed to connect to IPMI at 192.168.5.191 -2026-02-20 15:37:36,533 - fan_controller - ERROR - Cannot start service - IPMI connection failed -INFO: 192.168.5.30:62376 - "POST /api/control/auto HTTP/1.1" 200 OK -/home/devmatrix/projects/fan-controller-v2/web_server.py:149: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). - if datetime.utcnow() > expiry: -INFO: 192.168.5.30:62376 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:62376 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:62376 - "POST /api/control/manual HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__ - await super().__call__(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__ - await self.middleware_stack(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__ - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 91, in __call__ - await self.simple_response(scope, receive, send, request_headers=headers) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 146, in simple_response - await self.app(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - await app(scope, receive, sender) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__ - await self.middleware_stack(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app - await route.handle(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle - await self.app(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - await app(scope, receive, sender) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app - response = await func(request) - ^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app - raise e - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app - raw_response = await run_endpoint_function( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function - return await dependant.call(**values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1352, in api_control_manual - if not service._init_controller(): - ^^^^^^^^^^^^^^^^^^^^^^^^ -AttributeError: 'FanControlService' object has no attribute '_init_controller'. Did you mean: '_init_ipmi_controller'? -INFO: 192.168.5.30:55640 - "POST /api/test HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/applications.py", line 1054, in __call__ - await super().__call__(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/applications.py", line 123, in __call__ - await self.middleware_stack(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__ - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 91, in __call__ - await self.simple_response(scope, receive, send, request_headers=headers) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/cors.py", line 146, in simple_response - await self.app(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 62, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - await app(scope, receive, sender) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 762, in __call__ - await self.middleware_stack(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 782, in app - await route.handle(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 297, in handle - await self.app(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 77, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app - raise exc - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - await app(scope, receive, sender) - File "/home/devmatrix/.local/lib/python3.12/site-packages/starlette/routing.py", line 72, in app - response = await func(request) - ^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 299, in app - raise e - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 294, in app - raw_response = await run_endpoint_function( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/.local/lib/python3.12/site-packages/fastapi/routing.py", line 191, in run_endpoint_function - return await dependant.call(**values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/devmatrix/projects/fan-controller-v2/web_server.py", line 1323, in api_test - if not service._init_controller(): - ^^^^^^^^^^^^^^^^^^^^^^^^ -AttributeError: 'FanControlService' object has no attribute '_init_controller'. Did you mean: '_init_ipmi_controller'? -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:61984 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK -INFO: 192.168.5.30:63389 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET / HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET / HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK +INFO: 192.168.5.30:49451 - "GET /api/status HTTP/1.1" 200 OK diff --git a/web_server.py b/web_server.py index a34ffb4..f70a6b3 100644 --- a/web_server.py +++ b/web_server.py @@ -1,32 +1,30 @@ """ -Web API for IPMI Fan Controller v2 - With Auth & SSH Support +IPMI Controller Web Server +Advanced web interface with dark mode, fan groups, multiple curves """ import asyncio import json import logging import hashlib import secrets -import re from contextlib import asynccontextmanager from pathlib import Path from typing import Optional, List, Dict from datetime import datetime, timedelta -from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Request +from fastapi import FastAPI, HTTPException, Depends, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field -# Import the fan controller import sys sys.path.insert(0, str(Path(__file__).parent)) -from fan_controller import get_service, FanControlService, IPMIFanController +from fan_controller import get_service, IPMIControllerService logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Security security = HTTPBearer(auto_error=False) # Data directories @@ -34,8 +32,6 @@ DATA_DIR = Path("/app/data") if Path("/app/data").exists() else Path(__file__).p DATA_DIR.mkdir(exist_ok=True) CONFIG_FILE = DATA_DIR / "config.json" USERS_FILE = DATA_DIR / "users.json" -SSH_KEYS_DIR = DATA_DIR / "ssh_keys" -SSH_KEYS_DIR.mkdir(exist_ok=True) # Pydantic models class UserLogin(BaseModel): @@ -45,59 +41,74 @@ class UserLogin(BaseModel): class ChangePassword(BaseModel): current_password: str new_password: str - - @validator('new_password') - def password_strength(cls, v): - if len(v) < 6: - raise ValueError('Password must be at least 6 characters') - return v - -class IPMIConfig(BaseModel): - host: str - username: str - password: Optional[str] = None # Only required on initial setup - port: int = 623 - -class SSHConfig(BaseModel): - enabled: bool = False - host: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None - use_key: bool = False - key_filename: Optional[str] = None - -class FanSettings(BaseModel): - enabled: Optional[bool] = None - interval: Optional[int] = Field(None, ge=5, le=300) - min_speed: Optional[int] = Field(None, ge=0, le=100) - max_speed: Optional[int] = Field(None, ge=0, le=100) - panic_temp: Optional[float] = Field(None, ge=50, le=100) - panic_speed: Optional[int] = Field(None, ge=0, le=100) - -class FanCurvePoint(BaseModel): - temp: float = Field(..., ge=0, le=100) - speed: int = Field(..., ge=0, le=100) - -class FanCurveUpdate(BaseModel): - points: List[FanCurvePoint] - -class ManualSpeedRequest(BaseModel): - speed: int = Field(..., ge=0, le=100) class SetupRequest(BaseModel): - admin_username: str = Field(..., min_length=3) - admin_password: str = Field(..., min_length=6) + admin_username: str + admin_password: str ipmi_host: str ipmi_username: str ipmi_password: str ipmi_port: int = 623 -# User management +class IPMIConfig(BaseModel): + host: str + username: str + password: Optional[str] = None + port: int = 623 + +class HTTPConfig(BaseModel): + enabled: bool = False + url: Optional[str] = None + timeout: int = 10 + +class FanSettings(BaseModel): + enabled: Optional[bool] = None + poll_interval: Optional[int] = Field(None, ge=5, le=300) + fan_update_interval: Optional[int] = Field(None, ge=5, le=300) + min_speed: Optional[int] = Field(None, ge=0, le=100) + max_speed: Optional[int] = Field(None, ge=0, le=100) + panic_temp: Optional[float] = Field(None, ge=50, le=100) + panic_speed: Optional[int] = Field(None, ge=0, le=100) + panic_on_no_data: Optional[bool] = None + no_data_timeout: Optional[int] = Field(None, ge=10, le=300) + primary_sensor: Optional[str] = None + sensor_preference: Optional[str] = None + theme: Optional[str] = None + +class FanCurvePoint(BaseModel): + temp: float = Field(..., ge=0, le=100) + speed: int = Field(..., ge=0, le=100) + +class FanCurveCreate(BaseModel): + name: str + points: List[FanCurvePoint] + sensor_source: str = "cpu" + applies_to: str = "all" + +class FanConfig(BaseModel): + fan_id: str + name: Optional[str] = None + group: Optional[str] = None + curve: Optional[str] = None + +class FanGroupCreate(BaseModel): + name: str + fans: List[str] + curve: str = "Default" + +class ManualSpeedRequest(BaseModel): + speed: int = Field(..., ge=0, le=100) + fan_id: str = "0xff" + +class IdentifyRequest(BaseModel): + fan_id: str + +# User Manager class UserManager: def __init__(self): self.users_file = USERS_FILE self._users = {} - self._sessions = {} # token -> (username, expiry) + self._sessions = {} self._load() def _load(self): @@ -108,38 +119,34 @@ class UserManager: self._users = data.get('users', {}) except Exception as e: logger.error(f"Failed to load users: {e}") - self._users = {} def _save(self): with open(self.users_file, 'w') as f: json.dump({'users': self._users}, f) - def _hash_password(self, password: str) -> str: + def _hash(self, password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() - def verify_user(self, username: str, password: str) -> bool: - if username not in self._users: - return False - return self._users[username] == self._hash_password(password) + def verify(self, username: str, password: str) -> bool: + return username in self._users and self._users[username] == self._hash(password) - def create_user(self, username: str, password: str) -> bool: + def create(self, username: str, password: str) -> bool: if username in self._users: return False - self._users[username] = self._hash_password(password) + self._users[username] = self._hash(password) self._save() return True def change_password(self, username: str, current: str, new: str) -> bool: - if not self.verify_user(username, current): + if not self.verify(username, current): return False - self._users[username] = self._hash_password(new) + self._users[username] = self._hash(new) self._save() return True def create_token(self, username: str) -> str: token = secrets.token_urlsafe(32) - expiry = datetime.utcnow() + timedelta(days=7) - self._sessions[token] = (username, expiry) + self._sessions[token] = (username, datetime.utcnow() + timedelta(days=7)) return token def verify_token(self, token: str) -> Optional[str]: @@ -156,7 +163,6 @@ class UserManager: user_manager = UserManager() -# Auth dependency async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: if not credentials: raise HTTPException(status_code=401, detail="Not authenticated") @@ -165,19 +171,831 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s raise HTTPException(status_code=401, detail="Invalid or expired token") return username -# HTML Templates +# CSS Themes +THEMES = { + "dark": """ + :root { + --bg-primary: #0f0f1e; + --bg-secondary: #1a1a2e; + --bg-card: rgba(255,255,255,0.05); + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --accent-primary: #2196f3; + --accent-success: #4caf50; + --accent-warning: #ff9800; + --accent-danger: #f44336; + --border: rgba(255,255,255,0.1); + } + """, + "light": """ + :root { + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --bg-card: #ffffff; + --text-primary: #333333; + --text-secondary: #666666; + --accent-primary: #1976d2; + --accent-success: #388e3c; + --accent-warning: #f57c00; + --accent-danger: #d32f2f; + --border: #e0e0e0; + } + body { + background: var(--bg-primary) !important; + color: var(--text-primary) !important; + } + .card { + background: var(--bg-card) !important; + border-color: var(--border) !important; + } + input, select, textarea { + background: #f0f0f0 !important; + color: #333 !important; + border-color: #ccc !important; + } + """ +} + +# HTML Template +def get_html(theme="dark"): + theme_css = THEMES.get(theme, THEMES["dark"]) + + return f''' + + + + + + IPMI Controller + + + +
+
+
+

🌡️ IPMI Controller

+
+ + + +
+
+ +
+
+
🖥️
+
IPMI
+
-
+
+
+
⚙️
+
Mode
+
-
+
+
+
🌡️
+
Max Temp
+
-
+
+
+
🌬️
+
Fan Speed
+
-
+
+
+
📊
+
Sensors
+
-
+
+
+
+ + +
+

🎛️ Quick Controls

+
+ + + + +
+ +
+ + + +
+
+ + +
+

🌡️ Temperatures

+
+
Loading...
+
+
+ + +
+

🌬️ Fans

+
+
Loading...
+
+
+ + +
+
+ + + + + + +
+ +
+

IPMI Configuration

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+

HTTP Sensor (lm-sensors over HTTP)

+
+ +
+
+ + +
+
+ + +
+ +
+ +
+

Fan Control Settings

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+ +
+

Fan Configuration

+
+

Connect to IPMI to see fans

+
+

Fan Groups

+
+

No groups defined

+
+ +
+ +
+

Fan Curves

+
+ +
+ +
+

System Logs

+
+ +
+
+
+ + + + + + + + +''' + LOGIN_HTML = ''' - Login - IPMI Fan Controller + Login - IPMI Controller
-

🌬️ Fan Controller Setup

-

Configure your server connection

- +

🌡️ IPMI Controller

+

Initial Setup

-

👤 Admin Account

-
- - -
-
- - -
+
+
-
-

🖥️ IPMI Connection (Required)

-
- - -
+

🖥️ IPMI Connection

+
-
- - -
-
- - -
-
-
- - +
+
+
-
- - -''' - # FastAPI app @asynccontextmanager async def lifespan(app: FastAPI): - """Application lifespan handler.""" - # Ensure data directory exists DATA_DIR.mkdir(parents=True, exist_ok=True) yield app = FastAPI( - title="IPMI Fan Controller v2", - description="Fan control for Dell servers with auth and SSH support", - version="2.1.0", + title="IPMI Controller", + description="Advanced fan control for Dell servers", + version="3.0.0", lifespan=lifespan ) @@ -1234,31 +1247,31 @@ app.add_middleware( # Routes @app.get("/favicon.ico") async def favicon(): - """Return a simple favicon to prevent 404 errors.""" - # Return a transparent 1x1 PNG - transparent_png = bytes([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, # PNG signature - 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, # IHDR chunk - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, # 1x1 pixel - 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, - 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, # IDAT chunk (transparent) - 0x08, 0xD7, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00, - 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, - 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, # IEND chunk - 0xAE, 0x42, 0x60, 0x82 - ]) - from fastapi.responses import Response - return Response(content=transparent_png, media_type="image/png") + """Transparent 1x1 PNG favicon.""" + return Response( + content=bytes([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, + 0x08, 0xD7, 0x63, 0x60, 0x60, 0x60, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, + 0xAE, 0x42, 0x60, 0x82 + ]), + media_type="image/png" + ) @app.get("/") async def root(request: Request): - """Main dashboard - always returns dashboard HTML, JS handles auth.""" - # Check if setup is needed + """Main dashboard.""" if not user_manager.is_setup_complete(): return HTMLResponse(content=SETUP_HTML) - # Always return dashboard - JavaScript will check token and redirect if needed - return HTMLResponse(content=DASHBOARD_HTML) + # Get theme preference from query or default to dark + theme = request.query_params.get('theme', 'dark') + return HTMLResponse(content=get_html(theme)) @app.get("/login") async def login_page(): @@ -1270,31 +1283,26 @@ async def login_page(): # Auth API @app.post("/api/auth/login") async def api_login(credentials: UserLogin): - """Login and get token.""" - if not user_manager.verify_user(credentials.username, credentials.password): - return {"success": False, "error": "Invalid username or password"} + if not user_manager.verify(credentials.username, credentials.password): + return {"success": False, "error": "Invalid credentials"} token = user_manager.create_token(credentials.username) return {"success": True, "token": token} @app.post("/api/auth/change-password") async def api_change_password(data: ChangePassword, username: str = Depends(get_current_user)): - """Change current user's password.""" if not user_manager.change_password(username, data.current_password, data.new_password): return {"success": False, "error": "Current password is incorrect"} return {"success": True} @app.post("/api/setup") async def api_setup(data: SetupRequest): - """Initial setup - create admin and configure IPMI.""" if user_manager.is_setup_complete(): return {"success": False, "error": "Setup already completed"} - # Create admin user - if not user_manager.create_user(data.admin_username, data.admin_password): + if not user_manager.create(data.admin_username, data.admin_password): return {"success": False, "error": "Failed to create user"} - # Configure IPMI service = get_service(str(CONFIG_FILE)) service.update_config( ipmi_host=data.ipmi_host, @@ -1303,65 +1311,53 @@ async def api_setup(data: SetupRequest): ipmi_port=data.ipmi_port ) - # Create token token = user_manager.create_token(data.admin_username) return {"success": True, "token": token} # Status API @app.get("/api/status") async def api_status(username: str = Depends(get_current_user)): - """Get current controller status.""" service = get_service(str(CONFIG_FILE)) - status = service.get_status() - return status + return service.get_status() @app.post("/api/test") async def api_test(username: str = Depends(get_current_user)): - """Test IPMI connection.""" service = get_service(str(CONFIG_FILE)) if not service.controller: if not service._init_controller(): - return {"success": False, "error": "Failed to initialize controller - check config"} + return {"success": False, "error": "Failed to connect - check config"} success = service.controller.test_connection() return {"success": success, "error": None if success else "Connection failed"} +# Control API @app.post("/api/control/auto") async def api_control_auto(data: dict, username: str = Depends(get_current_user)): - """Enable/disable automatic control.""" service = get_service(str(CONFIG_FILE)) - enabled = data.get('enabled', False) + service.set_auto_mode(data.get('enabled', False)) - if enabled and not service.config.get('ipmi_host'): - return {"success": False, "error": "IPMI host not configured"} - - service.set_auto_mode(enabled) - - if enabled and not service.running: + if data.get('enabled') and not service.running: if not service.start(): - return {"success": False, "error": "Failed to start service"} + return {"success": False, "error": "Failed to start"} return {"success": True} @app.post("/api/control/manual") async def api_control_manual(req: ManualSpeedRequest, username: str = Depends(get_current_user)): - """Set manual fan speed.""" service = get_service(str(CONFIG_FILE)) if not service.controller: if not service._init_controller(): - return {"success": False, "error": "Failed to connect to server"} + return {"success": False, "error": "Not connected"} - if service.set_manual_speed(req.speed): + if service.set_manual_speed(req.speed, req.fan_id): return {"success": True} - return {"success": False, "error": "Failed to set fan speed"} + return {"success": False, "error": "Failed"} # Config API @app.post("/api/config/ipmi") async def api_config_ipmi(data: IPMIConfig, username: str = Depends(get_current_user)): - """Update IPMI configuration.""" service = get_service(str(CONFIG_FILE)) - updates = { "ipmi_host": data.host, "ipmi_username": data.username, @@ -1373,73 +1369,63 @@ async def api_config_ipmi(data: IPMIConfig, username: str = Depends(get_current_ service.update_config(**updates) return {"success": True} -@app.post("/api/config/ssh") -async def api_config_ssh(data: dict, username: str = Depends(get_current_user)): - """Update SSH configuration.""" +@app.post("/api/config/http") +async def api_config_http(data: HTTPConfig, username: str = Depends(get_current_user)): service = get_service(str(CONFIG_FILE)) - - updates = { - "ssh_enabled": data.get('enabled', False), - "ssh_host": data.get('host'), - "ssh_username": data.get('username'), - "ssh_use_key": data.get('use_key', False), - "ssh_port": data.get('port', 22) - } - - if data.get('password'): - updates["ssh_password"] = data['password'] - - # Handle SSH key - if data.get('use_key') and data.get('key_data'): - key_filename = f"ssh_key_{username}" - key_path = SSH_KEYS_DIR / key_filename - try: - with open(key_path, 'w') as f: - f.write(data['key_data']) - key_path.chmod(0o600) - updates["ssh_key_file"] = str(key_path) - except Exception as e: - return {"success": False, "error": f"Failed to save SSH key: {e}"} - - service.update_config(**updates) + service.update_config( + http_sensor_enabled=data.enabled, + http_sensor_url=data.url, + http_sensor_timeout=data.timeout + ) return {"success": True} @app.post("/api/config/settings") async def api_config_settings(data: FanSettings, username: str = Depends(get_current_user)): - """Update fan control settings.""" service = get_service(str(CONFIG_FILE)) - - updates = {} - if data.enabled is not None: - updates['enabled'] = data.enabled - if data.interval is not None: - updates['interval'] = data.interval - if data.min_speed is not None: - updates['min_speed'] = data.min_speed - if data.max_speed is not None: - updates['max_speed'] = data.max_speed - if data.panic_temp is not None: - updates['panic_temp'] = data.panic_temp - if data.panic_speed is not None: - updates['panic_speed'] = data.panic_speed - + updates = {k: v for k, v in data.model_dump().items() if v is not None} service.update_config(**updates) return {"success": True} -@app.post("/api/config/curve") -async def api_config_curve(curve: FanCurveUpdate, username: str = Depends(get_current_user)): - """Update fan curve.""" +# Fan API +@app.post("/api/fans/identify") +async def api_identify_fan(req: IdentifyRequest, username: str = Depends(get_current_user)): service = get_service(str(CONFIG_FILE)) - points = [{"temp": p.temp, "speed": p.speed} for p in curve.points] - service.update_config(fan_curve=points) + if service.identify_fan(req.fan_id): + return {"success": True} + return {"success": False, "error": "Failed"} + +@app.post("/api/fans/stop-identify") +async def api_stop_identify(username: str = Depends(get_current_user)): + service = get_service(str(CONFIG_FILE)) + service.stop_identify() return {"success": True} -@app.post("/api/shutdown") -async def api_shutdown(username: str = Depends(get_current_user)): - """Return fans to automatic control and stop service.""" +# Public API (no auth required - for external integrations) +@app.get("/api/public/status") +async def api_public_status(): + """Public status endpoint for integrations.""" service = get_service(str(CONFIG_FILE)) - service.stop() - return {"success": True, "message": "Service stopped, fans returned to automatic control"} + status = service.get_status() + # Return limited public data + return { + "temperatures": status.get("temperatures", []), + "fans": status.get("fans", []), + "current_speeds": status.get("current_speeds", {}), + "connected": status.get("connected", False), + "enabled": status.get("enabled", False) + } + +@app.get("/api/public/temperatures") +async def api_public_temps(): + """Public temperatures endpoint.""" + service = get_service(str(CONFIG_FILE)) + return {"temperatures": service.get_status().get("temperatures", [])} + +@app.get("/api/public/fans") +async def api_public_fans(): + """Public fans endpoint.""" + service = get_service(str(CONFIG_FILE)) + return {"fans": service.get_status().get("fans", [])} if __name__ == "__main__": import uvicorn