# Active power management. One concern, one file: this is the system # side — the power daemon (power-profiles-daemon by default, or TLP), # thermald, the battery charge limit, and suspend-then-hibernate (#115). # 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, pkgs, ... }: let cfg = config.nomarchy.system.power; sysCfg = config.nomarchy.system; ppd = cfg.backend == "ppd"; tlp = cfg.backend == "tlp"; # settings.power.suspendThenHibernate → this option (ROADMAP § state # bridges #116). null = key absent → leave option default (true). sysState = if sysCfg.stateFile != null then import ../state-read.nix { inherit lib; } sysCfg.stateFile else { }; stateS2h = let v = ((sysState.settings or { }).power or { }).suspendThenHibernate or null; in if builtins.isBool v then v else null; # Hibernate needs a resume device (installer swapfile / partition). # Without it, offering s2h only produces a suspend that never wakes. canHibernate = (config.boot.resumeDevice or "") != ""; s2hActive = cfg.suspendThenHibernate && canHibernate; 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; # State bridge (#115 / #116): menu writes settings.power.suspendThenHibernate. nomarchy.system.power.suspendThenHibernate = lib.mkIf (stateS2h != null) (lib.mkDefault stateS2h); # Smart suspend helper (hypridle + Power menu). Live state read. environment.systemPackages = [ pkgs.nomarchy-suspend ]; # Clamshell / dock (BACKLOG #86): when the machine is "docked" in # logind's sense (≥1 external display connected), closing the lid # must NOT suspend — the external panel is the session. systemd's # built-in default is already ignore; we set it explicitly so a # downstream override or lock-bump drift is visible, and so # checks.clamshell-logind can assert the contract. # #115: undocked lid on battery → suspend-then-hibernate when the # toggle is on and resume is wired; on AC → plain suspend so a # docked-at-desk lid-close doesn't plan a hibernate. Display-profile # "docked" layouts (eDP off) are orthogonal (nomarchy.displayProfiles). services.logind.settings.Login = { HandleLidSwitchDocked = lib.mkDefault "ignore"; } // lib.optionalAttrs s2hActive { HandleLidSwitch = lib.mkDefault "suspend-then-hibernate"; HandleLidSwitchExternalPower = lib.mkDefault "suspend"; }; # 1h in suspend then hibernate; never start the countdown on AC # (systemd ≥257 HibernateOnACPower). Only matters when something # actually enters suspend-then-hibernate. systemd.sleep.settings.Sleep = lib.mkIf s2hActive { HibernateDelaySec = "1h"; HibernateOnACPower = false; }; # 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). # # Instant menu path (user decision 2026-07-10): the threshold node is # group-writable for `users` so nomarchy-menu can echo live without # rebuild; this oneshot still owns boot + AC-replug re-apply. Prefer # live state.json under /home/*/.nomarchy so a menu change # survives reboot before the next sys-rebuild bakes the Nix option. systemd.services.nomarchy-battery-charge-limit = lib.mkIf cfg.laptop { description = "Apply battery charge end threshold from state or config"; wantedBy = [ "multi-user.target" ]; path = [ pkgs.jq pkgs.coreutils ]; # A sustained dock/AC event storm can land SPACED starts — each run # finishes (~1s) before the next event, so nothing coalesces and 5 # successful starts in 10s trip systemd's default start limit: the # unit is marked failed (start-limit-hit) although every run # succeeded (T14s, 2026-07-13; the #101 coalescing only covers # events that arrive DURING a run). The write is idempotent and # sub-second — exempt it from rate limiting. unitConfig.StartLimitIntervalSec = 0; serviceConfig = { Type = "oneshot"; }; script = '' set -euo pipefail # Baked generation default (empty = full charge / no cap). limit=${if cfg.batteryChargeLimit != null then toString cfg.batteryChargeLimit else ""} # Prefer the newest live state write (menu path, no rebuild yet). for st in /home/*/.nomarchy/state.json; do [ -r "$st" ] || continue v=$(jq -r '.settings.power.batteryChargeLimit // empty' "$st" 2>/dev/null || true) case "$v" in ""|null|Null) ;; *[!0-9]*) ;; *) limit=$v ;; esac done [ -n "$limit" ] || limit=100 # Name-agnostic system batteries (BACKLOG #60). for d in /sys/class/power_supply/*/; do [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue thresh="$d/charge_control_end_threshold" [ -e "$thresh" ] || continue # Keep nodes group-writable for the menu's live path (udev # sets this on add; re-assert after firmware recreates attrs). for node in charge_control_end_threshold charge_control_start_threshold charge_types charge_type; do [ -e "$d$node" ] || continue chgrp users "$d$node" 2>/dev/null || true chmod 0664 "$d$node" 2>/dev/null || true done # Dell (and some others): charge_control_* thresholds are only # honoured in Custom mode. Adaptive/Standard ignore end_threshold # while still reporting the written value — battery keeps charging # past the cap (Latitude 5310: end=80, type=[Adaptive], capacity 96%+). # Off (100): restore Adaptive if listed, else leave type alone. ctypes="$d/charge_types" ctype="$d/charge_type" if [ -e "$ctypes" ] || [ -e "$ctype" ]; then listed=$(cat "$ctypes" 2>/dev/null || cat "$ctype" 2>/dev/null || true) write_type() { local t="$1" [ -n "$t" ] || return 0 if [ -w "$ctypes" ]; then echo "$t" > "$ctypes" 2>/dev/null || true elif [ -w "$ctype" ]; then echo "$t" > "$ctype" 2>/dev/null || true fi } if [ "$limit" -lt 100 ] 2>/dev/null; then # listed looks like: "Trickle Fast Standard [Adaptive] Custom" case "$listed" in *Custom*) write_type Custom ;; esac else case "$listed" in *Adaptive*) write_type Adaptive ;; *Standard*) write_type Standard ;; esac fi fi # Start threshold: must be < end. Keep a ~10% hysteresis band when # the node exists (firmware may reject start >= end). start_node="$d/charge_control_start_threshold" if [ -w "$start_node" ] && [ "$limit" -lt 100 ] 2>/dev/null; then start=$(( limit > 15 ? limit - 10 : 0 )) echo "$start" > "$start_node" 2>/dev/null || true fi # Some firmwares (Dell) reset the threshold on unplug *after* # the udev event — write once, wait, write again (type first). echo "$limit" > "$thresh" 2>/dev/null || true sleep 1 if [ -e "$ctypes" ] || [ -e "$ctype" ]; then listed=$(cat "$ctypes" 2>/dev/null || cat "$ctype" 2>/dev/null || true) if [ "$limit" -lt 100 ] 2>/dev/null; then case "$listed" in *Custom*) if [ -w "$ctypes" ]; then echo Custom > "$ctypes" 2>/dev/null || true elif [ -w "$ctype" ]; then echo Custom > "$ctype" 2>/dev/null || true fi ;; esac fi fi echo "$limit" > "$thresh" 2>/dev/null || true done exit 0 ''; }; # Writable threshold for the logged-in user (menu live apply) + AC # re-apply of the oneshot. Laptop only; no-op when the attr is absent. # Immediate + delayed start: Dell/Latitude firmware often stomps the # threshold to 100 right after unplug; a single immediate oneshot # loses the race (hardware: unplug→100, plug→80). systemd-run hands # the delayed start off so udev's short RUN budget is not held. Keep the # oneshot inactive after success and use `start`, not `restart`: a USB-C # dock can emit a burst of Mains change events, and restart would SIGTERM # the in-flight one-second settling pass until systemd hits its start # limit. Concurrent starts instead coalesce safely. services.udev.extraRules = lib.mkIf cfg.laptop ( let systemctl = "${config.systemd.package}/bin/systemctl"; systemdRun = "${config.systemd.package}/bin/systemd-run"; in '' SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_end_threshold", GROUP="users", MODE="0664" SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_types", GROUP="users", MODE="0664" SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_type", GROUP="users", MODE="0664" SUBSYSTEM=="power_supply", ATTR{type}=="Battery", TEST=="charge_control_start_threshold", GROUP="users", MODE="0664" SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemctl} --no-block start nomarchy-battery-charge-limit.service" SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="${systemdRun} --no-block --collect --on-active=2s --timer-property=AccuracySec=200ms ${systemctl} --no-block start nomarchy-battery-charge-limit.service" '' ); # Unprivileged restart of the oneshot so the menu can re-apply as # root when the threshold node is not (yet) user-writable. security.polkit.extraConfig = lib.mkIf cfg.laptop '' polkit.addRule(function(action, subject) { if (action.id == "org.freedesktop.systemd1.manage-units" && subject.isInGroup("users")) { var unit = action.lookup("unit"); if (unit == "nomarchy-battery-charge-limit.service") { return polkit.Result.YES; } } }); ''; }; }