6 releases
| new 0.3.0 | May 10, 2026 |
|---|---|
| 0.2.1 | Apr 30, 2026 |
| 0.1.2 | Apr 22, 2026 |
#193 in Audio
110KB
3K
SLoC
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:
- 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. - When the headset negotiates HFP, the default source can end up on
bluez_output.<addr>.monitorinstead of the realbluez_input.<addr>mic, making the mic look dead to applications. - 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.
- 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, thenmSBC, then any other HFP profile. On USB adapters in the broken-vendor list (MediaTek0e8dby default),LC3-24kHzis dropped so mSBC wins instead. - Default source. When the card profile is an HFP variant,
bthmanensures the default source is the headset'sbluez_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-monitorwatchesorg.freedesktop.login1.PrepareForSleep. On the suspend edge,bthmansnapshots 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-profileisfalse,bthmanassumes a recorder is holding the A2DP profile on purpose and skips the reconcile pass.
Requirements
- PipeWire with WirePlumber (or PulseAudio)
pactlwpctlbluetoothctldbus-monitor- A Linux distribution with systemd or OpenRC (elogind for OpenRC)
Installation
cargo (recommended)
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