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:
2026-06-14 17:35:42 +01:00
parent 8853f6ae49
commit 00f5311499
7 changed files with 233 additions and 37 deletions

View File

@@ -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
'';

View File

@@ -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;
}