From 9976ea06f57614332ce0a3bc2215c7eccfd8e7ca Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 30 Jun 2026 21:10:44 +0100 Subject: [PATCH] feat(power): re-apply battery charge limit on AC state change The threshold was written boot-only, so a firmware that clears charge_control_end_threshold on unplug would silently lose the cap until the next reboot. Add a services.udev rule on the mains adapter (ATTR{type}=="Mains", vendor-neutral) that restarts the oneshot via systemctl --no-block on every AC state change. Uses restart (not try-restart) so it re-applies even if the boot run was inactive. Eval-verified: rule present when the charge limit is on, absent when off; downstream-template-system still evaluates. Co-Authored-By: Claude Opus 4.8 --- docs/ROADMAP.md | 14 +++++++++++--- modules/nixos/options.nix | 5 +++-- modules/nixos/power.nix | 19 +++++++++++++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3cb4d3d..7af6345 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -347,7 +347,17 @@ how to override it. Items marked ✓ are shipped. turns it on for a `GenuineIntel` CPU. - ✓ **longevity:** `power.batteryChargeLimit` (e.g. 80) — a backend- independent sysfs oneshot writes `charge_control_end_threshold`. Off by - default; the installer scaffolds it commented-out on laptops. + default; the installer scaffolds it commented-out on laptops. ✓ the + threshold is now **re-applied on AC state changes** (a `services.udev` + rule on `SUBSYSTEM=="power_supply", ATTR{type}=="Mains"` restarts the + oneshot via `systemctl --no-block`), closing the firmware-resets-on- + unplug gap — the match is by adapter *type*, not kernel name + (AC/AC0/ADP1/ACAD vary), and `restart` (not `try-restart`) re-applies + even if the boot run was inactive. Eval-verified both ways (rule present + with charge limit on / absent off). **Pending an on-hardware check** (a + `sudo nixos-rebuild` + a physical unplug to fire the udev event — the + dev box has both an `AC` Mains adapter and a `BAT0` + `charge_control_end_threshold`, so it can exercise it). - ✓ **installer:** writes `power.laptop = true` (battery probe) and `power.thermal.enable` (Intel) into the generated `system.nix`. - ✓ **idle cohesion:** `modules/home/idle.nix` now suspends only on @@ -357,8 +367,6 @@ how to override it. Items marked ✓ are shipped. logind's lid handling is left at its defaults (suspend on lid close, ignore when docked) — an explicit "I'm done" that coheres with the idle behaviour. - - Remaining: a boot-only→event-driven charge-limit re-apply (udev) if a - firmware resets the threshold on unplug. - ✓ **Waybar parity:** the `custom/powerprofile` indicator now shows in the summer-day/night whole-swap themes too. `powerProfileStatus`/`Cycle` are named `writeShellScriptBin`s on PATH (`waybar.nix` home.packages), so the diff --git a/modules/nixos/options.nix b/modules/nixos/options.nix index df99dc1..b5cada8 100644 --- a/modules/nixos/options.nix +++ b/modules/nixos/options.nix @@ -109,8 +109,9 @@ where the hardware exposes a charge threshold (/sys/class/power_supply/BAT*/charge_control_end_threshold). null leaves charging at the firmware default. Backend-independent - (a small systemd unit writes the sysfs knob at boot), so it - works under PPD too; needs nomarchy.system.power.laptop. + (a small systemd unit writes the sysfs knob, re-applied on AC + state changes), so it works under PPD too; needs + nomarchy.system.power.laptop. ''; }; }; diff --git a/modules/nixos/power.nix b/modules/nixos/power.nix index 8e8ce22..7499790 100644 --- a/modules/nixos/power.nix +++ b/modules/nixos/power.nix @@ -38,10 +38,10 @@ in # Battery charge limit via sysfs. PPD can't cap charge at all, and # TLP's own knob only applies under TLP — so a tiny oneshot writes - # the threshold directly, independent of the backend. Boot-time only: - # some firmwares reset the threshold on unplug; revisit with a udev - # hook if that bites. `-` paths that don't exist are skipped, so this - # is a clean no-op on hardware without the control. + # the threshold directly, independent of the backend. Re-applied on + # AC state changes by the udev rule below (some firmwares reset the + # threshold when the charger is unplugged). `[ -w ]` skips paths that + # don't exist, so this is a clean no-op on hardware without the control. systemd.services.nomarchy-battery-charge-limit = lib.mkIf chargeLimit { description = "Cap battery charging at ${toString cfg.batteryChargeLimit}%"; wantedBy = [ "multi-user.target" ]; @@ -56,5 +56,16 @@ in exit 0 ''; }; + + # Re-apply the threshold whenever the mains adapter changes state: the + # boot oneshot above runs once, but some firmwares clear the limit when + # the charger is unplugged, so it must be re-asserted on the event. + # Matched by ATTR{type}=="Mains" (vendor-neutral — the kernel name + # AC/AC0/ADP1/ACAD varies); --no-block so the RUN+= returns at once + # (systemd kills long-running udev workers); restart (not try-restart) + # so it re-applies even if the boot run was inactive. + services.udev.extraRules = lib.mkIf chargeLimit '' + SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${config.systemd.package}/bin/systemctl --no-block restart nomarchy-battery-charge-limit.service" + ''; }; }