feat(audio): prefer dock/HDMI/USB sinks in WirePlumber (#87)
Some checks failed
Check / eval (push) Has been cancelled

Raise priority.session for HDMI/DisplayPort (1100) and USB (1050) ALSA
outputs so the default sink follows dock plug/unplug; built-in analog
wins again when externals disappear. Rules live in
modules/nixos/dock-audio-rules.nix; checks.dock-audio covers the contract.

Verified: V2 — flake check --no-build; checks.dock-audio green.
V3 pending: HARDWARE-QUEUE #87 real HDMI/USB-dock hotplug.
This commit is contained in:
Bernardo Magri
2026-07-11 10:36:07 +01:00
parent 27f18e4ccc
commit f47903a6a6
9 changed files with 125 additions and 8 deletions

View File

@@ -278,7 +278,7 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) | | `nomarchy.system.fileManager.enable` | `true` | Thunar GUI + gvfs/tumbler/udisks2 (the "open folder" handler) |
| `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet | | `nomarchy.system.greeter.enable` | `true` | greetd/tuigreet |
| `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) | | `nomarchy.system.greeter.autoLogin` | `null` | Auto-login this user into Hyprland (installer sets it on LUKS machines) |
| `nomarchy.system.audio.enable` | `true` | Pipewire stack | | `nomarchy.system.audio.enable` | `true` | Pipewire stack; WirePlumber prefers HDMI/USB (dock) sinks when present so default output follows dock/undock |
| `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman | | `nomarchy.system.bluetooth.enable` | `true` | Bluetooth + blueman |
| `nomarchy.system.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) | | `nomarchy.system.autoTimezone.enable` | `false` | Automatic timezone (geoclue + automatic-timezoned) — the clock follows your location; toggle from System Auto timezone (a menu enable rebuilds: it has to unset the static `time.timeZone`) |
| `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) |

View File

@@ -55,10 +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.
### 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.
## LATER ## LATER
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided, - **Wallpapers artifact split** (ROADMAP § Faster switches — decided,
@@ -111,8 +107,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#86 shipped 2026-07-11. Remaining dock slice: **#87** _(#80#83 + #85#87 shipped 2026-07-11. Dock V3 residuals: #86
dock audio. #86 V3 clamshell on real external display → HARDWARE-QUEUE.)_ clamshell + #87 audio on real hardware → HARDWARE-QUEUE.)_
### v1.0 pointer ### v1.0 pointer

View File

@@ -19,6 +19,13 @@ the **T14s** (webcam case).
— should suspend as usual. Optional: with a docked display-profile — should suspend as usual. Optional: with a docked display-profile
(`eDP-1` disable), confirm clamshell layout still looks right after (`eDP-1` disable), confirm clamshell layout still looks right after
lid close. Pass = no surprise suspend when docked. lid close. Pass = no surprise suspend when docked.
- [ ] **#87 dock audio sink follow** — V2 asserts WirePlumber
`90-nomarchy-dock-audio` rules (HDMI/USB priority). On a laptop
with HDMI or USB-dock audio: play sound on speakers, plug external
audio → default sink should move to HDMI/dock (`wpctl status` shows
`*` on the external sink); unplug → `*` returns to built-in.
Manual `wpctl set-default` still works; Bluetooth may outrank dock
(by design). Pass = auto switch without menu intervention.
- [ ] **#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,17 @@ Template:
--- ---
## 2026-07-11 — #87 dock audio sink priority
- **Task:** NEXT #87 — default audio follows dock plug/unplug.
- **Did:** WirePlumber `90-nomarchy-dock-audio` (HDMI/DP session prio
1100, USB 1050) via `modules/nixos/dock-audio-rules.nix` + default.nix
pipewire. `checks.dock-audio` pure rule asserts + conf-file VM. V3
hotplug steps in HARDWARE-QUEUE. Closed #87.
- **Verified:** **V2**`nix flake check --no-build`; `checks.dock-audio`
green (pure rule asserts + conf installed under wireplumber.conf.d).
- **Pending:** V3 real HDMI/USB-dock hotplug (HARDWARE-QUEUE).
- **Next suggestion:** QA sweep or LATER wallpapers split if promoted.
## 2026-07-11 — #86 clamshell HandleLidSwitchDocked ## 2026-07-11 — #86 clamshell HandleLidSwitchDocked
- **Task:** NEXT #86 — lid closed + external monitor must not suspend. - **Task:** NEXT #86 — lid closed + external monitor must not suspend.
- **Did:** Explicit `HandleLidSwitchDocked=ignore` in - **Did:** Explicit `HandleLidSwitchDocked=ignore` in

View File

@@ -394,6 +394,12 @@ Design/decision records and a running log of shipped work (items marked
power management is on (systemd docked = external display present). power management is on (systemd docked = external display present).
Undocked lid suspend left to upstream. `checks.clamshell-logind` asserts Undocked lid suspend left to upstream. `checks.clamshell-logind` asserts
logind.conf + active logind. Real lid+external V3 in HARDWARE-QUEUE. logind.conf + active logind. Real lid+external V3 in HARDWARE-QUEUE.
✓ **Dock audio sink priority (#87, `VISION § C`):** WirePlumber
`90-nomarchy-dock-audio` raises `priority.session` for HDMI/DP and USB
ALSA sinks so they become default when present; unplug falls back to
built-in. Rules in `modules/nixos/dock-audio-rules.nix`;
`checks.dock-audio` pure + conf-file VM. Real hotplug 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 | ✓ #86 clamshell logind ignore when docked; #87 audio still open | | **Dock life** | Lid closed + external only, wake, default audio sink on undock | ✓ #86 clamshell logind; ✓ #87 WirePlumber HDMI/USB priority (V3 hotplug QA) |
| **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,61 @@
''; '';
}; };
# Dock audio sink priority (#87): WirePlumber rules prefer
# HDMI/DP/USB sinks over built-in analog. Pure unit test of the
# rule set + a minimal VM that the conf file is installed where
# WirePlumber reads it (XDG_DATA_DIRS / share/wireplumber).
dock-audio =
let
inherit (nixpkgs) lib;
rules = import ./modules/nixos/dock-audio-rules.nix;
alsa = rules."monitor.alsa.rules" or [ ];
expect = cond: msg: lib.assertMsg cond "dock-audio: ${msg}";
json = builtins.toJSON alsa;
pureOk =
expect (builtins.isList alsa && builtins.length alsa >= 2)
"expected 2 alsa rule groups"
&& expect (lib.hasInfix "hdmi" json)
"HDMI match missing from rules JSON"
&& expect (lib.hasInfix "usb-" json || lib.hasInfix "usb" json)
"USB match missing from rules JSON"
&& expect (lib.hasInfix "priority.session" json)
"priority.session missing"
&& expect (lib.hasInfix "1100" json)
"HDMI session priority 1100 missing";
in
assert pureOk;
pkgs.testers.runNixOSTest {
name = "nomarchy-dock-audio";
nodes.machine = { ... }: {
services.pipewire = {
enable = true;
alsa.enable = true;
pulse.enable = true;
wireplumber.enable = true;
wireplumber.extraConfig."90-nomarchy-dock-audio" =
import ./modules/nixos/dock-audio-rules.nix;
};
# WirePlumber is a user service; enable linger-less check via
# the system data dir the package puts conf into.
environment.systemPackages = [ pkgs.wireplumber ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
conf = machine.succeed(
"find /nix/store -path "
"'*wireplumber.conf.d/90-nomarchy-dock-audio.conf' "
"2>/dev/null | head -1"
).strip()
assert conf, "90-nomarchy-dock-audio.conf not in the store"
body = machine.succeed(f"cat '{conf}'")
assert "monitor.alsa.rules" in body, body
assert "priority.session" in body, body
assert "hdmi" in body.lower() or "HDMI" in body, body
assert "usb" in body.lower(), body
'';
};
# Clamshell / dock lid policy (#86): logind must ignore lid close # Clamshell / dock lid policy (#86): logind must ignore lid close
# when docked (external display present). Real multi-monitor lid # when docked (external display present). Real multi-monitor lid
# events stay V3; this asserts the baked logind.conf + that # events stay V3; this asserts the baked logind.conf + that

View File

@@ -125,6 +125,10 @@ in
alsa.support32Bit = true; alsa.support32Bit = true;
pulse.enable = true; pulse.enable = true;
wireplumber.enable = true; wireplumber.enable = true;
# Prefer dock/HDMI/USB sinks when present; fall back to built-in when
# they disappear (BACKLOG #87). Rules: ./dock-audio-rules.nix.
wireplumber.extraConfig."90-nomarchy-dock-audio" =
import ./dock-audio-rules.nix;
}; };
# ── Desktop services ───────────────────────────────────────────── # ── Desktop services ─────────────────────────────────────────────

View File

@@ -0,0 +1,38 @@
# WirePlumber 0.5 ALSA rules: prefer dock / external sinks over built-in
# analog when they appear (BACKLOG #87). Higher priority.session wins as
# the default node; when HDMI/USB goes away, the next-highest (usually
# the laptop speakers/headphones) becomes default again.
#
# Pure attrset so checks.dock-audio can unit-test the contract without a
# full PipeWire stack. Consumed by modules/nixos/default.nix.
#
# Priorities stay ≤1500 (WirePlumber docs: sinks default ~6001000; going
# much higher can outrank streams / BT in surprising ways).
{
"monitor.alsa.rules" = [
{
# HDMI / DisplayPort monitor audio (names vary: .hdmi-stereo,
# .hdmi-surround, HiFi__HDMI1__sink, …).
matches = [
{ "node.name" = "~alsa_output\\..*\\.hdmi-.*"; }
{ "node.name" = "~alsa_output\\..*HDMI.*"; }
{ "node.name" = "~alsa_output\\..*\\.DisplayPort.*"; }
{ "node.name" = "~alsa_output\\..*\\.dp-.*"; }
];
actions."update-props" = {
"priority.driver" = 1100;
"priority.session" = 1100;
};
}
{
# USB dock / dongle audio (Thunderbolt dock, USB-C audio, …).
matches = [
{ "node.name" = "~alsa_output\\.usb-.*"; }
];
actions."update-props" = {
"priority.driver" = 1050;
"priority.session" = 1050;
};
}
];
}