TerslerThe little junk rig that could

Source files / scripts · raw

fan-control.sh

#!/usr/bin/env bash
# Drive a mainboard PWM fan header from the hottest GPU's temperature.
# The board itself only regulates on CPU temperature.
#
#   tersler-fan-control --discover   list hwmon chips and PWM outputs
#   tersler-fan-control --test PWM   sweep one output (e.g. nct6779/pwm2) to identify it
#   tersler-fan-control              run the control loop (needs FAN_HWMON_NAME + FAN_PWM)
#
# Fails safe: on any error or exit it sets the fan to full speed and hands
# control back to the board.
set -euo pipefail
# shellcheck source=lib.sh
source "$(dirname "$(readlink -f "$0")")/lib.sh"
jr_load_config

hwmon_by_name() {
    local d
    for d in /sys/class/hwmon/hwmon*; do
        [[ $(cat "$d/name" 2>/dev/null) == "$1" ]] && { echo "$d"; return 0; }
    done
    return 1
}

if [[ ${1:-} == --discover ]]; then
    command -v sensors-detect >/dev/null && jr_log "tip: 'sudo sensors-detect --auto' loads Super-I/O drivers first"
    for d in /sys/class/hwmon/hwmon*; do
        n=$(cat "$d/name" 2>/dev/null || echo '?')
        pwms=$(cd "$d" && ls pwm[0-9] 2>/dev/null | tr '\n' ' ' || true)
        fans=$(cd "$d" && for f in fan[0-9]_input; do [[ -f $f ]] && printf '%s=%s ' "${f%_input}" "$(cat "$f")"; done; true)
        printf '%-10s %-12s pwm: %-24s rpm: %s\n' "${d##*/}" "$n" "${pwms:--}" "${fans:--}"
    done
    echo "No PWM listed for the board chip? Then the BMC owns the fans: set a fixed speed in BIOS instead."
    exit 0
fi

if [[ ${1:-} == --test ]]; then
    [[ $EUID -eq 0 ]] || jr_die "run as root"
    chip=${2%%/*}; pwm=${2##*/}
    d=$(hwmon_by_name "$chip") || jr_die "no hwmon named $chip"
    echo 1 > "$d/${pwm}_enable"
    for v in 255 120 60 255; do
        echo "$v" > "$d/$pwm"; sleep 4
        printf '%s=%s  rpm: %s\n' "$pwm" "$v" "$(cat "$d"/fan*_input 2>/dev/null | tr '\n' ' ')"
    done
    echo 2 > "$d/${pwm}_enable" 2>/dev/null || true
    exit 0
fi

[[ $EUID -eq 0 ]] || jr_die "run as root"
[[ -n $FAN_HWMON_NAME && -n $FAN_PWM ]] || jr_die "set FAN_HWMON_NAME and FAN_PWM (see --discover)"
dir=$(hwmon_by_name "$FAN_HWMON_NAME") || jr_die "hwmon $FAN_HWMON_NAME not found"
out=$dir/$FAN_PWM
[[ -w $out ]] || jr_die "$out not writable"

failsafe() { echo 255 > "$out" 2>/dev/null || true; echo 2 > "${out}_enable" 2>/dev/null || true; }
trap failsafe EXIT
echo 1 > "${out}_enable"

# Temperatures come in whole degrees, so with a steep curve the target jumps
# by one degree's worth of pwm (30+ at 72-78 °C) and the fan pulses. The
# output follows the target with a time constant of FAN_SMOOTH intervals;
# at FAN_T_MAX or without readings it goes to full speed immediately.
FAN_SMOOTH=${FAN_SMOOTH:-8}
last=-1
acc=$(( 255 * FAN_SMOOTH ))               # smoothed pwm x FAN_SMOOTH; start at full speed
while true; do
    if t=$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits 2>/dev/null | sort -n | tail -1) \
       && [[ $t =~ ^[0-9]+$ ]]; then
        if   (( t <= FAN_T_MIN )); then target=$FAN_PWM_MIN
        elif (( t >= FAN_T_MAX )); then target=255
        else target=$(( FAN_PWM_MIN + (255 - FAN_PWM_MIN) * (t - FAN_T_MIN) / (FAN_T_MAX - FAN_T_MIN) ))
        fi
    else
        t='?'; target=255                  # can't read temps: full speed
    fi
    if (( target == 255 )); then acc=$(( 255 * FAN_SMOOTH ))
    else acc=$(( acc + target - acc / FAN_SMOOTH ))
    fi
    v=$(( acc / FAN_SMOOTH ))
    if (( v != last )); then echo "$v" > "$out"; last=$v; echo "gpu max ${t}°C -> pwm $v (target $target)"; fi
    sleep "$FAN_INTERVAL"
done