feat(power): suspend-then-hibernate 1h on battery (#115)
All checks were successful
Check / eval (push) Successful in 6m16s
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:
@@ -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}";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user