All checks were successful
Check / eval (push) Successful in 3m16s
USB-C power event bursts could repeatedly restart and SIGTERM the settling oneshot until systemd reached its start limit. Let successful runs return inactive and use coalescing start jobs for immediate and delayed reapplication. Verified: V0 nix flake check --no-build, module parse, and git diff --check; V2 focused battery-charge-limit KVM test with a 12-event active-run burst. V3 hardware item #101 remains queued.
192 lines
9.3 KiB
Nix
192 lines
9.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, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.nomarchy.system.power;
|
|
ppd = cfg.backend == "ppd";
|
|
tlp = cfg.backend == "tlp";
|
|
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;
|
|
|
|
# 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.
|
|
# Lid alone (undocked) keeps the normal suspend path
|
|
# (HandleLidSwitch / ExternalPower left to upstream defaults).
|
|
# Display-profile "docked" layouts (eDP off) are orthogonal and
|
|
# already handled by nomarchy.displayProfiles.
|
|
services.logind.settings.Login = {
|
|
HandleLidSwitchDocked = lib.mkDefault "ignore";
|
|
};
|
|
|
|
# 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 theme-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 ];
|
|
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/theme-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;
|
|
}
|
|
}
|
|
});
|
|
'';
|
|
};
|
|
}
|