fan.nix (6320B)
1 # modules/home/fan.nix — `fan`: the laptop's fans from the terminal, safely. 2 # 3 # fan one line: CPU °C, fan rpm and %, clock cap, load, clamp yes/no, EC mode 4 # fan watch [s] the same line every s seconds (default 2), Ctrl-C to stop 5 # fan max 100 % now, with the watchdog (below) fan 60 fixed 60 % (30-100) 6 # fan auto back to the EC's own curve; stops the watchdog 7 # fan log what the watchdog has done 8 # 9 # Why a watchdog: this laptop's EC, once in manual fan mode, asserts PROCHOT 10 # and pins every core at 399 MHz as soon as load starts (fan-throttle-guard.nix). 11 # `fan max` therefore starts a transient user unit (fan-watchdog) that samples 12 # /proc/stat and /proc/cpuinfo every second and, the moment CPU busy ≥ 25 % 13 # while no core is above 600 MHz (fanfix's clamp rule), runs `fan-ec auto`, 14 # sends a notification and exits. Root access is `sudo -n fan-ec …` 15 # (modules/hosts/laptop/fan-cli.nix: a store script with a passwordless rule for wheel). 16 # Reads need no root at all: k10temp and the uniwill hwmon are world-readable. 17 { ... }: 18 { 19 flake.homeModules.fan = 20 { pkgs, lib, ... }: 21 let 22 bin = lib.makeBinPath [ pkgs.coreutils pkgs.gawk pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.libnotify ]; 23 # NixOS's setuid sudo lives in /run/wrappers; the store copy is not setuid 24 # and refuses to run ("must be owned by uid 0 and have the setuid bit set"). 25 sudo = "/run/wrappers/bin/sudo"; 26 27 # shared read-only sampler, sourced by both scripts 28 lib-sh = pkgs.writeText "fan-lib.sh" '' 29 TRIP_MHZ=600; BUSY_MIN=25 30 STATE=''${XDG_RUNTIME_DIR:-/tmp}/fan.stat 31 hw() { local n; for h in /sys/class/hwmon/hwmon*; do n=$(cat "$h/name" 2>/dev/null); [ "$n" = "$1" ] && { echo "$h"; return; }; done; } 32 cpu_temp() { local h; h=$(hw k10temp); [ -n "$h" ] && echo $(( $(cat "$h/temp1_input") / 1000 )) || echo -; } 33 fan_rpm() { local h; h=$(hw uniwill); [ -n "$h" ] && cat "$h/fan1_input" || echo -; } 34 fan_pct() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/pwm1") * 100 / 255 )) || echo -; } 35 gpu_temp() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/temp2_input") / 1000 )) || echo -; } 36 cap_mhz() { echo $(( $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq) / 1000 )); } 37 clocks() { awk '/^cpu MHz/ {s+=$4; n++; if ($4>m) m=$4} END {printf "%d %d", (n?s/n:0), m}' /proc/cpuinfo; } 38 # CPU busy % since the previous call (snapshot in $STATE); 0 on the first call 39 busy() { 40 local cur prev b=0 41 cur=$(head -1 /proc/stat) 42 [ -r "$STATE" ] && prev=$(cat "$STATE") || prev= 43 printf '%s' "$cur" > "$STATE" 44 [ -n "$prev" ] && b=$(awk -v a="$prev" -v b="$cur" 'BEGIN { 45 na=split(a,x," "); nb=split(b,y," "); ta=0; tb=0 46 for (i=2;i<=na;i++) ta+=x[i]; for (i=2;i<=nb;i++) tb+=y[i] 47 ia=x[5]+x[6]; ib=y[5]+y[6]; d=tb-ta; if (d<=0) {print 0; exit} 48 printf "%d", 100*(d-(ib-ia))/d }') 49 echo "$b" 50 } 51 clamped() { # 1 when busy yet no core above TRIP_MHZ 52 local b=$1 mx=$2; [ "$b" -ge "$BUSY_MIN" ] && [ "$mx" -gt 0 ] && [ "$mx" -lt "$TRIP_MHZ" ] && echo 1 || echo 0 53 } 54 ec_mode() { ${sudo} -n /run/current-system/sw/bin/fan-ec mode 2>/dev/null || echo "?"; } 55 status_line() { 56 local b mx avg; b=$(busy); read -r avg mx <<< "$(clocks)" 57 local cl; cl=$(clamped "$b" "$mx") 58 printf 'CPU %s°C · GPU %s°C · fan %s rpm %s%% · cap %s MHz · clocks avg %s / max %s · load %s%% · clamp %s · fans %s\n' \ 59 "$(cpu_temp)" "$(gpu_temp)" "$(fan_rpm)" "$(fan_pct)" "$(cap_mhz)" "$avg" "$mx" "$b" \ 60 "$([ "$cl" = 1 ] && echo YES || echo no)" "$(ec_mode)" 61 } 62 ''; 63 64 fan-watchdog = pkgs.writeShellScriptBin "fan-watchdog" '' 65 set -uo pipefail 66 PATH=${bin}:$PATH 67 . ${lib-sh} 68 LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log; mkdir -p "$(dirname "$LOG")" 69 log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; } 70 log "armed: fans manual ($1), watching for the EC clamp" 71 busy >/dev/null; sleep 1 72 while :; do 73 b=$(busy); read -r _ mx <<< "$(clocks)" 74 if [ "$(clamped "$b" "$mx")" = 1 ]; then 75 out=$(${sudo} -n /run/current-system/sw/bin/fan-ec auto 2>&1) 76 log "CLAMP: load $b %, max core $mx MHz -> fan-ec auto: $out" 77 notify-send -a fan -u critical "Fans back to auto" "EC clamp hit (load $b %, cores at $mx MHz). Manual fan mode released." 78 exit 0 79 fi 80 sleep 1 81 done 82 ''; 83 84 fan = pkgs.writeShellScriptBin "fan" '' 85 set -uo pipefail 86 PATH=${bin}:$PATH 87 . ${lib-sh} 88 UNIT=fan-watchdog 89 EC=/run/current-system/sw/bin/fan-ec 90 LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log 91 92 arm() { # start (or restart) the watchdog as a transient user unit 93 systemctl --user stop "$UNIT" 2>/dev/null || true 94 systemd-run --user --unit="$UNIT" --collect --quiet ${fan-watchdog}/bin/fan-watchdog "$1" \ 95 && echo "watchdog armed: back to auto the moment the EC clamps the CPU (fan log)" 96 } 97 disarm() { systemctl --user stop "$UNIT" 2>/dev/null || true; } 98 99 if [ "$(id -u)" = 0 ]; then 100 echo "fan: run this as yourself, not under sudo (it needs your user session for the watchdog; root access is handled inside)" >&2 101 exit 1 102 fi 103 104 case "''${1:-}" in 105 ""|status) status_line ;; 106 watch) 107 iv=''${2:-2}; busy >/dev/null; sleep "$iv" 108 while :; do status_line; sleep "$iv"; done ;; 109 max) ${sudo} -n "$EC" max && arm max ;; 110 auto) disarm; ${sudo} -n "$EC" auto ;; 111 [0-9]*) p=''${1%\%}; ${sudo} -n "$EC" "$p" && arm "$p %" ;; 112 ec) ${sudo} -n "$EC" status ;; 113 log) [ -r "$LOG" ] && tail -n ''${2:-20} "$LOG" || echo "no watchdog log yet" ;; 114 -h|--help|help) 115 echo "usage: fan [status] | watch [sec] | max | <30-100> | auto | ec | log [n]" 116 echo " max / <pct> put the fans in manual mode WITH the clamp watchdog; auto releases them." ;; 117 *) echo "fan: unknown command '$1' (try: fan help)" >&2; exit 2 ;; 118 esac 119 ''; 120 in 121 { 122 home.packages = [ fan fan-watchdog ]; 123 } 124 ; 125 }