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

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