Files
Nomarchy/modules/nixos/power.nix
Bernardo Magri d8e1a13d50
Some checks failed
Check / eval (push) Has been cancelled
refactor(#107): theme-state.json → state.json, theme-sync → state-sync
The machine flake's git-tracked settings file is system state, not
"theme" only — rename it to state.json. CLI becomes nomarchy-state-sync
with a nomarchy-theme-sync symlink for scripts and muscle memory.

Eval (mkFlake, doctor, lifecycle) still accepts theme-state.json; the
next write migrates to state.json and removes the legacy file.
Documented in MIGRATION.md; drop the CLI alias after release notes.
2026-07-15 11:26:59 +01:00

200 lines
9.8 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 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;
}
}
});
'';
};
}