fix(docking): dock automatically; repair cards parked on pro-audio
All checks were successful
Check / eval (push) Successful in 3m9s
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:
@@ -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
|
||||
|
||||
10
flake.nix
10
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user