GNU/LINUX

Bluetooth Headphones on Debian 13: From Bare Packages to Multi-User Audio

2026.10.26 ALFONSO BILBAO

Pairing a pair of Bluetooth headphones on a Linux desktop is usually a two-click affair, until it isn’t. This post walks through the full stack on Debian 13 (Trixie), starting from package installation and ending with multi-user sessions, profile switching, and system-wide configuration. Every command output shown here comes from a real machine; along the way we hit (and fix) five distinct failure modes that most guides never mention.

Test environment

Component Version / Model
OS Debian 13 (Trixie), amd64
BlueZ 5.82
PipeWire 1.4.2
WirePlumber 0.5.8
Bluetooth adapter Realtek RTL8852BU combo (USB ID 13d3:3571, IMC Networks)
Headphones Razer Opus X

How the stack fits together

Bluetooth audio on a modern Debian desktop involves three layers, and most problems come from a broken link between two of them:

┌──────────────────────────────────────────────┐
│  Applications (Firefox, mpv, Meet...)        │
├──────────────────────────────────────────────┤
│  PipeWire + WirePlumber   (per-user service) │  ← audio routing, profiles, codecs
│    └─ libspa-0.2-bluetooth (bluez5 plugin)   │  ← the glue
├──────────────────────────────────────────────┤
│  BlueZ: bluetoothd        (system service)   │  ← discovery, pairing, trust, links
├──────────────────────────────────────────────┤
│  Kernel: btusb + vendor firmware             │  ← the radio itself
└──────────────────────────────────────────────┘

Two facts drive everything that follows:

  1. BlueZ is system-wide; PipeWire is per-user. Pairing keys live under /var/lib/bluetooth, while the audio server runs inside each user’s systemd instance.
  2. The PipeWire Bluetooth plugin is a separate package that nothing depends on. Debian ships it separately because not every user needs Bluetooth audio; desktop metapackages such as gnome-core usually pull it in, but minimal installs and window-manager setups often lack it.

1. Installing the packages

sudo apt update
sudo apt install bluez rfkill \
    pipewire pipewire-pulse pipewire-audio wireplumber \
    libspa-0.2-bluetooth
Package Role
bluez bluetoothd daemon and the bluetoothctl CLI
rfkill Inspect and clear radio kill switches
pipewire, wireplumber Audio server and its session/policy manager
pipewire-pulse PulseAudio-compatible server (also provides pactl)
pipewire-audio Metapackage that makes PipeWire the complete audio system
libspa-0.2-bluetooth The bluez5 SPA plugin: exposes Bluetooth audio devices to PipeWire, with SBC, AAC, aptX, LDAC, LC3 and Opus codec support

Optional extras: blueman (GTK manager, handy outside GNOME/KDE) and pavucontrol (graphical mixer).

On the test machine, the transaction looked like this:

Installing:
  libspa-0.2-bluetooth  pipewire-audio

Installing dependencies:
  libldacbt-abr2  pipewire-alsa

REMOVING:
  pulseaudio  pulseaudio-module-bluetooth

Seeing pulseaudio removed is expected. pipewire-audio conflicts with it. In this case PulseAudio was a leftover that was no longer serving audio (pactl info already reported PulseAudio (on PipeWire 1.4.2)), but keeping both Bluetooth modules around invites confusion about which server owns the device.

Firmware

Most Bluetooth chips need vendor firmware from the non-free-firmware component (enabled by default in the Trixie installer). Install the one matching your chipset: firmware-realtek, firmware-iwlwifi (Intel), firmware-atheros or firmware-brcm80211 (Broadcom).

2. Verifying each layer

Work bottom-up: if a lower layer is broken, nothing above it will work.

Kernel and firmware

lsusb | grep -i bluetooth
sudo dmesg | grep -i -E 'bluetooth|btusb|firmware'
Bus 003 Device 004: ID 13d3:3571 IMC Networks Bluetooth Radio
...
[    6.730035] usbcore: registered new interface driver btusb
[    6.741849] Bluetooth: hci0: RTL: loading rtl_bt/rtl8852bu_fw.bin
[    6.742999] Bluetooth: hci0: RTL: loading rtl_bt/rtl8852bu_config.bin
[    7.459571] Bluetooth: hci0: RTL: fw version 0x0474842d

The RTL8852B is a combo chip: Wi-Fi sits on PCIe (rtw89_8852be) while Bluetooth hangs off an internal USB bus (btusb). The firmware and config blobs load cleanly. A failed to load line here means the firmware package is missing.

Radio kill switches

sudo rfkill list
1: hci0: Bluetooth
    Soft blocked: no
    Hard blocked: no

If you get rfkill: command not found as a regular user, the package is not missing: rfkill lives in /usr/sbin, which is not in an unprivileged user’s PATH on Debian. Use sudo or the full path. Clear a soft block with sudo rfkill unblock bluetooth; a hard block is a physical switch or a BIOS setting.

The BlueZ daemon

systemctl status bluetooth
sudo systemctl enable --now bluetooth
bluetoothctl show

From bluetoothctl show, the relevant fields are:

Controller 1C:CE:51:47:09:C9 (public)
    Manufacturer: 0x005d (93)
    Version: 0x0b (11)
    Powered: yes
    Pairable: yes

0x005d is Realtek’s company identifier, and HCI version 0x0b corresponds to Bluetooth 5.2. If Powered: no, run bluetoothctl power on.

The user audio services

These are user units, so no sudo:

systemctl --user status pipewire pipewire-pulse wireplumber
pactl info | grep "Server Name"
Server Name: PulseAudio (on PipeWire 1.4.2)

A WirePlumber warning about api.libcamera.enum.manager is unrelated to audio; it disappears after installing libspa-0.2-libcamera.

The check most guides miss: are audio profiles registered?

When the bluez5 plugin loads, PipeWire registers its audio endpoints with BlueZ over D-Bus, and the controller starts advertising them. If you installed libspa-0.2-bluetooth into a running session, the plugin is not loaded yet:

systemctl --user restart wireplumber pipewire pipewire-pulse
bluetoothctl show | grep -i -E 'audio|headset|handsfree'

Expected output once the plugin is active:

UUID: Audio Source              (0000110a-...)
UUID: Audio Sink                (0000110b-...)
UUID: Headset AG                (00001112-...)
UUID: Handsfree Audio Gateway   (0000111f-...)

No audio UUIDs means no Bluetooth audio, no matter how successfully you pair. This is a cheap and conclusive test.

3. Pairing with bluetoothctl

Graphical tools hide too much while debugging, so close Blueman (or your desktop’s Bluetooth panel) and use the CLI. Running two pairing agents at once is itself a source of failures.

Before starting, disconnect the headphones from any other device (turn off Bluetooth on your phone) and put them into pairing mode.

Keep a log open in a second terminal:

sudo journalctl -f -u bluetooth
# or, for full HCI traffic:
sudo btmon

Then:

bluetoothctl
[bluetoothctl]> scan bredr          # Classic only; fall back to `scan on` if unsupported
[bluetoothctl]> scan off
[bluetoothctl]> pair 28:FA:19:3C:B6:DD
# wait for "Pairing successful"
[bluetoothctl]> trust 28:FA:19:3C:B6:DD
[bluetoothctl]> connect 28:FA:19:3C:B6:DD
[bluetoothctl]> info 28:FA:19:3C:B6:DD

pair is asynchronous. Issuing trust or connect before it finishes yields org.bluez.Error.InProgress.

4. Troubleshooting: what actually went wrong

The first attempts failed in several instructive ways.

error updating services: Connection timed out (110)

bluetoothd: src/device.c:search_cb() 28:FA:19:3C:B6:DD: error updating services: Connection timed out (110)
blueman-manager: GDBus.Error:org.bluez.Error.AuthenticationCanceled: Authentication Canceled

After the initial link, BlueZ runs an SDP query to discover which profiles the device offers. A timeout means the headphones never answered: typically they left pairing mode, are still bound to another host, or a previous half-finished attempt left stale state. AuthenticationCanceled is the downstream symptom. The fix is to remove the device, re-enter pairing mode, and retry with a single agent.

Dual-mode headphones expose two addresses

Scanning revealed two devices with nearly identical addresses:

Address Name Purpose
28:FA:19:3C:B6:DD Razer Opus X Classic BR/EDR: carries audio
28:FA:19:3C:B6:DE Razer Stereo Bluetooth LE: vendor app control channel

Pairing the ...DE address “succeeded” partially but could never produce sound. Its info gives it away:

Device 28:FA:19:3C:B6:DE (public)
    Name: Razer Stereo
    UUID: Generic Access Profile    (00001800-...)
    UUID: Generic Attribute Profile (00001801-...)
    UUID: Razer Inc.                (0000fd65-...)
    UUID: Vendor specific           (416d656c-2d52-617a-6572-424c4501f40a)
    AdvertisingFlags:
  06
  • Only GATT services, with no Audio Sink or Handsfree.
  • The vendor UUID decodes from hex to the ASCII string Amel-Razer BLE.
  • AdvertisingFlags 0x06 = 0x02 (LE General Discoverable) | 0x04 (BR/EDR Not Supported). The device explicitly states that this address carries no Classic Bluetooth, and therefore no A2DP.

That channel is used by the phone companion app for EQ, firmware and ANC settings. Rule of thumb: when two entries share a name, pick the one whose info lists audio UUIDs. scan bredr avoids the LE entry entirely.

a2dp-sink profile connect failed: Protocol not available

bluetoothd: src/service.c:btd_service_connect() a2dp-sink profile connect failed for 28:FA:19:3C:B6:DD: Protocol not available

Right address, wrong moment. This error means no audio server has registered an A2DP endpoint with BlueZ: either libspa-0.2-bluetooth is not installed, or the running WirePlumber has not loaded it. Restart the user services and re-check the audio UUIDs on the controller.

Paired, trusted… and Blocked: yes

The correct address eventually paired, with all the right profiles, yet stayed disconnected:

Device 28:FA:19:3C:B6:DD (public)
    Name: Razer Opus X
    Class: 0x00240404 (2360324)
    Icon: audio-headset
    Paired: yes
    Bonded: yes
    Trusted: yes
    Blocked: yes
    Connected: no
    UUID: Audio Sink                (0000110b-...)
    UUID: A/V Remote Control        (0000110e-...)
    UUID: Handsfree                 (0000111e-...)

A blocked device is refused in both directions regardless of pairing state; GUI managers can set this flag silently during failed attempts. The fix:

[bluetoothctl]> unblock 28:FA:19:3C:B6:DD
[bluetoothctl]> connect 28:FA:19:3C:B6:DD

As a bonus, the class of device 0x240404 decodes to major class Audio/Video, minor class Wearable Headset, with the Audio and Rendering service bits set, which is why BlueZ assigns the audio-headset icon.

5. The SSH trap: only “Dummy Output”

With the headset connected, wpctl status showed no hardware at all: no internal sound card, no webcam, no Bluetooth sink.

Audio
 ├─ Devices:
 │
 ├─ Sinks:
 │  *   35. Dummy Output                        [vol: 1.00]

When everything is missing, the problem is not a device but access to devices. The session data told the story:

$ aplay -l
aplay: device_list:279: no soundcards found...

$ loginctl list-sessions
SESSION  UID USER      SEAT  LEADER CLASS   TTY IDLE SINCE
      2 1001 devops    -     1900   manager -   no   -
      4 1001 devops    -     2471   user    -   no   -
      5 1000 alejandra seat0 2453   user    -   no   -

$ loginctl show-session "$XDG_SESSION_ID" -p Seat -p Remote -p Type
Seat=
Remote=yes
Type=tty

$ getfacl /dev/snd/controlC0
user::rw-
user:alejandra:rw-
group::rw-

The shell was an SSH session (Remote=yes, no seat), while the graphical session on seat0 belonged to another user. systemd-logind grants ACLs on /dev/snd/* and camera nodes only to the user of the active seat session, and WirePlumber 0.5 enables its Bluetooth monitor only when its session holds an active seat. The SSH user’s PipeWire therefore sees nothing and falls back to a dummy sink.

This is correct behaviour: one physical device, one owning session. Two PipeWire instances fighting over a headset would be far worse.

Operating on the seat owner’s session from a terminal

su - alejandra is not enough: the shell lacks XDG_RUNTIME_DIR and the user D-Bus address, so wpctl and systemctl --user cannot reach that user’s PipeWire. Use:

sudo apt install systemd-container
sudo machinectl shell alejandra@

or, without extra packages:

sudo -iu alejandra
export XDG_RUNTIME_DIR=/run/user/1000
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus

From there:

systemctl --user restart wireplumber pipewire pipewire-pulse
bluetoothctl show | grep -i -E 'audio|handsfree'
bluetoothctl connect 28:FA:19:3C:B6:DD
wpctl status
pw-play /usr/share/sounds/alsa/Front_Center.wav

Nothing needs to be removed from the SSH user: pairing data is system-wide, and a seatless WirePlumber never claims the headset.

Anti-patterns

  • sudo pactl ... / sudo wpctl ...: root has no audio server, so you get Connection refused. Audio tools always run as the session user.
  • Adding users to the audio group: it grants raw /dev/snd access, causes “device busy” conflicts with the seat owner’s PipeWire, and still leaves Bluetooth disabled in the seatless session. It bypasses logind’s model without fixing anything.

6. Listen mode vs. talk mode: A2DP and HFP

Headsets with a microphone expose two families of profiles, and Bluetooth Classic cannot give you both qualities at once:

Mode Profile Audio Microphone
Listen A2DP (a2dp-sink*) High quality, stereo (SBC, SBC-XQ, AAC…) No
Talk HFP (headset-head-unit*) Narrowband/wideband mono Yes

A2DP is unidirectional. Enabling the microphone requires HFP’s bidirectional, low-bandwidth link, which is why music sounds like a phone call while the mic is active. Prefer the msbc variant of HFP when offered: wideband speech is noticeably better than cvsd.

On the desktop, both modes simply appear as selectable profiles in the sound settings. From the CLI, list the exact profile names first, since they vary with the negotiated codecs:

pactl list cards | grep -A 40 'Name: bluez_card' | grep -E 'Name:|a2dp|headset|Active Profile'

Then switch:

CARD=bluez_card.28_FA_19_3C_B6_DD
pactl set-card-profile "$CARD" headset-head-unit   # talk: mic appears under Sources
pactl set-card-profile "$CARD" a2dp-sink           # listen

A toggle script suitable for a keyboard shortcut (~/.local/bin/bt-mode):

#!/usr/bin/env bash
set -euo pipefail

card=$(pactl list short cards | awk '/bluez_card/ {print $2; exit}')
[[ -z "$card" ]] && { echo "No Bluetooth headset connected" >&2; exit 1; }

active=$(pactl list cards | awk -v c="$card" '$0 ~ "Name: "c {f=1} f && /Active Profile/ {print $3; exit}')

if [[ "$active" == a2dp* ]]; then
    pactl set-card-profile "$card" headset-head-unit && echo "Talk mode (HFP)"
else
    pactl set-card-profile "$card" a2dp-sink && echo "Listen mode (A2DP)"
fi

Automatic switching

WirePlumber 0.5 switches to HFP automatically when an application opens the headset’s microphone (a video call, for instance) and back to A2DP when it closes:

wpctl settings bluetooth.autoswitch-to-headset-profile
wpctl settings --save bluetooth.autoswitch-to-headset-profile false   # per user

Disable it if some application keeps the microphone open needlessly and degrades your music. The usual alternative is to use the laptop’s built-in mic and keep the headset permanently in A2DP.

Note that hardware features such as ANC or ambient/“quick attention” modes are handled by the headset firmware (buttons or the vendor app over the LE channel), not by the Linux audio stack.

7. System-wide vs. per-user configuration

What is already global:

Item Location
Packages and plugins System-wide
Pairing keys and trust /var/lib/bluetooth/<adapter>/<device>/
Daemon behaviour /etc/bluetooth/main.conf
PipeWire/WirePlumber user units /usr/lib/systemd/user/, started for every session

WirePlumber reads configuration in layers, each overriding the previous one:

/usr/share/wireplumber/                     package defaults (do not edit)
/etc/wireplumber/wireplumber.conf.d/*.conf  all users
~/.config/wireplumber/wireplumber.conf.d/   one user

A drop-in that applies to everyone, preferring Bluetooth output whenever a headset connects and fixing the auto-switch policy:

sudo mkdir -p /etc/wireplumber/wireplumber.conf.d
sudo tee /etc/wireplumber/wireplumber.conf.d/51-bluetooth-global.conf >/dev/null <<'EOF'
# Bluetooth audio defaults for all users
monitor.bluez.properties = {
  bluez5.enable-sbc-xq = true
  bluez5.enable-hw-volume = true
}

monitor.bluez.rules = [
  {
    matches = [ { node.name = "~bluez_output.*" } ]
    actions = {
      update-props = {
        priority.session = 2000
        priority.driver  = 2000
      }
    }
  }
]

wireplumber.settings = {
  bluetooth.autoswitch-to-headset-profile = true
}
EOF

Each user picks it up on their next login or after systemctl --user restart wireplumber. Per-user state, such as volumes and manually selected defaults, lives in ~/.local/state/wireplumber/ and rightly stays per user; settings saved with wpctl settings --save override the global value.

What cannot be shared is simultaneous use: the headset belongs to whichever session holds the active seat. A system-wide PipeWire instance exists, but it is not a standard Debian setup, it breaks isolation between users (anyone could capture anyone’s microphone) and loses logind integration. It only makes sense for headless or kiosk machines.

Quick reference

Symptom Likely cause Fix
No audio UUIDs in bluetoothctl show bluez5 plugin missing or not loaded Install libspa-0.2-bluetooth, restart user services
Protocol not available on a2dp-sink Same as above Same as above
org.bluez.Error.InProgress Overlapping operations Wait for pair to finish; use one agent
Connection timed out (110) during service search Device not in pairing mode or bound elsewhere remove, re-enter pairing mode, retry
Device pairs but has only GATT UUIDs You paired the LE address Use the BR/EDR address (scan bredr)
Paired: yes but never connects Blocked: yes unblock <MAC>
wpctl status shows only Dummy Output Session has no active seat (SSH, inactive user) Work from the seat owner’s session (machinectl shell user@)
Connection refused from pactl Running with sudo Run as the session user
Music sounds like a phone call HFP profile active Switch to a2dp-sink; review autoswitch

Wrapping up

The happy path is short: install libspa-0.2-bluetooth, pair the Classic address, connect. The interesting part is that every layer has its own failure mode, and each one can be identified with a single command: dmesg for firmware, bluetoothctl show for registered profiles, bluetoothctl info for the address type and block state, and loginctl for session ownership. Check them bottom-up and Bluetooth audio on Debian 13 becomes predictable rather than magical.

#audio #bluetooth #debian #linux