feat(power): clamshell — ignore lid suspend when docked (#86)
Some checks failed
Check / eval (push) Has been cancelled

Explicit HandleLidSwitchDocked=ignore under services.logind so closing
the lid with an external display connected does not suspend. Undocked
lid path left to upstream defaults. checks.clamshell-logind asserts the
baked logind.conf and active logind; real multi-monitor lid V3 queued.

Verified: V2 — flake check --no-build; checks.clamshell-logind green.
V3 pending: HARDWARE-QUEUE #86 lid+external on a laptop.
This commit is contained in:
Bernardo Magri
2026-07-11 10:32:18 +01:00
parent ce05eed754
commit 27f18e4ccc
9 changed files with 73 additions and 13 deletions

View File

@@ -284,7 +284,7 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) | | `nomarchy.system.snapper.enable` | `false` | Hourly/daily BTRFS timeline snapshots + `nixos-rebuild-snap` (installer enables it; no-op unless root is BTRFS) |
| `nomarchy.system.power.enable` | `true` | Active power management (see below) | | `nomarchy.system.power.enable` | `true` | Active power management (see below) |
| `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive | | `nomarchy.system.power.backend` | `"ppd"` | `"ppd"` (power-profiles-daemon + menu/Waybar switcher) or `"tlp"` (deeper battery tuning, no switcher) — mutually exclusive |
| `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present | | `nomarchy.system.power.laptop` | `false` | Marks a laptop, gating battery-only features; the installer sets it when a battery is present. With power management on, clamshell is also configured: lid close ignores suspend when logind considers the machine docked (external display) — `HandleLidSwitchDocked=ignore` |
| `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.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 |

View File

@@ -55,13 +55,6 @@ runtime-computed `resume_offset` into the boot cmdline, and the dev box runs
exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in
HARDWARE-QUEUE. Close #76 on PASS. HARDWARE-QUEUE. Close #76 on PASS.
### 86. Clamshell dock: lid + external monitor (`VISION § C`)
Lid closed with external monitor connected must not suspend
(`logind` `lidSwitchDocked` / `ExternalPower` audit + display-profile
interplay); wake behaves. Display profiles + auto-switch already exist —
this is the lid/suspend edge case. VM where possible; residual V3 on
real dock hardware.
### 87. Dock audio sink follows plug/unplug (`VISION § C`) ### 87. Dock audio sink follows plug/unplug (`VISION § C`)
Default audio sink: dock sink on plug, internal on unplug (WirePlumber Default audio sink: dock sink on plug, internal on unplug (WirePlumber
policy or udev hook). Complements #86; can ship independently. policy or udev hook). Complements #86; can ship independently.
@@ -118,8 +111,8 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.*
2026-07-10). Keep #59 commented install guidance; no 2026-07-10). Keep #59 commented install guidance; no
`nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue. `nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue.
_(#80#83 + #85 shipped 2026-07-11. #84 split → **NEXT #86#87** dock _(#80#83 + #85#86 shipped 2026-07-11. Remaining dock slice: **#87**
clamshell + dock audio.)_ dock audio. #86 V3 clamshell on real external display → HARDWARE-QUEUE.)_
### v1.0 pointer ### v1.0 pointer

View File

@@ -11,6 +11,14 @@ the **T14s** (webcam case).
## Any machine (dev box is fine) ## Any machine (dev box is fine)
- [ ] **#86 clamshell lid + external display** — V2 asserts
`HandleLidSwitchDocked=ignore` in logind.conf. On a laptop with a
real external monitor: open a session, plug external, close lid —
machine must **stay awake** (no suspend); external panel remains
usable (or wakes with keyboard if DPMS). Unplug external, close lid
— should suspend as usual. Optional: with a docked display-profile
(`eDP-1` disable), confirm clamshell layout still looks right after
lid close. Pass = no surprise suspend when docked.
- [ ] **#55 fingerprint enroll on real reader** — with - [ ] **#55 fingerprint enroll on real reader** — with
`nomarchy.hardware.fingerprint.enable` and a physical reader: System `nomarchy.hardware.fingerprint.enable` and a physical reader: System
Fingerprint Enroll a finger; List shows it; Verify succeeds; optional Fingerprint Enroll a finger; List shows it; Verify succeeds; optional

View File

@@ -19,6 +19,18 @@ Template:
--- ---
## 2026-07-11 — #86 clamshell HandleLidSwitchDocked
- **Task:** NEXT #86 — lid closed + external monitor must not suspend.
- **Did:** Explicit `HandleLidSwitchDocked=ignore` in
`modules/nixos/power.nix` (logind settings.Login). Undocked lid
behaviour unchanged. idle.nix comment corrected. `checks.clamshell-logind`
asserts conf + active logind. V3 steps in HARDWARE-QUEUE. Closed #86.
- **Verified:** **V2**`nix flake check --no-build`;
`checks.clamshell-logind` green (`HandleLidSwitchDocked=ignore` +
logind active).
- **Pending:** V3 real lid+external on laptop (HARDWARE-QUEUE).
- **Next suggestion:** #87 dock audio sink follow.
## 2026-07-11 — #85 hardware hall of fame + split #84 ## 2026-07-11 — #85 hardware hall of fame + split #84
- **Task:** NEXT #85 docs hall of fame; #84 was `[big]` → split. - **Task:** NEXT #85 docs hall of fame; #84 was `[big]` → split.
- **Did:** HARDWARE.md §9 install-tested table (T14s Gen 4 AMD, Latitude - **Did:** HARDWARE.md §9 install-tested table (T14s Gen 4 AMD, Latitude

View File

@@ -389,6 +389,11 @@ Design/decision records and a running log of shipped work (items marked
✓ **Hardware hall of fame (#85, `VISION § F`):** HARDWARE.md §9 ✓ **Hardware hall of fame (#85, `VISION § F`):** HARDWARE.md §9
install-tested table (T14s Gen 4, Latitude 5410/5310) + “send your DMI install-tested table (T14s Gen 4, Latitude 5410/5310) + “send your DMI
line” PR invite; §10 points contributors at the table. line” PR invite; §10 points contributors at the table.
✓ **Clamshell logind (#86, `VISION § C`):** explicit
`services.logind.settings.Login.HandleLidSwitchDocked = "ignore"` when
power management is on (systemd docked = external display present).
Undocked lid suspend left to upstream. `checks.clamshell-logind` asserts
logind.conf + active logind. Real lid+external V3 in HARDWARE-QUEUE.
- ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS - ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS
passphrase already gates the machine, but locking hyprlock before sleep passphrase already gates the machine, but locking hyprlock before sleep
meant a second password. Fixed by *not locking* before an encrypted meant a second password. Fixed by *not locking* before an encrypted

View File

@@ -80,7 +80,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4§5, §11 (doctor).
| Idea | Intent | Notes | | Idea | Intent | Notes |
|------|--------|-------| |------|--------|-------|
| **Dock life** | Lid closed + external only, wake, default audio sink on undock | Display profiles exist — polish edge cases | | **Dock life** | Lid closed + external only, wake, default audio sink on undock | #86 clamshell logind ignore when docked; #87 audio still open |
| **Hibernate/sleep confidence** | Doctor: resume device, swap size, clean suspend journal | Trust > new power UI | | **Hibernate/sleep confidence** | Doctor: resume device, swap size, clean suspend journal | Trust > new power UI |
| **Battery health readout** ✓ | Cycles + design capacity % in doctor where sysfs allows | ✓ shipped #80 (report-only; charge limit is a separate row) | | **Battery health readout** ✓ | Cycles + design capacity % in doctor where sysfs allows | ✓ shipped #80 (report-only; charge limit is a separate row) |
| **Charge-limit instant apply** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit | | **Charge-limit instant apply** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit |

View File

@@ -510,6 +510,33 @@
''; '';
}; };
# Clamshell / dock lid policy (#86): logind must ignore lid close
# when docked (external display present). Real multi-monitor lid
# events stay V3; this asserts the baked logind.conf + that
# logind is alive to consume it. Imports options + power only.
clamshell-logind = pkgs.testers.runNixOSTest {
name = "nomarchy-clamshell-logind";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
nomarchy.system.power = {
enable = true;
laptop = true;
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("systemd-logind.service")
conf = machine.succeed("cat /etc/systemd/logind.conf")
assert "HandleLidSwitchDocked=ignore" in conf.replace(" ", ""), (
f"clamshell: logind.conf missing HandleLidSwitchDocked=ignore:\n{conf}"
)
# logind must be running (policy is dead if the unit is down).
machine.succeed("systemctl is-active systemd-logind.service")
# busctl property: Docked lid policy is not always exported;
# re-read conf after logind start is enough for the contract.
'';
};
# Runtime VM check for the battery-charge-limit re-apply: the udev # Runtime VM check for the battery-charge-limit re-apply: the udev
# rule must restart the oneshot on an AC state change (firmware can # rule must restart the oneshot on an AC state change (firmware can
# clear the threshold on unplug). The `test_power` module fakes a # clear the threshold on unplug). The `test_power` module fakes a

View File

@@ -85,8 +85,9 @@ 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.
# Closing the lid still suspends on AC (logind's default): that's # Lid close is logind's job (not hypridle): undocked lid still
# an explicit "I'm done", distinct from sitting idle. # suspends (HandleLidSwitch); docked/clamshell lid is ignore
# (HandleLidSwitchDocked — modules/nixos/power.nix, #86).
{ timeout = 900; on-timeout = "${onAc} || systemctl suspend"; } { timeout = 900; on-timeout = "${onAc} || systemctl suspend"; }
]; ];
}; };

View File

@@ -31,6 +31,20 @@ 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;
# 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.
services.logind.settings.Login = {
HandleLidSwitchDocked = lib.mkDefault "ignore";
};
# thermald is Intel-only, so off unless asked (the installer enables # thermald is Intel-only, so off unless asked (the installer enables
# it on a GenuineIntel CPU). Sits happily next to either backend. # it on a GenuineIntel CPU). Sits happily next to either backend.
services.thermald.enable = lib.mkDefault cfg.thermal.enable; services.thermald.enable = lib.mkDefault cfg.thermal.enable;