feat(power): suspend-then-hibernate 1h on battery (#115)
All checks were successful
Check / eval (push) Successful in 6m16s

Human decision: 1h delay, battery only, Preferences enable/disable.
Wire systemd suspend-then-hibernate + HibernateDelaySec=1h +
HibernateOnACPower=false when settings.power.suspendThenHibernate is on
and boot.resumeDevice is set. Lid: s2h undocked, plain suspend on AC.
nomarchy-suspend for hypridle + Power menu (live state). LUKS unlock
before encrypted hibernate so s2h→disk is not double-password.
state-bridges assert the policy; HARDWARE-QUEUE #115 + suggested order.

V1: nix flake check --no-build. V3: bag-carry on hardware.
This commit is contained in:
2026-07-15 12:13:23 +01:00
parent e9dd3d14c5
commit 52d1581131
13 changed files with 304 additions and 51 deletions

View File

@@ -258,7 +258,7 @@ two tables below are split along exactly that line.
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery | | `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
| `nomarchy.dockAudio.enable` | `true` | On a fresh external-monitor plug, PipeWire/WirePlumber are reprobed after hardware settles and the highest-priority available HDMI/DP/USB sink becomes default, with a toast and journal result. A later manual speaker choice sticks until the next physical plug; unplug falls back to built-in. Manual route: System Audio | | `nomarchy.dockAudio.enable` | `true` | On a fresh external-monitor plug, PipeWire/WirePlumber are reprobed after hardware settles and the highest-priority available HDMI/DP/USB sink becomes default, with a toast and journal result. A later manual speaker choice sticks until the next physical plug; unplug falls back to built-in. Manual route: System Audio |
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout | | `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) | | `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only; uses `nomarchy-suspend` so suspend-then-hibernate applies when enabled) |
| `nomarchy.idle.fingerprint` | `false` | Unlock the lock screen with a fingerprint as well as the password, and say so on the input field. Set it alongside `nomarchy.hardware.fingerprint.pam` in system.nix: hyprlock is configured from Home Manager, which cannot read the NixOS option — and hyprlock does **not** take a fingerprint through PAM at all (its PAM stack runs only on submit), so it uses its own fprintd backend that this switch turns on | | `nomarchy.idle.fingerprint` | `false` | Unlock the lock screen with a fingerprint as well as the password, and say so on the input field. Set it alongside `nomarchy.hardware.fingerprint.pam` in system.nix: hyprlock is configured from Home Manager, which cannot read the NixOS option — and hyprlock does **not** take a fingerprint through PAM at all (its PAM stack runs only on submit), so it uses its own fprintd backend that this switch turns on |
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins | | `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute | | `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
@@ -302,6 +302,7 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. Clamshell uses logind's `HandleLidSwitchDocked=ignore`; the desktop additionally holds a low-level lid-switch inhibitor across dock/undock, releasing only after the internal panel is restored and the lid opens. Normal future undocked lid-close suspend remains enabled | | `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. Clamshell uses logind's `HandleLidSwitchDocked=ignore`; the desktop additionally holds a low-level lid-switch inhibitor across dock/undock, releasing only after the internal panel is restored and the lid opens. Normal future undocked lid-close suspend remains enabled |
| `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU | | `nomarchy.system.power.thermal.enable` | `false` | thermald (Intel-only); the installer enables it on a GenuineIntel CPU |
| `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` | | `nomarchy.system.power.batteryChargeLimit` | `null` | Stop charging at this % (e.g. `80`) where the hardware supports it; needs `power.laptop` |
| `nomarchy.system.power.suspendThenHibernate` | `true` (state) | On battery, suspend falls through to hibernate after **1 hour** (`suspend-then-hibernate` + `HibernateDelaySec=1h`; never while on AC). Needs a hibernate resume path (`boot.resumeDevice`). Toggle: System Preferences **Suspend then hibernate** (`settings.power.suspendThenHibernate`; system rebuild for logind lid policy). Idle and the Power menu use `nomarchy-suspend` (live state). |
| `nomarchy.hardware.intel.enable` | `false` | Intel enablement above nixos-hardware (GuC/HuC firmware via `i915.enable_guc=3``.guc` toggles just that; the installer unsets it on `xe`-driver GPUs); the installer sets it on an Intel CPU/GPU | | `nomarchy.hardware.intel.enable` | `false` | Intel enablement above nixos-hardware (GuC/HuC firmware via `i915.enable_guc=3``.guc` toggles just that; the installer unsets it on `xe`-driver GPUs); the installer sets it on an Intel CPU/GPU |
| `nomarchy.hardware.intel.computeRuntime` | `false` | Opt-in: Intel GPU compute — OpenCL/Level-Zero (`intel-compute-runtime`) + oneVPL (`vpl-gpu-rt`) | | `nomarchy.hardware.intel.computeRuntime` | `false` | Opt-in: Intel GPU compute — OpenCL/Level-Zero (`intel-compute-runtime`) + oneVPL (`vpl-gpu-rt`) |
| `nomarchy.hardware.amd.enable` | `false` | AMD enablement above nixos-hardware (amd-pstate EPP + radeonsi VA-API, each toggleable via `.pstate` / `.vaapi`); installer-set on an AMD CPU/GPU | | `nomarchy.hardware.amd.enable` | `false` | AMD enablement above nixos-hardware (amd-pstate EPP + radeonsi VA-API, each toggleable via `.pstate` / `.vaapi`); installer-set on an AMD CPU/GPU |

View File

@@ -123,36 +123,6 @@ the measured number, both ISOs built from one tree), installs successfully with
a network in a QEMU run, says clearly at boot that it needs one, and leaves the a network in a QEMU run, says clearly at boot that it needs one, and leaves the
offline ISO's behaviour untouched (`checks.*` for the offline path stay green). offline ISO's behaviour untouched (`checks.*` for the offline path stay green).
### 115. Suspend-then-hibernate, with a way to set it up
Bernardo, 2026-07-14: a suspended laptop should be able to fall through to
hibernate after a while, so a bag-carried machine stops draining, and setting
that up must be easy — not a systemd-sleep man-page trip.
The mechanism is `systemd`'s `suspend-then-hibernate` plus
`HibernateDelaySec`; the work is the surfacing, and the constraints are
already in the tree. Hibernate needs the resume offset + swap the installer
detects (`resumeOffset`/`rootUuid` in patch-template.py) — on a machine
without them this must self-gate, not fail at suspend time, the way Battery
limit gates on a battery. `modules/nixos/default.nix` already declines to
hyprlock before an encrypted hibernate (the LUKS resume gates it); check that
reasoning still holds when the suspend leads to hibernate. Menu placement: it
belongs with the power settings, not the root (§ menu placement convention).
Settle first (`[human]`): whether the delay is a preset list (30 min / 1 h /
2 h / never) or a free-form entry, and whether it applies on battery only or
always — the same on-AC question `modules/home/idle.nix`'s suspend listener
already answers with `${onAc} ||`.
In-flake state like the rest (`settings.*`), menu-writable, and read via
`state-read.nix` on the NixOS side — NOT `config.nomarchy.settings`,
which does not exist there (ROADMAP § *NixOS-side state bridges (#116)*; copy
the `bluetooth.enable` shape in `modules/nixos/default.nix` and add a case to
`checks.state-bridges`). Pass = the toggle survives a rebuild,
a suspended machine hibernates after the configured delay and resumes, and a
machine without hibernate support hides the row instead of offering a
suspend that never wakes.
## LATER ## LATER
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided, - **Wallpapers artifact split** (ROADMAP § Faster switches — decided,

View File

@@ -9,8 +9,39 @@ git history is the archive. Machines: the **AMD dev box** (Ryzen AI
laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA), laptop: AMD + fingerprint + NPU), the **Latitude 5310/5410** (Intel QA),
the **T14s** (webcam case). the **T14s** (webcam case).
## Suggested order (sweep 2026-07-15)
Run these first when you have a laptop session (AMD dev box unless noted):
| # | Item | Why first |
|---|------|-----------|
| 1 | **#115** suspend-then-hibernate | Just shipped; quick path is minutes (battery vs AC + `nomarchy-suspend`) |
| 2 | **#127** docked idle blackout | Real incident; capture path before it bit-rots |
| 3 | **Docking recovery round 8** + headphone jack-follow | Same dock session; relogin once, then undock ×5 + jack |
| 4 | **#104** airplane mode | Fast radio smoke after `nomarchy-home` |
| 5 | **#101** charge-limit USB-C burst | Dock plug/unplug storm; pairs with #3 |
| 6 | **Parallel fingerprint** hyprlock/greeter residual | sudo path already PASSED 2026-07-14 |
| 7 | **#95** Kitty-only on Acer | Validates terminal stack on oldest GPU |
| 8 | **#123** / **#124** install bake + flake pin | Next full reinstall window |
Everything else below stays open; order is convenience, not a gate.
## Any machine (dev box is fine) ## Any machine (dev box is fine)
- [ ] **#115 suspend-then-hibernate (laptop with hibernate/resume wired)** —
after system rebuild: Preferences shows **Suspend then hibernate (on)**
(hidden if `CanHibernate=no`). Confirm:
`grep -E 'HibernateDelaySec|HibernateOnACPower' /etc/systemd/sleep.conf`
`1h` / false; `grep HandleLidSwitch /etc/systemd/logind.conf`
undocked s2h, ExternalPower=suspend, Docked=ignore.
**Quick path:** on battery, `nomarchy-suspend` then
`systemctl list-jobs` / journal should show suspend-then-hibernate
(not plain suspend). On AC, same command → plain suspend.
**Full path (optional, long):** battery, lid close or idle past 15m,
leave ~1h+, open → single LUKS unlock (encrypted) or lock screen
(unencrypted), session intact. Toggle **off** + rebuild → plain
suspend only. **Pass:** battery s2h + 1h hibernate resume works;
AC never plans hibernate; no row without hibernate support.
- [ ] **#104 airplane mode radios (any laptop with Wi-Fi + BT)** — - [ ] **#104 airplane mode radios (any laptop with Wi-Fi + BT)** —
after `nomarchy-home` + waybar restart: System **Airplane mode (off)** after `nomarchy-home` + waybar restart: System **Airplane mode (off)**
(or SUPER+CTRL+R). **Pass on:** Wi-Fi and Bluetooth both drop (nm-applet / (or SUPER+CTRL+R). **Pass on:** Wi-Fi and Bluetooth both drop (nm-applet /
@@ -241,13 +272,13 @@ the **T14s** (webcam case).
a no-op (charge-limit re-apply passed on `BAT0` — Latitude a no-op (charge-limit re-apply passed on `BAT0` — Latitude
2026-07-10). 2026-07-10).
- [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot - [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot
open Ghostty, so the hand `btop.theme` assets were only guest-file open a real terminal UI, so the hand `btop.theme` assets were only
asserted (main_bg / inactive_fg keys) + desktop/bar rendered for guest-file asserted (main_bg / inactive_fg keys) + desktop/bar
rose-pine, everforest, summer-night, vantablack, catppuccin. On a rendered for rose-pine, everforest, summer-night, vantablack,
real session: `nomarchy-state-sync apply <slug>` then `btop` and catppuccin. On a real session: `nomarchy-state-sync apply <slug>`
confirm backgrounds/text match the theme (esp. rose-pine Dawn light then `btop` and confirm backgrounds/text match the theme (esp.
bg `#faf4ed`, vantablack near-black `#0d0d0d`, catppuccin Mocha rose-pine Dawn light bg `#faf4ed`, vantablack near-black `#0d0d0d`,
`#1E1E2E`). One pass cycling those five is enough. catppuccin Mocha `#1E1E2E`). One pass cycling those five is enough.
- [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git - [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git
client (or `rofi`-launched terminal-less app) and confirm it reaches client (or `rofi`-launched terminal-less app) and confirm it reaches
gpg-agent's SSH socket without an interactive shell parent. gpg-agent's SSH socket without an interactive shell parent.

View File

@@ -19,6 +19,16 @@ Template:
--- ---
## 2026-07-15 — #115 suspend-then-hibernate (1h, battery-only + menu)
- **Task:** Human settled delay=1h, battery only, menu enable/disable.
- **Did:** `nomarchy-suspend` + logind/sleep bridge from
`settings.power.suspendThenHibernate` (default on; needs resumeDevice).
Preferences toggle (rebuild). Unlock-before-encrypted-hibernate for s2h→disk.
HARDWARE-QUEUE: #115 steps + suggested-order sweep (top 8).
- **Verified:** V0/V1 `nix flake check --no-build` incl. state-bridges s2h cases.
- **Pending:** V3 bag-carry / 1h on battery (HARDWARE-QUEUE #115).
- **Next suggestion:** human runs HARDWARE-QUEUE suggested order, or #120.
## 2026-07-15 — #98 GTK dialog button black-on-dark (Boreal) ## 2026-07-15 — #98 GTK dialog button black-on-dark (Boreal)
- **Task:** Bernardo: GTK dialog button labels black vs dark Boreal chrome. - **Task:** Bernardo: GTK dialog button labels black vs dark Boreal chrome.
- **Did:** Root cause — Stylix pins light `adw-gtk3` for all polarities. - **Did:** Root cause — Stylix pins light `adw-gtk3` for all polarities.

View File

@@ -432,6 +432,15 @@ Design/decision records and a running log of shipped work (items marked
sheet hardcodes near-black button text → black-on-dark in GTK dialogs sheet hardcodes near-black button text → black-on-dark in GTK dialogs
(Boreal). Fix: force `adw-gtk3-dark` / `adw-gtk3` from palette mode, (Boreal). Fix: force `adw-gtk3-dark` / `adw-gtk3` from palette mode,
prefer-dark, and pin dialog button colours in Stylix extraCss. prefer-dark, and pin dialog button colours in Stylix extraCss.
- ✓ **Suspend-then-hibernate (#115):** human decision — **1h** delay, **battery
only**, Preferences enable/disable. Mechanism: `suspend-then-hibernate` +
`HibernateDelaySec=1h` + `HibernateOnACPower=false`; lid
`HandleLidSwitch=suspend-then-hibernate` /
`HandleLidSwitchExternalPower=suspend` when on and `boot.resumeDevice` set.
State: `settings.power.suspendThenHibernate` (default on). Live path:
`nomarchy-suspend` (hypridle + Power menu). LUKS: unlock session before
encrypted hibernate phase so resume is LUKS-only (not hyprlock twice).
Menu row self-gates on CanHibernate. V3: HARDWARE-QUEUE bag-carry test.
- ✓ **Generation prune (#128):** weekly `nomarchy-gen-prune` (system + HM): - ✓ **Generation prune (#128):** weekly `nomarchy-gen-prune` (system + HM):
delete only gens older than **14 days** that are also beyond the **3 most delete only gens older than **14 days** that are also beyond the **3 most
recent past** gens (current always kept). Stock `nix.gc` no longer uses recent past** gens (current always kept). Stock `nix.gc` no longer uses

View File

@@ -90,6 +90,7 @@
nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { }; nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { };
nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { }; nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { };
nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { }; nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { };
nomarchy-suspend = final.callPackage ./pkgs/nomarchy-suspend { };
nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { }; nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { };
# pull / rebuild / home (+ legacy aliases) — also on HM so a home # pull / rebuild / home (+ legacy aliases) — also on HM so a home
# switch can refresh a broken system-package nomarchy-pull. # switch can refresh a broken system-package nomarchy-pull.
@@ -171,6 +172,11 @@
settings.bluetooth.enable = false; # default true → flipped settings.bluetooth.enable = false; # default true → flipped
settings.printing.enable = true; # default false → flipped settings.printing.enable = true; # default false → flipped
settings.greeter.autoLogin = "me"; settings.greeter.autoLogin = "me";
settings.power.suspendThenHibernate = false; # default true → off
};
# #115: s2h on needs a resume device or logind never gets the policy.
s2hOnState = stateFileOf "state-s2h-on.json" {
settings.power.suspendThenHibernate = true;
}; };
in in
{ {
@@ -206,7 +212,17 @@
nomarchy.system.bluetooth.enable = true; nomarchy.system.bluetooth.enable = true;
nomarchy.services.printing.enable = false; nomarchy.services.printing.enable = false;
nomarchy.system.greeter.autoLogin = null; nomarchy.system.greeter.autoLogin = null;
nomarchy.system.power.suspendThenHibernate = true;
}; };
# #115: resume device required for s2h logind policy.
s2hOn = configWith s2hOnState {
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-0000000000aa";
};
s2hOff = configWith flippedState {
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-0000000000aa";
};
# Toggle true but no resume → must NOT claim s2h lid policy.
s2hNoResume = configWith s2hOnState { };
cases = [ cases = [
{ n = "absent key leaves bluetooth at its default (true)"; { n = "absent key leaves bluetooth at its default (true)";
@@ -231,6 +247,26 @@
ok = pinned.services.printing.enable == false; } ok = pinned.services.printing.enable == false; }
{ n = "hand-set autoLogin outranks the state"; { n = "hand-set autoLogin outranks the state";
ok = !(pinned.services.greetd.settings ? initial_session); } ok = !(pinned.services.greetd.settings ? initial_session); }
{ n = "s2h on + resume HandleLidSwitch=suspend-then-hibernate";
ok = (s2hOn.services.logind.settings.Login.HandleLidSwitch or null)
== "suspend-then-hibernate"; }
{ n = "s2h on + resume HibernateDelaySec=1h";
ok = (s2hOn.systemd.sleep.settings.Sleep.HibernateDelaySec or null)
== "1h"; }
{ n = "s2h on + resume HibernateOnACPower=false";
ok = (s2hOn.systemd.sleep.settings.Sleep.HibernateOnACPower or null)
== false; }
{ n = "s2h on + resume ExternalPower lid = suspend";
ok = (s2hOn.services.logind.settings.Login.HandleLidSwitchExternalPower or null)
== "suspend"; }
{ n = "s2h off + resume lid not forced to s2h";
ok = (s2hOff.services.logind.settings.Login.HandleLidSwitch or null)
!= "suspend-then-hibernate"; }
{ n = "s2h on without resume no s2h lid policy";
ok = (s2hNoResume.services.logind.settings.Login.HandleLidSwitch or null)
!= "suspend-then-hibernate"; }
{ n = "hand-set s2h=true outranks state false";
ok = pinned.nomarchy.system.power.suspendThenHibernate == true; }
]; ];
failed = builtins.filter (c: !c.ok) cases; failed = builtins.filter (c: !c.ok) cases;
in in

View File

@@ -22,6 +22,9 @@ let
in in
{ {
config = lib.mkIf cfg.idle.enable { config = lib.mkIf cfg.idle.enable {
# Smart suspend on PATH for manual use; hypridle uses the store path.
home.packages = [ pkgs.nomarchy-suspend ];
programs.hyprlock = { programs.hyprlock = {
enable = true; enable = true;
settings = { settings = {
@@ -122,10 +125,15 @@ in
# Suspend only on battery, and sooner than the old fixed 30 min # Suspend only on battery, and sooner than the old fixed 30 min
# (it now only fires unplugged). Plugged in, the machine stays # (it now only fires unplugged). Plugged in, the machine stays
# up — long builds, media, presentations aren't killed mid-idle. # up — long builds, media, presentations aren't killed mid-idle.
# nomarchy-suspend (#115): on battery + hibernate wired + toggle
# on → suspend-then-hibernate (1h → disk); else plain suspend.
# Lid close is logind's job (not hypridle): undocked lid still # Lid close is logind's job (not hypridle): undocked lid still
# suspends (HandleLidSwitch); docked/clamshell lid is ignore # suspends / s2h (HandleLidSwitch); docked/clamshell lid is ignore
# (HandleLidSwitchDocked — modules/nixos/power.nix, #86). # (HandleLidSwitchDocked — modules/nixos/power.nix, #86).
{ timeout = 900; on-timeout = "${onAc} || systemctl suspend"; } {
timeout = 900;
on-timeout = "${onAc} || ${lib.getExe pkgs.nomarchy-suspend}";
}
]; ];
}; };
}; };

View File

@@ -216,6 +216,18 @@ let
done done
return 1 return 1
} }
# Hibernate path available (logind CanHibernate, or resume= fallback).
# Gates the #115 Preferences row so machines without swap/resume hide it.
can_hibernate() {
local v
v=$(busctl get-property org.freedesktop.login1 \
/org/freedesktop/login1 org.freedesktop.login1.Manager \
CanHibernate 2>/dev/null || true)
case "$v" in
's "yes"') return 0 ;;
esac
grep -qE '(^|[[:space:]])resume=' /proc/cmdline 2>/dev/null
}
urlencode() { urlencode() {
local s="$1" out="" ch i local s="$1" out="" ch i
@@ -304,7 +316,13 @@ let
"$BACK") exec "$0" ;; "$BACK") exec "$0" ;;
*Lock) loginctl lock-session ;; *Lock) loginctl lock-session ;;
*Logout) hyprctl dispatch exit ;; *Logout) hyprctl dispatch exit ;;
*Suspend) systemctl suspend ;; *Suspend)
# #115: nomarchy-suspend s2h on battery when enabled; else suspend.
if command -v nomarchy-suspend >/dev/null 2>&1; then
nomarchy-suspend
else
systemctl suspend
fi ;;
*Hibernate) *Hibernate)
# zram can't hold a hibernate image; a machine installed with # zram can't hold a hibernate image; a machine installed with
# swap=0 has no disk swap. Attempt, and if logind rejects it, # swap=0 has no disk swap. Attempt, and if logind rejects it,
@@ -1324,6 +1342,13 @@ ${themeRows}
row "Power profile" preferences-system-power row "Power profile" preferences-system-power
fi fi
has_system_battery && row "Battery limit" battery-080 has_system_battery && row "Battery limit" battery-080
# #115: only offer when hibernate can work (self-gate).
if can_hibernate; then
case "$(nomarchy-state-sync get settings.power.suspendThenHibernate 2>/dev/null)" in
false|False) row "Suspend then hibernate (off)" system-suspend ;;
*) row "Suspend then hibernate (on)" system-suspend ;;
esac
fi
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
back back
} | rofi_menu -show-icons -markup-rows -p Preferences) || exit 0 } | rofi_menu -show-icons -markup-rows -p Preferences) || exit 0
@@ -1338,6 +1363,7 @@ ${themeRows}
*Printing*) exec "$0" printing-toggle ;; *Printing*) exec "$0" printing-toggle ;;
*"Power profile"*) exec "$0" power-profile ;; *"Power profile"*) exec "$0" power-profile ;;
*"Battery limit"*) exec "$0" batterylimit ;; *"Battery limit"*) exec "$0" batterylimit ;;
*"Suspend then hibernate"*) exec "$0" suspend-then-hibernate-toggle ;;
*Doctor*) exec "$0" doctor ;; *Doctor*) exec "$0" doctor ;;
esac ;; esac ;;
@@ -1368,6 +1394,25 @@ ${themeRows}
echo echo
printf 'Enter to close.'; read -r _" ;; printf 'Enter to close.'; read -r _" ;;
suspend-then-hibernate-toggle)
# #115: settings.power.suspendThenHibernate logind + sleep.conf.
# hypridle/Power menu pick it up live via nomarchy-suspend; lid policy
# needs the system rebuild (same shape as Bluetooth package).
can_hibernate \
|| { notify-send "Suspend then hibernate" \
"Hibernate is not available on this machine (no resume/swap path)."; exit 0; }
case "$(nomarchy-state-sync get settings.power.suspendThenHibernate 2>/dev/null)" in
false|False) new=true; label=On ;;
*) new=false; label=Off ;;
esac
exec ${cfg.terminal} -e sh -c "
nomarchy-state-sync --quiet set settings.power.suspendThenHibernate $new --no-switch
echo \"Suspend then hibernate $label (1h on battery; system rebuild)\"
echo
nomarchy-rebuild
echo
printf 'Enter to close.'; read -r _" ;;
printing-toggle) printing-toggle)
case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in
true|True) new=false; label=Off ;; true|True) new=false; label=Off ;;
@@ -1485,7 +1530,7 @@ ${themeRows}
esac ;; esac ;;
*) *)
echo "usage: nomarchy-menu [lookfeel|tools|system|system-connectivity|system-devices|system-preferences|recovery|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|airplane|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autologin|autocommit|vpn|snapshot|doctor|firmware|fingerprint|rollback|whatchanged|blur|gaps|updates-toggle|bluetooth-package|printing-toggle]" >&2 echo "usage: nomarchy-menu [lookfeel|tools|system|system-connectivity|system-devices|system-preferences|recovery|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|airplane|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autologin|autocommit|vpn|snapshot|doctor|firmware|fingerprint|rollback|whatchanged|blur|gaps|updates-toggle|bluetooth-package|printing-toggle|suspend-then-hibernate-toggle]" >&2
exit 64 ;; exit 64 ;;
esac esac
''; '';

View File

@@ -226,6 +226,13 @@ in
# system unit hooked to the sleep targets: it locks on the RAM-resume # system unit hooked to the sleep targets: it locks on the RAM-resume
# sleeps always, and on hibernate only when the disk is unencrypted (no # sleeps always, and on hibernate only when the disk is unencrypted (no
# LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd. # LUKS gate to rely on). Replaces hypridle's old before_sleep_cmd.
#
# #115 suspend-then-hibernate: the first phase is RAM-resume (lock),
# then after HibernateDelaySec systemd enters pure hibernate. Without
# an unlock before that second phase, an encrypted resume would demand
# LUKS *and* still-locked hyprlock. Drop the session lock immediately
# before encrypted hibernate — LUKS is the gate; unencrypted still
# locks via the unit below on hibernate.target.
systemd.services.nomarchy-lock-before-sleep = systemd.services.nomarchy-lock-before-sleep =
let let
encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ]; encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
@@ -244,6 +251,20 @@ in
}; };
}; };
systemd.services.nomarchy-unlock-before-encrypted-hibernate =
let encrypted = builtins.attrNames config.boot.initrd.luks.devices != [ ];
in lib.mkIf encrypted {
description = "Unlock session before encrypted hibernate (LUKS is the resume gate)";
before = [ "systemd-hibernate.service" ];
wantedBy = [ "hibernate.target" ];
# After lock-before-sleep on the s2h RAM phase; before the image.
after = [ "nomarchy-lock-before-sleep.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${config.systemd.package}/bin/loginctl unlock-sessions";
};
};
# zsh as the default login shell (the desktop's shell experience — # zsh as the default login shell (the desktop's shell experience —
# starship/bat/eza/zoxide — is configured home-side in shell.nix). # starship/bat/eza/zoxide — is configured home-side in shell.nix).
# programs.zsh.enable wires /etc/zshrc, completion and /etc/shells; # programs.zsh.enable wires /etc/zshrc, completion and /etc/shells;

View File

@@ -143,6 +143,26 @@
Needs nomarchy.system.power.laptop. Needs nomarchy.system.power.laptop.
''; '';
}; };
# #115: suspend → hibernate after 1h on battery. Default true so a
# bag-carried laptop stops draining without a menu trip; Preferences
# flips settings.power.suspendThenHibernate (state bridge). No-op
# when boot.resumeDevice is unset (no hibernate path).
suspendThenHibernate = lib.mkOption {
type = lib.types.bool;
default = true;
defaultText = lib.literalExpression
"(settings.power.suspendThenHibernate from state.json) or true";
description = ''
When true and hibernation is wired (boot.resumeDevice), idle and
undocked lid-close use systemd suspend-then-hibernate on battery:
sleep, then hibernate after HibernateDelaySec (1 hour). On AC,
plain suspend only (HibernateOnACPower=false; lid uses
HandleLidSwitchExternalPower=suspend). Toggle from System
Preferences Suspend then hibernate needs a system rebuild for
logind; nomarchy-suspend reads the live state for menu/hypridle.
'';
};
}; };
}; };
} }

View File

@@ -1,16 +1,33 @@
# Active power management. One concern, one file: this is the system # Active power management. One concern, one file: this is the system
# side — the power daemon (power-profiles-daemon by default, or TLP), # side — the power daemon (power-profiles-daemon by default, or TLP),
# thermald, and the battery charge limit. The profile *switcher* and the # thermald, the battery charge limit, and suspend-then-hibernate (#115).
# Waybar *indicator* live home-side (rofi.nix / waybar.nix); they self- # The profile *switcher* and the Waybar *indicator* live home-side
# gate on powerprofilesctl being present, so there's no system→home wiring # (rofi.nix / waybar.nix); they self-gate on powerprofilesctl being
# to keep in sync (the same way the Waybar battery widget auto-hides on # present, so there's no system→home wiring to keep in sync (the same
# desktops). See the roadmap in README.md. # way the Waybar battery widget auto-hides on desktops). See the
# roadmap in README.md.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.nomarchy.system.power; cfg = config.nomarchy.system.power;
sysCfg = config.nomarchy.system;
ppd = cfg.backend == "ppd"; ppd = cfg.backend == "ppd";
tlp = cfg.backend == "tlp"; tlp = cfg.backend == "tlp";
# settings.power.suspendThenHibernate → this option (ROADMAP § state
# bridges #116). null = key absent → leave option default (true).
sysState =
if sysCfg.stateFile != null
then import ../state-read.nix { inherit lib; } sysCfg.stateFile
else { };
stateS2h =
let v = ((sysState.settings or { }).power or { }).suspendThenHibernate or null;
in if builtins.isBool v then v else null;
# Hibernate needs a resume device (installer swapfile / partition).
# Without it, offering s2h only produces a suspend that never wakes.
canHibernate = (config.boot.resumeDevice or "") != "";
s2hActive = cfg.suspendThenHibernate && canHibernate;
in in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@@ -31,18 +48,36 @@ in
services.power-profiles-daemon.enable = lib.mkDefault ppd; services.power-profiles-daemon.enable = lib.mkDefault ppd;
services.tlp.enable = lib.mkDefault tlp; services.tlp.enable = lib.mkDefault tlp;
# State bridge (#115 / #116): menu writes settings.power.suspendThenHibernate.
nomarchy.system.power.suspendThenHibernate =
lib.mkIf (stateS2h != null) (lib.mkDefault stateS2h);
# Smart suspend helper (hypridle + Power menu). Live state read.
environment.systemPackages = [ pkgs.nomarchy-suspend ];
# Clamshell / dock (BACKLOG #86): when the machine is "docked" in # Clamshell / dock (BACKLOG #86): when the machine is "docked" in
# logind's sense (≥1 external display connected), closing the lid # logind's sense (≥1 external display connected), closing the lid
# must NOT suspend — the external panel is the session. systemd's # must NOT suspend — the external panel is the session. systemd's
# built-in default is already ignore; we set it explicitly so a # built-in default is already ignore; we set it explicitly so a
# downstream override or lock-bump drift is visible, and so # downstream override or lock-bump drift is visible, and so
# checks.clamshell-logind can assert the contract. # checks.clamshell-logind can assert the contract.
# Lid alone (undocked) keeps the normal suspend path # #115: undocked lid on battery → suspend-then-hibernate when the
# (HandleLidSwitch / ExternalPower left to upstream defaults). # toggle is on and resume is wired; on AC → plain suspend so a
# Display-profile "docked" layouts (eDP off) are orthogonal and # docked-at-desk lid-close doesn't plan a hibernate. Display-profile
# already handled by nomarchy.displayProfiles. # "docked" layouts (eDP off) are orthogonal (nomarchy.displayProfiles).
services.logind.settings.Login = { services.logind.settings.Login = {
HandleLidSwitchDocked = lib.mkDefault "ignore"; HandleLidSwitchDocked = lib.mkDefault "ignore";
} // lib.optionalAttrs s2hActive {
HandleLidSwitch = lib.mkDefault "suspend-then-hibernate";
HandleLidSwitchExternalPower = lib.mkDefault "suspend";
};
# 1h in suspend then hibernate; never start the countdown on AC
# (systemd ≥257 HibernateOnACPower). Only matters when something
# actually enters suspend-then-hibernate.
systemd.sleep.settings.Sleep = lib.mkIf s2hActive {
HibernateDelaySec = "1h";
HibernateOnACPower = false;
}; };
# thermald is Intel-only, so off unless asked (the installer enables # thermald is Intel-only, so off unless asked (the installer enables

View File

@@ -0,0 +1,65 @@
{ lib
, writeShellScriptBin
, coreutils
, gnugrep
, jq
, systemd
}:
# Smart suspend (#115): on battery, with hibernate available and the
# in-flake toggle on, enter suspend-then-hibernate (1h → hibernate via
# systemd.sleep HibernateDelaySec). Otherwise plain suspend.
#
# Reads the *live* state.json (menu may have flipped before rebuild);
# logind lid policy still needs a system rebuild to match (power.nix).
writeShellScriptBin "nomarchy-suspend" ''
set -euo pipefail
PATH=${lib.makeBinPath [ coreutils gnugrep jq systemd ]}:$PATH
on_ac() {
local f
for f in /sys/class/power_supply/*/online; do
[ -r "$f" ] && [ "$(cat "$f")" = "1" ] && return 0
done
return 1
}
can_hibernate() {
# Prefer logind's live answer (swap + resume wiring).
local v
v=$(busctl get-property org.freedesktop.login1 \
/org/freedesktop/login1 org.freedesktop.login1.Manager \
CanHibernate 2>/dev/null || true)
case "$v" in
"s \"yes\"") return 0 ;;
esac
# Offline / early-boot fallback: resume= on the kernel cmdline.
grep -q '[[:space:]]resume=' /proc/cmdline 2>/dev/null \
|| grep -q '^resume=' /proc/cmdline 2>/dev/null
}
want_s2h() {
# Default on (absent key / no state file) bag-carry drains less without
# a Preferences trip; menu writes false to opt out.
local st v="" found=0
for st in \
"''${NOMARCHY_PATH:-$HOME/.nomarchy}/state.json" \
/home/*/.nomarchy/state.json
do
[ -r "$st" ] || continue
found=1
v=$(jq -r '.settings.power.suspendThenHibernate // true' "$st" 2>/dev/null || true)
break
done
[ "$found" -eq 0 ] && return 0
case "$v" in
false|False|0) return 1 ;;
*) return 0 ;;
esac
}
if want_s2h && ! on_ac && can_hibernate; then
exec systemctl suspend-then-hibernate
fi
exec systemctl suspend
''

View File

@@ -47,6 +47,8 @@
# backend = "ppd"; # "ppd" (default) or "tlp" # backend = "ppd"; # "ppd" (default) or "tlp"
# laptop = true; # required by batteryChargeLimit # laptop = true; # required by batteryChargeLimit
# batteryChargeLimit = 80; # cap charging for longevity # batteryChargeLimit = 80; # cap charging for longevity
# # suspendThenHibernate = true; # 1h on battery → hibernate (#115);
# # menu: Preferences Suspend then hibernate
# thermal.enable = true; # thermald (Intel CPUs) # thermal.enable = true; # thermald (Intel CPUs)
# }; # };
# #