All checks were successful
Check / eval (push) Successful in 3m19s
Restore the old flake's external-only workspace handoff, protect closed-lid cable removal with a verified low-level logind inhibitor, and use monitor-added as the settled audio reprobe boundary. Verified: V2 — nix flake check --no-build; docking-ux, option-docs, and template-sot checks; KVM tools/monitor-fallback.nix lifecycle test. V3 pending: closed-lid BenQ round 4 in agent/HARDWARE-QUEUE.md.
148 lines
5.6 KiB
Nix
148 lines
5.6 KiB
Nix
# Automatic audio-output follow for docks / external monitors (#100).
|
|
# WirePlumber's stored default outranks priority.session, and some HDMI
|
|
# codecs only expose their usable route after the display hotplug. The
|
|
# Hyprland monitor watcher therefore calls `reprobe monitoradded` for the
|
|
# unambiguous physical-plug event: restart the graph (the recovery used by
|
|
# the old working flake), wait for it to settle, then explicitly select an
|
|
# available dock-class sink. Ordinary sink changes never select anything,
|
|
# so a manual speaker choice sticks until a fresh monitor plug.
|
|
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.nomarchy;
|
|
rules = import ../nixos/dock-audio-rules.nix;
|
|
dockSinkRe = lib.concatStringsSep "|"
|
|
(map (m: lib.removePrefix "~" m."node.name")
|
|
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
|
|
|
|
tool = pkgs.writeShellScriptBin "nomarchy-dock-audio" ''
|
|
set -u
|
|
PACTL=${pkgs.pulseaudio}/bin/pactl
|
|
JQ=${pkgs.jq}/bin/jq
|
|
SYSTEMCTL=${pkgs.systemd}/bin/systemctl
|
|
LOGGER=${pkgs.util-linux}/bin/logger
|
|
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
lock="$rt/nomarchy-dock-audio-reprobe.lock"
|
|
TAB=$(printf '\t')
|
|
|
|
log() { "$LOGGER" -t nomarchy-dock-audio -- "$*"; }
|
|
wait_for_pulse() {
|
|
tries=0
|
|
while [ "$tries" -lt 40 ]; do
|
|
"$PACTL" info >/dev/null 2>&1 && return 0
|
|
tries=$((tries+1)); sleep 0.25
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Highest-priority dock-class sink 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() {
|
|
"$PACTL" --format=json list sinks 2>/dev/null \
|
|
| "$JQ" -r --arg re '^(${dockSinkRe})$' '
|
|
[ .[] as $sink
|
|
| $sink
|
|
| select(.name | test($re))
|
|
| select(
|
|
((.ports // []) | length) == 0
|
|
or .active_port == null
|
|
or ([.ports[]?
|
|
| select(.name == $sink.active_port)
|
|
| (.availability // "unknown")][0]
|
|
// "unknown") != "not available"
|
|
) ]
|
|
| sort_by(.priority // 0) | reverse[]
|
|
| "\(.name)\t\(.description // .name)"'
|
|
}
|
|
|
|
select_first_dock() { # $1 = observable trigger
|
|
trigger="$1"
|
|
candidate=$(dock_sinks | ${pkgs.coreutils}/bin/head -n 1) || candidate=
|
|
if [ -z "$candidate" ]; then
|
|
log "trigger=$trigger result=no-available-dock-sink"
|
|
return 1
|
|
fi
|
|
name=''${candidate%%"$TAB"*}
|
|
desc=''${candidate#*"$TAB"}
|
|
if "$PACTL" set-default-sink "$name" 2>/dev/null; then
|
|
log "trigger=$trigger selected=$name description=$desc"
|
|
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
|
|
"Output → $desc" 2>/dev/null || true
|
|
return 0
|
|
fi
|
|
log "trigger=$trigger result=set-default-failed candidate=$name"
|
|
return 1
|
|
}
|
|
|
|
case "''${1:-watch}" in
|
|
candidates)
|
|
dock_sinks ;;
|
|
select)
|
|
wait_for_pulse || { log "trigger=''${2:-manual} result=pulse-unavailable"; exit 1; }
|
|
select_first_dock "''${2:-manual}" ;;
|
|
reprobe)
|
|
trigger="''${2:-monitoradded}"
|
|
# mkdir is the debounce/lock: simultaneous monitoradded events from
|
|
# an MST dock collapse into one graph restart. Runtime-only state,
|
|
# removed on every exit path.
|
|
if ! ${pkgs.coreutils}/bin/mkdir "$lock" 2>/dev/null; then
|
|
log "trigger=$trigger result=debounced"
|
|
exit 0
|
|
fi
|
|
trap '${pkgs.coreutils}/bin/rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
|
|
log "trigger=$trigger action=reprobe-start"
|
|
sleep 2
|
|
"$SYSTEMCTL" --user restart \
|
|
pipewire.service pipewire-pulse.service wireplumber.service \
|
|
>/dev/null 2>&1 || log "trigger=$trigger action=graph-restart-returned-error"
|
|
if wait_for_pulse; then
|
|
sleep 0.75
|
|
select_first_dock "$trigger" || true
|
|
else
|
|
log "trigger=$trigger result=pulse-did-not-return"
|
|
fi ;;
|
|
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`.
|
|
wait_for_pulse || true
|
|
# A graph restart closes `pactl subscribe`, sometimes with status 0.
|
|
# Always resubscribe; log one concise closure line for diagnosis.
|
|
while :; do
|
|
if LC_ALL=C "$PACTL" subscribe 2>/dev/null \
|
|
| while IFS= read -r line; do
|
|
case "$line" in
|
|
*"'new' on sink"*|*"'change' on card"*) : ;;
|
|
esac
|
|
done
|
|
then status=0; else status=$?; fi
|
|
log "subscription-closed status=$status; retrying"
|
|
sleep 1
|
|
wait_for_pulse || sleep 1
|
|
done ;;
|
|
*)
|
|
echo "usage: nomarchy-dock-audio [watch|candidates|select [trigger]|reprobe [trigger]]" >&2
|
|
exit 64 ;;
|
|
esac
|
|
'';
|
|
in
|
|
{
|
|
config = lib.mkIf cfg.dockAudio.enable {
|
|
home.packages = [ tool ];
|
|
systemd.user.services.nomarchy-dock-audio = {
|
|
Unit = {
|
|
Description = "Reprobe and select dock/monitor audio on display hotplug";
|
|
After = [ "graphical-session.target" ];
|
|
PartOf = [ "graphical-session.target" ];
|
|
};
|
|
Service = {
|
|
ExecStart = "${tool}/bin/nomarchy-dock-audio watch";
|
|
Restart = "on-failure";
|
|
RestartSec = 2;
|
|
};
|
|
Install.WantedBy = [ "graphical-session.target" ];
|
|
};
|
|
};
|
|
}
|