Initial commit
This commit is contained in:
Executable
+442
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" Dynamically control fan speed according to hard drive temperature. """
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import enum
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import daemon
|
||||
import daemon.pidlockfile
|
||||
|
||||
import bin_dep
|
||||
import colored_logging
|
||||
|
||||
|
||||
class Drive:
|
||||
|
||||
""" Drive represented by a device file like /dev/sdX. """
|
||||
|
||||
DriveState = enum.Enum("DriveState", ("UNKNOWN", "ACTIVE_IDLE", "STANDBY", "SLEEPING"))
|
||||
|
||||
def __init__(self, device_filepath, stat_filepath):
|
||||
assert(stat.S_ISBLK(os.stat(device_filepath).st_mode))
|
||||
self.device_filepath = device_filepath
|
||||
self.stat_filepath = stat_filepath
|
||||
self.logger = logging.getLogger(str(self))
|
||||
# test if drive supports hdparm -H
|
||||
cmd = ("hdparm", "-H", self.device_filepath)
|
||||
try:
|
||||
subprocess.check_call(cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError:
|
||||
self.logger.warning("Drive does not allow querying temperature without going out of low power mode.")
|
||||
self.supports_hitachi_temp_query = False
|
||||
else:
|
||||
self.supports_hitachi_temp_query = True
|
||||
|
||||
def __str__(self):
|
||||
""" Return a pretty drive name. """
|
||||
return os.path.basename(self.device_filepath).rsplit("-", 1)[-1]
|
||||
|
||||
def getState(self):
|
||||
""" Get drive power state, as a DriveState enum. """
|
||||
states = {"unknown": __class__.DriveState.UNKNOWN,
|
||||
"active/idle": __class__.DriveState.ACTIVE_IDLE,
|
||||
"standby": __class__.DriveState.STANDBY,
|
||||
"sleeping": __class__.DriveState.SLEEPING}
|
||||
cmd = ("hdparm", "-C", self.device_filepath)
|
||||
output = subprocess.check_output(cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
universal_newlines=True)
|
||||
str_state = output.rsplit(" ", 1)[-1].strip()
|
||||
state = states[str_state]
|
||||
self.logger.debug("Drive state: %s" % (state.name))
|
||||
return state
|
||||
|
||||
def isSleeping(self):
|
||||
""" Return True if drive is in low power state, False otherwise. """
|
||||
return (self.getState() in (Drive.DriveState.STANDBY, Drive.DriveState.SLEEPING))
|
||||
|
||||
def getTemperature(self):
|
||||
""" Get drive temperature in Celcius using either hddtemp or hdparm. """
|
||||
if not self.supports_hitachi_temp_query:
|
||||
cmd = ("hddtemp", "-u", "C", "-n", self.device_filepath)
|
||||
output = subprocess.check_output(cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
universal_newlines=True)
|
||||
temp = int(output.strip())
|
||||
else:
|
||||
cmd = ("hdparm", "-H", self.device_filepath)
|
||||
output = subprocess.check_output(cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
universal_newlines=True)
|
||||
temp = int(re.search("drive temperature \(celsius\) is:\s*([0-9]*)", output).group(1))
|
||||
self.logger.debug("Drive temperature: %u°C" % (temp))
|
||||
return temp
|
||||
|
||||
def spinDown(self):
|
||||
""" Spin down a drive, effectively setting it to DriveState.STANDBY state. """
|
||||
logging.getLogger().info("Spinning down drive %s" % (self))
|
||||
cmd = ("hdparm", "-y", self.device_filepath)
|
||||
subprocess.check_call(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
def getActivityStats(self):
|
||||
""" Return drive stats as in /proc/diskstats, as a tuple of integer. """
|
||||
with open(self.stat_filepath, "rt") as stat_file:
|
||||
stats = stat_file.read()
|
||||
stats = filter(None, map(str.strip, stats.strip().split(" ")))
|
||||
stats = tuple(map(int, stats))
|
||||
return stats
|
||||
|
||||
|
||||
class DriveSpinDownThread(threading.Thread):
|
||||
|
||||
""" Thread responsible for spinning down a drive when it is not active for a certain amount of time. """
|
||||
|
||||
def __init__(self, drive, spin_down_time_s):
|
||||
self.drive = drive
|
||||
self.spin_down_time_s = spin_down_time_s
|
||||
super().__init__(name="%s-%s" % (__class__.__name__, drive))
|
||||
|
||||
def run(self):
|
||||
""" Thread loop. """
|
||||
try:
|
||||
logger = logging.getLogger(self.name)
|
||||
previous_stats = None
|
||||
while True:
|
||||
if self.drive.isSleeping():
|
||||
logger.debug("Drive is already sleeping")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
if previous_stats is None:
|
||||
# get stats
|
||||
previous_stats = self.drive.getActivityStats()
|
||||
previous_stats_time = time.time()
|
||||
|
||||
# sleep
|
||||
time.sleep(min(self.spin_down_time_s, 60))
|
||||
|
||||
# get stats again
|
||||
stats = self.drive.getActivityStats()
|
||||
now = time.time()
|
||||
if stats != previous_stats:
|
||||
logger.debug("Drive is active")
|
||||
previous_stats = None
|
||||
else:
|
||||
delta = now - previous_stats_time
|
||||
logger.debug("Drive is inactive for %u min" % (int(delta / 60)))
|
||||
if delta > self.spin_down_time_s:
|
||||
self.drive.spinDown()
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger().error(e)
|
||||
|
||||
|
||||
class Fan:
|
||||
|
||||
""" Represent a fan associated with a PWM file to control its speed. """
|
||||
|
||||
def __init__(self, id, pwm_filepath, start_value, stop_value):
|
||||
assert(0 <= start_value <= 255)
|
||||
assert(0 <= stop_value <= 255)
|
||||
self.id = id
|
||||
self.pwm_filepath = pwm_filepath
|
||||
self.start_value = start_value
|
||||
self.stop_value = stop_value
|
||||
self.startup = False
|
||||
self.logger = logging.getLogger("Fan #%u" % (self.id))
|
||||
|
||||
def getRpm(self):
|
||||
""" Read fan speed in revolutions per minute. """
|
||||
pwm_num = int(re.search("[^0-9]*([0-9]*)$", self.pwm_filepath).group(1))
|
||||
fan_input_filepath = os.path.join(os.path.dirname(self.pwm_filepath), "fan%u_input" % (pwm_num))
|
||||
with open(fan_input_filepath, "rt") as fan_input_file:
|
||||
rpm = int(fan_input_file.read().strip())
|
||||
self.logger.debug("Rotation speed is currently %u rpm" % (rpm))
|
||||
return rpm
|
||||
|
||||
def isRunning(self):
|
||||
""" Return True if fan is moving, False instead. """
|
||||
return (self.getRpm() > 0)
|
||||
|
||||
def setSpeed(self, speed_prct, min_prct):
|
||||
""" Set fan speed to a percentage of its maximum speed. """
|
||||
# preconditions
|
||||
assert(0 <= speed_prct <= 100)
|
||||
assert(0 <= min_prct <= 100)
|
||||
|
||||
self.logger.info("Setting fan speed to %u%%" % (speed_prct))
|
||||
|
||||
# calculate target PWM value
|
||||
if speed_prct == 0:
|
||||
target_value = 0
|
||||
else:
|
||||
target_value = self.stop_value + ((255 - self.stop_value) * speed_prct) // 100
|
||||
if min_prct > 0:
|
||||
min_value = self.stop_value + ((255 - self.stop_value) * min_prct) // 100
|
||||
target_value = max(min_value, target_value)
|
||||
|
||||
if (0 < target_value < self.start_value) and (not self.isRunning()):
|
||||
self.startup = True
|
||||
else:
|
||||
self.startup = False
|
||||
|
||||
# set speed
|
||||
if self.startup:
|
||||
# fan startup boost
|
||||
self.logger.debug("Applying startup boost")
|
||||
self.setPwmValue(self.start_value)
|
||||
else:
|
||||
self.setPwmValue(target_value)
|
||||
|
||||
def setPwmValue(self, value):
|
||||
""" Set fan PWM value. """
|
||||
assert(0 <= value <= 255)
|
||||
assert(not stat.S_ISBLK(os.stat(self.pwm_filepath).st_mode)) # check we are not mistakenly writing to a device file
|
||||
enabled_filepath = "%s_enable" % (self.pwm_filepath)
|
||||
with open(enabled_filepath, "r+t") as enabled_file:
|
||||
enabled_val = int(enabled_file.read().strip())
|
||||
if enabled_val != 1:
|
||||
self.logger.warning("%s was %u, setting it to 1", enabled_filepath, enabled_val)
|
||||
enabled_file.seek(0)
|
||||
enabled_file.write("1")
|
||||
with open(self.pwm_filepath, "wt") as pwm_file:
|
||||
self.logger.debug("Setting PWM value to %u" % (value))
|
||||
pwm_file.write("%u" % (value))
|
||||
|
||||
|
||||
def main(drive_filepaths, fan_pwm_filepaths, fan_start_values, fan_stop_values, min_fan_speed_prct, min_temp, max_temp,
|
||||
interval_s, spin_down_time_s, stat_filepaths):
|
||||
try:
|
||||
fans = [Fan(i,
|
||||
fan_pwm_filepath,
|
||||
fan_start_value,
|
||||
fan_stop_value) for i,
|
||||
(fan_pwm_filepath,
|
||||
fan_start_value,
|
||||
fan_stop_value) in enumerate(zip(fan_pwm_filepaths,
|
||||
fan_start_values,
|
||||
fan_stop_values),
|
||||
1)]
|
||||
drives = [Drive(drive_filepath,
|
||||
stat_filepath) for drive_filepath, stat_filepath in zip(drive_filepaths,
|
||||
stat_filepaths)]
|
||||
|
||||
drives_startup_time = time.time()
|
||||
|
||||
# start spin down threads if needed
|
||||
spin_down_threads = []
|
||||
if (spin_down_time_s is not None) and (spin_down_time_s > interval_s):
|
||||
for drive in drives:
|
||||
spin_down_threads.append(DriveSpinDownThread(drive, spin_down_time_s))
|
||||
for thread in spin_down_threads:
|
||||
thread.start()
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# calc max drive temperature
|
||||
temps = []
|
||||
awakes = []
|
||||
logger = logging.getLogger("Fan speed control")
|
||||
for drive in drives:
|
||||
awake = not drive.isSleeping()
|
||||
awakes.append(awake)
|
||||
if awake or drive.supports_hitachi_temp_query:
|
||||
temps.append(drive.getTemperature())
|
||||
else:
|
||||
logger.debug("Drive %s is in low power state, unable to query temperature" % (drive))
|
||||
if temps:
|
||||
temp = max(temps)
|
||||
logger.info("Maximum drive temperature: %u°C" % (temp))
|
||||
else:
|
||||
assert(not any(awakes))
|
||||
logger.info("All drives are in low power state")
|
||||
|
||||
if not any(awakes):
|
||||
drives_startup_time = now
|
||||
|
||||
# calc target percentage speed
|
||||
if temps and (temp - min_temp > 0):
|
||||
speed_prct = 100 * (temp - min_temp) // (max_temp - min_temp)
|
||||
speed_prct = int(min(speed_prct, 100))
|
||||
else:
|
||||
speed_prct = 0
|
||||
|
||||
# set speed
|
||||
for fan in fans:
|
||||
fan.setSpeed(speed_prct, min_fan_speed_prct)
|
||||
|
||||
# sleep
|
||||
if any(map(operator.attrgetter("startup"), fans)):
|
||||
# at least one fan is starting up, quickly cancel startup boost
|
||||
current_interval_s = min(10, interval_s)
|
||||
elif (now - drives_startup_time) < (60 * 5):
|
||||
# if all drives were started or waken up less than 5 min ago, dont' sleep too long because they
|
||||
# can heat up quickly
|
||||
current_interval_s = min(20, interval_s)
|
||||
else:
|
||||
current_interval_s = interval_s
|
||||
logger.debug("Sleeping for %u seconds" % (current_interval_s))
|
||||
time.sleep(current_interval_s)
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger().error(e)
|
||||
|
||||
|
||||
# check deps
|
||||
bin_dep.check_bin_dependency(("hddtemp", "hdparm"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# parse args
|
||||
arg_parser = argparse.ArgumentParser(description=__doc__)
|
||||
arg_parser.add_argument("-d",
|
||||
"--drives",
|
||||
required=True,
|
||||
nargs="+",
|
||||
dest="drive_filepaths",
|
||||
help="Drive(s) to get temperature from (ie. /dev/sdX)")
|
||||
arg_parser.add_argument("-p",
|
||||
"--pwm",
|
||||
required=True,
|
||||
nargs="+",
|
||||
dest="fan_pwm_filepath",
|
||||
help="PWM filepath(s) to control fan speed (under /sys)")
|
||||
arg_parser.add_argument("--pwm-start-value",
|
||||
required=True,
|
||||
type=int,
|
||||
nargs="+",
|
||||
dest="fan_start_value",
|
||||
help="""PWM value (0-255), at which the fan starts moving.
|
||||
Run pwmconfig to find this value.""")
|
||||
arg_parser.add_argument("--pwm-stop-value",
|
||||
required=True,
|
||||
type=int,
|
||||
nargs="+",
|
||||
dest="fan_stop_value",
|
||||
help="""PWM value (0-255), at which the fan stop moving.
|
||||
Run pwmconfig to find this value.
|
||||
Often 20-40 lower than start speed.""")
|
||||
arg_parser.add_argument("--min-temp",
|
||||
type=int,
|
||||
default=30,
|
||||
dest="min_temp",
|
||||
help="Temperature in Celcius at which the fan(s) will be set to minimum speed.")
|
||||
arg_parser.add_argument("--max-temp",
|
||||
type=int,
|
||||
default=50,
|
||||
dest="max_temp",
|
||||
help="Temperature in Celcius at which the fan(s) will be set to maximum speed.")
|
||||
arg_parser.add_argument("--min-fan-speed-prct",
|
||||
type=int,
|
||||
default=20,
|
||||
dest="min_fan_speed_prct",
|
||||
help="""Minimum percentage of full fan speed to set the fan to.
|
||||
Never set to 0 unless you have other fans to cool down your system,
|
||||
or a case specially designed for passive cooling.""")
|
||||
arg_parser.add_argument("-i",
|
||||
"--interval",
|
||||
type=int,
|
||||
default=60,
|
||||
dest="interval_s",
|
||||
help="Interval in seconds to check temperature and adjust fan speed.")
|
||||
arg_parser.add_argument("--stat-files",
|
||||
required=True,
|
||||
nargs="+",
|
||||
dest="stat_filepaths",
|
||||
help="Filepath of drive stats file (ie. /sys/block/sdX/stat)")
|
||||
arg_parser.add_argument("--spin-down-time",
|
||||
type=int,
|
||||
default=None,
|
||||
dest="spin_down_time_s",
|
||||
help="Interval in seconds after which inactive drives will be put to standby state.")
|
||||
arg_parser.add_argument("-v",
|
||||
"--verbosity",
|
||||
action="store",
|
||||
choices=("warning", "normal", "debug"),
|
||||
default="normal",
|
||||
dest="verbosity",
|
||||
help="Level of output to display")
|
||||
arg_parser.add_argument("-b",
|
||||
"--background",
|
||||
action="store_true",
|
||||
dest="daemonize",
|
||||
help="Daemonize process")
|
||||
arg_parser.add_argument("-l",
|
||||
"--log-file",
|
||||
action="store",
|
||||
default=None,
|
||||
dest="log_filepath",
|
||||
help="Filepath for log output when using deamon mode")
|
||||
arg_parser.add_argument("--pid-file",
|
||||
action="store",
|
||||
default=None,
|
||||
dest="pid_filepath",
|
||||
help="Filepath for lock file when using deamon mode")
|
||||
args = arg_parser.parse_args()
|
||||
if not (len(args.fan_pwm_filepath) == len(args.fan_start_value) == len(args.fan_stop_value) ==
|
||||
len(args.stat_filepaths)):
|
||||
raise ValueError("Invalid parameter count")
|
||||
|
||||
# setup logger
|
||||
logging_level = {"warning": logging.WARNING,
|
||||
"normal": logging.INFO,
|
||||
"debug": logging.DEBUG}
|
||||
logging.getLogger().setLevel(logging_level[args.verbosity])
|
||||
logging_formatter = colored_logging.ColoredFormatter(fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
||||
logging_handler = logging.StreamHandler()
|
||||
logging_handler.setFormatter(logging_formatter)
|
||||
logging.getLogger().addHandler(logging_handler)
|
||||
|
||||
# check if root
|
||||
if os.geteuid() != 0:
|
||||
logging.getLogger().error("You need to run this script as root")
|
||||
exit(1)
|
||||
|
||||
# main
|
||||
with contextlib.ExitStack() as deamon_context:
|
||||
if args.daemonize:
|
||||
preserved_fds = None
|
||||
if args.log_filepath is not None:
|
||||
log_output = deamon_context.enter_context(open(args.log_filepath, "at+"))
|
||||
preserved_fds = [log_output.fileno()]
|
||||
else:
|
||||
log_output = None
|
||||
if args.pid_filepath is not None:
|
||||
pidfile = daemon.pidlockfile.PIDLockFile(args.pid_filepath)
|
||||
if pidfile.is_locked():
|
||||
logging.getLogger().error("Daemon already running")
|
||||
exit(1)
|
||||
else:
|
||||
pidfile = None
|
||||
deamon_context.enter_context(daemon.DaemonContext(stdout=log_output,
|
||||
stderr=log_output,
|
||||
pidfile=pidfile,
|
||||
files_preserve=preserved_fds))
|
||||
main(args.drive_filepaths,
|
||||
args.fan_pwm_filepath,
|
||||
args.fan_start_value,
|
||||
args.fan_stop_value,
|
||||
args.min_fan_speed_prct,
|
||||
args.min_temp,
|
||||
args.max_temp,
|
||||
args.interval_s,
|
||||
args.spin_down_time_s,
|
||||
args.stat_filepaths)
|
||||
Reference in New Issue
Block a user