Skip to content

Instantly share code, notes, and snippets.

@enoy19
Last active July 1, 2025 13:18
Show Gist options
  • Save enoy19/933582120e7782cadbad54100dbcf419 to your computer and use it in GitHub Desktop.
Save enoy19/933582120e7782cadbad54100dbcf419 to your computer and use it in GitHub Desktop.
fbt.sh – Fix Linux Bluetooth headphones stuck in hands-free mode by forcing A2DP; includes interactive installer and Zsh tab-completion.

fbt: quick Bluetooth reconnect on Linux

On Ubuntu, Debian, Arch, Fedora and others, Bluetooth headsets sometimes drop to HFP/HSP hands-free mode.
fbt.sh simply reconnects the device with one command. This is often enough to make the desktop switch back to A2DP automatically. Zsh tab completion lets you pick the headset by name.

Features

  • One-shot reconnect, no profile forcing
  • Works with PipeWire and PulseAudio
  • Installs to ~/.local/bin by default, no root
  • Zsh completion for Bluetooth device names
  • Self-update and uninstall options

Dependencies

  • bluetoothctl (BlueZ package, usually pre-installed)
  • zsh for running the script and completion
  • Standard CLI tools: grep, awk, sed, curl

Install in one line

zsh -i <(curl -fsSL https://gist.githubusercontent.com/enoy19/933582120e7782cadbad54100dbcf419/raw/install_fbt.sh)

Follow the prompts, then source ~/.zshrc or start a new shell.

Alternatives

You can configure WirePlumber, PipeWire, PulseAudio or udev rules to force A2DP every time. Those setups are great if the bug happens daily. If it is only occasional, they are overkill. This script is overkill too, but typing fbt <device> is still easier than writing rules.

#!/bin/zsh
# fbt.sh – reconnect Bluetooth headset (hands-free bug helper)
VERSION="1.0.0"
GIST_ID="933582120e7782cadbad54100dbcf419"
RC="$HOME/.zshrc"
usage() {
echo "Usage: $0 <device fragment>"
echo " $0 -u | --update update via installer"
echo " $0 -r | --remove uninstall fbt"
echo " $0 -v | --version show version"
exit 1
}
case "$1" in
-v|--version) echo "fbt $VERSION"; exit ;;
-u|--update) curl -fsSL "https://gist.githubusercontent.com/enoy19/${GIST_ID}/raw/install_fbt.sh" | zsh; exit ;;
-r|--remove)
inst_path=$(grep '^# FBT_PATH=' "$RC" 2>/dev/null | cut -d'=' -f2)
if [[ -f $inst_path ]]; then
printf "Remove script '%s' and zshrc entries? [y/N]: " "$inst_path"
else
printf "Remove zshrc entries (script path invalid)? [y/N]: "
fi
read ans
if [[ $ans =~ ^[yY]$ ]]; then
sed -i '/# BEGIN FBT/,/# END FBT/d' "$RC"
[[ -f $inst_path ]] && rm -f "$inst_path"
echo "Uninstalled. source $RC to reload."
else
echo "Aborted."
fi
exit ;;
-h|--help|"") usage ;;
esac
# ── connect device ─────────────────────────────────────────────────────────
matches=(${(f)"$(bluetoothctl devices | grep -i "$1")"})
(( ${#matches} == 0 )) && { echo "No device matching '$1'"; exit 1; }
(( ${#matches} > 1 )) && { echo "Multiple matches:"; printf '%s\n' $matches; exit 1; }
mac=$(echo $matches | awk '{print $2}')
name=$(echo $matches | cut -d' ' -f3-)
echo "Connecting to $name ($mac)…"
bluetoothctl connect "$mac" >/dev/null
# A short pause helps PipeWire / PulseAudio settle
sleep 2
echo "Reconnected. If audio is still in hands-free mode, run fbt again."
#!/usr/bin/env zsh
# install_fbt.sh – install / update fbt.sh + completion
INSTALLER_VERSION="1.0.0"
# — source & defaults
GIST_ID="933582120e7782cadbad54100dbcf419"
RAW_URL="https://gist.githubusercontent.com/enoy19/${GIST_ID}/raw/fbt.sh"
RC="$HOME/.zshrc"
DEFAULT_DIR="$HOME/.local/bin"
DEFAULT_ALIAS="fbt"
# keep prompts usable even when invoked via: zsh <(curl …)
[[ -t 0 ]] || exec < /dev/tty
# previous installation, if any
old_path=$(grep '^# FBT_PATH=' "$RC" 2>/dev/null | cut -d'=' -f2)
old_alias=$(grep '^# FBT_ALIAS=' "$RC" 2>/dev/null | cut -d'=' -f2)
old_dir=""
[[ -n $old_path ]] && old_dir=${old_path:h} # dirname (zsh shorthand)
# ── prompt ────────────────────────────────────────────────────────────────
print -n "Install directory [${old_dir:-$DEFAULT_DIR}]: "
read dir
dir=${dir:-${old_dir:-$DEFAULT_DIR}}
dir=${dir%/} # strip trailing slash
[[ $dir == */fbt.sh ]] && dir=${dir%/*} # user typed filename => drop it
mkdir -p "$dir" || { echo "Cannot access '$dir'"; exit 1; }
print -n "Alias (blank = skip) [${old_alias:-$DEFAULT_ALIAS}]: "
read alias
alias=${alias:-${old_alias:-$DEFAULT_ALIAS}}
dest="$dir/fbt.sh"
# ── version check ─────────────────────────────────────────────────────────
cur_ver=$(grep '^VERSION=' "$dest" 2>/dev/null | cut -d'"' -f2)
if [[ -n $cur_ver ]]; then
print -n "Current fbt version $cur_ver. Replace with latest? [y/N]: "
read ans
[[ $ans =~ ^[yY]$ ]] || { echo "Aborted."; exit 0; }
fi
# ── download & install ────────────────────────────────────────────────────
curl -fsSL "$RAW_URL" -o "$dest" || { echo "Download failed"; exit 1; }
chmod +x "$dest"
echo "Downloaded fbt.sh → $dest"
# ── refresh .zshrc block ─────────────────────────────────────────────────
sed -i '/# BEGIN FBT/,/# END FBT/d' "$RC"
{
echo "# BEGIN FBT"
echo "# FBT_PATH=$dest"
echo "# FBT_ALIAS=$alias"
[[ -n $alias ]] && echo "alias $alias='$dest'"
echo "_fbt_c() {"
echo " local -a devs"
echo " devs=(\${(@f)\$(bluetoothctl devices | sed 's/^Device [^ ]* //')})"
echo " compadd -Q -a devs"
echo "}"
comps="$dest"
[[ -n $alias ]] && comps="$comps $alias"
echo "compdef _fbt_c $comps"
echo "# END FBT"
} >> "$RC"
echo "Installation complete. Run 'source $RC' or open a new shell to activate fbt."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment