fix(docking): don't lose the hotplug event; one prompt per keyboard; follow the jack
All checks were successful
Check / eval (push) Successful in 3m31s
All checks were successful
Check / eval (push) Successful in 3m31s
Four failures from Bernardo's round-4 dock pass on the AMD dev box, plus a headphone regression, tracked to three causes. The display watcher ran socat with `-T 1`, which closes Hyprland's IPC socket after one second of *inactivity*. A dock is normally plugged into an idle desktop, so the socket was almost always mid-reconnect at that moment and `monitoradded` was lost outright — and the wallpaper repaint, the docking profile, and the dock-audio reprobe all hung off that single event. The reconnect path recovered only the lid inhibitor, so the dock stayed half-configured until something else poked the watcher. Make the connection long-lived and take the periodic tick from a 1s `read` timeout instead, then let the *output set* — not the event — decide: a new `reconcile` diffs it and is called on both ticks and monitor events, so an event now only makes the reaction instant rather than necessary. A newly present external output is as unambiguous a physical plug as the event was, so it may still override a manual sink choice. Wallpaper paints are retried over the first few seconds because awww registers an output on its own schedule, not the compositor's. Hyprland calls every key-capable evdev node a "keyboard": the lid and power buttons, a monitor's control channel, and each extra HID collection of a real keyboard. One dock with one keyboard asked for a layout four times. udev already draws the distinction properly, so take ID_INPUT_KEYBOARD from it, map the names into Hyprland's spelling, and ask once per physical keyboard — applying the answer to every node it brought with it. Finally, a sink WirePlumber has been told to prefer is stored as the configured default and outranks every priority rule. On UCM cards the headphones are a sink of their own rather than a route, so once anything pinned a sink (this feature on a dock plug, or the user in the Audio menu), plugging headphones in could no longer take the audio back. The `watch` loop already subscribed to exactly the right events with an empty handler; finish it. Unplug needs no undo — the pin goes stale and WirePlumber falls back by priority on its own, to the dock when docked and the speakers otherwise. Verified V2: checks.docking-ux and checks.dock-audio pass with new guards against each regression; the keyboard filter and both sink selectors run against the dev box's real devices and sink names. The hotplug paths need hardware — queued as docking round 5. Also queues #107, renaming theme.json now that it holds the system state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,12 @@ let
|
||||
(map (m: lib.removePrefix "~" m."node.name")
|
||||
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
|
||||
|
||||
# Cards driven by ALSA UCM expose speakers and headphones as separate
|
||||
# sinks (HiFi__Headphones__sink), the headphone one existing only while
|
||||
# the jack is occupied — so on those machines a jack plug is a sink
|
||||
# appearing, not the route switch WirePlumber handles by itself.
|
||||
headphoneSinkRe = "alsa_output\\..*[Hh]eadphone.*";
|
||||
|
||||
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
|
||||
set -u
|
||||
PACTL=${pkgs.pulseaudio}/bin/pactl
|
||||
@@ -35,12 +41,12 @@ let
|
||||
return 1
|
||||
}
|
||||
|
||||
# Highest-priority dock-class sink whose active port is not explicitly
|
||||
# Highest-priority sink matching $1 whose active port is not explicitly
|
||||
# unavailable. With pro-audio profiles a sink may have no ports; its
|
||||
# existence is then the only availability signal PipeWire exposes.
|
||||
dock_sinks() {
|
||||
sinks_matching() {
|
||||
"$PACTL" --format=json list sinks 2>/dev/null \
|
||||
| "$JQ" -r --arg re '^(${dockSinkRe})$' '
|
||||
| "$JQ" -r --arg re "^($1)$" '
|
||||
[ .[] as $sink
|
||||
| $sink
|
||||
| select(.name | test($re))
|
||||
@@ -55,12 +61,15 @@ let
|
||||
| sort_by(.priority // 0) | reverse[]
|
||||
| "\(.name)\t\(.description // .name)"'
|
||||
}
|
||||
dock_sinks() { sinks_matching '${dockSinkRe}'; }
|
||||
headphone_sinks() { sinks_matching '${headphoneSinkRe}'; }
|
||||
default_sink() { "$PACTL" get-default-sink 2>/dev/null; }
|
||||
|
||||
select_first_dock() { # $1 = observable trigger
|
||||
trigger="$1"
|
||||
candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate=
|
||||
select_first() { # $1 = sink lister, $2 = observable trigger
|
||||
lister="$1"; trigger="$2"
|
||||
candidate=$("$lister" | ${pkgs.coreutils}/bin/head -n 1) || candidate=
|
||||
if [ -z "$candidate" ]; then
|
||||
log "trigger=$trigger result=no-available-dock-sink"
|
||||
log "trigger=$trigger result=no-available-sink"
|
||||
return 1
|
||||
fi
|
||||
name=''${candidate%%"$TAB"*}
|
||||
@@ -74,6 +83,7 @@ let
|
||||
log "trigger=$trigger result=set-default-failed candidate=$name"
|
||||
return 1
|
||||
}
|
||||
select_first_dock() { select_first dock_sinks "$1"; }
|
||||
|
||||
case "''${1:-watch}" in
|
||||
candidates)
|
||||
@@ -102,10 +112,30 @@ let
|
||||
else
|
||||
log "trigger=$trigger result=pulse-did-not-return"
|
||||
fi ;;
|
||||
headphones)
|
||||
# Follow the jack, but only ever *towards* the headphones. On unplug
|
||||
# the sink disappears and any pin naming it goes stale, which makes
|
||||
# WirePlumber fall back by priority on its own — to the dock when
|
||||
# docked, to the speakers otherwise — so there is nothing to undo.
|
||||
wait_for_pulse || exit 0
|
||||
hp=$(headphone_sinks | ${pkgs.coreutils}/bin/head -n 1) || hp=
|
||||
[ -n "$hp" ] || exit 0
|
||||
name=''${hp%%"$TAB"*}
|
||||
[ "$(default_sink)" = "$name" ] && exit 0
|
||||
select_first headphone_sinks "''${2:-jack}" || true ;;
|
||||
watch)
|
||||
# Selection is deliberately NOT done at service startup: a restart or
|
||||
# relogin while docked must not erase a manual speaker choice. Only
|
||||
# the compositor's fresh monitoradded event calls `reprobe`.
|
||||
# Dock selection is deliberately NOT done at service startup: a
|
||||
# restart or relogin while docked must not erase a manual speaker
|
||||
# choice. Only the compositor's fresh monitoradded event calls
|
||||
# `reprobe`.
|
||||
#
|
||||
# The jack is different, and is why this loop exists. A sink that
|
||||
# WirePlumber has been told to prefer (by us on a dock plug, or by
|
||||
# the user in the Audio menu / a mixer) is stored as the configured
|
||||
# default, and that outranks every priority rule — so on UCM cards,
|
||||
# where the headphones are a sink of their own rather than a route,
|
||||
# plugging them in could no longer steal the audio back. Watch for
|
||||
# that sink appearing and select it explicitly.
|
||||
wait_for_pulse || true
|
||||
# A graph restart closes `pactl subscribe`, sometimes with status 0.
|
||||
# Always resubscribe; log one concise closure line for diagnosis.
|
||||
@@ -113,7 +143,10 @@ let
|
||||
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
|
||||
| while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*"'new' on sink"*|*"'change' on card"*) : ;;
|
||||
*"'new' on sink"*|*"'change' on card"*)
|
||||
# Re-entering the tool keeps the decision out of this
|
||||
# read loop, which must never block on the graph.
|
||||
"$0" headphones jack || true ;;
|
||||
esac
|
||||
done
|
||||
then status=0; else status=$?; fi
|
||||
@@ -122,7 +155,7 @@ let
|
||||
wait_for_pulse || sleep 1
|
||||
done ;;
|
||||
*)
|
||||
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2
|
||||
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]|headphones [trigger]]" >&2
|
||||
exit 64 ;;
|
||||
esac
|
||||
'';
|
||||
|
||||
Reference in New Issue
Block a user