diff --git a/README.md b/README.md index d1ca9e3..a639ef6 100644 --- a/README.md +++ b/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..enable` toggle a diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index e3a222a..2365afb 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -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 ''; diff --git a/modules/home/waybar.nix b/modules/home/waybar.nix index 00cf348..0cf1f59 100644 --- a/modules/home/waybar.nix +++ b/modules/home/waybar.nix @@ -7,11 +7,39 @@ # //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; } diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index c24c95d..c3fb98a 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -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 diff --git a/modules/nixos/options.nix b/modules/nixos/options.nix index 10634e8..9815bbf 100644 --- a/modules/nixos/options.nix +++ b/modules/nixos/options.nix @@ -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. + ''; + }; + }; }; } diff --git a/modules/nixos/power.nix b/modules/nixos/power.nix new file mode 100644 index 0000000..8e8ce22 --- /dev/null +++ b/modules/nixos/power.nix @@ -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 + ''; + }; + }; +} diff --git a/pkgs/nomarchy-install/nomarchy-install.sh b/pkgs/nomarchy-install/nomarchy-install.sh index ee7ac37..2d108ea 100644 --- a/pkgs/nomarchy-install/nomarchy-install.sh +++ b/pkgs/nomarchy-install/nomarchy-install.sh @@ -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 < "$FLAKE_DIR/system.nix" < "$FLAKE_DIR/system.nix" <