fan-throttle-guard.nix (5968B)
1 # modules/hosts/laptop/fan-throttle-guard.nix 2 # 3 # Dead-GPU-fan workaround for the PCSpecialist Valeon II 17 (TongFang GM7RGxM, 4 # Ryzen 9 6900HX + RTX 3070 Ti Laptop). The embedded controller sees the dead 5 # "Secondary" fan (fan2: 0 rpm while commanded 100 %), raises its fan-abnormal 6 # flag and, once Tctl reaches ~79 °C, asserts PROCHOT and pins all 16 threads at 7 # 399 MHz until ~47 °C. That policy is firmware; Linux cannot switch it off. 8 # The fix is to keep the CPU from ever reaching the trip point: 9 # 10 # 1. a static scaling_max_freq floor of 3.2 GHz applied by tmpfiles at boot 11 # (3.2 GHz ≈ base clock → ~67-70 °C under all-core load, no trips), 12 # 2. a staged guard (3200 → 2400 → 1800 MHz) driven by k10temp + the uniwill 13 # board sensor, which also covers the "latched" low-temperature clamp, 14 # 3. the surviving CPU fan held at 60 % duty through the EC's own ACPI 15 # methods (acpi_call → \_SB.INOU.ECRR/ECRW, TUXEDO register recipe), 16 # 4. power-profiles-daemon kept on, profile "performance", and the global 17 # cpufreq boost flag left at 1 — never use boost=0, ppd 0.30 writes 18 # per-policy boost on every switch and fails with EINVAL otherwise. 19 # 20 # Everything here was measured on the Arch install this was captured from 21 # (fanfix 2026-08-23, stability guard 2026-09-07). Files beside this module: 22 # fanfix the CLI/daemon (bash) ← copied verbatim 23 # stability_guard.py the staged ceiling (python3) ← copied verbatim 24 # fan-ctl, fan-state bar-widget helpers; need a polkit agent and a bar slot 25 # 26 # Verify on first boot: fanfix status · fanfix fan status · fanfix test 30 27 # expected: cap 3200 MHz, boost 1, profile performance, no THROTTLE event, 28 # peak < 75 °C. If a trip still happens: lower the floor to 3000000 below. 29 { ... }: 30 { 31 flake.nixosModules.laptop-fan-throttle-guard = 32 { config, pkgs, lib, ... }: 33 34 let 35 # fanfix is plain bash; wrap it so the shebang resolves and PATH is supplied 36 # by the unit (fanDeps) rather than by whatever shell invoked it. 37 fanfix = pkgs.writeShellScriptBin "fanfix" (builtins.readFile ./fanfix); 38 39 fanDeps = with pkgs; [ 40 coreutils gnugrep gawk gnused procps util-linux 41 kmod # modprobe acpi_call / uniwill-laptop 42 systemd # systemctl, systemd-tmpfiles 43 power-profiles-daemon # powerprofilesctl 44 ]; 45 46 capKhz = 3200000; # the floor. 3000000 is the documented fallback. 47 in 48 { 49 ##### 1. EC access and fan/temperature readout ############################## 50 # acpi_call is out-of-tree (nixpkgs: linuxPackages.acpi_call). uniwill-laptop 51 # is in-tree; `force=1` is required because the DMI match list does not carry 52 # this GM7RGxM. Verify `modinfo uniwill-laptop` exists on the chosen kernel. 53 boot.extraModulePackages = [ config.boot.kernelPackages.acpi_call ]; 54 boot.kernelModules = [ "acpi_call" "uniwill-laptop" ]; 55 boot.extraModprobeConfig = '' 56 options uniwill-laptop force=1 57 ''; 58 59 ##### 2. Static floor, applied before any user load exists ################### 60 systemd.tmpfiles.rules = [ 61 "w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${toString capKhz}" 62 ]; 63 64 ##### 3. power-profiles-daemon stays on ###################################### 65 services.power-profiles-daemon.enable = true; 66 67 ##### 4. Staged thermal ceiling (replaces /etc/systemd/system/motherboard-stability.service) 68 systemd.services.motherboard-stability = { 69 description = "CPU stability limits for the GM7RGxM fan/power fault workaround"; 70 after = [ "systemd-tmpfiles-setup.service" ]; 71 wantedBy = [ "multi-user.target" ]; 72 serviceConfig = { 73 Type = "simple"; 74 ExecStart = "${pkgs.python3}/bin/python3 -I ${./stability_guard.py}"; 75 Restart = "on-failure"; 76 RestartSec = 3; 77 RuntimeDirectory = "motherboard-stability"; 78 RuntimeDirectoryMode = "0755"; 79 NoNewPrivileges = true; 80 ProtectSystem = "strict"; 81 ProtectHome = true; 82 ReadWritePaths = [ "/sys/devices/system/cpu" "/run/motherboard-stability" ]; 83 PrivateTmp = true; 84 PrivateDevices = true; 85 ProtectKernelModules = true; 86 ProtectControlGroups = true; 87 RestrictAddressFamilies = "AF_UNIX"; 88 LockPersonality = true; 89 RestrictSUIDSGID = true; 90 CapabilityBoundingSet = ""; 91 UMask = "0022"; 92 }; 93 }; 94 95 ##### 5. Surviving CPU fan at a fixed 60 % duty ############################## 96 # CURVE is "temp:pct …" pairs; a flat 0:60 100:60 is what has been running. 97 # fanfix refuses anything below 30 %. Edit here, not in /etc, then rebuild. 98 # 99 # NOT started at boot (wantedBy = []). Measured 2026-10-07 on NixOS: while the 100 # daemon holds the EC in manual/custom-table fan mode, the EC asserts PROCHOT 101 # (all cores 399 MHz) the moment any load starts, even at 37 °C. Stopping the 102 # unit and `fanfix fan auto` cleared it instantly; 10 s all-core test then ran 103 # at 3112 MHz, peak 53 °C, 0 trips. The 3.2 GHz floor + stability guard are 104 # enough on their own. Start by hand to experiment: systemctl start fanfix-fan 105 environment.etc."fanfix.conf".text = '' 106 CURVE="0:60 100:60" 107 ''; 108 109 systemd.services.fanfix-fan = { 110 description = "fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround)"; 111 after = [ "multi-user.target" ]; 112 wantedBy = [ ]; # see note above; manual start only 113 path = fanDeps; 114 serviceConfig = { 115 Type = "simple"; 116 ExecStart = "${fanfix}/bin/fanfix fan-daemon"; 117 ExecStopPost = "${fanfix}/bin/fanfix fan-release"; 118 Restart = "on-failure"; 119 RestartSec = 5; 120 }; 121 }; 122 123 ##### 6. Tools on PATH ###################################################### 124 environment.systemPackages = [ fanfix pkgs.lm_sensors ] ++ fanDeps; 125 } 126 ; 127 }