diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 5fb24a4..01f57f0 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -54,6 +54,28 @@ the **T14s** (webcam case). `journalctl --user -t nomarchy-display-watch` should show `outputs-changed added=…`, and `-t nomarchy-dock-audio` `trigger=jack selected=…` on a headphone plug. + + **Round 5 was itself run and failed (2026-07-14); this entry now covers + round 6.** Two of the four reports were never actually testing the fix: + both watchers are Hyprland `exec-once`, so `nomarchy-home` swaps the + config but leaves the *old* processes running — only `nomarchy-dock-audio` + (a user service) had picked up new code. **Relogin is mandatory before + testing either watcher**; confirm with `tr '\0' '\n' < /proc/$(pgrep -f + bin/nomarchy-display-profile-watch)/cmdline | sed -n 2p` and grep the + script for `outputs-changed`. The other two were real and are fixed in + the commit carrying this entry: automatic dock mode did not exist at all + (the watcher auto-*undocked* but only ever matched display profiles on + plug, and this box has none saved with `displayProfileAuto` unset, so + nothing happened — `Dock mode` was interactive-only), and the BenQ's + audio was unreachable because WirePlumber had `alsa_card.pci-0000_c3_00.1` + pinned to `pro-audio`, whose raw `pro-output-N` sinks have no ports and + no routing. The BenQ's own USB card is input-only (all profiles + `sinks: 0`), so DisplayPort is the only path to its speakers. + **Also check:** no display profile is saved, yet plugging the BenQ must + now dock by itself; and `journalctl --user -t nomarchy-dock-audio` shows + `repaired-card=alsa_card.pci-0000_c3_00.1 profile=HiFi` on the first + plug, after which audio reaches the monitor's speakers. A saved+matching + display profile must still win over auto-dock. - [ ] **Docking recovery round 4 (#100, closed-lid BenQ sequence)** — after updating to the commit carrying this entry, run `nomarchy-home` and relogin. With the lid open, put visible windows on at least workspaces diff --git a/flake.nix b/flake.nix index 277a47e..9c252d0 100644 --- a/flake.nix +++ b/flake.nix @@ -957,6 +957,16 @@ # followed explicitly or plugging in stops switching output. grep -q 'headphone_sinks' "$audio" grep -Fq '"$0" headphones jack' "$audio" + # pro-audio/off publish no routable sink, and the profile is + # stored per card — so a monitor's audio stays unreachable + # however it is picked until the card itself is repaired. + grep -q 'repair_dock_cards' "$audio" + grep -q 'set-card-profile' "$audio" + grep -q 'repaired-card=' "$audio" + # Auto-undock without auto-dock leaves the pair half manual. + grep -q 'auto_dock' "$watcher" + grep -q 'auto-dock internal=' "$watcher" + grep -Fq '"$TRANSITION" dock' "$watcher" ! grep -q 'power_save' "$audio" echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present" touch $out diff --git a/modules/home/dock-audio.nix b/modules/home/dock-audio.nix index 6d9a830..2a12bdf 100644 --- a/modules/home/dock-audio.nix +++ b/modules/home/dock-audio.nix @@ -83,7 +83,54 @@ let log "trigger=$trigger result=set-default-failed candidate=$name" return 1 } - select_first_dock() { select_first dock_sinks "$1"; } + # A card parked on `pro-audio` (or `off`) publishes no sink we can route: + # pro-audio exposes raw `pro-output-N` nodes with no ports, no routing and + # no volume, so a monitor's audio is unreachable however it is chosen — + # and WirePlumber stores that profile per card, so it survives forever + # once set. When a dock plug finds nothing to select, repair the cards + # that carry an available HDMI/DisplayPort output by moving them to their + # best real profile. Deliberately narrow: an internal analog card is + # never touched, and a card already on a routable profile is left alone, + # so a considered pro-audio setup on anything else survives. + repair_dock_cards() { # $1 = observable trigger + trigger="$1" + cards=$("$PACTL" --format=json list cards 2>/dev/null \ + | "$JQ" -r ' + .[] + | select(.active_profile == "pro-audio" or .active_profile == "off") + # An available HDMI/DP port is the monitor itself asking for + # audio over the cable — that is what makes this card a dock. + | select([.ports[]? | select((.type // "") == "HDMI") + | select((.availability // "unknown") == "available")] + | length > 0) + | . as $card + | [ $card.profiles // {} | to_entries[] + | select(.key != "pro-audio" and .key != "off") + | select((.value.sinks // 0) > 0) + | select((.value.available // true) != false) ] + | sort_by(-(.value.priority // 0)) + | if length == 0 then empty else "\($card.name)\t\(.[0].key)" end') || cards= + [ -n "$cards" ] || return 1 + printf '%s\n' "$cards" | while IFS= read -r row; do + card=''${row%%"$TAB"*} + prof=''${row#*"$TAB"} + if "$PACTL" set-card-profile "$card" "$prof" 2>/dev/null; then + log "trigger=$trigger repaired-card=$card profile=$prof" + else + log "trigger=$trigger repair-failed=$card profile=$prof" + fi + done + } + + select_first_dock() { # $1 = observable trigger + select_first dock_sinks "$1" && return 0 + # Nothing routable to select is the symptom a parked card produces, so + # it is also the only moment worth touching profiles. Retry once the + # repaired card has had time to publish its sinks. + repair_dock_cards "$1" || return 1 + sleep 1 + select_first dock_sinks "$1" + } case "''${1:-watch}" in candidates) diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 8f83ba0..eae6c7e 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -454,6 +454,31 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList fi } + # Entering dock mode is the mirror of the automatic undock below: the + # session already comes back to the panel by itself when the last + # external leaves, so an arriving external has to take it the other way + # or the pair is only half-automatic and the menu has to finish the job. + # A display profile is a deliberate, more specific layout, so it wins — + # the caller only reaches here when none is in effect. + auto_dock() { + internal=$(internal_output) + target=$(external_outputs | ${pkgs.coreutils}/bin/head -n 1) + [ -n "$internal" ] && [ -n "$target" ] || return + # `monitors` (unlike `monitors all`) lists only enabled outputs, so the + # panel still being there is what "not docked yet" means. Re-docking + # an already-docked session would move nothing and disable nothing. + hyprctl monitors -j 2>/dev/null \ + | jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return + if "$TRANSITION" dock "$internal" "$target" "$target, preferred, auto, 1"; then + log "auto-dock internal=$internal target=$target result=ok" + ${pkgs.libnotify}/bin/notify-send -a Nomarchy "Display" \ + "Docked — every laptop workspace moved to $target; laptop panel off." \ + 2>/dev/null || true + else + log "auto-dock internal=$internal target=$target result=failed" + fi + } + # The set of connected outputs — not the IPC event stream — is the source # of truth. Hyprland's socket can miss or drop events (see the loop # below), and a lost monitoradded used to mean no docking profile, no @@ -506,6 +531,10 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList log "outputs-changed removed=$gone transition=undock" fi check_monitors + if [ -n "$added_ext" ] \ + && [ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ]; then + auto_dock + fi fi # Safety invariants, re-checked regardless of whether the set moved.