130 lines of bash to connect my headphones
Putting on Bluetooth headphones should be the last step before hearing sound. On my Linux desktop it was the first step of a ritual: bluetoothctl reports Connected: yes, nothing plays, the card profile is stuck on off, and the fix is some sequence of disconnecting, restarting WirePlumber, or power-cycling the headphones like 3 times. I don't know why this happens, and it's boring, so I wrote a script that tries a list of things until the sound works.
#!/usr/bin/env bash
set -uo pipefail
DEVICE="XX:XX:XX:XX:XX:XX"
DEVICE_US="${DEVICE//:/_}" # pactl names use underscores
SINK_WAIT=10 # seconds to wait for the sink after connecting
info() { bluetoothctl info "$DEVICE" 2>/dev/null; }
sink_present() {
pactl list short sinks 2>/dev/null | grep -q "bluez_output.$DEVICE_US"
}
# Wait for the PipeWire sink to actually show up — bluetoothctl saying
# "Connected: yes" means nothing if WirePlumber never creates the sink.
wait_for_sink() {
for _ in $(seq "$SINK_WAIT"); do
if sink_present; then
return 0
fi
sleep 1
done
return 1
}
ensure_a2dp() {
# Card can appear stuck in profile "off" or headset-only; force A2DP if available.
local card="bluez_card.$DEVICE_US"
if pactl list cards short 2>/dev/null | grep -q "$card"; then
pactl set-card-profile "$card" a2dp-sink 2>/dev/null \
|| pactl set-card-profile "$card" a2dp_sink 2>/dev/null \
|| true
fi
}
try_connect() {
bluetoothctl connect "$DEVICE"
ensure_a2dp
wait_for_sink
}
# Make the headphones the default output and move every running app stream over
switch_audio_to_headphones() {
local sink
sink=$(pactl list short sinks | grep "bluez_output.$DEVICE_US" | head -1 | cut -f2)
if [ -z "$sink" ]; then
echo "Warning: could not resolve sink name; leaving default output unchanged." >&2
return 1
fi
pactl set-default-sink "$sink"
local input
for input in $(pactl list short sink-inputs | cut -f1); do
pactl move-sink-input "$input" "$sink" 2>/dev/null || true
done
echo "Default output set to $sink; existing app streams moved."
}
echo "Connecting to Bluetooth headphones ($DEVICE)..."
# Ensure bluetooth is powered on
if ! bluetoothctl show | grep -q "Powered: yes"; then
bluetoothctl power on
sleep 1
fi
# Pair if not already paired
if ! info | grep -q "Paired: yes"; then
bluetoothctl scan on &
SCAN_PID=$!
sleep 5
bluetoothctl pair "$DEVICE"
kill "$SCAN_PID" 2>/dev/null || true
fi
# Trust if not already trusted
if ! info | grep -q "Trusted: yes"; then
bluetoothctl trust "$DEVICE"
fi
# "Already connected" only counts if the sink actually exists
if info | grep -q "Connected: yes"; then
ensure_a2dp
if sink_present; then
echo "Already connected and audio sink is up."
switch_audio_to_headphones
exit 0
fi
echo "BlueZ claims connected but no audio sink exists — resetting connection."
bluetoothctl disconnect "$DEVICE"
sleep 2
fi
# Attempt 1: plain connect
echo "Attempt 1: connecting..."
if try_connect; then
echo "Done. Sink is up: bluez_output.$DEVICE_US"
switch_audio_to_headphones
exit 0
fi
# Attempt 2: restart WirePlumber (recreates all PipeWire BT nodes), reconnect
echo "No sink appeared. Attempt 2: restarting WirePlumber and reconnecting..."
bluetoothctl disconnect "$DEVICE"
systemctl --user restart wireplumber
sleep 3
if try_connect; then
echo "Done. Sink is up: bluez_output.$DEVICE_US"
switch_audio_to_headphones
exit 0
fi
# Attempt 3: power-cycle the adapter, restart WirePlumber, reconnect
echo "Still no sink. Attempt 3: power-cycling Bluetooth adapter..."
bluetoothctl disconnect "$DEVICE"
bluetoothctl power off
sleep 2
bluetoothctl power on
sleep 2
systemctl --user restart wireplumber
sleep 3
if try_connect; then
echo "Done. Sink is up: bluez_output.$DEVICE_US"
switch_audio_to_headphones
exit 0
fi
echo "FAILED: headphones never produced an audio sink after 3 attempts." >&2
echo "Last resort: sudo systemctl restart bluetooth, then re-run this script." >&2
echo "If that fails too, the headphones themselves may need a power cycle." >&2
exit 1
The core trick is refusing to trust BlueZ. Success is defined as a bluez_output sink existing in PipeWire, and the script escalates until that is true: plain connect, then restart WirePlumber, then power-cycle the adapter. Once the sink exists it becomes the default output and every running app stream moves over, for fuck's sake.