diff --git a/README.md b/README.md index 3a636c8..acea1a3 100644 --- a/README.md +++ b/README.md @@ -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.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.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.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins | | `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.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.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.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 | diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 888a4aa..c770e09 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -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 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 - **Wallpapers artifact split** (ROADMAP § Faster switches — decided, diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 4942700..8d2158a 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -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), 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) +- [ ] **#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)** — after `nomarchy-home` + waybar restart: System › **Airplane mode (off)** (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 2026-07-10). - [ ] **btop theme fidelity (#52 residual)** — softGL theme-shot cannot - open Ghostty, so the hand `btop.theme` assets were only guest-file - asserted (main_bg / inactive_fg keys) + desktop/bar rendered for - rose-pine, everforest, summer-night, vantablack, catppuccin. On a - real session: `nomarchy-state-sync apply ` then `btop` and - confirm backgrounds/text match the theme (esp. rose-pine Dawn light - bg `#faf4ed`, vantablack near-black `#0d0d0d`, catppuccin Mocha - `#1E1E2E`). One pass cycling those five is enough. + open a real terminal UI, so the hand `btop.theme` assets were only + guest-file asserted (main_bg / inactive_fg keys) + desktop/bar + rendered for rose-pine, everforest, summer-night, vantablack, + catppuccin. On a real session: `nomarchy-state-sync apply ` + then `btop` and confirm backgrounds/text match the theme (esp. + rose-pine Dawn light bg `#faf4ed`, vantablack near-black `#0d0d0d`, + catppuccin Mocha `#1E1E2E`). One pass cycling those five is enough. - [ ] **SSH_AUTH_SOCK for GUI clients** — after relogin, launch a GUI git client (or `rofi`-launched terminal-less app) and confirm it reaches gpg-agent's SSH socket without an interactive shell parent. diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 00e209a..037595c 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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) - **Task:** Bernardo: GTK dialog button labels black vs dark Boreal chrome. - **Did:** Root cause — Stylix pins light `adw-gtk3` for all polarities. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c9a2380..f163013 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 (Boreal). Fix: force `adw-gtk3-dark` / `adw-gtk3` from palette mode, 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): 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 diff --git a/flake.nix b/flake.nix index 065d513..e54446f 100644 --- a/flake.nix +++ b/flake.nix @@ -90,6 +90,7 @@ nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { }; nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { }; nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { }; + nomarchy-suspend = final.callPackage ./pkgs/nomarchy-suspend { }; nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { }; # pull / rebuild / home (+ legacy aliases) — also on HM so a home # switch can refresh a broken system-package nomarchy-pull. @@ -171,6 +172,11 @@ settings.bluetooth.enable = false; # default true → flipped settings.printing.enable = true; # default false → flipped 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 { @@ -206,7 +212,17 @@ nomarchy.system.bluetooth.enable = true; nomarchy.services.printing.enable = false; 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 = [ { n = "absent key leaves bluetooth at its default (true)"; @@ -231,6 +247,26 @@ ok = pinned.services.printing.enable == false; } { n = "hand-set autoLogin outranks the state"; 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; in diff --git a/modules/home/idle.nix b/modules/home/idle.nix index 7f43e89..5bc869c 100644 --- a/modules/home/idle.nix +++ b/modules/home/idle.nix @@ -22,6 +22,9 @@ let in { 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 = { enable = true; settings = { @@ -122,10 +125,15 @@ in # Suspend only on battery, and sooner than the old fixed 30 min # (it now only fires unplugged). Plugged in, the machine stays # 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 - # suspends (HandleLidSwitch); docked/clamshell lid is ignore + # suspends / s2h (HandleLidSwitch); docked/clamshell lid is ignore # (HandleLidSwitchDocked — modules/nixos/power.nix, #86). - { timeout = 900; on-timeout = "${onAc} || systemctl suspend"; } + { + timeout = 900; + on-timeout = "${onAc} || ${lib.getExe pkgs.nomarchy-suspend}"; + } ]; }; }; diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index c1a314f..85b38d5 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -216,6 +216,18 @@ let done 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() { local s="$1" out="" ch i @@ -304,7 +316,13 @@ let "$BACK") exec "$0" ;; *Lock) loginctl lock-session ;; *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) # zram can't hold a hibernate image; a machine installed with # swap=0 has no disk swap. Attempt, and if logind rejects it, @@ -1324,6 +1342,13 @@ ${themeRows} row "Power profile" preferences-system-power fi 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 back } | rofi_menu -show-icons -markup-rows -p Preferences) || exit 0 @@ -1338,6 +1363,7 @@ ${themeRows} *Printing*) exec "$0" printing-toggle ;; *"Power profile"*) exec "$0" power-profile ;; *"Battery limit"*) exec "$0" batterylimit ;; + *"Suspend then hibernate"*) exec "$0" suspend-then-hibernate-toggle ;; *Doctor*) exec "$0" doctor ;; esac ;; @@ -1368,6 +1394,25 @@ ${themeRows} echo 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) case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in true|True) new=false; label=Off ;; @@ -1485,7 +1530,7 @@ ${themeRows} 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 ;; esac ''; diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index 8205fde..f9a6f50 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -226,6 +226,13 @@ in # 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 # 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 = let 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 — # starship/bat/eza/zoxide — is configured home-side in shell.nix). # programs.zsh.enable wires /etc/zshrc, completion and /etc/shells; diff --git a/modules/nixos/options.nix b/modules/nixos/options.nix index 534a30a..d38f34a 100644 --- a/modules/nixos/options.nix +++ b/modules/nixos/options.nix @@ -143,6 +143,26 @@ 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. + ''; + }; }; }; } diff --git a/modules/nixos/power.nix b/modules/nixos/power.nix index 84f89e8..7cc4d5e 100644 --- a/modules/nixos/power.nix +++ b/modules/nixos/power.nix @@ -1,16 +1,33 @@ # 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. +# thermald, the battery charge limit, and suspend-then-hibernate (#115). +# 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; + sysCfg = config.nomarchy.system; ppd = cfg.backend == "ppd"; 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 { config = lib.mkIf cfg.enable { @@ -31,18 +48,36 @@ in services.power-profiles-daemon.enable = lib.mkDefault ppd; 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 # 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. + # #115: undocked lid on battery → suspend-then-hibernate when the + # toggle is on and resume is wired; on AC → plain suspend so a + # docked-at-desk lid-close doesn't plan a hibernate. Display-profile + # "docked" layouts (eDP off) are orthogonal (nomarchy.displayProfiles). services.logind.settings.Login = { 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 diff --git a/pkgs/nomarchy-suspend/default.nix b/pkgs/nomarchy-suspend/default.nix new file mode 100644 index 0000000..347b79e --- /dev/null +++ b/pkgs/nomarchy-suspend/default.nix @@ -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 +'' diff --git a/templates/downstream/system.nix b/templates/downstream/system.nix index b4843b3..8adefe9 100644 --- a/templates/downstream/system.nix +++ b/templates/downstream/system.nix @@ -47,6 +47,8 @@ # backend = "ppd"; # "ppd" (default) or "tlp" # laptop = true; # required by batteryChargeLimit # batteryChargeLimit = 80; # cap charging for longevity + # # suspendThenHibernate = true; # 1h on battery → hibernate (#115); + # # menu: Preferences › Suspend then hibernate # thermal.enable = true; # thermald (Intel CPUs) # }; #