Created
September 1, 2025 15:34
-
-
Save zhaoweizhong/cdea2b9476260e554ce43a6daf147967 to your computer and use it in GitHub Desktop.
Temperature-based fan speed controller for Dell T630 (v2)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import psutil | |
| import subprocess | |
| import sys | |
| import time | |
| import signal | |
| import logging | |
| # Configuration - hardcoded for simplicity | |
| TEMP_THRESHOLDS = [45, 55, 65, 75] # °C | |
| FAN_SPEEDS = [9, 10, 15, 20] # % | |
| HYSTERESIS = 3 # °C | |
| CHECK_INTERVAL = 30 # seconds | |
| EMERGENCY_TEMP = 78 # °C - switch to automatic | |
| # Global state | |
| current_fan_speed = 0 | |
| current_mode = "automatic" | |
| debug_mode = False | |
| def setup_logging(): | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(levelname)s - %(message)s' | |
| ) | |
| def ipmitool_cmd(cmd_args): | |
| """Execute ipmitool command with error handling""" | |
| cmd = ["ipmitool"] + cmd_args.split() | |
| if debug_mode: | |
| logging.info(f"IPMI Command: {' '.join(cmd)}") | |
| return True | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) | |
| if result.returncode != 0: | |
| logging.error(f"IPMI command failed: {' '.join(cmd)} - {result.stderr}") | |
| return False | |
| return True | |
| except subprocess.TimeoutExpired: | |
| logging.error(f"IPMI command timeout: {' '.join(cmd)}") | |
| return False | |
| except Exception as e: | |
| logging.error(f"IPMI command error: {e}") | |
| return False | |
| def set_fan_mode(mode): | |
| """Set fan control mode (manual/automatic)""" | |
| global current_mode | |
| if mode == current_mode: | |
| return True | |
| if mode == "manual": | |
| logging.info("Switching to manual fan control") | |
| success = ipmitool_cmd("raw 0x30 0x30 0x01 0x00") | |
| elif mode == "automatic": | |
| logging.info("Switching to automatic fan control") | |
| success = ipmitool_cmd("raw 0x30 0x30 0x01 0x01") | |
| else: | |
| logging.error(f"Invalid fan mode: {mode}") | |
| return False | |
| if success: | |
| current_mode = mode | |
| if mode == "automatic": | |
| global current_fan_speed | |
| current_fan_speed = 0 | |
| return success | |
| def set_fan_speed(speed_percent): | |
| """Set fan speed percentage (5-100%)""" | |
| global current_fan_speed | |
| if speed_percent == current_fan_speed: | |
| return True | |
| if not (5 <= speed_percent <= 100): | |
| logging.error(f"Invalid fan speed: {speed_percent}%") | |
| return False | |
| # Always ensure manual mode before setting speed | |
| if not set_fan_mode("manual"): | |
| logging.error("Failed to switch to manual mode") | |
| return False | |
| time.sleep(2) # Give IPMI more time to switch modes | |
| # Convert to hex | |
| speed_hex = f"0x{speed_percent:02x}" | |
| logging.info(f"Setting fan speed to {speed_percent}%") | |
| success = ipmitool_cmd(f"raw 0x30 0x30 0x02 0xff {speed_hex}") | |
| if success: | |
| current_fan_speed = speed_percent | |
| return success | |
| def get_cpu_temperature(): | |
| """Get average CPU package temperature""" | |
| try: | |
| temps = psutil.sensors_temperatures() | |
| if 'coretemp' not in temps: | |
| logging.error("No CPU temperature sensors found") | |
| return None | |
| # Get package temperatures only (ignore individual cores) | |
| package_temps = [] | |
| for sensor in temps['coretemp']: | |
| if 'Package id' in sensor.label: | |
| package_temps.append(sensor.current) | |
| if not package_temps: | |
| logging.error("No CPU package temperature sensors found") | |
| return None | |
| avg_temp = sum(package_temps) / len(package_temps) | |
| return round(avg_temp, 1) | |
| except Exception as e: | |
| logging.error(f"Error reading temperature: {e}") | |
| return None | |
| def check_hysteresis(temp, threshold_index): | |
| """Check if temperature change should trigger fan speed change""" | |
| if threshold_index < 0 or threshold_index >= len(TEMP_THRESHOLDS): | |
| return True | |
| current_threshold = TEMP_THRESHOLDS[threshold_index] | |
| target_speed = FAN_SPEEDS[threshold_index] | |
| # If we want to increase fan speed, do it immediately | |
| if current_fan_speed < target_speed: | |
| return True | |
| # If we want to decrease fan speed, apply hysteresis | |
| if current_fan_speed > target_speed: | |
| return temp <= (current_threshold - HYSTERESIS) | |
| return True | |
| def control_fans(temp): | |
| """Main fan control logic""" | |
| if temp is None: | |
| logging.warning("No temperature reading, keeping current settings") | |
| return | |
| # Emergency mode - very high temperature | |
| if temp >= EMERGENCY_TEMP: | |
| logging.warning(f"Emergency temperature {temp}°C! Switching to automatic") | |
| set_fan_mode("automatic") | |
| return | |
| # Determine target fan speed based on temperature | |
| target_speed = FAN_SPEEDS[0] # Default to lowest speed | |
| threshold_index = 0 | |
| for i, threshold in enumerate(TEMP_THRESHOLDS): | |
| if temp > threshold: | |
| target_speed = FAN_SPEEDS[min(i + 1, len(FAN_SPEEDS) - 1)] | |
| threshold_index = min(i + 1, len(FAN_SPEEDS) - 1) | |
| # Apply hysteresis check | |
| if check_hysteresis(temp, threshold_index): | |
| set_fan_speed(target_speed) | |
| logging.info(f"Temperature: {temp}°C, Fan: {current_fan_speed}%, Mode: {current_mode}") | |
| def graceful_shutdown(signum, frame): | |
| """Handle shutdown signals""" | |
| logging.info(f"Received signal {signum}, shutting down...") | |
| logging.info("Switching fans back to automatic control") | |
| set_fan_mode("automatic") | |
| sys.exit(0) | |
| def main(): | |
| global debug_mode | |
| # Check for debug flag | |
| if len(sys.argv) > 1 and sys.argv[1] == "--debug": | |
| debug_mode = True | |
| logging.getLogger().setLevel(logging.DEBUG) | |
| logging.info("Debug mode enabled") | |
| setup_logging() | |
| # Setup signal handlers for graceful shutdown | |
| signal.signal(signal.SIGTERM, graceful_shutdown) | |
| signal.signal(signal.SIGINT, graceful_shutdown) | |
| logging.info("Starting fan control service") | |
| logging.info(f"Temperature thresholds: {TEMP_THRESHOLDS}°C") | |
| logging.info(f"Fan speeds: {FAN_SPEEDS}%") | |
| logging.info(f"Hysteresis: {HYSTERESIS}°C") | |
| logging.info(f"Check interval: {CHECK_INTERVAL}s") | |
| try: | |
| while True: | |
| temp = get_cpu_temperature() | |
| control_fans(temp) | |
| time.sleep(CHECK_INTERVAL) | |
| except KeyboardInterrupt: | |
| graceful_shutdown(signal.SIGINT, None) | |
| except Exception as e: | |
| logging.error(f"Unexpected error: {e}") | |
| set_fan_mode("automatic") | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment