NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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