This essay is the end of the Bluetooth arc. Essays 9–11 got the chip attached, woken, paired, and encrypted under AES-CCM. None of that produced audio. To play music to a speaker, the path is:
mpg123 → /dev/dsp → virtual_oss → SBC encode → AVDTP MEDIA frames
→ L2CAP CID 0x40 → ACL handle 0x000c → BCM4345 chip
→ AES-CCM encrypted bytes over the air → speaker → wireplumber
→ PipeWire default sink → cone vibrating
Eight transitions. Each one was its own debugging session, but only three of them required code changes in our tree once the chip was talking. The other five required getting the userland incantation right.
The breakthrough commit message was A2DP AUDIO CONFIRMED PLAYING 🎉, and the user’s contemporaneous Slack messages were YE SSIR and, twenty seconds later, A2DP AUDIO CONFIRMED PLAYING. After 37 sessions of BT debugging across roughly three weeks, that’s what shipped.
The L2CAP empty-ACL quirk
The first thing that broke once the encrypted link came up: ACL data started flowing — but ng_l2cap was logging errors and dropping every packet. The chip was emitting 4-byte ACL packets with length=0 between real PDUs as a kind of keepalive. ng_l2cap’s ng_l2cap_lp_acldata_input treated those as malformed fragments and called goto drop with an unexpected-fragment error log entry — but goto drop was after the error path, which already counted the packet against malformed-input limits. Enough keepalives in a row and the L2CAP connection got torn down.
sys/netgraph/bluetooth/l2cap/ng_l2cap_llpi.c
/* Check if we have requested/accepted this connection */ con = ng_l2cap_con_by_addr(l2cap, &ep->bdaddr, ep->link_type); if (con == NULL) { NG_L2CAP_ERR( "%s: %s - unexpected LP_ConnectCfm event. Connection does not exist\n", __func__, NG_NODE_NAME(l2cap->node)); error = ENOENT; goto out; /* * Raw-HCI-initiated connection (hccontrol create_connection * or similar): no prior lp_con_req from us, so no descriptor * exists yet. Allocate one so ACL data forwards correctly. */ if (ep->status != 0) goto out; con = ng_l2cap_new_con(l2cap, &ep->bdaddr, ep->link_type); if (con == NULL) { error = ENOMEM; goto out; } con->state = NG_L2CAP_W4_LP_CON_CFM; goto raw_accepted; } /* Check connection state */ if ((error = ng_l2cap_lp_untimeout(con)) != 0) goto out; raw_accepted: if (ep->status == 0) { con->state = NG_L2CAP_CON_OPEN; con->con_handle = ep->con_handle; con_handle = NG_HCI_CON_HANDLE(acl_hdr->con_handle); pb = NG_HCI_PB_FLAG(acl_hdr->con_handle); length = le16toh(acl_hdr->length); /* * Some controllers (BCM4345 observed) emit empty ACL packets * (PB=continuation, length=0) as keepalive/filler between real * PDUs. These have no payload; drop silently rather than treat * as unexpected-fragment. */ if (length == 0) goto drop; NG_L2CAP_INFO( "%s: %s - got ACL data packet, con_handle=%d, PB=%#x, length=%d\n", Two changes in one patch. The empty-ACL drop is the second hunk: if (length == 0) goto drop; before the fragment-tracking logic. Silently swallow them — they aren’t malformed, they’re a chip quirk. 033ecf4 ng_l2cap: drop empty ACL packets (BCM4345 keepalive quirk) first; 313881d l2cap: regenerate patch via diff -u regenerated the diff with diff -u after a rebase scrambled the context lines.
The first hunk is the other half of the L2CAP fix and is the more interesting one.
Raw-HCI-initiated connections
The bringup scripts use hccontrol create_connection to open ACL links. That goes through ng_hci’s raw socket path, not through L2CAP’s normal lp_con_req flow. So when Connection_Complete arrives for that handle and L2CAP’s ng_l2cap_lp_con_cfm runs to confirm the connection, L2CAP looks up its own per-peer connection descriptor and finds nothing. Originally it logged unexpected LP_ConnectCfm event. Connection does not exist and bailed.
The patch’s first hunk fixes this: if no descriptor exists and the status is success, allocate a fresh ng_l2cap_con for the bdaddr and continue down the normal “connection accepted” path via a new raw_accepted label. ACL data inbound on that handle now has a place to land. 912a091 ng_l2cap: accept raw-HCI-initiated connections via lp_con_cfm .
This is structurally important because all the bringup scripts use raw-HCI create_connection — there’s no userland code path in our tree that goes through L2CAP’s connect API directly. The kernel had to learn to accept connections it didn’t initiate.
virtual_oss path buffer
virtual_oss is the FreeBSD audio framework that bridges OSS clients (/dev/dsp writers like mpg123) to alternate sinks. Its A2DP backend takes a -P /dev/bluetooth/<bd_addr> argument that opens the netgraph BT socket for that peer. The peer paths look like /dev/bluetooth/e0:d4:64:c5:70:eb — 27 characters. Plus null terminator, plus formatting allowance — well over VMAX_STRING (27). Trying to open the device with the original voss_dsp_tx_device[VMAX_STRING] truncated the bdaddr and the open returned ENOENT.
The patch is small and entirely typing:
+#define VMAX_PATH 96
...
-extern char voss_dsp_tx_device[VMAX_STRING];
+extern char voss_dsp_tx_device[VMAX_PATH];
Same change for voss_dsp_rx_device and voss_ctl_device. Plus updating the length check in main.c’s argument parsing. 7b3fcf4 virtual_oss: bump path buffer to 96 chars for /dev/bluetooth/<bd_addr> , with bc3b9fb virtual_oss int.h.patch: fix trailing context regenerating the int.h hunk after a context-line drift. None of this is interesting, but without it virtual_oss exits with Device name too long and audio doesn’t move.
The AVDTP / SBC chain — that wasn’t ours
Worth being explicit: we did not write a Bluetooth audio profile stack. virtual_oss’s voss_bt backend already implements AVDTP capability negotiation, SBC encoding, and the L2CAP PSM 25 (AVDTP signaling) / PSM 27 (AVDTP transport) split. All we had to do was give it a working L2CAP underneath. Once the L2CAP empty-ACL fix was in and the path buffer was wide enough to hold the device path, virtual_oss did the rest.
The first audio packet that left the chip:
1776846994.269508 ACLlen=1002 02 0c 20 e5 03 e1 03 40 00 80 60 00 19 00 00 57 80 00 00 00 01 07 9c f9 40 fb ba a9 a9 99 ba a9 99 99 84 96 bc 57 43 53 9d ae 9c f8 5b 3b 4a 7c 2a 76 34 b8 7e 59 20 64 3f b1 23 88 d3 e7 18 bc 63 ae ab 53 cb 49 52 a2 22 92 a3 14 eb e6 cf 8a
Decoded:
02 0c 20: ACL packet header. Handle0x000c, PB=2 (start, non-flushable), BC=0.e5 03: ACL length 0x03e5 = 997 bytes.e1 03 40 00: L2CAP header. Length 0x03e1 = 993 bytes, CID 0x0040 (the AVDTP transport channel — dynamically allocated in the 0x0040–0xFFFF range).80 60 00 19 00 00 57 80 ...: RTP header (V=2, M=1, PT=0x60, seq=0x0019, timestamp 0x00005780, SSRC follows). Then the SBC frame begins:01 07 9c f9 ...— first byte 0x01 is the SBC frame count (1 frame), then SBC header bytes carrying sample rate / block length / channel mode / allocation method, then encoded subband samples.
That single packet encodes ~26 ms of stereo 48 kHz audio at the SBC bitrate virtual_oss negotiated. Not particularly compressed (SBC isn’t), but the speaker takes it and the music plays.
The breakthrough
[WAR STORY]
The day audio finally played
▸ symptom
Pairing works (essay 11). Encryption is AES-CCM. ACL handle is open. virtual_oss starts and reports it’s connected to /dev/bluetooth/e0:d4:64:c5:70:eb. mpg123 is reading the file. /dev/dsp is being written to. The speaker is silent.
▸ hypothesis 1
The L2CAP AVDTP signaling channel (PSM 25) isn’t getting open. Watched hcisniff. Saw the AVDTP Connect and Get_Capabilities exchanges complete. Saw Set_Configuration succeed. Saw Open succeed. Saw Start succeed. Then nothing. The transport channel was open and the speaker was waiting for media frames.
▸ hypothesis 2
SBC encoding is failing inside virtual_oss. Watched voss.log. SBC frames are being produced. They’re being handed to the L2CAP transport channel. The send is returning success. But no ACL packet ever leaves the chip.
▸ hypothesis 3
ng_l2cap is dropping the outbound ACL on the floor because of the raw-HCI connection issue — the ACL handle exists in the chip but L2CAP doesn’t have a con descriptor for it, so ng_l2cap_lp_send can’t find a target. Confirmed with kernel printfs. The descriptor was missing. Adding the raw_accepted path in ng_l2cap_llpi.c (the first hunk in the patch above) gave L2CAP a descriptor to write into.
▸ breakthrough
With the raw-accepted path in place, the next playback attempt produced the 1002-byte ACL packet shown above. wireplumber on the speaker side logged:
spa.bluez5: Acquiring transport /org/bluez/hci0/dev_E0_D4_64_C5_70_EB/fd0
spa.bluez5.source.media: using A2DP codec SBC
pw.node: bluez_input.E0_D4_64_C5_70_EB.2-106 suspended -> runningThe speaker played the opening notes of One More Time. We heard music.
▸ fix
The L2CAP raw_accepted path ( 912a091 ng_l2cap: accept raw-HCI-initiated connections via lp_con_cfm ) plus the empty-ACL drop ( 033ecf4 ng_l2cap: drop empty ACL packets (BCM4345 keepalive quirk) ) plus the virtual_oss path-buffer expansion ( 7b3fcf4 virtual_oss: bump path buffer to 96 chars for /dev/bluetooth/<bd_addr> ) plus the bt_a2dp.sh orchestration script ( 64b2c55 bt_one_shot.sh: single-ACL pair+encrypt+A2DP sequence for the consolidated bt_one_shot.sh variant) — all of these landed on April 22. The breakthrough commit message: baafaea A2DP AUDIO CONFIRMED PLAYING 🎉 — A2DP AUDIO CONFIRMED PLAYING 🎉.
▸ lesson
The last fix is rarely the heroic one. By the time A2DP played, every individual change in the path was small — a goto drop on length==0, a raw_accepted label, a buffer size constant, a few orchestration steps in a shell script. The hard work was knowing which small changes were the ones the system needed, and that took the entire arc of essays 9–11 to discover. There is no shortcut. You do the work and at the end audio plays.
The orchestration
bt_a2dp.sh (and its later one-shot consolidation bt_one_shot.sh, 64b2c55 bt_one_shot.sh: single-ACL pair+encrypt+A2DP sequence ) is the script that drives the whole chain from a cold chip through audio playback. It’s 116 lines of set -e shell, structured as:
kldload ng_h4frame ng_hci ng_l2cap ng_btsocket cuse- Toggle BT_REG_ON via
gpioctlto power-cycle the chip. - Run
bcm_firmware_load.pl(essay 9). kldload bcm_hostwake(essay 10).- Spin up
bt_attachto wire the netgraph mesh:ng_tty→ng_h4frame→ng_hci→ng_l2cap→btsock_l2c/btsock_hci_raw/btsock_sco. - Start
hcisnifffor the diagnostic log. hccontrol initialize,write_class_of_device 24:04:14(audio sink),write_scan_enable 3,write_page_timeout 16384.- Issue
Write_Secure_Connections_Host_Support=1via the helperhcicmdtool (essay 11). - Start
hcsecdwith the SSP-aware Just Works config. create_connectionto the speaker bdaddr; poll forConnection_Completein the sniff log; extract the ACL handle.auth_requeston the handle; poll forAuth_Complete.set_connection_encryptionenable=1; poll forEncryption_Changeand checkenabled=0x02.- Spawn
virtual_oss -C 2 -c 2 -r 48000 -b 16 -s 4ms -R /dev/null -P /dev/bluetooth/<bdaddr> -d dsp. mpg123 /tmp/music/01\ One\ More\ Time.mp3.
It works. It is also fragile — the polling loops use grep against a hex-formatted sniff log, the timing windows are tuned by sleep-and-check, and any change in the chip’s response timing breaks step 10 silently. Filed.
sudo /usr/local/sbin/virtual_oss \
-C 2 -c 2 -r 48000 -b 16 -s 4ms \
-R /dev/null \
-P /dev/bluetooth/<peer-bdaddr> \
-d dsp &
AUDIODEV=/dev/dsp mpg123 /tmp/music/song.mp3 What’s still open
Three honest gaps:
-
Murata firmware integration is a manual blob copy.
overlay/usr/share/firmware/brcm/BCM4345C0.hcdis committed, but nothing in the build pipeline verifies which build is actually present, andbcm_firmware_load.pldoesn’t refuse to proceed if the SC-disabled RPi build is in place. A user who replaces the blob with the linux-firmware default will silently get E0 encryption again. Filed. -
virtual_oss isn’t being upstreamed. The two patches (
int.h.patch,main.c.patch) are local — they belong upstream in the FreeBSD portsaudio/virtual_ossrecipe. We haven’t sent them. Filed. -
The userland orchestration is fragile.
bt_a2dp.shparseshcisniffoutput withgrepand usessleep 0.1polling loops. Any latency change in the chip or the kernel breaks the script in place. The right fix is an event-driven driver (a small C tool that opens the raw HCI socket and dispatches on event codes), but that’s a second iteration; for now the shell script is what works.
This is the end of the Bluetooth arc. The chip is attached (essay 9), woken (essay 10), paired and encrypted under AES-CCM (essay 11), and now playing audio to a speaker (essay 12). Nothing in this stack is upstream-quality yet — every component has at least one open follow-up — but every component works end-to-end against real hardware on the bench. The next arc is whatever comes after Bluetooth. Probably modem.