NixDaemon

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

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 }