fan-cli.nix (6780B)
1 # modules/hosts/laptop/fan-cli.nix — root side of the `fan` command (modules/home/fan.nix). 2 # 3 # `fan-ec` talks to the embedded controller the way fanfix does (same 4 # acpi_call recipe, same registers; see ~/.local/bin/fanfix and the TUXEDO 5 # driver), but it is a fixed script in the Nix store, so the wheel group may 6 # run it through sudo without a password. That is what lets the watchdog in 7 # fan.nix put the fans back to EC-automatic from a background unit, where 8 # sudo could not ask for one. fanfix itself lives in the user-writable 9 # ~/.local/bin and must never get such a rule. 10 # 11 # fan-ec status mode, duty %, EC flags fan-ec max 100 % (manual) 12 # fan-ec auto hand control back to the EC fan-ec <30-100> fixed % (manual) 13 # fan-ec mode one word: auto | manual | curve-daemon 14 # 15 # Manual mode on this laptop makes the EC assert PROCHOT (all cores 399 MHz) 16 # under load (fan-throttle-guard.nix, line ~97). fan-ec does not try to 17 # prevent that; the user-side watchdog detects the clamp and calls `fan-ec auto`. 18 { ... }: 19 { 20 flake.nixosModules.laptop-fan-cli = 21 { pkgs, lib, ... }: 22 let 23 fan-ec = pkgs.writeShellScriptBin "fan-ec" '' 24 set -uo pipefail 25 [ "$(id -u)" = 0 ] || { echo "fan-ec: run as root (sudo fan-ec …)" >&2; exit 1; } 26 PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.kmod pkgs.systemd ]}:$PATH 27 28 ACPI_CALL=/proc/acpi/call 29 EC_DEV='\_SB.INOU' 30 FAN_UNIT=fanfix-fan.service 31 FAN_MIN_PCT=30 32 33 ec_ready() { [ -w "$ACPI_CALL" ] || modprobe acpi_call 2>/dev/null; [ -w "$ACPI_CALL" ]; } 34 ec_raw() { printf '%s' "$1" > "$ACPI_CALL" || return 1; local o; o=$(tr -d '\0\n\r ' < "$ACPI_CALL"); printf '%s' "$o"; } 35 ec_read() { local out; out=$(ec_raw "$(printf '%s.ECRR 0x%04x' "$EC_DEV" "$1")") || return 1 36 [[ "$out" =~ ^0x[0-9a-fA-F]+$ ]] || { echo "EC read $(printf '0x%04x' "$1") failed: $out" >&2; return 1; } 37 echo $(( out )); } 38 ec_write() { local out; out=$(ec_raw "$(printf '%s.ECRW 0x%04x 0x%02x' "$EC_DEV" "$1" "$2")") || return 1 39 case "$out" in Error*|"not called"|"") echo "EC write $(printf '0x%04x' "$1") failed: ''${out:-no result}" >&2; return 1;; esac 40 sleep 0.005; } 41 ec_set_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v | $2 )); } 42 ec_clear_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v & ~$2 & 0xff )); } 43 ec_bit() { local v; v=$(ec_read "$1") || return 1; echo $(( (v >> $2) & 1 )); } 44 45 R_AP_OEM=0x0741; R_FAN_MODE=0x0751; R_PWM1=0x075B; R_PWM2=0x075C 46 R_FAN_CTRL=0x078E; R_TBL_SPLIT=0x07C5; R_TBL_ENABLE=0x07C6 47 R_CPU_TBL_END=0x0F00; R_CPU_TBL_START=0x0F10; R_CPU_TBL_SPEED=0x0F20 48 R_GPU_TBL_END=0x0F30; R_GPU_TBL_START=0x0F40; R_GPU_TBL_SPEED=0x0F50 49 R_PWM1_W=0x1804; R_PWM2_W=0x1809 50 51 universal_ctrl() { ec_bit $R_FAN_CTRL 6; } 52 tables_enabled() { ec_bit $R_TBL_ENABLE 2; } 53 pct_to_duty() { echo $(( $1 * 200 / 100 )); } 54 duty_to_pct() { echo $(( $1 * 100 / 200 )); } 55 56 fan_init_tables() { 57 local i 58 ec_clear_bits $R_FAN_MODE 0x40 59 [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] || ec_set_bits $R_TBL_SPLIT 0x80 60 ec_write $R_CPU_TBL_END 115; ec_write $R_CPU_TBL_START 0; ec_write $R_CPU_TBL_SPEED 1 61 ec_write $R_GPU_TBL_END 120; ec_write $R_GPU_TBL_START 0; ec_write $R_GPU_TBL_SPEED 1 62 for i in $(seq 1 15); do 63 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 64 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 65 done 66 [ "$(tables_enabled)" = 1 ] || ec_set_bits $R_TBL_ENABLE 0x04 67 } 68 fan_apply_duty() { 69 local d=$1 70 if [ "$(universal_ctrl)" = 1 ]; then 71 [ "$(tables_enabled)" = 1 ] && [ "$(ec_bit $R_FAN_MODE 6)" = 0 ] || fan_init_tables 72 ec_write $R_CPU_TBL_SPEED "$d"; ec_write $R_GPU_TBL_SPEED "$d" 73 ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d" 74 else 75 local i; [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || ec_set_bits $R_FAN_MODE 0x40 76 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 77 fi 78 } 79 fan_set_auto() { 80 if [ "$(universal_ctrl)" = 1 ]; then 81 [ "$(tables_enabled)" = 1 ] && ec_clear_bits $R_TBL_ENABLE 0x04 82 [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] && ec_clear_bits $R_TBL_SPLIT 0x80 83 fi 84 [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] && ec_clear_bits $R_FAN_MODE 0x40 85 return 0 86 } 87 mode_word() { 88 if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then echo curve-daemon; return; fi 89 if [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || [ "$(tables_enabled)" = 1 ]; then echo manual; else echo auto; fi 90 } 91 stop_daemon() { systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && systemctl stop "$FAN_UNIT"; return 0; } 92 guard() { ec_ready || { echo "fan-ec: acpi_call not available" >&2; exit 1; } 93 ec_read $R_AP_OEM >/dev/null || { echo "fan-ec: EC not reachable via $EC_DEV.ECRR" >&2; exit 1; }; } 94 95 case "''${1:-status}" in 96 mode) guard; mode_word ;; 97 status) guard 98 printf 'mode=%s duty=%s%% target=%s%% fan-abnormal=%s full-fan-bit=%s tables=%s\n' \ 99 "$(mode_word)" "$(duty_to_pct "$(ec_read $R_PWM1)")" \ 100 "$([ "$(tables_enabled)" = 1 ] && duty_to_pct "$(ec_read $R_CPU_TBL_SPEED)" || echo -)" \ 101 "$(ec_bit $R_AP_OEM 5)" "$(ec_bit $R_FAN_MODE 6)" "$(tables_enabled)" ;; 102 auto) guard; stop_daemon; fan_set_auto && echo "fans: auto (EC curve)" ;; 103 max) guard; stop_daemon; fan_apply_duty 200 && echo "fans: 100 % (manual)" ;; 104 [0-9]*) p=''${1%\%}; [[ "$p" =~ ^[0-9]+$ ]] && [ "$p" -le 100 ] && [ "$p" -ge "$FAN_MIN_PCT" ] \ 105 || { echo "fan-ec: percent must be $FAN_MIN_PCT-100 (below that the EC trips at 79 °C)" >&2; exit 2; } 106 guard; stop_daemon; fan_apply_duty "$(pct_to_duty "$p")" && echo "fans: $p % (manual)" ;; 107 *) echo "usage: fan-ec status|mode|auto|max|<30-100>" >&2; exit 2 ;; 108 esac 109 ''; 110 in 111 { 112 environment.systemPackages = [ fan-ec ]; 113 114 # wheel may run fan-ec without a password: it is immutable store content 115 # (via the system profile symlink, which is root-owned), does one thing, 116 # and the watchdog has no terminal to type into. 117 security.sudo.extraRules = [ 118 { 119 groups = [ "wheel" ]; 120 commands = [ 121 { command = "/run/current-system/sw/bin/fan-ec"; options = [ "NOPASSWD" ]; } 122 ]; 123 } 124 ]; 125 } 126 ; 127 }