feat(power): laptop power management (power-profiles-daemon + TLP)
Adds a `nomarchy.system.power.*` surface (modules/nixos/power.nix), replacing the bare `services.upower` (Waybar reporting only) baseline: - power-profiles-daemon ships by default; switched through polkit (no root prompt). TLP is the opt-in `backend = "tlp"` for deeper battery tuning — the two are mutually exclusive (asserted). - thermald behind `power.thermal.enable` (Intel-only). - `power.batteryChargeLimit` (e.g. 80): a backend-independent sysfs oneshot writes charge_control_end_threshold; gated on `power.laptop`. Switcher + indicator live home-side and self-gate on a battery being present + powerprofilesctl running, so they hide on desktops and under TLP — no system->home wiring (like the Waybar battery widget): - rofi.nix: `nomarchy-menu power-profile` module + a picker entry shown only on laptops with PPD. - waybar.nix: first `custom/*` module — click cycles the profile. Installer writes `power.laptop = true` (battery probe) and `power.thermal.enable` (GenuineIntel) into the generated system.nix, and scaffolds the charge limit commented-out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
39
README.md
39
README.md
@@ -412,23 +412,28 @@ close · `SUPER+1..9` workspaces · `Print` region screenshot.
|
||||
comments/docs for drift against the code; and re-check that every file
|
||||
still earns its place under the `modules`/`hosts`/`themes`/`pkgs`/`tools`
|
||||
rule of thumb.
|
||||
- **Laptop power / battery management:** today the distro does *no* active
|
||||
power management — only `services.upower` is on, and that's just battery
|
||||
*reporting* for the Waybar widget; CPU/thermal/charge behaviour is whatever
|
||||
the kernel and the nixos-hardware profile default to. Add a real power
|
||||
story behind a `nomarchy.system.power.*` surface, gated to laptops (the
|
||||
installer already detects chassis):
|
||||
- **profiles:** ship `power-profiles-daemon` by default (the upstream-
|
||||
aligned choice — power-saver/balanced/performance via `powerprofilesctl`),
|
||||
with a switcher in the rofi menu and a Waybar indicator. Offer **TLP** as
|
||||
an opt-in for maximum battery life (note: mutually exclusive with PPD, and
|
||||
more aggressive/opaque). **Decision to make:** PPD-default vs TLP-default.
|
||||
- **thermal:** `thermald` on Intel (the Latitude is Intel) to avoid
|
||||
throttling.
|
||||
- **longevity:** battery charge thresholds (e.g. stop charging at ~80%)
|
||||
where the hardware supports it.
|
||||
- **cohere** with `modules/home/idle.nix` (idle lock / display-off /
|
||||
suspend) and review logind lid handling so the power behaviours line up.
|
||||
- **Laptop power / battery management:** a real power story behind a
|
||||
`nomarchy.system.power.*` surface (`modules/nixos/power.nix`), replacing the
|
||||
old "only `services.upower` for Waybar reporting" baseline:
|
||||
- ✓ **profiles:** `power-profiles-daemon` ships by default (the upstream-
|
||||
aligned choice — power-saver/balanced/performance via `powerprofilesctl`,
|
||||
switched through polkit so no root prompt). A `power-profile` menu module
|
||||
(in the SUPER+M picker) and the first Waybar `custom/*` indicator (click to
|
||||
cycle) both **self-gate** on a battery being present + PPD running, so they
|
||||
hide on desktops and under TLP — no system→home wiring. **TLP** is the
|
||||
opt-in (`backend = "tlp"`) for deeper battery tuning; the two are mutually
|
||||
exclusive (asserted).
|
||||
- ✓ **thermal:** `thermald` behind `power.thermal.enable`; the installer
|
||||
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.
|
||||
- ✓ **installer:** writes `power.laptop = true` (battery probe) and
|
||||
`power.thermal.enable` (Intel) into the generated `system.nix`.
|
||||
- Remaining: **cohere** with `modules/home/idle.nix` (AC-vs-battery idle
|
||||
timeouts / no auto-suspend on AC) and review logind lid handling, plus a
|
||||
boot-only→event-driven charge-limit re-apply (udev) if a firmware resets
|
||||
the threshold on unplug.
|
||||
- **Opt-in services & integrations:** the counterpart to the opt-*out*
|
||||
application suite above — heavier or more personal integrations shipped
|
||||
**off by default**, each a `nomarchy.services.<name>.enable` toggle a
|
||||
|
||||
@@ -77,6 +77,20 @@ let
|
||||
*Shutdown) systemctl poweroff ;;
|
||||
esac ;;
|
||||
|
||||
power-profile)
|
||||
# power-profiles-daemon profile switcher (the system side ships it
|
||||
# by default on laptops — nomarchy.system.power). Switching goes
|
||||
# through polkit, so no root prompt.
|
||||
if ! command -v powerprofilesctl >/dev/null 2>&1; then
|
||||
notify-send "Power profiles" "power-profiles-daemon isn't running on this machine."
|
||||
exit 0
|
||||
fi
|
||||
cur=$(powerprofilesctl get 2>/dev/null)
|
||||
choice=$(powerprofilesctl list 2>/dev/null \
|
||||
| sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
|
||||
| rofi -dmenu -p "profile (now: $cur)") || exit 0
|
||||
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
||||
|
||||
theme)
|
||||
choice=$(nomarchy-theme-sync list | rofi -dmenu -p theme) || exit 0
|
||||
[ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;;
|
||||
@@ -148,20 +162,28 @@ let
|
||||
exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;;
|
||||
|
||||
"")
|
||||
choice=$(printf '%s\n' \
|
||||
" Apps" \
|
||||
" Theme" \
|
||||
" Clipboard" \
|
||||
" Calculator" \
|
||||
" Files" \
|
||||
" Web search" \
|
||||
" Network" \
|
||||
" Bluetooth" \
|
||||
" Capture" \
|
||||
" Keybindings" \
|
||||
" Ask Claude" \
|
||||
" Power" \
|
||||
| rofi -dmenu -p menu) || exit 0
|
||||
entries=(
|
||||
" Apps"
|
||||
" Theme"
|
||||
" Clipboard"
|
||||
" Calculator"
|
||||
" Files"
|
||||
" Web search"
|
||||
" Network"
|
||||
" Bluetooth"
|
||||
" Capture"
|
||||
" Keybindings"
|
||||
" Ask Claude"
|
||||
)
|
||||
# Power-profile picker only when this is a laptop (battery present)
|
||||
# running power-profiles-daemon — matches the Waybar indicator's
|
||||
# self-gating; desktops and the TLP backend don't show it.
|
||||
bats=(/sys/class/power_supply/BAT*)
|
||||
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
|
||||
entries+=(" Power profile")
|
||||
fi
|
||||
entries+=(" Power")
|
||||
choice=$(printf '%s\n' "''${entries[@]}" | rofi -dmenu -p menu) || exit 0
|
||||
case "$choice" in
|
||||
*Apps*) exec rofi -show drun ;;
|
||||
*Theme*) exec "$0" theme ;;
|
||||
@@ -174,11 +196,12 @@ let
|
||||
*Capture*) exec "$0" capture ;;
|
||||
*Keybindings*) exec "$0" keybinds ;;
|
||||
*Ask*) exec "$0" ask ;;
|
||||
*"Power profile"*) exec "$0" power-profile ;;
|
||||
*Power*) exec "$0" power ;;
|
||||
esac ;;
|
||||
|
||||
*)
|
||||
echo "usage: nomarchy-menu [power|theme|clipboard|calc|files|web|network|bluetooth|capture|keybinds|ask]" >&2
|
||||
echo "usage: nomarchy-menu [power|power-profile|theme|clipboard|calc|files|web|network|bluetooth|capture|keybinds|ask]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
|
||||
@@ -7,11 +7,39 @@
|
||||
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
|
||||
# replace the generated style/layout entirely. Probed at eval time —
|
||||
# pure, it's flake source.
|
||||
{ config, lib, ... }:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
t = config.nomarchy.theme;
|
||||
|
||||
# Power-profile indicator (power-profiles-daemon). Both scripts self-
|
||||
# gate: they exit silently unless this is a laptop (a battery is
|
||||
# present) running PPD, so the module hides itself on desktops and
|
||||
# under the TLP backend — no system→home coupling. Speedometer glyphs
|
||||
# (slow/medium/fast) map to power-saver/balanced/performance.
|
||||
powerProfileStatus = pkgs.writeShellScript "nomarchy-powerprofile-status" ''
|
||||
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
|
||||
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
||||
prof=$(powerprofilesctl get 2>/dev/null) || exit 0
|
||||
case "$prof" in
|
||||
power-saver) icon="" ;;
|
||||
performance) icon="" ;;
|
||||
*) icon="" ;;
|
||||
esac
|
||||
printf '{"text":"%s","tooltip":"Power profile: %s","class":"%s"}\n' "$icon" "$prof" "$prof"
|
||||
'';
|
||||
# Cycle to the next profile this machine actually offers (wraps around).
|
||||
powerProfileCycle = pkgs.writeShellScript "nomarchy-powerprofile-cycle" ''
|
||||
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
||||
cur=$(powerprofilesctl get 2>/dev/null) || exit 0
|
||||
avail=$(powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p')
|
||||
next=$(printf '%s\n' "$avail" | awk -v cur="$cur" '
|
||||
{ a[NR] = $0 }
|
||||
END { for (i = 1; i <= NR; i++) if (a[i] == cur) { print a[i % NR + 1]; f = 1 }
|
||||
if (!f && NR) print a[1] }')
|
||||
[ -n "$next" ] && powerprofilesctl set "$next"
|
||||
'';
|
||||
|
||||
# Per-theme override probe.
|
||||
assetDir = config.nomarchy.themesDir + "/${t.slug}";
|
||||
styleOverride = assetDir + "/waybar.css";
|
||||
@@ -38,7 +66,7 @@ let
|
||||
|
||||
modules-left = [ "hyprland/workspaces" "hyprland/window" ];
|
||||
modules-center = [ "clock" ];
|
||||
modules-right = [ "tray" "pulseaudio" "network" "cpu" "memory" "battery" ];
|
||||
modules-right = [ "tray" "pulseaudio" "network" "cpu" "memory" "custom/powerprofile" "battery" ];
|
||||
|
||||
"hyprland/workspaces" = {
|
||||
format = "{icon}";
|
||||
@@ -83,6 +111,13 @@ let
|
||||
format-icons = [ "" "" "" "" "" ];
|
||||
};
|
||||
|
||||
"custom/powerprofile" = {
|
||||
exec = "${powerProfileStatus}";
|
||||
return-type = "json";
|
||||
interval = 5;
|
||||
on-click = "${powerProfileCycle}";
|
||||
};
|
||||
|
||||
tray.spacing = 8;
|
||||
};
|
||||
|
||||
@@ -129,7 +164,7 @@ let
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#tray, #pulseaudio, #network, #cpu, #memory, #battery {
|
||||
#tray, #pulseaudio, #network, #cpu, #memory, #custom-powerprofile, #battery {
|
||||
color: @subtext;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ let
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ];
|
||||
imports = [ ./options.nix ./plymouth.nix ./file-manager.nix ./power.nix ];
|
||||
|
||||
config = {
|
||||
# The safe half of distro branding: distroName flows into
|
||||
|
||||
@@ -51,5 +51,58 @@
|
||||
`nixos-rebuild-snap` pre-rebuild-snapshot helper. No-op unless the
|
||||
root filesystem is BTRFS with a /.snapshots subvolume (the installer
|
||||
creates one)'';
|
||||
|
||||
power = {
|
||||
enable = lib.mkEnableOption ''
|
||||
active power management. By default ships power-profiles-daemon —
|
||||
the upstream-aligned power-saver/balanced/performance model,
|
||||
switchable from the menu (nomarchy-menu power-profile) and shown
|
||||
in Waybar on laptops — plus thermald and a battery charge limit
|
||||
where applicable. Harmless on desktops (the profile daemon just
|
||||
offers balanced/performance)'' // { default = true; };
|
||||
|
||||
backend = lib.mkOption {
|
||||
type = lib.types.enum [ "ppd" "tlp" ];
|
||||
default = "ppd";
|
||||
description = ''
|
||||
Which daemon governs CPU/platform power. "ppd"
|
||||
(power-profiles-daemon) is the default: a clean three-profile
|
||||
model with the menu switcher and Waybar indicator. "tlp" trades
|
||||
that for TLP's deeper, more aggressive battery tuning, at the
|
||||
cost of the profile switcher (TLP has no profile concept). The
|
||||
two manage the same knobs and are mutually exclusive.
|
||||
'';
|
||||
};
|
||||
|
||||
laptop = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Marks this machine as a laptop, gating battery-only features
|
||||
(the charge limit below). The installer sets it to true when it
|
||||
detects a battery at install time.
|
||||
'';
|
||||
};
|
||||
|
||||
thermal.enable = lib.mkEnableOption ''
|
||||
thermald, Intel's thermal-management daemon, to avoid aggressive
|
||||
throttling under sustained load. Intel-only — the installer
|
||||
enables it when it detects a GenuineIntel CPU. Harmless alongside
|
||||
either backend'';
|
||||
|
||||
batteryChargeLimit = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.ints.between 50 100);
|
||||
default = null;
|
||||
example = 80;
|
||||
description = ''
|
||||
Stop charging at this percentage to extend battery lifespan,
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
60
modules/nixos/power.nix
Normal file
60
modules/nixos/power.nix
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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. 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.
|
||||
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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -408,6 +408,26 @@ NIX
|
||||
)
|
||||
fi
|
||||
|
||||
# Laptop power: power-profiles-daemon ships by default; mark this a laptop
|
||||
# so battery-only features apply, and enable thermald on Intel. Keyed off
|
||||
# the same battery probe that chose the common-pc-laptop hardware profile.
|
||||
POWER_CONFIG=""
|
||||
if [[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]]; then
|
||||
power_lines=" # Laptop power management (power-profiles-daemon + menu/Waybar
|
||||
# switcher). Uncomment to stop charging at 80% to extend battery life.
|
||||
nomarchy.system.power.laptop = true;
|
||||
# nomarchy.system.power.batteryChargeLimit = 80;"
|
||||
if grep -q GenuineIntel /proc/cpuinfo 2>/dev/null; then
|
||||
power_lines+="
|
||||
nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
|
||||
fi
|
||||
POWER_CONFIG=$(cat <<NIX
|
||||
|
||||
$power_lines
|
||||
NIX
|
||||
)
|
||||
fi
|
||||
|
||||
# initialHashedPassword is safe to template: mkpasswd's alphabet is
|
||||
# [a-zA-Z0-9./$] — no Nix string metacharacters.
|
||||
cat > "$FLAKE_DIR/system.nix" <<EOF
|
||||
@@ -440,7 +460,7 @@ cat > "$FLAKE_DIR/system.nix" <<EOF
|
||||
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
||||
initialHashedPassword = "$HASHED_PASSWORD";
|
||||
};
|
||||
$AUTOLOGIN_CONFIG$RESUME_CONFIG
|
||||
$AUTOLOGIN_CONFIG$POWER_CONFIG$RESUME_CONFIG
|
||||
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
|
||||
nomarchy.system.snapper.enable = true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user