#bluetooth #pipe-wire #daemon #pulse-audio #hfp

bin+lib bthman

Bluetooth HFP profile manager with suspend/resume reconnect for PipeWire + WirePlumber

6 releases

new 0.3.0 May 10, 2026
0.2.1 Apr 30, 2026
0.1.2 Apr 22, 2026

#193 in Audio

MIT license

110KB
3K SLoC

Rust 2.5K SLoC // 0.0% comments Shell 434 SLoC // 0.0% comments

bthman

Manage Bluetooth HFP profiles and reconnect headsets after suspend/resume on Linux systems running PipeWire + WirePlumber or PulseAudio.

The Problem

Bluetooth headsets on Linux have a couple of recurring annoyances:

  1. After connecting, the HFP codec sometimes lands on LC3-24kHz, which some USB Bluetooth adapters (notably MediaTek 0e8d) advertise but cannot actually drive, resulting in choppy or dropped mic audio. The fix is to switch the card profile to mSBC if the system is known to not support it.
  2. When the headset negotiates HFP, the default source can end up on bluez_output.<addr>.monitor instead of the real bluez_input.<addr> mic, making the mic look dead to applications.
  3. After suspend/resume, paired audio devices often disconnect and do not come back on their own. You have to open a tray applet and click them.
  4. PipeWire frequently sets the mic input volume lower than configured.

bthman runs as a service (systemd or OpenRC) and handles all four.

How It Works

  • Profile selection. Preferred profile order defaults to LC3-24kHz, then mSBC, then any other HFP profile. On USB adapters in the broken-vendor list (MediaTek 0e8d by default), LC3-24kHz is dropped so mSBC wins instead.
  • Default source. When the card profile is an HFP variant, bthman ensures the default source is the headset's bluez_input.<addr> (not the .monitor).
  • Input volume. Raises the default source volume to the configured target (100% by default) when it is below that.
  • Suspend/resume reconnect. dbus-monitor watches org.freedesktop.login1.PrepareForSleep. On the suspend edge, bthman snapshots connected audio devices. On resume, it schedules reconnect attempts with exponential backoff ([0.0, 0.5, 1.5, 3.5] seconds).
  • External recorder deference. If wpctl settings bluetooth.autoswitch-to-headset-profile is false, bthman assumes a recorder is holding the A2DP profile on purpose and skips the reconcile pass.

Requirements

  • PipeWire with WirePlumber (or PulseAudio)
  • pactl
  • wpctl
  • bluetoothctl
  • dbus-monitor
  • A Linux distribution with systemd or OpenRC (elogind for OpenRC)

Installation

cargo install bthman
bthman install-service

This installs bthman to ~/.cargo/bin/ and sets up the service for your init system. To start it:

systemctl --user start bthman.service          # systemd
rc-service --user bthman start                 # OpenRC 0.60+
sudo rc-service bthman start                   # older OpenRC

Prebuilt binary

Download the tarball for your platform from the GitHub releases page, extract, and move bthman to ~/.local/bin/ (or /usr/local/bin/). Then:

bthman install-service

install.sh (systemd, source build)

git clone https://github.com/mwolson/bthman.git
cd bthman
./install.sh
systemctl --user start bthman.service

install.sh runs cargo build --release, copies the binary to ~/.local/bin/, and calls bthman install-service.

OpenRC system service (older OpenRC)

sudo cargo install --root /usr/local bthman
sudo bthman install-service
sudo rc-service bthman start

On OpenRC versions before 0.60, install-service installs a system-level init script to /etc/init.d/bthman and adds it to the default runlevel. To configure the user and environment for the daemon, create /etc/conf.d/bthman:

command_user="youruser"
supervise_daemon_args="--env XDG_RUNTIME_DIR=/run/user/1000"

Replace 1000 with your user's UID (id -u youruser).

Usage

The service runs automatically. To check status:

systemd

systemctl --user status bthman.service
journalctl --user -u bthman.service -f

OpenRC (user, 0.60+)

rc-service --user bthman status

OpenRC (system, older)

rc-service bthman status

Commands

bthman                    Run as a daemon (default)
bthman install-service    Install and enable the service (systemd or OpenRC)
bthman once               Reconcile once and exit
bthman probe              Probe HFP sources for stuck-SCO silence and exit
bthman uninstall-service  Disable and remove the service (systemd or OpenRC)

bthman probe runs the same capture-and-classify logic the daemon uses to detect AirPods-style stuck HFP SCO (see below), but bypasses the daemon's cooldown and recorder-present gating. With no arguments it discovers every bluez card currently on an HFP profile. Pass --source=<name> to probe a specific source, or --duration-ms=<N> to override the default 500 ms capture window. Muted sources are skipped either way (a muted mic is guaranteed to produce zero samples, which would read as a false positive).

Daemon options

These flags apply to the daemon and to once. They can also be set in the config file, one per line.

--auto-recover-stuck-sco=MODE off, dry-run, or on (default: off).
--broken-vendor=HEX           Repeatable. USB vendor IDs for which to skip LC3.
--debounce-ms=N               Event debounce window in ms (default: 500).
--input-volume=N              Mic volume target percent (default: 100).
--preferred-profile=PROFILE   Repeatable. Profile priority order.
--probe-stuck-sco=BOOL        Enable the stuck-SCO probe (default: true).

Stuck-SCO detection

After rapid HFP profile churn, some Bluetooth headsets (notably AirPods Pro) leave the SCO uplink in a state where the card still reports headset-head-unit active, the bluez_input.<addr> source still runs, but every captured sample is bit-exact zero. Downlink still works; the mic is silent. The only known recovery is to disconnect and reconnect at the BlueZ layer (bluetoothctl disconnect <addr> then connect).

When --probe-stuck-sco=true (the default), the daemon runs a short parecord capture after each reconcile pass whenever a bluez card is on an HFP profile and its source is not muted. A per-source 20s cooldown prevents rapid events from triggering back-to-back probes. If the capture is bit-exact zero, the daemon logs a warning identifying the stuck source.

By default, detection is log-only. With --auto-recover-stuck-sco=dry-run, the daemon logs the remediation decision and trips the rate limiter without touching the Bluetooth connection. With --auto-recover-stuck-sco=on, confirmed stuck SCO disconnects the device and schedules reconnect through the daemon's existing backoff path. Confirmation requires either an all-zero capture plus a recent WirePlumber BT_PKT_SEQNUM failure, or two all-zero captures within 3 seconds. Auto-recovery is not systemd-only: hosts without journald, such as OpenRC systems, use the two-probe confirmation path. The faster Tier 1 path requires the WirePlumber journal log line, which is emitted at INFO level. On systemd user services, set WIREPLUMBER_DEBUG=I; DEBUG is not required and is noisy enough to make unrelated audio scheduling problems harder to diagnose. Hosts without that log path fall back to Tier 2.

parecord is a soft dependency: if it is missing from PATH, the probe is disabled and a warning is logged at startup.

Configuration file

bthman reads defaults from ~/.config/bthman.conf (or $XDG_CONFIG_HOME/bthman.conf). One --flag=value per line; blank lines and # comments are allowed. Example:

# skip LC3-24kHz on MediaTek BT adapters
--broken-vendor=0e8d
--preferred-profile=headset-head-unit-msbc
--input-volume=85

Unrecognized flags cause an error at startup. Command-line arguments always take precedence over the config file.

Send SIGHUP to the running daemon (or systemctl --user reload bthman) to reload the config file without restarting. Parse errors are logged at WARN and the previous config keeps running.

Uninstall

bthman uninstall-service
cargo uninstall bthman       # or: rm ~/.local/bin/bthman
rm -f ~/.config/bthman.conf

Development

The Rust toolchain is pinned via rust-toolchain.toml; rustup auto-installs it (with clippy, rustfmt, and rust-analyzer) on your first cargo invocation in the project directory.

bun run test                # unit tests
bun run test:integration    # Docker-based integration tests
bun run test:all            # both

Hooks

bun run hooks:check         # run checks against working tree
lefthook install            # install git hooks

The pre-commit hook runs cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test.

License

MIT

Dependencies

~9–16MB
~204K SLoC