From 27f18e4ccc88d43888f8220f10f7a42e9199d151 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 11 Jul 2026 10:32:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(power):=20clamshell=20=E2=80=94=20ignore?= =?UTF-8?q?=20lid=20suspend=20when=20docked=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- agent/BACKLOG.md | 11 ++--------- agent/HARDWARE-QUEUE.md | 8 ++++++++ agent/JOURNAL.md | 12 ++++++++++++ docs/ROADMAP.md | 5 +++++ docs/VISION.md | 2 +- flake.nix | 27 +++++++++++++++++++++++++++ modules/home/idle.nix | 5 +++-- modules/nixos/power.nix | 14 ++++++++++++++ 9 files changed, 73 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0451d31..c14c7f7 100644 --- a/README.md +++ b/README.md @@ -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.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.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.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 | diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index a3e8fb2..0bb3b22 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -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 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`) Default audio sink: dock sink on plug, internal on unplug (WirePlumber 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 `nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue. -_(#80–#83 + #85 shipped 2026-07-11. #84 split → **NEXT #86–#87** dock - clamshell + dock audio.)_ +_(#80–#83 + #85–#86 shipped 2026-07-11. Remaining dock slice: **#87** + dock audio. #86 V3 clamshell on real external display → HARDWARE-QUEUE.)_ ### v1.0 pointer diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index bef66b4..43eacd9 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -11,6 +11,14 @@ the **T14s** (webcam case). ## 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 `nomarchy.hardware.fingerprint.enable` and a physical reader: System › Fingerprint › Enroll a finger; List shows it; Verify succeeds; optional diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index d0d2961..482c1a6 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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 - **Task:** NEXT #85 docs hall of fame; #84 was `[big]` → split. - **Did:** HARDWARE.md §9 install-tested table (T14s Gen 4 AMD, Latitude diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fbee770..e98ca99 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 install-tested table (T14s Gen 4, Latitude 5410/5310) + “send your DMI 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 passphrase already gates the machine, but locking hyprlock before sleep meant a second password. Fixed by *not locking* before an encrypted diff --git a/docs/VISION.md b/docs/VISION.md index 89cc7af..f82c23d 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -80,7 +80,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §11 (doctor). | 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 | | **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 | diff --git a/flake.nix b/flake.nix index 9d1deed..940ecbe 100644 --- a/flake.nix +++ b/flake.nix @@ -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 # rule must restart the oneshot on an AC state change (firmware can # clear the threshold on unplug). The `test_power` module fakes a diff --git a/modules/home/idle.nix b/modules/home/idle.nix index 7a5eac1..acdade1 100644 --- a/modules/home/idle.nix +++ b/modules/home/idle.nix @@ -85,8 +85,9 @@ 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. - # Closing the lid still suspends on AC (logind's default): that's - # an explicit "I'm done", distinct from sitting idle. + # Lid close is logind's job (not hypridle): undocked lid still + # suspends (HandleLidSwitch); docked/clamshell lid is ignore + # (HandleLidSwitchDocked — modules/nixos/power.nix, #86). { timeout = 900; on-timeout = "${onAc} || systemctl suspend"; } ]; }; diff --git a/modules/nixos/power.nix b/modules/nixos/power.nix index fb61a58..38f5280 100644 --- a/modules/nixos/power.nix +++ b/modules/nixos/power.nix @@ -31,6 +31,20 @@ in services.power-profiles-daemon.enable = lib.mkDefault ppd; 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 # it on a GenuineIntel CPU). Sits happily next to either backend. services.thermald.enable = lib.mkDefault cfg.thermal.enable;