Files
Nomarchy/modules/nixos/power.nix
Bernardo Magri 9976ea06f5 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 <noreply@anthropic.com>
2026-06-30 21:10:44 +01:00

72 lines
3.3 KiB
Nix

# Active power management. One concern, one file: this is the system
# side — the power daemon (power-profiles-daemon by default, or TLP),
# thermald, and the battery charge limit. The profile *switcher* and the
# Waybar *indicator* live home-side (rofi.nix / waybar.nix); they self-
# gate on powerprofilesctl being present, so there's no system→home wiring
# to keep in sync (the same way the Waybar battery widget auto-hides on
# desktops). See the roadmap in README.md.
{ config, lib, ... }:
let
cfg = config.nomarchy.system.power;
ppd = cfg.backend == "ppd";
tlp = cfg.backend == "tlp";
chargeLimit = cfg.laptop && cfg.batteryChargeLimit != null;
in
{
config = lib.mkIf cfg.enable {
# PPD and TLP both drive CPU/platform power — running both fights.
# `backend` selects exactly one; this assertion only fires if a
# downstream force-enables the other service directly.
assertions = [{
assertion = !(config.services.power-profiles-daemon.enable
&& config.services.tlp.enable);
message = ''
nomarchy: power-profiles-daemon and TLP are mutually exclusive.
Pick one with nomarchy.system.power.backend ("ppd" or "tlp").
'';
}];
# Unprivileged profile switching (the menu/Waybar) goes through
# polkit, already enabled distro-wide in default.nix.
services.power-profiles-daemon.enable = lib.mkDefault ppd;
services.tlp.enable = lib.mkDefault tlp;
# thermald is Intel-only, so off unless asked (the installer enables
# it on a GenuineIntel CPU). Sits happily next to either backend.
services.thermald.enable = lib.mkDefault cfg.thermal.enable;
# 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. 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" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
for thresh in /sys/class/power_supply/BAT*/charge_control_end_threshold; do
[ -w "$thresh" ] && echo ${toString cfg.batteryChargeLimit} > "$thresh"
done
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"
'';
};
}