feat(power): clamshell — ignore lid suspend when docked (#86)
Some checks failed
Check / eval (push) Has been cancelled
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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
27
flake.nix
27
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
|
# 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
|
||||||
|
|||||||
@@ -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"; }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user