fanfix (21921B)
1 #!/bin/bash 2 # fanfix — toolkit for living with the dead GPU fan on this laptop 3 # (PCSpecialist Valeon II 17 / TongFang GM7RGxM, Ryzen 9 6900HX + RTX 3070 Ti). 4 # 5 # Background: with a fan dead, the embedded controller (EC) pins every CPU core at 399 MHz the 6 # instant the CPU reaches ~79 °C and holds it until ~47 °C. That policy lives in firmware and 7 # cannot be switched off from Linux; the workaround is to cap CPU clocks so the CPU never gets 8 # there (3.2 GHz ≈ base clock → ~70 °C under full load), and optionally to run the surviving 9 # CPU fan harder than the EC's quiet-tuned curve. 10 # 11 # Fan control talks to the EC through the firmware's own ACPI methods (\_SB.INOU.ECRR/ECRW — 12 # the same ones the kernel's uniwill-laptop driver uses) via the acpi_call module, and follows 13 # the register procedure of TUXEDO's driver for this chassis family (uniwill_keyboard.h). 14 # 15 # Usage: fanfix help 16 set -uo pipefail 17 18 CAP_DEFAULT_KHZ=3200000 19 CONF=/etc/tmpfiles.d/99-cpu-freq-cap.conf 20 OLD_CONF=/etc/tmpfiles.d/99-cpu-no-boost.conf 21 TRIP_MHZ=600 # any core reporting below this under load = EC clamp active 22 CPU0=/sys/devices/system/cpu/cpu0/cpufreq 23 ACPI_CALL=/proc/acpi/call 24 EC_DEV='\_SB.INOU' # ACPI device behind INOU0000 (Uniwill EC bridge) 25 FAN_UNIT=fanfix-fan.service 26 FAN_CONF=/etc/fanfix.conf # optional: CURVE="45:30 55:45 60:55 65:70 70:85 75:100" 27 DEFAULT_CURVE="45:30 55:45 60:55 65:70 70:85 75:100" 28 FAN_MIN_PCT=30 # refuse slower manual settings (EC trips at 79 °C; 34 % held 69 °C capped) 29 SELF=$(readlink -f "$0") 30 [ -r "$FAN_CONF" ] && . "$FAN_CONF" 31 32 # ---------- generic helpers ---------------------------------------------------------------- 33 hwmon_by_name() { local h; for h in /sys/class/hwmon/hwmon*; do [ "$(cat "$h/name" 2>/dev/null)" = "$1" ] && { echo "$h"; return 0; }; done; return 1; } 34 rd() { cat "$1" 2>/dev/null || echo 0; } 35 tctl() { local h; h=$(hwmon_by_name k10temp) && echo $(( $(rd "$h/temp1_input") / 1000 )) || echo 0; } 36 ppt_w() { local h; h=$(hwmon_by_name amdgpu) && awk "BEGIN{printf \"%.1f\", $(rd "$h/power1_input")/1e6}" || echo "?"; } 37 freqs() { grep MHz /proc/cpuinfo | awk '{s+=$4; if($4>m)m=$4; if(n==""||$4<n)n=$4} END{printf "%d %d %d", s/NR, n, m}'; } # avg min max 38 fan_line() { 39 local h; h=$(hwmon_by_name uniwill) || { echo "fans: driver not loaded (fanfix fans)"; return; } 40 printf "CPU fan %4d rpm (%3d%%) GPU fan %4d rpm (%3d%%) GPU %2d°C" \ 41 "$(rd "$h/fan1_input")" $(( $(rd "$h/pwm1")*100/255 )) "$(rd "$h/fan2_input")" $(( $(rd "$h/pwm2")*100/255 )) $(( $(rd "$h/temp2_input")/1000 )) 42 } 43 cpu_busy_pct() { # CPU utilisation over ~0.3 s 44 local a b; a=($(head -1 /proc/stat)); sleep 0.3; b=($(head -1 /proc/stat)) 45 local ia=$(( a[4]+a[5] )) ib=$(( b[4]+b[5] )) ta=0 tb=0 i 46 for i in 1 2 3 4 5 6 7 8; do ta=$(( ta + a[i] )); tb=$(( tb + b[i] )); done 47 local dt=$(( tb - ta )); [ "$dt" -gt 0 ] && echo $(( 100 - (ib-ia)*100/dt )) || echo 0 48 } 49 cap_khz() { rd "$CPU0/scaling_max_freq"; } 50 max_khz() { rd "$CPU0/cpuinfo_max_freq"; } 51 bold() { printf '\033[1m%s\033[0m\n' "$*"; } 52 ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; } 53 warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } 54 bad() { printf ' \033[31m✘\033[0m %s\n' "$*"; } 55 die() { bad "$*"; exit 1; } 56 need_root() { [ "$(id -u)" = 0 ] || exec sudo -p "[sudo] password (needed to talk to the EC): " "$SELF" "$@"; } 57 58 stress_start() { STRESS_PIDS=(); local i n=${1:-$(nproc)} secs=${2:-30}; for i in $(seq "$n"); do timeout $((secs+5)) bash -c 'while :; do :; done' & STRESS_PIDS+=($!); done; } 59 stress_stop() { [ -n "${STRESS_PIDS:-}" ] && kill -9 "${STRESS_PIDS[@]}" 2>/dev/null; wait 2>/dev/null; } 60 61 # ---------- EC access (root; needs acpi_call) ---------------------------------------------- 62 ec_ready() { [ -w "$ACPI_CALL" ] || modprobe acpi_call 2>/dev/null; [ -w "$ACPI_CALL" ]; } 63 ec_raw() { printf '%s' "$1" > "$ACPI_CALL" || return 1; local o; o=$(tr -d '\0\n\r ' < "$ACPI_CALL"); printf '%s' "$o"; } 64 ec_read() { # ec_read 0x0741 -> decimal value 65 local out; out=$(ec_raw "$(printf '%s.ECRR 0x%04x' "$EC_DEV" "$1")") || return 1 66 [[ "$out" =~ ^0x[0-9a-fA-F]+$ ]] || { echo "EC read $(printf '0x%04x' "$1") failed: $out" >&2; return 1; } 67 echo $(( out )) 68 } 69 ec_write() { # ec_write 0x1804 200 70 local out; out=$(ec_raw "$(printf '%s.ECRW 0x%04x 0x%02x' "$EC_DEV" "$1" "$2")") || return 1 71 case "$out" in Error*|"not called"|"") echo "EC write $(printf '0x%04x' "$1") failed: ${out:-no result}" >&2; return 1;; esac 72 sleep 0.005 73 } 74 ec_set_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v | $2 )); } 75 ec_clear_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v & ~$2 & 0xff )); } 76 ec_bit() { local v; v=$(ec_read "$1") || return 1; echo $(( (v >> $2) & 1 )); } 77 78 # EC register map (TongFang/Uniwill; from kernel uniwill-laptop + tuxedo-drivers + qc71_laptop) 79 R_AP_OEM=0x0741 # bit0 manual-ctrl/OEM-app-present, bit5 FAN_ABNORMAL 80 R_FAN_MODE=0x0751 # bit6 "full fan mode" (old direct control), bit4 turbo, bit5 high, bit7 user 81 R_PWM1=0x075B; R_PWM2=0x075C # current duty 0..200 (read-only) 82 R_FAN_CTRL=0x078E # bit6 = universal (custom-table) fan control supported 83 R_TBL_SPLIT=0x07C5 # bit7 = separate CPU/GPU tables 84 R_TBL_ENABLE=0x07C6 # bit2 = enable custom tables 0x0F00.. 85 R_CPU_TBL_END=0x0F00; R_CPU_TBL_START=0x0F10; R_CPU_TBL_SPEED=0x0F20 86 R_GPU_TBL_END=0x0F30; R_GPU_TBL_START=0x0F40; R_GPU_TBL_SPEED=0x0F50 87 R_PWM1_W=0x1804; R_PWM2_W=0x1809 # writable duty 0..200 (old direct control) 88 R_CPU_TEMP=0x043E; R_GPU_TEMP=0x044F 89 R_FAN1_RPM=0x0464; R_FAN2_RPM=0x046C # 16-bit big-endian 90 91 universal_ctrl() { ec_bit $R_FAN_CTRL 6; } # 1 = custom-table path available 92 tables_enabled() { ec_bit $R_TBL_ENABLE 2; } 93 pct_to_duty() { echo $(( $1 * 200 / 100 )); } 94 duty_to_pct() { echo $(( $1 * 100 / 200 )); } 95 96 fan_init_tables() { # TUXEDO uw_init_fan(): one controllable zone 0-115 °C + dummy fail-safe zones at 100 % 97 local i 98 ec_clear_bits $R_FAN_MODE 0x40 99 [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] || ec_set_bits $R_TBL_SPLIT 0x80 100 ec_write $R_CPU_TBL_END 115; ec_write $R_CPU_TBL_START 0; ec_write $R_CPU_TBL_SPEED 1 101 ec_write $R_GPU_TBL_END 120; ec_write $R_GPU_TBL_START 0; ec_write $R_GPU_TBL_SPEED 1 102 for i in $(seq 1 15); do 103 ec_write $(( R_CPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_CPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_CPU_TBL_SPEED + i )) 200 104 ec_write $(( R_GPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_GPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_GPU_TBL_SPEED + i )) 200 105 done 106 [ "$(tables_enabled)" = 1 ] || ec_set_bits $R_TBL_ENABLE 0x04 107 } 108 fan_apply_duty() { # fan_apply_duty <0..200> (both fans; GPU fan is dead anyway) 109 local d=$1 110 if [ "$(universal_ctrl)" = 1 ]; then 111 [ "$(tables_enabled)" = 1 ] && [ "$(ec_bit $R_FAN_MODE 6)" = 0 ] || fan_init_tables 112 ec_write $R_CPU_TBL_SPEED "$d"; ec_write $R_GPU_TBL_SPEED "$d" 113 ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d" 114 else # old path: "full fan mode" + direct duty; EC ramps to 100 % on entry, so write quickly & repeatedly 115 local i; [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || ec_set_bits $R_FAN_MODE 0x40 116 for i in 1 2 3 4 5 6 7 8 9 10; do ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"; sleep 0.01; done 117 fi 118 } 119 fan_set_auto() { 120 if [ "$(universal_ctrl)" = 1 ]; then 121 [ "$(tables_enabled)" = 1 ] && ec_clear_bits $R_TBL_ENABLE 0x04 122 [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] && ec_clear_bits $R_TBL_SPLIT 0x80 123 fi 124 [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] && ec_clear_bits $R_FAN_MODE 0x40 125 return 0 126 } 127 fan_mode_text() { # what is controlling the fans right now 128 if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then echo "curve daemon (fanfix)"; return; fi 129 if [ "$(ec_bit $R_FAN_MODE 6)" = 1 ]; then echo "manual (old full-fan mode)"; return; fi 130 if [ "$(tables_enabled)" = 1 ]; then echo "manual (custom table, $(duty_to_pct "$(ec_read $R_CPU_TBL_SPEED)") %)"; return; fi 131 echo "auto (EC curve)" 132 } 133 curve_pct_for_temp() { # linear interpolation over CURVE="t:p t:p ..." 134 local t=$1 curve=${CURVE:-$DEFAULT_CURVE} pt pp lt=-1 lp=-1 135 for pair in $curve; do pt=${pair%%:*}; pp=${pair##*:} 136 if [ "$t" -le "$pt" ]; then 137 if [ "$lt" -lt 0 ]; then echo "$pp"; else echo $(( lp + (pp - lp) * (t - lt) / (pt - lt) )); fi; return 138 fi; lt=$pt; lp=$pp 139 done 140 echo "$lp" 141 } 142 143 # ---------- commands ----------------------------------------------------------------------- 144 cmd_status() { 145 bold "fanfix status — $(date '+%H:%M:%S')" 146 local cap=$(( $(cap_khz)/1000 )) mx=$(( $(max_khz)/1000 )) 147 if [ "$cap" -le 3300 ]; then ok "CPU cap: ${cap} MHz (turbo disabled; hardware max ${mx} MHz)"; else bad "CPU NOT capped (${cap} MHz) — run: fanfix install"; fi 148 [ -e "$CONF" ] && ok "cap persists across reboots ($CONF)" || warn "cap is not persisted — run: fanfix install" 149 [ "$(rd /sys/devices/system/cpu/cpufreq/boost)" = 1 ] && ok "global boost flag = 1 (needed by power-profiles-daemon)" || warn "global boost flag = 0 — power-profiles-daemon profile switching will fail; run: fanfix install" 150 local prof; prof=$(powerprofilesctl get 2>/dev/null || echo '?') 151 [ "$prof" = performance ] && ok "power profile: performance (instant ramp-up)" || warn "power profile: $prof (performance is snappier; power-saver also safe)" 152 read -r avg mn mxf <<<"$(freqs)" 153 echo " temp: CPU $(tctl) °C package $(ppt_w) W clocks avg ${avg} / max ${mxf} MHz" 154 echo " $(fan_line)" 155 if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then ok "fan curve daemon running (fanfix fan curve off to stop)"; fi 156 local busy; busy=$(cpu_busy_pct); read -r avg mn mxf <<<"$(freqs)" 157 if [ "$busy" -gt 25 ] && [ "$mxf" -lt "$TRIP_MHZ" ]; then bad "EC THROTTLE ACTIVE RIGHT NOW (cores at ${mxf} MHz with ${busy}% load) — lower the cap: fanfix install 3000"; else ok "no EC throttle active (load ${busy}%)"; fi 158 } 159 160 cmd_watch() { 161 local iv=${1:-1} 162 echo "live view every ${iv}s — Ctrl-C to quit (THROTTLE = EC clamp active)" 163 printf "%-8s %-6s %-8s %-20s %-s\n" "time" "Tctl" "pkg W" "clocks avg/max MHz" "fans" 164 while true; do 165 read -r avg mn mxf <<<"$(freqs)" 166 local flag=""; [ "$mxf" -lt "$TRIP_MHZ" ] && flag=" <-- THROTTLE" 167 printf "%-8s %3d°C %6s %5d / %5d %s%s\n" "$(date +%H:%M:%S)" "$(tctl)" "$(ppt_w)" "$avg" "$mxf" "$(fan_line)" "$flag" 168 sleep "$iv" 169 done 170 } 171 172 cmd_test() { 173 local secs=${1:-30} threads=${2:-$(nproc)} 174 bold "fanfix test — ${threads} threads for ${secs}s (Ctrl-C aborts)" 175 echo "cap: $(( $(cap_khz)/1000 )) MHz profile: $(powerprofilesctl get 2>/dev/null)" 176 stress_start "$threads" "$secs"; trap 'stress_stop; exit 130' INT TERM 177 local i trips=0 prev=9999 tmax=0 fsum=0 n=0 178 for i in $(seq 1 "$secs"); do 179 sleep 1 180 read -r avg mn mxf <<<"$(freqs)"; local t; t=$(tctl) 181 [ "$mxf" -lt "$TRIP_MHZ" ] && [ "$prev" -ge "$TRIP_MHZ" ] && trips=$((trips+1)); prev=$mxf 182 [ "$t" -gt "$tmax" ] && tmax=$t; fsum=$((fsum+avg)); n=$((n+1)) 183 printf " %3ds Tctl=%2d°C pkg=%5sW avg=%4d max=%4d MHz %s%s\n" "$i" "$t" "$(ppt_w)" "$avg" "$mxf" "$(fan_line)" "$([ "$mxf" -lt "$TRIP_MHZ" ] && echo ' <-- THROTTLE')" 184 done 185 stress_stop; trap - INT TERM 186 echo; bold "result: peak ${tmax}°C, mean clock $((fsum/n)) MHz, EC throttle events: ${trips}" 187 if [ "$trips" -gt 0 ]; then bad "throttling still happens — lower the cap: fanfix install 3000"; else ok "no throttling — cap is doing its job"; fi 188 [ "$tmax" -ge 75 ] && warn "peak ${tmax}°C is close to the 79°C trip; consider: fanfix install 3000" 189 } 190 191 cmd_install() { 192 local cap=${1:-$CAP_DEFAULT_KHZ} 193 [[ "$cap" =~ ^[0-9]+$ ]] || die "cap must be a number (MHz or kHz), e.g. 3200 or 3200000" 194 [ "$cap" -lt 100000 ] && cap=$((cap*1000)) # MHz given → kHz 195 if [ "$cap" -lt 1500000 ] || [ "$cap" -gt "$(max_khz)" ]; then die "cap out of range (1500–$(( $(max_khz)/1000 )) MHz)"; fi 196 bold "fanfix install — cap every core at $((cap/1000)) MHz (asks for sudo)" 197 if [ -e "$OLD_CONF" ] || [ "$(rd /sys/devices/system/cpu/cpufreq/boost)" = 0 ]; then 198 echo " undoing old global-boost approach (conflicts with power-profiles-daemon)" 199 sudo rm -f "$OLD_CONF"; echo 1 | sudo tee /sys/devices/system/cpu/cpufreq/boost >/dev/null 200 fi 201 sudo tee "$CONF" >/dev/null <<EOF 202 # fanfix: broken GPU fan → EC pins CPU at 399 MHz at ~79 C. Cap every core so it never gets there. 203 # Remove with: fanfix uninstall (after the fan is replaced) 204 w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${cap} 205 EOF 206 sudo systemd-tmpfiles --create "$CONF" 207 powerprofilesctl set performance 2>/dev/null || warn "could not set performance profile (powerprofilesctl get)" 208 sudo systemd-tmpfiles --create "$CONF" # profile switches toggle boost; re-assert the cap 209 ok "cap = $(( $(cap_khz)/1000 )) MHz on all cores, persisted in $CONF" 210 ok "profile = $(powerprofilesctl get 2>/dev/null)" 211 echo; cmd_test 20 212 } 213 214 cmd_uninstall() { 215 bold "fanfix uninstall — remove the CPU cap (asks for sudo)" 216 sudo rm -f "$CONF" "$OLD_CONF" 217 echo 1 | sudo tee /sys/devices/system/cpu/cpufreq/boost >/dev/null 218 local f; for f in /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq; do sudo tee "$f" >/dev/null < "${f%scaling_max_freq}cpuinfo_max_freq"; done 219 ok "cap removed: max = $(( $(cap_khz)/1000 )) MHz, turbo boost back on" 220 warn "only do this once the GPU fan is replaced — otherwise the 399 MHz stalls come back" 221 } 222 223 cmd_fans() { 224 local persist=0; [ "${1:-}" = "--persist" ] && persist=1 225 if ! hwmon_by_name uniwill >/dev/null; then 226 bold "loading fan driver (uniwill-laptop force=1) (asks for sudo)" 227 sudo modprobe uniwill-laptop force=1 || die "modprobe failed"; sleep 1 228 fi 229 if [ "$persist" = 1 ]; then 230 echo 'uniwill-laptop' | sudo tee /etc/modules-load.d/uniwill-laptop.conf >/dev/null 231 echo 'options uniwill-laptop force=1' | sudo tee /etc/modprobe.d/uniwill-laptop.conf >/dev/null 232 ok "fan driver will load at every boot (remove /etc/modules-load.d/uniwill-laptop.conf to undo)" 233 fi 234 echo " $(fan_line)" 235 local h; h=$(hwmon_by_name uniwill); [ "$(rd "$h/fan2_input")" = 0 ] && [ "$(rd "$h/pwm2")" -gt 0 ] && bad "GPU fan commanded $(( $(rd "$h/pwm2")*100/255 ))% but 0 rpm → dead/jammed" || true 236 } 237 238 # ---- fan control ---- 239 cmd_fan() { 240 local sub=${1:-status}; shift || true 241 case "$sub" in 242 setup) cmd_fan_setup "$@" ;; 243 status) need_root fan status; cmd_fan_status ;; 244 auto) need_root fan auto; fan_guard; fan_stop_daemon; fan_set_auto && ok "fans back to automatic (EC curve)" ;; 245 max) need_root fan max; fan_guard; fan_stop_daemon; fan_apply_duty 200 && ok "fans set to 100 % (manual) — 'fanfix fan auto' to release" ;; 246 curve) cmd_fan_curve "$@" ;; 247 [0-9]*) local p=${sub%\%}; [[ "$p" =~ ^[0-9]+$ ]] || die "percent must be a number" 248 [ "$p" -le 100 ] || die "max is 100" 249 [ "$p" -ge "$FAN_MIN_PCT" ] || die "refusing < ${FAN_MIN_PCT} % — EC stalls the CPU at 79 °C; 30 % is the floor (use 'fanfix fan auto' for idle fan-off)" 250 need_root fan "$sub"; fan_guard; fan_stop_daemon 251 fan_apply_duty "$(pct_to_duty "$p")" && ok "fans set to ${p} % (manual) — stays until 'fanfix fan auto' (EC trip at 79 °C still protects you)" ;; 252 -h|--help|help) cmd_help ;; 253 *) die "unknown fan command: $sub" ;; 254 esac 255 } 256 fan_guard() { ec_ready || die "acpi_call not available — run: fanfix fan setup"; ec_read $R_AP_OEM >/dev/null || die "EC not reachable via ${EC_DEV}.ECRR"; } 257 fan_stop_daemon() { systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && { systemctl stop "$FAN_UNIT"; echo " (stopped fan curve daemon)"; }; return 0; } 258 259 cmd_fan_setup() { 260 bold "fanfix fan setup — install EC access (acpi_call) and load drivers (asks for sudo)" 261 if ! modinfo acpi_call >/dev/null 2>&1; then 262 sudo pacman -S --needed --noconfirm acpi_call-dkms || die "pacman failed (needs internet; package: acpi_call-dkms)" 263 fi 264 sudo modprobe acpi_call || die "could not load acpi_call" 265 sudo modprobe uniwill-laptop force=1 2>/dev/null || true 266 printf 'acpi_call\nuniwill-laptop\n' | sudo tee /etc/modules-load.d/fanfix.conf >/dev/null 267 echo 'options uniwill-laptop force=1' | sudo tee /etc/modprobe.d/uniwill-laptop.conf >/dev/null 268 ok "acpi_call + uniwill-laptop load at every boot" 269 sudo "$SELF" fan status 270 } 271 272 cmd_fan_status() { 273 fan_guard 274 bold "fanfix fan status" 275 local ap; ap=$(ec_read $R_AP_OEM) 276 echo " mode : $(fan_mode_text)" 277 echo " CPU fan : $(( $(ec_read $R_FAN1_RPM)*256 + $(ec_read $((R_FAN1_RPM+1))) )) rpm duty $(duty_to_pct "$(ec_read $R_PWM1)") % CPU $(ec_read $R_CPU_TEMP) °C" 278 echo " GPU fan : $(( $(ec_read $R_FAN2_RPM)*256 + $(ec_read $((R_FAN2_RPM+1))) )) rpm duty $(duty_to_pct "$(ec_read $R_PWM2)") % GPU $(ec_read $R_GPU_TEMP) °C" 279 echo " EC flags : fan-abnormal=$(( (ap>>5)&1 )) manual-ctrl=$(( ap&1 )) universal-fan-ctrl=$(universal_ctrl) custom-tables=$(tables_enabled) full-fan-mode=$(ec_bit $R_FAN_MODE 6) (0x0751=$(printf '0x%02x' "$(ec_read $R_FAN_MODE)"))" 280 [ "$(( (ap>>5)&1 ))" = 1 ] && warn "EC reports FAN ABNORMAL — this is the flag behind the 79 °C throttle policy" 281 systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && echo " curve : ${CURVE:-$DEFAULT_CURVE} (from ${FAN_CONF}, if present)" 282 return 0 283 } 284 285 cmd_fan_curve() { 286 local a=${1:-status} 287 case "$a" in 288 on|start) 289 need_root fan curve on; fan_guard 290 cat > /etc/systemd/system/$FAN_UNIT <<EOF 291 [Unit] 292 Description=fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround) 293 After=multi-user.target 294 [Service] 295 Type=simple 296 ExecStart=$SELF fan-daemon 297 ExecStopPost=$SELF fan-release 298 Restart=on-failure 299 RestartSec=5 300 [Install] 301 WantedBy=multi-user.target 302 EOF 303 systemctl daemon-reload && systemctl enable --now "$FAN_UNIT" && ok "fan curve daemon enabled + started (survives reboot). Curve: ${CURVE:-$DEFAULT_CURVE}" \ 304 && echo " edit ${FAN_CONF} to change it, e.g. CURVE=\"45:30 55:45 65:70 75:100\" then: fanfix fan curve restart" ;; 305 off|stop) 306 need_root fan curve off 307 systemctl disable --now "$FAN_UNIT" 2>/dev/null; rm -f /etc/systemd/system/$FAN_UNIT; systemctl daemon-reload 308 fan_guard; fan_set_auto && ok "curve daemon removed, fans back to automatic" ;; 309 restart) need_root fan curve restart; systemctl restart "$FAN_UNIT" && ok "restarted" ;; 310 status|*) systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && { ok "curve daemon running"; systemctl status "$FAN_UNIT" --no-pager -n 5 2>/dev/null | tail -6; } || echo " curve daemon not running (fanfix fan curve on)" ;; 311 esac 312 } 313 314 cmd_fan_daemon() { # root; run by the systemd unit 315 [ -r "$FAN_CONF" ] && . "$FAN_CONF" 316 ec_ready || { echo "acpi_call missing"; exit 1; } 317 trap 'fan_set_auto; exit 0' TERM INT 318 local last=-1 t p d n=0 319 echo "fanfix fan-daemon: curve ${CURVE:-$DEFAULT_CURVE}" 320 while true; do 321 t=$(tctl); [ "$t" -le 0 ] && t=$(ec_read $R_CPU_TEMP 2>/dev/null || echo 0) 322 if [ "$t" -le 0 ]; then fan_set_auto; echo "no temperature reading — fans released to auto"; sleep 5; continue; fi 323 p=$(curve_pct_for_temp "$t"); [ "$p" -lt "$FAN_MIN_PCT" ] && p=$FAN_MIN_PCT 324 d=$(pct_to_duty "$p") 325 # re-apply when the target changes by ≥2 %, and at least every 60 s (old-path ECs revert after a while) 326 local changed=0; [ $(( d > last ? d - last : last - d )) -ge 4 ] && changed=1 327 if [ "$changed" = 1 ] || [ $(( n % 30 )) = 0 ]; then 328 fan_apply_duty "$d" && last=$d 329 [ "$changed" = 1 ] && echo "CPU ${t} °C → fan ${p} %" 330 fi 331 n=$((n+1)); sleep 2 332 done 333 } 334 335 cmd_help() { 336 cat <<EOF 337 fanfix — live with the dead GPU fan (firmware pins CPU at 399 MHz when CPU ≥ ~79 °C) 338 339 CPU clock cap (the fix for the stalls) 340 fanfix status what's applied, temps, fans, is the throttle active right now 341 fanfix watch [sec] live one-line-per-second view (Ctrl-C to quit) 342 fanfix test [sec] [thr] all-core stress test with readout; counts throttle events (default 30 s) 343 fanfix install [MHz] apply + persist the clock cap (default 3200) and performance profile [sudo] 344 fanfix uninstall remove the cap — only after the fan is replaced [sudo] 345 fanfix fans [--persist] load the fan-RPM driver (and optionally at every boot) [sudo] 346 347 Fan control (surviving CPU fan; talks to the EC the way TUXEDO's driver does) 348 fanfix fan setup one-time: install acpi_call-dkms, load + persist drivers [sudo] 349 fanfix fan status mode, rpm, duty, EC flags (incl. the fan-abnormal flag) [sudo] 350 fanfix fan <30-100> fixed fan speed in % (manual, until 'auto'); e.g. fanfix fan 60 [sudo] 351 fanfix fan max 100 % [sudo] 352 fanfix fan auto give control back to the EC [sudo] 353 fanfix fan curve on|off background daemon: CPU temp → fan %, more cooling than the EC's curve [sudo] 354 default curve ${DEFAULT_CURVE}; override in ${FAN_CONF} 355 356 Typical: fanfix status · stalls seen? → fanfix install 3000 · want more headroom? → fanfix fan curve on · fan replaced? → fanfix uninstall 357 EOF 358 } 359 360 case "${1:-help}" in 361 status) shift; cmd_status "$@" ;; 362 watch) shift; cmd_watch "$@" ;; 363 test) shift; cmd_test "$@" ;; 364 install|cap) shift; cmd_install "$@" ;; 365 uninstall|uncap) shift; cmd_uninstall "$@" ;; 366 fans) shift; cmd_fans "$@" ;; 367 fan) shift; cmd_fan "$@" ;; 368 fan-daemon) cmd_fan_daemon ;; 369 fan-release) ec_ready && fan_set_auto ;; 370 help|-h|--help) cmd_help ;; 371 *) bad "unknown command: $1"; cmd_help; exit 2 ;; 372 esac