Frog Wizard

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.

#bash #bluetooth #linux #pipewire