fix(docking): dock automatically; repair cards parked on pro-audio
All checks were successful
Check / eval (push) Successful in 3m9s

Bernardo's retest on the AMD dev box. Two of the four reports were never
running the new code — both watchers are Hyprland `exec-once`, so a
`nomarchy-home` switch leaves the old processes alive until relogin; only
`nomarchy-dock-audio`, a user service, had picked the fix up. The journal
showed the old watcher's `event=monitoradded` next to the new audio tool's
`result=no-available-sink`, which is what gave it away. The other two were
real.

Automatic dock mode did not exist. The watcher auto-*undocked* when the last
external left, but on plug it only ever matched a display profile — and with
no profile saved and displayProfileAuto unset it did nothing at all, while
`Dock mode` was reachable only from the menu. Half the pair being automatic
is what made the dock feel broken: the session came home by itself but never
left. Mirror it, letting a matched profile win as the more specific layout.

The BenQ's audio was unreachable by any means because WirePlumber had the
GPU's DisplayPort card pinned to `pro-audio`, whose raw `pro-output-N` sinks
carry no ports, no routing and no volume — so no sink matched, nothing was
selectable, and the profile is stored per card and so survived forever. The
monitor's own USB card is input-only (every profile has sinks: 0), leaving
DisplayPort as the only path to its speakers. Repair the card when a plug
finds nothing routable, which is both the symptom and the only moment worth
touching profiles: narrow to cards carrying an *available* HDMI/DP port —
the monitor asking for audio over the cable — so the internal analog card is
never touched and a considered pro-audio setup elsewhere survives.

Verified V2: checks.docking-ux and checks.dock-audio pass with guards for
both; the repair filter picks exactly the GPU card → HiFi against the dev
box's live graph, leaving the BenQ USB, internal analog and Bluetooth cards
alone. Hotplug itself still needs hardware — HARDWARE-QUEUE round 6, which
now leads with the relogin requirement that invalidated round 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:56:52 +01:00
parent 38ae6775e4
commit ce480f3669
4 changed files with 109 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.