From 63af1e883b7c703c1f8ef5258f7ba3ddb8e2be89 Mon Sep 17 00:00:00 2001 From: devmatrix Date: Fri, 20 Feb 2026 15:50:59 +0000 Subject: [PATCH] IPMI Controller v3: All features implemented --- README.md | 160 +- __pycache__/fan_controller.cpython-312.pyc | Bin 36295 -> 35456 bytes data/config.json | 56 +- fan_controller.py | 765 ++++---- server.log | 300 +-- web_server.py | 1986 ++++++++++---------- 6 files changed, 1504 insertions(+), 1763 deletions(-) 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 ae16cad99b8f8abf7a1bae4e2cb8eac79d8675da..20555c4605b2425097400dbabf81b604ff076be7 100644 GIT binary patch literal 35456 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 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