← NextBSD Research

NextBSD Bluetooth — bluetoothd, pairing, HID and audio Research · epic plan

The plan for EPIC E16 (nextbsd#473): 38 tickets in six stages, from five parallel deep dives covering the kernel and hardware, the engine and Classic-vs-BLE decision, the daemon and CLI, HID, and audio. Seed ticket and decision record: nextbsd#412. Background: Apple stack research. Template: WLAN plan. Every ticket in full: E16 ticket drafts.

TL;DR

Decided (2026-09-20)

Index — all 38 tickets, filed

IDIssueTitleSizeTrackDepends on
Stage 0 — kernel & image
K1nextbsd-kernel#231config: bake netgraph + the Bluetooth stack, vkbd and cuse into NEXTBSD — no .ko tree, so nothing can autoload (#412 phase 0)SShip
K2nextbsd#475boot-test: assert the Bluetooth stack is present on both arches (ngctl list/types, net.bluetooth, hccontrol exits 7) and guard the kernel optionsSShipK1
K3nextbsd#476Ship Intel (ibt-*) and Realtek (rtl_bt) Bluetooth firmware in the image, with licences — fetched at assembly like the Pi DTBsSShipK1
A2nextbsd#485Ship virtual_oss's Bluetooth backend (`/usr/local/lib/virtual_oss/voss_bt.so`) on the imageSShipK1
A3nextbsd-kernel#236kldload(2) of a statically linked module returns ENOENT on NextBSD — virtual_oss dies at `kldload("cuse.ko")` even with cuse bakedSShipK1
Stage 1 — hand verification
K6nextbsd#477Phase 1: hand-verify the baked stack on real USB dongles (amd64 + Pi 500+ via RP1 xhci) and start the Bluetooth hardware matrixSShipK1
B4nextbsd#478Spike: hand-verify FreeBSD 15.1's LE path on NextBSD hardware with FreeBSD-BLE (scan, connect, SMP pair, HOGP mouse) and record the controller matrixSNeeds answerK1
Stage 2 — bluetoothd core
D1nextbsd-userland#206bluetoothd v0 skeleton: launchd job, Mach service, no-stack/no-adapter idle, `bluetooth status`, CI markersMShip
B2nextbsd-userland#204bluetoothd: engine interface (engine.h) that Classic and LE both implement - typed addresses, one pairing-prompt shape, engine-neutral bond recordMShip
D2nextbsd-userland#207bluetoothd: adapter lifecycle — detect ubtN, build the netgraph graph, initialise the controller, publish State:/Bluetooth/Adapter, power on/offLShipD1, K1, B2
D3nextbsd-userland#208bluetoothd: Classic inquiry scan with cached results and remote names; `bluetooth scan`MShipD2
D5nextbsd-userland#210bluetoothd: SCPreferences persistence — PairedDevices metadata plus a separate 0600 link-key file; `bluetooth list`MShipD1
D4nextbsd-userland#209bluetoothd: security manager replacing hcsecd — link-key/PIN replies, SSP (numeric comparison, passkey), pair/unpair RPCs, pull-model user confirmationLShipD2, D5
D7nextbsd-userland#212bluetoothd: supervise stock sdpd (and the child-supervision hook bthidd/btpand will use)SShipD2
D8nextbsd-userland#213bluetoothd: connect/disconnect, per-device state publishing (State:/Bluetooth/Device), auto-reconnect of paired devices at bootMShipD4, D5
D6nextbsd-userland#211bluetooth(8): full CLI — status|list|scan|pair|confirm|unpair|connect|disconnect|power|discoverable, `--no-prompt` exit codes, man pagesMShipD1, D3, D4, D5, D8
D9nextbsd#480CI: Bluetooth boot-test gates (BT-RPC-OK, BT-CLI-OK, BT-PREFS-OK, BT-NOSTACK→BT-NOADAPTER flip when the kernel bakes netgraph)SShipD1
K4nextbsd-userland#202Contract: bluetoothd runs iwmbtfw/rtlbtfw on USB arrival for controllers that boot in bootloader/ROM mode (devd's iwmbtfw.conf/rtlbtfw.conf, without devd)MSequenceK3, D2
D10nextbsd#481Bluetooth hardware bring-up run: first execution of bluetoothd scan/pair/reconnect on a real USB dongle (test matrix + findings)SSequenceD2, D3, D4, D8
Stage 3 — keyboards, mice & trackpads (HID)
H2nextbsd-userland#214bluetoothd: HID profile driver — generate bthidd.conf on pair, supervise bthidd -u, keyboards usable pre-loginMShipK1, D4, D5, D7
H3nextbsd-userland#215bthidd: reload config on SIGHUP without dropping sessions; rescan all devices per tick (NextBSD patch, upstream candidate)SHardenH2
H5nextbsd#483Bluetooth mice and trackpads in X via per-device uinput nodes (blocked on input hotplug)MSequenceH2, H4
Stage 4 — audio
A4nextbsd-userland#218bluetoothd: A2DP source — on connect of an AudioSink device, run/retarget virtual_oss at `/dev/bluetooth/<addr>`, publish the node, fall back on disconnectLShipK1, A2, A3, D1, D8
A5nextbsd-userland#219bluetoothd: A2DP sink — supervise virtual_bt_speaker so a phone can play through the machine's speakersMSequenceK1, A3, D1
A7nextbsd-userland#220bluetoothd: AVRCP target — accept the headset's play/pause/next/volume keys and deliver them to the sessionLSequenceA4, D1
Stage 5 — Bluetooth LE
B5nextbsd-kernel#234ng_hci/ng_l2cap LE hardening: honour LE_Read_Buffer_Size for LE ACL flow control, forward LE connection-parameter updates to the controller, implement the stub LE meta handlersMHardenB4, K1
B6nextbsd-userland#205bluetoothd LE engine: GAP scan/connect, ATT/GATT client, SMP pairing agent and LTK responder over the kernel's ATT/SMP socketsXLSequenceB2, D4, B4, B5
H7nextbsd-userland#217Scope HOGP (HID over GATT) for LE-only keyboards/mice — blocked on the Bluetooth engine decisionXLParkedB6
Needs an answer
H4nextbsd#482Decide how Bluetooth HID input reaches X: kern.evdev.rcpt_mask policy and the input-hotplug interfaceSNeeds answer
A6nextbsd-kernel#237Decide: HFP/HSP headset microphone — needs eSCO in ng_hci, alt-setting fix in ng_ubt, sco hook wiring, and a from-scratch HFP daemon (XL)XLNeeds answerK1, A4
K7nextbsd-kernel#232rpi5: measure what the firmware leaves for the onboard CYW43455 Bluetooth (BT_ON gio 29, uarta pinmux, 96 MHz clock, bdaddr) before any driver workSNeeds answer
Harden — CI without a radio
K5nextbsd-userland#203btemu: a userland HCI controller on ng_hci's drv hook so CI can exercise hci/l2cap/btsocket and bluetoothd without a radioMHardenK1, K2
H8nextbsd#484CI: synthetic input-path test in qemu (vkbd → kbdmux/evdev, uinput → /dev/input → X) without a radioMHardenK1
A8nextbsd#486Boot-test: prove the Bluetooth-audio plumbing without a radio (cuse, virtual_oss under launchd, backend load, sndstat/sndctl visibility)SHardenK1, A2, A3
Docs
B8nextbsd#479Docs: correct freebsd-bluetooth-management.html - FreeBSD's kernel already has LE HCI/L2CAP fixed channels and SMP sockets; the gap is userlandXSShip
Parked
K8nextbsd-kernel#233rpi5: onboard Bluetooth over uarta — bcm7271 UART compat, brcmstb-gpio, bcm2712-pinctrl, an H4 transport on ttyhook, BCM4345C0.hcd patchram (parked)XLParkedK1, K7
B7nextbsd-kernel#235Spike: price a netgraph LE tee node (route LE connection-handle ACL to a userland ng_socket) that would let a full host like NimBLE run beside kernel ClassicMParkedB4
H6nextbsd-userland#216bthidd: Apple Magic Mouse 2 and Magic Trackpad 1/2 vendor reports (scroll, multitouch via uinput)LParkedH2, H5

Kernel & hardware (K)

Deep-dive notes, lightly edited. Draft ids map to issues in the index.

Scoping notes for nextbsd/nextbsd#473 (epic) and #412 (seed). Read-only research; nothing filed. Evidence paths: nextbsd-kernel/…, nextbsd/…, ul/… are the local clones; sys/…, usr.sbin/… are FreeBSD releng/15.1 unless marked 13.5.

TL;DR

  1. Seven options lines bake the whole USB Bluetooth stack. Every token is stock and machine-independent (sys/conf/files:4299-4320, sys/conf/options:516-526, NOTES:813-818); the only bus dependency is usb, which GENERIC already has on amd64 and arm64 (sys/arm64/conf/std.dev:101). One change to config/NEXTBSD covers both arches and NEXTBSD-RPI5 (which includes it, config/NEXTBSD-RPI5:39).
  2. There is exactly one lazy-kldload in the netgraph/BT code: sys/netgraph/ng_socket.c:281-303 — an NGM_MKPEER for an unregistered type does kern_kldload("ng_<type>"). That is how stock FreeBSD pulls in ng_hci/ng_l2cap from rc.d/bluetooth. On NextBSD it fails (no .ko tree, nextbsd-kernel/ci/assemble-image.sh:168). Baking the types makes ng_findtype() succeed before the fallback is reached, so the path is never taken. The BT directories themselves contain no kldload at all.
  3. Nothing in the kernel builds the graph. ng_ubt attaches a bare ubt0 node; on stock FreeBSD sbin/devd/bluetooth.conf runs service bluetooth quietstart ubt0, and rc.d/bluetooth's bluetooth_setup_stack() does the mkpeer/connect/hccontrol initialize dance. NextBSD strips devd and ships no rc.d, so bluetoothd (area 2) owns graph assembly — the interface is spelled out in §3.
  4. Firmware is a userland problem, not a firmware(9) problem. Intel (7260…AX-class, VID 0x8087) and Realtek controllers ship in bootloader/ROM mode; the in-tree ng_ubt_intel.c / ng_ubt_rtl.c refuse to attach until iwmbtfw(8) / rtlbtfw(8) have pushed firmware over ugen. The blobs come from Debian firmware-nonfree (ports comms/iwmbt-firmware, comms/rtlbt-firmware; licences allow redistribution). The trigger on stock FreeBSD is a devd rule; NextBSD has no devd, launchd's HardwareMatch is iter-1 (parse only, ul/src/launchd/src/core.c:262-270), so the trigger must be bluetoothd's job (interface in §4.5). Broadcom BCM20702/Apple parts work in ROM firmware; the only Broadcom firmware tool in tree (bcmfw/ubtbcmfw) is for the 2003-era BCM2033 and ships no firmware — skip it.
  5. Raspberry Pi 5 / Pi 500 onboard Bluetooth is unsupported at every layer in 15.1 and should be parked. The BT radio hangs off uarta (brcm,bcm7271-uart, an 8250 variant FreeBSD has no compat entry for), needs brcm,brcmstb-gpio (BT_ON = gio 29) and brcm,bcm2712-pinctrl (neither in tree), a UART HCI transport (ng_h4 removed in 2021, commit 79a100e28e; hcseriald removed 66d6299848), and a patchram download of BCM4345C0.hcd that FreeBSD has never had. USB dongles over RP1's xhci are the E16 path on the Pi.
  6. Bake, don't kext. The in-kernel IOCatalogue matches PCI ids and FDT compatible strings only (src-overlay/sys/sys/iocatalogue.h:24-31, iokit_catalogue.c:270-279); there is no USB provider, the personality generators are PCI-only, and the protocol nodes are not devices at all (nothing fires device_nomatch for them). ng_ubt also fails the delivery plan's own kext rule (kext ⇐ has-firmware(9) ∨ multi-version). ~10.6k LOC total, order of half a megabyte of kernel.
  7. CI can prove presence, not radio. QEMU removed Bluetooth emulation in 2019 (commits 1d4ffe8dc7, 43d68d0a94). CI can assert ngctl list shows btsock_hci_raw/btsock_l2c_raw/ btsock_l2c, sysctl net.bluetooth.version, and hccontrol exiting 7 with Could not find HCI nodes (hccontrol.c:110-112) instead of EAFNOSUPPORT. A userland HCI controller emulator on the hci node's drv hook (ng_hci.h:62) can exercise the whole protocol stack without a radio — a worthwhile hardening ticket. Real dongles: qemu -device usb-host,vendorid=…,productid=… on a Linux host, or real hardware.

1. What must be compiled into NEXTBSD

1.1 The change
### config/NEXTBSD  (proposed)
options     NETGRAPH            # netgraph core: ng_base.c, ng_parse.c
options     NETGRAPH_SOCKET         # ng_socket: PF_NETGRAPH, what ngctl/libnetgraph/hccontrol use to find nodes
options     NETGRAPH_BLUETOOTH      # ng_bluetooth: net.bluetooth sysctls, shared version/timeouts
options     NETGRAPH_BLUETOOTH_HCI      # ng_hci
options     NETGRAPH_BLUETOOTH_L2CAP    # ng_l2cap
options     NETGRAPH_BLUETOOTH_SOCKET   # ng_btsocket: PF_BLUETOOTH (HCI raw, L2CAP, RFCOMM, SCO)
options     NETGRAPH_BLUETOOTH_UBT      # ng_ubt + ng_ubt_intel + ng_ubt_rtl (USB transport)

Why each is required, with the evidence:

Token Files it gates (sys/conf/files) Why it cannot be left out
NETGRAPH netgraph/ng_base.c (:4326), ng_parse.c (:4353) Every node type MODULE_DEPEND(…, netgraph, …) (e.g. ng_ubt.c:2055, ng_btsocket.c:218). sys/conf/options:516
NETGRAPH_SOCKET netgraph/ng_socket.c (:4361) hccontrol calls find_hci_nodes()NgMkSockNode() + NGM_LISTNAMES before it opens the BT socket (usr.sbin/bluetooth/hccontrol/hccontrol.c:110); ngctl(8), l2ping, sdpcontrol, and bluetoothd's graph assembly all need PF_NETGRAPH. options:561
NETGRAPH_BLUETOOTH common/ng_bluetooth.c (:4299) MODULE_DEPEND of ng_hci (ng_hci_main.c:95), ng_l2cap (:95), ng_btsocket (:216), ng_ubt (:2057). Provides net.bluetooth.version and the HCI timeouts (ng_bluetooth.c:59-113)
NETGRAPH_BLUETOOTH_HCI hci/ng_hci_*.c (:4304-4308) MODULE_DEPEND(ng_ubt, ng_hci, …) (ng_ubt.c:2056); the hci type must be registered before bluetoothd mkpeers it
NETGRAPH_BLUETOOTH_L2CAP l2cap/ng_l2cap_*.c (:4309-4314) Same: mkpeer ubt0hci: l2cap acl hci (rc.d/bluetooth bluetooth_setup_stack)
NETGRAPH_BLUETOOTH_SOCKET socket/ng_btsocket*.c (:4315-4320) DOMAIN_SET(ng_btsocket_) (ng_btsocket.c:248) registers PF_BLUETOOTH. Domains are never autoloaded; without it socket(PF_BLUETOOTH, …) is EAFNOSUPPORT and hccontrol dies with Could not create socket (hccontrol.c:122-124)
NETGRAPH_BLUETOOTH_UBT drivers/ubt/ng_ubt.c, ng_ubt_intel.c, ng_ubt_rtl.c — all optional netgraph_bluetooth_ubt usb (:4300-4302) The USB HCI transport. DRIVER_MODULE(ng_ubt, uhub, …) + USB_PNP_HOST_INFO(ubt_devs) (ng_ubt.c:2053,2059). One token brings all three files, so the Intel/Realtek "refuse to attach in bootloader mode" guards come for free

Deliberately not baked:

1.2 Module dependency check (both arches)

All MODULE_DEPENDs in the BT tree resolve inside the set above plus usb: ng_ubt→{netgraph, ng_hci, ng_bluetooth, usb} (ng_ubt.c:2055-2058), ng_ubt_intel/ng_ubt_rtl→{netgraph, ng_hci, usb} (ng_ubt_intel.c:288-290, ng_ubt_rtl.c:270-272), ng_hci/ng_l2cap→ng_bluetooth, ng_btsocket→{ng_bluetooth, netgraph}. device usb is in amd64 GENERIC and in arm64 std.dev:101 (device usb # USB Bus (required)), which arm64 GENERIC includes (sys/arm64/conf/GENERIC:24). The Pi 5's USB is behind RP1 (patches/0018-…, 0020-…) and the Pi 500+ already runs a USB keyboard through it, so ng_ubt on a dongle has a working uhub parent there.

amd64 vs arm64: no difference. All tokens are in the MI sys/conf/files/options, not files.amd64/files.arm64. Neither GENERIC carries any of them (grep -i netgraph sys/amd64/conf/GENERIC and sys/arm64/conf/GENERIC are empty), so this is purely additive on both. NEXTBSD-RPI5 inherits via include NEXTBSD.

1.3 Cost

Source: 10,578 lines for ng_base.c + ng_socket.c + every file under netgraph/bluetooth/ (measured, wc -l). Binary: not measured here — no build in this session. From memory of stock 14.x .ko sizes the resident text+data is on the order of 0.4–0.5 MB (netgraph ≈ 115K, ng_btsocket ≈ 105K, ng_hci/ng_l2cap ≈ 80K each, ng_ubt ≈ 40K, ng_socket ≈ 20K, ng_bluetooth ≈ 10K). K1's acceptance asks for the real size(1) delta on the CI kernel. Against the ~120 MB wired baseline the delivery plan measured, it is noise. On a machine with no radio the code is never entered beyond the SYSINIT that registers types and the btsock_* nodes.

1.4 Hidden lazy-load paths

Grep of sys/netgraph/** for kern_kldload|kldload|linker_load|modfind:


2. What the kernel gives you, and what it does not

After K1, plugging in a class-E0/01/01 dongle yields:

ubt0 on uhub0
ubt0: <vendor product> on usbus0

and a netgraph node ubt0: of type ubt with one hook, hook. Nothing else. No ubt0hci, no L2CAP, no socket wiring, no HCI reset. On stock FreeBSD that is done by:

NextBSD strips /sbin/devd and /sbin/devmatch from the base (nextbsd-freebsd-compat README, "Superseded base tools": replaced by kextd + libIOKit) and never installs /etc/rc.d. So this is an interface to area 2: bluetoothd must subscribe to device arrival (libIOKit IOKitNotify / the kernel registry, the same path diskarbitrationd uses in ul/src/DiskArbitration/da_iokit_subscribe.c), and on ubt%d arrival run the equivalent of bluetooth_setup_stack() through libnetgraph (NgMkSockNode + NgSendMsg), then the HCI init sequence. Until it exists, §8's hand recipe does it with ngctl.

Good news on the userland tools: the compat base is a full buildworld with only WITHOUT_TOOLCHAIN/TESTS/LIB32/DEBUG_FILES (nextbsd-freebsd-compat .github/workflows/build.yml:30-31, README "What it builds"), and MK_BLUETOOTH/MK_NETGRAPH default to yes (share/mk/src.opts.mk:70,150; MK_BLUETOOTH is only forced off when MK_NETGRAPH=no, :444-445). So hccontrol, l2ping, l2control, sdpcontrol, sdpd, hcsecd, bthidd, bthidcontrol, iwmbtfw, rtlbtfw, ngctl, libbluetooth, libsdp, libnetgraph should already be in nextbsd-base-<arch>.tar.gz. Not verified on an image — the site's srclist plan (site/freebsd-srclist-build-plan.html) marks usr.sbin/ngctl as DEFER, but that plan is not what the compat repo builds today. K1's acceptance includes ls /usr/sbin/hccontrol /usr/sbin/ngctl on the CI image so this is settled by evidence.


3. Controllers, chipsets, firmware

3.1 What ng_ubt claims (sys/netgraph/bluetooth/drivers/ubt/ng_ubt.c)

Broadcom patchram: ng_ubt contains no patchram/.hcd download (grep broadcom|BCM in the file shows only id tables). Linux's btbcm would push a .hcd into BCM20702/BCM4350-class parts; FreeBSD runs them on ROM firmware. They work (Macs have run FreeBSD BT this way for years) but with whatever bugs the ROM has and no vendor updates. Not an E16 blocker; noted as a known gap.

3.2 Intel — ng_ubt_intel.c + iwmbtfw(8)
3.3 Realtek — ng_ubt_rtl.c + rtlbtfw(8)
3.4 Others
3.5 How NextBSD ships firmware today, and why BT is different
3.6 The trigger problem (interface to area 2)

On stock FreeBSD devd runs the loader on USB attach. On NextBSD:

So the trigger belongs to bluetoothd: subscribe to USB device arrival via libIOKit, match the union of iwmbtfw.conf/rtlbtfw.conf ids (or simply "VID 0x8087 or 0x0bda, or class E0/01/01 with no ubt sibling after N ms"), run /usr/sbin/iwmbtfw|rtlbtfw -d ugenX.Y -f /usr/share/firmware/bluetooth/<vendor>, and expect the device to detach/re-attach as ubtN. K4 records this contract for the daemon ticket set. An in-kernel port of Linux btintel/btrtl into ng_ubt_* (which would let firmware(9) + a kext bundle work) is the long-term "right" shape but is thousands of lines and not needed for E16.


4. Raspberry Pi 5 / Pi 500 onboard Bluetooth

4.1 The hardware, from the vendor device tree

raspberrypi/linux rpi-6.6.y (the tree whose bcm2712.dtsi still carries the SoC UART nodes; the rpi-6.12.y copy has moved them into includes):

4.2 What FreeBSD 15.1 has for each piece
Piece Status in 15.1 Evidence
UART driver for brcm,bcm7271-uart None. uart_dev_ns8250.c's FDT table is ns16550/ns16550a only (:527-528); the DT node carries no such fallback. sys/dev/uart/ has no uart_dev_bcm7271.c. reg-shift/reg-io-width are honoured generically (uart_bus_fdt.c:87,96), so an added compat entry with a 96 MHz rclk may be enough for basic TX/RX — the bcm7271 auto-flow-control and >115200 behaviour are unverified sys/dev/uart listing
GPIO for BT_ON (brcm,brcmstb-gpio) None. sys/dev/gpio/ has no brcmstb driver; files.arm64:570-595 has only the 2837/2838 bcm2835_gpio/raspberrypi_gpio, and NEXTBSD-RPI5 switches those SoC options off (config/NEXTBSD-RPI5:63-64) sys/conf/files.arm64
Pinctrl (brcm,bcm2712-pinctrl) None (the RP1 pinmux is patched in, 0048-rp1_gpio-implement-fdt-pinctrl…; the SoC-side one is not)
UART HCI transport (H4) Removed. ng_h4 deleted 2021-09-05, commit 79a100e28e ("disconnected 13 years ago … completely fails to compile … Bluetooth has largely (completely?) moved on from bluetooth over UART transport"); hcseriald removed 66d6299848. Present in releng/13.5 (sys/netgraph/bluetooth/drivers/h4/ng_h4.c, 1019 lines, old linesw line discipline — ng_h4.c:98-107), absent in 14.0+. A rewrite would follow ng_tty(4)'s ttyhook pattern (sys/netgraph/ng_tty.c:47,116-121), which is alive in 15.1 gh api search/commits
Patchram (firmware download) Never existed on FreeBSD. Linux btbcm sends BCM4345C0.hcd (63,806 bytes, RPi-Distro/bluez-firmware debian/firmware/broadcom/) over HCI vendor commands (0xFC2E download-minidriver, then the .hcd records, then 0xFC18 baud update) before the stack starts. Licence: "CYPRESS WIRELESS CONNECTIVITY DEVICES DRIVER END USER LICENSE AGREEMENT", Debian non-free-firmware; Raspberry Pi confirms it is released "on the expectation that we will release them to our users" (debian/copyright:19-131). Redistributable with the licence text, same class as LICENCE.broadcom the image already ships
BD address local-bd-address is all-zeros in the DT; on Linux the Pi firmware patches it (bdaddr override) and btbcm applies it with vendor cmd 0xFC01. Without that the ROM default (43:45:C0:00:1F:AC) is shared by every board
3 Mbaud + RTS/CTS Needs the bcm7271 clock (96 MHz from the board dts) and hardware flow control; whether uart(4)'s ns8250 path drives auto-flow on this block is unverified
Power/mux state left by the Pi firmware Unknown. Whether start4.elf/EEPROM leaves gio 29 high and gpio24-27 muxed to uart0 (Linux hci_bcm toggles the shutdown GPIO itself). K7 measures this before anyone writes a driver needs-hardware
4.3 Verdict

Five independent missing pieces (UART compat/clock, brcmstb-gpio, bcm2712-pinctrl, an H4 transport rebuilt on ttyhook, a patchram tool + firmware packaging), plus a BD-address fix-up, plus an hcseriald equivalent that has to be owned by bluetoothd. Every one is a first for FreeBSD. That is XL and off E16's critical path. kernel#99 (BCM43455 WiFi over SDIO) shares the chip but not a single line of this — the BT interface is a separate UART.

Recommendation: park (K8) behind a measurement spike (K7). For E16 the supported Pi path is a USB dongle in one of RP1's xhci ports, which exercises exactly the baked stack from §1. Say so in the epic so nobody expects the onboard radio to light up.


5. Kext or baked? — Recommendation: bake all of it

The driver-delivery plan's rule (site/nextbsd-driver-delivery-plan.html §3): three tiers, and "kext ⇐ has-firmware ∨ needs-multi-version"; otherwise compiled-in or plain kld. Applying it:

  1. The protocol nodes (ng_hci, ng_l2cap, ng_btsocket, ng_bluetooth, netgraph itself) are not device drivers. They are created by NGM_MKPEER from userland; no device_nomatch ever fires for them, so neither the IOCatalogue nor kextd would ever be asked to load them. The only "autoload" mechanism is ng_socket.c's kern_kldload hack, and turning that into a kextd Mach request is a new kernel feature for no benefit. They must be baked.
  2. ng_ubt cannot be matched by the catalogue today. iocatalogue.h:24-31 defines exactly IOCAT_PROVIDER_IOPCIDEVICE and IOCAT_PROVIDER_IOPLATFORMDEVICE (FDT compatible); the nomatch work item carries a PCI match_word "0 if FDT" and a compat[] string (iokit_catalogue.c:270-279). The six gen-*-personalities.sh in nextbsd-kernel-extensions all emit IOProviderClass IOPCIDevice. Matching on USB class E0/01/01 (IOUSBInterface + bInterfaceClass/bInterfaceSubClass/bInterfaceProtocol, or idVendor/idProduct) is a new provider kind, a new capture path on the USB nomatch, and a new generator — real work that E16 does not need.
  3. ng_ubt fails the kext bar anyway. It carries no firmware(9) firmware (Intel/Realtek firmware goes through userland tools, §3), and there is one version of it. ~2,600 LOC across the three files.
  4. Cost of baking is small (§1.3) and the code is inert without a radio.

Where a kext would fit later: an IOUSBDevice provider in the catalogue is worth having for the general USB-driver population (audio, serial, WLAN dongles), and ng_ubt could move out then. That is a K-series feature, not an E16 one — listed as an open question, not a ticket. For the Pi 5 UART transport, the BT node is a child of a UART node with no bus driver enumerating it, so it too would be baked into NEXTBSD-RPI5 whenever it exists.


6. Testing

6.1 What is and is not possible
6.2 What CI can prove without a radio (K2)

After K1, on the existing qemu boot test (nextbsd/tests/boot-test.sh, both arches):

ngctl list                       # must show btsock_hci_raw, btsock_l2c_raw, btsock_l2c (and a
                                 # `ngctl(pid)` socket node) — the ng_btsocket nodes exist at boot
                                 # (ng_btsocket_hci_raw.c:197-207 creates and names them at init)
ngctl types                      # must list types hci, l2cap, ubt, socket (registered by NETGRAPH_INIT)
sysctl -n net.bluetooth.version  # prints NG_BLUETOOTH_VERSION (ng_bluetooth.c:59-62); ENOENT before K1
hccontrol read_bd_addr; echo $?  # exit 7, stderr "Could not find HCI nodes" (hccontrol.c:110-112)
                                 # NOT "Could not create socket: Address family not supported…"
l2ping -a 00:00:00:00:00:01      # must fail on the connect, not on socket(2)
ls /usr/sbin/hccontrol /usr/sbin/ngctl /usr/sbin/iwmbtfw /usr/sbin/rtlbtfw /usr/sbin/sdpd /usr/sbin/bthidd

Plus the guard that the WLAN plan asked for in kernel#52: fail the kernel build if config -x of the built kernel lacks any of the seven options.

6.3 A controller emulator (K5, hardening)

ng_hci does not care what is on its drv hook (ng_hci.h:62: "drv" /* Driver <-> HCI */). A userland process can NgMkSockNode(), mkpeer . hci <hook> drv, name it emu0hci, wire l2cap and the btsock_* nodes exactly as rc.d/bluetooth does, and then answer HCI commands arriving on its data socket with Command Complete events (Reset, Read_BD_ADDR, Read_Local_Supported_Features, Read_Buffer_Size, Write_Scan_Enable, …), and optionally fake an inquiry result and an ACL connection. That runs the whole ng_hci/ng_l2cap/ng_btsocket path plus hccontrol/l2ping/sdpd in qemu with no hardware — the Bluetooth analogue of a loopback interface, and the only way CI will ever exercise bluetoothd's state machine before real dongles do. ~400 LOC of C over libnetgraph. Linux has the same thing (vhci/btvirt); FreeBSD never grew one.


7. Phase 1 hand-verification recipe (real dongle, after K1)

Class-E0 CSR/Cambridge dongles (0a12:0001) are the zero-firmware baseline; test Intel/Realtek only after K3/K4.

### 0. enumeration
usbconfig                                  # ugenX.Y: <…> … cfg=0 md=HOST spd=FULL (12Mbps) pwr=ON
dmesg | grep ubt                           # ubt0 on uhubN
ngctl list                                 # ubt0 (type ubt), btsock_* nodes

### 1. build the graph by hand (what bluetoothd will do; verbatim from rc.d/bluetooth)
ngctl mkpeer ubt0: hci hook drv
ngctl name ubt0:hook ubt0hci
ngctl mkpeer ubt0hci: l2cap acl hci
ngctl name ubt0hci:acl ubt0l2cap
ngctl connect ubt0hci: btsock_hci_raw: raw ubt0raw
ngctl connect ubt0l2cap: btsock_l2c_raw: ctl ubt0ctl
ngctl connect ubt0l2cap: btsock_l2c: l2c ubt0l2c

### 2. bring the controller up
hccontrol -n ubt0hci reset
hccontrol -n ubt0hci read_bd_addr                 # BD_ADDR: xx:xx:xx:xx:xx:xx  <- the real proof
hccontrol -n ubt0hci read_local_version_information
hccontrol -n ubt0hci read_local_supported_features
hccontrol -n ubt0hci read_buffer_size
hccontrol -n ubt0hci write_scan_enable 3          # discoverable + connectable
hccontrol -n ubt0hci write_class_of_device 0x1c0104
hccontrol -n ubt0hci change_local_name nextbsd
hccontrol -n ubt0hci initialize
hccontrol -n ubt0hci read_node_state              # must be "up"

### 3. see the world
hccontrol -n ubt0hci inquiry                      # lists BD_ADDR + class of nearby discoverable devices
hccontrol -n ubt0hci remote_name_request <addr> 0 0 0

### 4. L2CAP + SDP against a phone/headset in pairing mode
l2ping -a <addr>                                  # 44 bytes from … seq_no=0 time=… ms result=0
sdpcontrol -a <addr> browse                       # service records (Handsfree, A2DP sink, HID, …)

### 5. teardown (what bluetoothd does on detach)
ngctl shutdown ubt0hci:
ngctl shutdown ubt0l2cap:

Intel/Realtek addendum (after K3): usbconfig shows the part with no ubt sibling; run iwmbtfw -d ugenX.Y -f /usr/share/firmware/bluetooth/intel (or rtlbtfw … /rtl_bt), watch the device detach and come back as ubt0, then continue from step 1. Pairing (hcsecd), HID (bthidd) and audio are areas 2–4.

Record the result in the hardware matrix (K6): dongle ids, arch, board (a Pi 500+ via RP1 xhci is on the list), what worked, kernel/userland SHAs.


8. Open questions

  1. Kernel size delta — measure in K1 (size /boot/kernel/kernel before/after) rather than trusting §1.3's estimate.
  2. Are the BT userland tools really on the image? Expected yes (§2) but unverified; K1's acceptance settles it.
  3. Firmware set size for Intel ibt-* — measure in K3; if it is tens of MB, consider trimming to the ids ng_ubt_intel.c lists (the tool picks by hw_variant/hw_revision, so the mapping is derivable from iwmbtfw's source).
  4. Who runs the firmware loaders — §3.6 argues bluetoothd; if area 2 prefers a separate org.nextbsd.btfw one-shot, K4's contract still holds.
  5. Pi firmware state of gio 29 / uart0 pinmux / uarta clock — K7 measures; changes K8's scope.
  6. IOUSBDevice matching in the catalogue — not needed for E16; worth a K-series ticket later so USB drivers can be kexts.
  7. Broadcom patchram for USB parts (BCM20702 on Macs) — ROM firmware works; a .hcd loader over ugen (~300 LOC, same shape as iwmbtfw) would be a Harden item if Mac users hit ROM bugs.
  8. BLE — the stack supports LE HCI commands (hccontrol has an le_commands table, hccontrol.c:190) but no LE L2CAP/ATT/SMP; that is the seed ticket's "classic first" question, not a kernel-config one.

9. Risks

10. Ticket index (see 1-kernel-hardware-tickets.json)

ID Repo Title Track Size
K1 nextbsd-kernel config: bake netgraph + the Bluetooth stack (ng_bluetooth/hci/l2cap/btsocket/ubt) into NEXTBSD — no .ko tree, so nothing can autoload (#412 phase 0) Ship S
K2 nextbsd boot-test: assert the Bluetooth stack is present on both arches (ngctl list/types, net.bluetooth, hccontrol exits 7) and guard the kernel options Ship S
K3 nextbsd Ship Intel (ibt-*) and Realtek (rtl_bt) Bluetooth firmware in the image, with licences — fetched at assembly like the Pi DTBs Ship S
K4 nextbsd-userland Contract: bluetoothd runs iwmbtfw/rtlbtfw on USB arrival for controllers that boot in bootloader/ROM mode (devd's iwmbtfw.conf/rtlbtfw.conf without devd) Sequence M
K5 nextbsd-userland btemu: a userland HCI controller on ng_hci's drv hook so CI can exercise hci/l2cap/btsocket and bluetoothd without a radio Harden M
K6 nextbsd Phase 1: hand-verify the baked stack on real USB dongles (amd64 + Pi 500+ via RP1 xhci) and start the Bluetooth hardware matrix Ship S
K7 nextbsd-kernel rpi5: measure what the firmware leaves for the onboard CYW43455 Bluetooth (BT_ON gio 29, uarta pinmux, 96 MHz clock, bdaddr) before any driver work Needs answer S
K8 nextbsd-kernel rpi5: onboard Bluetooth over uarta — bcm7271 UART compat, brcmstb-gpio, bcm2712-pinctrl, an H4 transport on ttyhook, BCM4345C0.hcd patchram (parked) Parked XL

Engine & Classic vs BLE (B)

Deep-dive notes, lightly edited. Draft ids map to issues in the index.

Research for nextbsd/nextbsd#473 (E16 Bluetooth), seed nextbsd/nextbsd#412. Read-only; nothing filed. All FreeBSD file:line cites are against releng/15.1 at 88e7371d9dc26f85dfc1b008cbe59ebc7e4a33da (2026-08-24, "Add UPDATING entries and bump version"), sparse-cloned to freebsd-src.

TL;DR

  1. The plans-site page is wrong on the central fact. site/freebsd-bluetooth-management.html §4–5 says FreeBSD's stack "supports Classic Bluetooth only — no BLE, no GATT, no SMP" and that "the gap is in the kernel". It is not. Since Takanori Watanabe's 2015 commits (fbc48c2bfb "Initial Bluetooth LE support", 3a601a2381 "Bluetooth LE Security Management channel support") the kernel does LE HCI, LE ACL connections, and exposes the ATT (CID 4) and SMP (CID 6) fixed channels as ordinary AF_BLUETOOTH L2CAP sockets with an LE address type in sockaddr_l2cap. What is missing is entirely userland: there is no GATT client, no SMP pairing agent, no LTK store, no HOGP driver in base. Those exist as an out-of-tree prototype (takawata/FreeBSD-BLE, HOGP mouse working; rhaberkorn/FreeBSD-BLE 2025 fork adds a one-shot HID pairing script) with no license file.
  2. Classic-first is still right, but for a different reason than "FreeBSD can't do LE". Classic covers every headset (A2DP/HFP are BR/EDR; LE Audio is not on Apple and is still a minority of shipping headsets), Apple's Magic Keyboard/Mouse/Trackpad (BR/EDR HID), and the older/cheaper keyboard population. It does not cover the Logitech MX line, Microsoft Surface keyboards, Keychron, or essentially any mouse/keyboard designed after ~2018 — those are LE-only (HOGP). So "Classic first" ships headsets + Apple peripherals; LE HID is the second milestone, not a someday, and the engine interface must be written for both from day one.
  3. Engine recommendation: Classic engine = netgraph kernel stack driven directly by bluetoothd over the raw HCI socket (absorbing hcsecd's three-event loop and adding SSP, which the kernel now passes through since 4ae0fa8a2f, 2025-01). LE engine = a small userland ATT/GATT/SMP host over the kernel's ATT/SMP sockets — the FreeBSD-BLE shape, clean-room or relicensed — not a full third-party host. NimBLE/BTstack-style full hosts need the raw ACL stream, which on netgraph means taking ng_hci's single acl hook away from ng_l2cap and killing Classic (BTstack's own FreeBSD port documents exactly this). BlueZ stays out. A NimBLE spike is worth one ticket only to price the "tee node" alternative; the default is the thin host.
  4. Kernel LE gaps that matter are small and fixable in nextbsd-kernel: LE buffer accounting ignores LE_Read_Buffer_Size; LE_Connection_Update_Complete, LE_Read_Remote_Features, LE_Enhanced_Connection_Complete are stubs; LE signalling accepts parameter-update requests but never forwards them to the controller; LE CoC (dynamic LE channels) unsupported. None block HOGP.

1. Exact state of FreeBSD 15.1's stack

1.1 What exists (LE), with cites
Layer Present in 15.1 Evidence
HCI LE command group (OGF 0x08) All Core 4.0 LE commands + data-length + buffer-size v2 defined; kernel post-processes Command_Complete / Command_Status for them sys/netgraph/bluetooth/include/ng_hci.h:1587-1848 (OCFs 0x01–0x1f, 0x23, 0x24, 0x60); hci/ng_hci_cmds.c:817-876 process_le_params, :877-916 process_le_status
LE Meta event (0x3e) Dispatched. Handled: LE_Connection_Complete (0x01), LE_Advertising_Report (0x02), LE_Connection_Update_Complete (0x03, body is /*TBD*/). Not handled (freed): LE_Read_Remote_Features_Complete (0x04), LE_Long_Term_Key_Request (0x05), everything else. Every event is still mirrored to the raw hook before the switch (ng_hci_mtap, hci/ng_hci_main.c:719, hci/ng_hci_misc.c:63-78), so userland sees LTK requests etc. hci/ng_hci_evnt.c:129 (case NG_HCI_EVENT_LE), :376-620 (le_event, le_advertizing_report, le_connection_complete, le_connection_update)
LE connections (central) LP_CON_REQ with link type NG_HCI_LINK_LE_PUBLIC/_RANDOM emits LE_Create_Connection; connection descriptor created on LE_Connection_Complete; peripheral-role (incoming) connection also creates a descriptor and sends LP_CON_IND up hci/ng_hci_ulpi.c:107-109, :486-623 (ng_hci_lp_le_con_req); hci/ng_hci_evnt.c:471-569
LE address types NG_HCI_LINK_LE_PUBLIC 0x02 / NG_HCI_LINK_LE_RANDOM 0x03; userland BDADDR_BREDR 0 / BDADDR_LE_PUBLIC 1 / BDADDR_LE_RANDOM include/ng_hci.h:123-124; include/ng_bluetooth.h:228-229; hci/ng_hci_main.c:102-105
LE advertising cache Advertising reports populate the neighbor cache with addrtype, AD payload (extinq_data, up to 240 B) and RSSI (stored in page_scan_mode); exported to userland via the neighbor-cache ioctl (4aa92fe2f3, 2017) hci/ng_hci_evnt.c:379-469; include/ng_hci.h:632-634
L2CAP LE fixed channels CIDs defined: ATT 0x04, LE-signal 0x05, SMP 0x06; ATT/SMP channels are auto-created per LE connection and opened/closed with it; NG_L2CAP_MTU_LE_MINIMAM 23 include/ng_l2cap.h:75-87, :359-361 (IDTYPE_ATT/LE/SMP); l2cap/ng_l2cap_misc.c:343-345; l2cap/ng_l2cap_ulpi.c:131-136,196-199,245-246,872-875,1006-1020
L2CAP LE signalling (CID 5) Parses Connection_Parameter_Update_Request/Response; always replies ACCEPT but never issues LE_Connection_Update to the controller (/*TBD.*/) l2cap/ng_l2cap_evnt.c:134-136,278-372 (send_l2cap_param_urs(..., ACCEPT) at :363); l2cap/ng_l2cap_cmds.c:224-226
L2CAP LE CoC (dynamic LE channels, PSM-based) Not supported: connect() with LE addr type and l2cap_cid == 0 returns EINVAL (// Not supported yet) socket/ng_btsocket_l2cap.c:2183-2192
Socket API struct sockaddr_l2cap gained l2cap_cid and l2cap_bdaddr_type (with a compat struct); connect()/bind() to {bdaddr, type=LE_*, cid=ATT_CID or SMP_CID} gives a SEQPACKET socket on the fixed channel; SO_L2CAP_ENCRYPTED makes the connect wait for encryption; encryption-change events update the socket; LE socket is torn down when the HCI connection drops (b32073c458, 2016) include/ng_btsocket.h:225-247; socket/ng_btsocket_l2cap.c:228-231,465-483,690-720,2366-2372,2533-2537
Raw HCI socket Root sockets bypass the security filter and may send any opcode incl. all of OGF LE; per-socket packet/event filter; commands only (ACL data cannot be injected: ng_hci_raw_rcvdata drops anything but NG_HCI_CMD_PKT) but outbound and inbound ACL are mirrored to raw for sniffing socket/ng_btsocket_hci_raw.c:676-724,878,937; hci/ng_hci_main.c:1025-1102; hci/ng_hci_evnt.c:319, hci/ng_hci_main.c:719
Classic SSP plumbing IO_Capability_Request (0x31), Simple_Pairing_Complete (0x36) events and the IO-cap / user-confirmation reply OCFs (0x2b–0x2d, 0x34) defined; kernel passes the events through untouched to raw (commit 4ae0fa8a2f, 2025-01-27, PR 265066, FreeBSD Foundation). hcsecd does not use them — it filters only PIN_CODE_REQ, LINK_KEY_REQ, LINK_KEY_NOTIFICATION include/ng_hci.h:865-898,2089-2094; hci/ng_hci_evnt.c:123-124; usr.sbin/bluetooth/hcsecd/hcsecd.c:123-126
hccontrol LE 20 le_* subcommands: le_enable, scan/advertise params & data, whitelist, le_connect -a addr -t public|random, le_read_buffer_size, channel map, remote features, le_rand; generic AD-data builder adv_data.c (2c6feab253, 2026-03) usr.sbin/bluetooth/hccontrol/le.c:1240-1365, adv_data.c
Controllers ng_ubt + Intel (iwmbtfw, AX200/AX210/AX411/BE200 as of d82bcf5681 2026-03) and Realtek (rtlbtfw, 8852CE 2e93669827 2026-03) firmware loaders; all dual-mode sys/netgraph/bluetooth/drivers/ubt/, usr.sbin/bluetooth/{iwmbtfw,rtlbtfw}

Commit trail (all git log via gh api repos/freebsd/freebsd-src/commits?path=…&sha=releng/15.1): bcff2d9100 2015-04-18 hccontrol LE commands; fbc48c2bfb 2015-04-07 kernel initial LE; 3a601a2381 2015-10-27 SMP channel; 483ed39512 2015-11-07 encryption error handling; b32073c458 2016-06-07 LE socket teardown; 4aa92fe2f3 2017-04-27 AD cache to userland; 2020-04..07 hselasky/takawata hccontrol le_scan, le_connect, whitelist, advertising; 4ae0fa8a2f 2025-01-27 SSP events; 2c6feab253 2026-03-18 generic AdvData. Nothing LE-related landed on main between 2025-06 and 2026-09 beyond that (checked sys/netgraph/bluetooth and usr.sbin/bluetooth paths).

1.2 What is missing (precisely)

Kernel (nextbsd-kernel, all small):

Userland (base has none of this):

All of that is what takawata/FreeBSD-BLE implements: le_enable (scan, connect ATT, dump attributes, ~23 KB C), lepair (SMP legacy pairing, prints EDIV/Rand/LTK, ~12 KB), lesecd (hcsecd-shaped LTK responder daemon, ~12 KB + yacc/lex config), lehid (GATT client with HOGP mouse via uinput, battery, HRP, MIDI… ~100 KB of small service drivers). README: "Kernel support was already committed to main trunk." Last upstream push 2023-05-01; rhaberkorn fork (improvements branch, 2025-01) adds HOGP heuristics, mouse events via uevent, random addresses, and hid-pair.sh. License: none in either repo (GitHub reports no license; all rights reserved by default). The FreeBSD forum thread "The current state of BT-LE?" (2024-11 → 2025-02) shows it being used against an MX Master 3S with ATT timeouts on that device — i.e. a prototype, not a product.

1.3 Classic state (for completeness)

Complete for HID/A2DP-transport/RFCOMM/SDP: ng_l2cap, ng_btsocket_{l2cap,rfcomm,sco,hci_raw}, sdpd, bthidd (kbd + mouse → uinput/vkbd), btpand, rfcomm_pppd, hcsecd. Pairing is legacy PIN only in hcsecd; a device that insists on SSP/Secure Connections Only will not pair until a daemon answers IO_Capability_Request and User_Confirmation_Request over the raw socket — which the kernel now permits. No A2DP/AVRCP/HFP codec or audio path in base (area A).


2. Permissively licensed LE host stacks — comparison

Evaluated for: running in NextBSD userland, over what transport, coexisting with kernel Classic on the same controller.

Stack License Language / size Classic OS abstraction needed Transport it wants Fit on netgraph Verdict
Kernel + thin userland host (FreeBSD-BLE shape) Kernel BSD-2; userland ours (or relicensed FreeBSD-BLE) C; ~30–40 KB for ATT/GATT/SMP core + HOGP Coexists (kernel L2CAP stays) None — plain sockets + raw HCI AF_BLUETOOTH L2CAP sockets on CID 4/6 + raw HCI for GAP Native Recommended LE engine
Apache NimBLE host Apache-2.0 C; nimble/host/src 46 files, ~1.2 MB source; Bluetooth 5.4, LE-only ("Host runs on any board and architecture") No BR/EDR NPL (porting/npl/linux: pthread mutex/sem/eventq/callout/task, ~20 KB) — trivially compiles on FreeBSD Raw H4 stream: HCI cmd/evt and ACL data. Linux port uses AF_BLUETOOTH HCI_CHANNEL_USER/RAW (nimble/transport/socket/src/ble_hci_socket.c:54-62,672-747) Bad by default: netgraph raw socket cannot inject ACL (ng_hci_raw_rcvdata rejects non-CMD). Needs a custom ng_socket on ng_hci's acl hook, which is a single hook (ng_hci_main.c:205-206) — displacing ng_l2cap and all Classic. Fix would be a new tee node routing LE conn-handles to userland (see B7). Spike only
Zephyr Bluetooth host Apache-2.0 C, large; LE + partial Classic (BR/EDR is experimental) Partial Deeply tied to Zephyr kernel (k_work, net_buf, k_fifo, Kconfig, devicetree). No standalone port; native_sim runs Zephyr-the-RTOS on POSIX, not the host as a library H4/H5/USB via Zephyr drivers Would need the Zephyr kernel emulation layer — far more OS glue than NimBLE, for no gain No
BTstack (BlueKitchen) BSD-3 + clause 4 "solely for personal benefit and not for any commercial purpose"; commercial licence by quote C; dual-mode, mature, qualified Yes (A2DP, HFP, HID, plus LE GATT/SMP) Own run loop; POSIX port exists Has a FreeBSD netgraph port (port/freebsd-netgraph/, active 2025-26): creates an ng_socket on ng_hci's acl and raw hooks — README: "The OS Bluetooth functionality will be interrupted" Proves the full-host-on-netgraph pattern and its cost Licence excludes it from an OS base; useful as a reference for the ng_socket transport
Google Bumble Apache-2.0 Python; LE + Classic incl. A2DP, HFP, HID, SDP, RFCOMM Yes asyncio; HCI over USB (pyusb), UART, VHCI Raw H4 (owns the controller) Same acl-hook problem; Python in a system daemon path is a non-starter Excellent test/dev tool (virtual peers, protocol fuzzing), not an engine
Android Fluoride / GD Apache-2.0 C++/Rust, very large (AOSP packages/modules/Bluetooth), Android-only build, binder Yes Enormous Raw H4 / vendor HAL No No
NetBSD netbt BSD-2 C kernel; Classic only. LE: sys/netbt/hci_le.h (368 lines of defines, commit ace5c075a2 2024-03-13 "Bluetooth low energy - A beginning at least… I have not as yet completed") — no LE code in hci_event.c Yes n/a n/a Behind FreeBSD; nothing to take No
OpenBSD Removed Bluetooth from the tree in 2014 No
Direct-BT (jausoft) MIT C++; LE-centric, over Linux HCI + L2CAP sockets + SMP interception Little Linux-socket coupled; FreeBSD forum thread (2025-09 → 2026-01) discussed porting using FreeBSD-BLE as reference; no port exists Kernel L2CAP sockets (same shape as ours!) Conceptually compatible but C++/GPL-ish history (MIT now), heavy No, but its design validates the thin-host-over-kernel-L2CAP approach
BlueZ GPL-2 / LGPL Linux kernel BT subsystem, D-Bus Out (agreed) No

Coexistence rule, from the code: anything that needs to send LE ACL data must go through ng_l2cap, because ng_hci has exactly one acl hook and the raw hook is command-only. ng_l2cap already provides exactly the two LE fixed channels a GATT/SMP host needs, as sockets, with per-socket encryption state. So the thin host is not a compromise — it is the design the kernel was built for, and it coexists with Classic by construction: the same raw HCI socket, with different event filters (SO_HCI_RAW_FILTER), serves inquiry + SSP for Classic and scan + LTK for LE, and one daemon owns both.


3. Market reality — what users bring

Device class Radio Evidence
Logitech MX Master 3S (Bluetooth Edition), MX Keys, MX Anywhere 3 BLE only ("Connection Type: Bluetooth Low Energy"; Logitech's own "Check for Bluetooth Low Energy Support" article; Logi Bolt otherwise) support.logi.com spec page 31605553413783; hub.sync.logitech.com "Check for Bluetooth Low Energy Support"
Microsoft Surface Keyboard / Designer line BLE 4.0/4.1 (nRF51822) microsoft.com Surface Keyboard page; teardown notes
Keychron K-series Bluetooth 5.1 (BLE; nRF-class SoC) keychron.com K2 spec
Apple Magic Keyboard / Magic Mouse 2 / Magic Trackpad 2 Classic BR/EDR HID (pairs via inquiry; Linux hid-magicmouse/hid-apple over BlueZ HIDP; the recurring Linux fix is UserspaceHID=false) Linux Mint forum t=471463 / t=387452; kernel hid-magicmouse
Headsets, speakers, AirPods Classic A2DP/HFP/AVRCP; LE Audio (LC3/Auracast) is shipping on some Android-first earbuds but "Apple does not support LE Audio as of early 2026", and every LE Audio headset still carries Classic for fallback avantree LE Audio list; bluetooth.com LE Audio; alphonsolabs 2026 checklist
Game controllers (DualShock 4, Xbox pre-2021) Classic (general knowledge, unverified here)
Watches, trackers, sensors, phones' Continuity BLE / GATT plans-site §7

Reading: Classic covers audio and Apple input; LE covers nearly every third-party keyboard/mouse sold since ~2018. A desktop user with an MX Master and a Keychron gets nothing from a Classic-only stack; a user with AirPods and a Magic Keyboard gets everything. Neither half is optional for "a desktop", but Classic first is correct because (a) its kernel path is complete and battle-tested, (b) bthidd exists, (c) it is where pairing UX (PIN/SSP) and the bond store get designed once, and (d) LE's userland is the same daemon plus one more engine module, not a new architecture.


4. Recommendation

4.1 Decision

Classic-first on netgraph, LE second on the same daemon via a thin userland ATT/GATT/SMP host over the kernel's LE fixed-channel sockets. Concretely:

  1. bluetoothd owns the raw HCI socket per adapter (root; privileged filter bypass). It absorbs hcsecd (three events → link-key file) and adds SSP (IO-cap reply "DisplayYesNo"/"KeyboardOnly"/"NoInputNoOutput" per UI availability, numeric comparison, passkey entry) — the kernel now forwards those events. This is engine work, not a wrapper: hcsecd is 500 lines and has no SSP; supervising it buys nothing (unlike wpa_supplicant, which is 300 k lines).
  2. sdpd and bthidd are supervised as launchd jobs, wpa_supplicant-style, not absorbed; bthidd.conf is generated by bluetoothd from the bond store (area H).
  3. LE engine = new module in bluetoothd (or a libbtle it links): GAP over raw HCI (scan/adv-report parsing, LE_Create_Connection — or let the kernel do it via socket connect() with an LE address type, which is simpler), ATT/GATT client over the CID-4 socket, SMP over the CID-6 socket, LTK responder for LE_Long_Term_Key_Request on raw HCI, LE_Start_Encryption for central role. HOGP → uhid/evdev bridge lives in area H but consumes this engine's GATT API.
  4. Do not vendor a full host. File one spike (B7) to price a ng_btle_tee node that would let NimBLE take LE ACL — only if the thin host stalls on GATT breadth (EATT, CoC, LE Audio).
  5. Engine-neutral bond record from day one: {addr, addr_type, kind: BREDR|LE, link_key | {ltk, ediv, rand, irk, csrk, key_size, authenticated, sc}, name, class/appearance, hid: bool} in SCPreferences (WLAN precedent; keychain later).
4.2 Engine interface bluetoothd should code against

Modelled on ul/src/WLAN/supplicant.h (opaque engine object, fd for the event loop, pump-events returning "state changed", caller-owned caches, no callbacks across threads). One header, two implementations sharing the adapter/raw-HCI plumbing:

/* engine.h — what bluetoothd's policy layer sees. Transport-agnostic. */
enum bt_addr_type { BT_ADDR_BREDR, BT_ADDR_LE_PUBLIC, BT_ADDR_LE_RANDOM };
struct bt_addr    { uint8_t b[6]; enum bt_addr_type type; };      /* identity, not connection */

struct bt_found   { struct bt_addr addr; char name[249]; int rssi;
                    uint32_t cod;            /* Classic class-of-device */
                    uint16_t appearance;     /* LE GAP appearance */
                    uint8_t  ad[240]; uint8_t ad_len;            /* raw AD/EIR */
                    bool     bredr, le; };   /* which engines saw it */

enum bt_pair_io   { BT_IO_NONE, BT_IO_DISPLAY_YESNO, BT_IO_KEYBOARD, BT_IO_DISPLAY_ONLY };
enum bt_pair_ask  { BT_ASK_PIN, BT_ASK_PASSKEY, BT_ASK_CONFIRM, BT_ASK_DISPLAY_PASSKEY };
struct bt_pair_prompt { enum bt_pair_ask kind; uint32_t passkey; };   /* surfaced to CLI/UI */

struct bt_bond    { struct bt_addr addr; enum { BOND_BREDR, BOND_LE } kind;
                    uint8_t link_key[16];                              /* Classic */
                    uint8_t ltk[16], irk[16], csrk[16]; uint16_t ediv; uint64_t rand;
                    uint8_t key_size; bool authenticated, secure_conn;  /* LE */ };

struct bt_engine_ops {
    const char *name;                                 /* "classic" | "le" */
    int  (*attach)(struct bt_adapter *, struct bt_engine **);
    void (*detach)(struct bt_engine *);
    int  (*event_fd)(const struct bt_engine *);       /* raw HCI / L2CAP fds for kqueue */
    bool (*pump)(struct bt_engine *);                 /* drain events; true if state changed */
    int  (*discover)(struct bt_engine *, bool on);    /* inquiry+name / LE scan */
    int  (*found_count)(const struct bt_engine *);
    bool (*found_get)(const struct bt_engine *, int i, struct bt_found *);
    int  (*pair)(struct bt_engine *, const struct bt_addr *, enum bt_pair_io);
    int  (*pair_reply)(struct bt_engine *, const struct bt_addr *, const char *pin_or_passkey, bool yes);
    int  (*connect)(struct bt_engine *, const struct bt_addr *);
    int  (*disconnect)(struct bt_engine *, const struct bt_addr *);
    int  (*bond_export)(struct bt_engine *, const struct bt_addr *, struct bt_bond *);
    int  (*bond_import)(struct bt_engine *, const struct bt_bond *);   /* at boot, from SCPreferences */
    int  (*profiles)(struct bt_engine *, const struct bt_addr *, uint16_t *uuids, int max); /* SDP / GATT primary services */
};
/* Events the policy layer consumes (queue drained by pump()): */
enum bt_ev { BT_EV_FOUND, BT_EV_PAIR_PROMPT, BT_EV_PAIRED, BT_EV_PAIR_FAILED,
             BT_EV_CONNECTED, BT_EV_DISCONNECTED, BT_EV_BOND_UPDATED };

Rules that keep the LE engine a drop-in: device identity is bt_addr with type everywhere (the CLI shows AA:BB:..:FF/le-random); the bond store is keyed on it; bt_found carries raw AD/EIR so the UI can grow icons/appearance without an interface change; pairing prompts are one shape for PIN, SSP numeric comparison and LE passkey; the dynamic-store publication State:/Bluetooth/Device/<addr> uses the same dictionary for both. The Classic engine ships first with profiles() = SDP; the LE engine adds gatt_* calls in a sub-interface (struct bt_le_ops obtained via bt_engine_le(engine)) that area H's HOGP bridge uses — the policy layer never sees GATT.

4.3 How LE slots in later without rework

5. Open questions

  1. Licence of FreeBSD-BLE. Neither takawata/FreeBSD-BLE nor rhaberkorn/FreeBSD-BLE has a licence file. Ask Takanori Watanabe (FreeBSD committer takawata@) for BSD-2 — the kernel half is already BSD in base, and Hans Petter Selasky contributed PRs to the repo. Otherwise clean-room from the spec; the ATT/GATT/SMP core is ~2–3 kLOC.
  2. Does PR 265066 include an hcsecd SSP patch that never landed? bugs.freebsd.org is behind an Anubis wall for automated fetch — unverified. If a userland SSP patch exists it shortcuts B4.
  3. LE buffer accounting on Intel AX2xx. Needs hccontrol le_read_buffer_size on real hardware; if the LE pool is separate and smaller than the ACL pool, B5's fix is mandatory before HOGP notifications at 125 Hz.
  4. Which hardware to bless. Intel AX200/AX210 (needs iwmbtfw at boot — NextBSD has no devd; how is the firmware loader triggered? area K), Realtek 8852, and a CSR 8510 dongle as the reference; Apple Silicon/Broadcom is out.
  5. Address privacy. LE peripherals using RPAs need IRK resolution in the daemon and LE_Set_Random_Address for our own privacy; MVP uses identity addresses only (rhaberkorn -r shows random works at the socket level).
  6. Does the policy layer expose GATT to apps (CoreBluetooth-shaped API for Gershwin) in this epic? Recommend no — E16 ships HID/audio; a CBCentralManager face is a later epic on the same engine.

6. Risks

7. Sources

bluetoothd & the bluetooth CLI (D)

Deep-dive notes, lightly edited. Draft ids map to issues in the index.

Scoping notes for nextbsd/nextbsd#473 (E16 Bluetooth), seed nextbsd/nextbsd#412. Read-only research; no issues filed. Written 2026-09-20 against local clones of the NextBSD repos and FreeBSD releng/15.1.

Agreed and not relitigated: /usr/sbin/bluetoothd, job org.nextbsd.bluetoothd, Mach service org.nextbsd.bluetooth, CLI /usr/sbin/bluetooth; C + CoreFoundation below the Mach line; MIG; pure-C MIG client CLI; SCPreferences persistence; SCDynamicStore publishing; supervise stock daemons rather than modify them.


0. TL;DR

  1. wland is a 4.1 kLOC, seven-file daemon (ul/src/WLAN/, 4119 lines total) with one CF translation unit, one Mach-service thread, one flat MIG .defs, one plist, one build block, and a hand-linked CLI. Copying its file layout one-for-one gives bluetoothd its skeleton for free (§1).
  2. Replace hcsecd; do not supervise it. Evidence: hcsecd handles exactly three HCI events and logs everything else as an error (hcsecd.c:124-127, :190-193); it only stores a new link key for a device that is already declared in /etc/bluetooth/hcsecd.conf (hcsecd.c:392-396 calls get_key(.., exact_match=1)), so a device paired at runtime never gets its key saved unless something rewrites the config and SIGHUPs it first; it writes keys to disk only on SIGHUP or exit (hcsecd.c:202, :423); it answers PIN requests from a static file and has no way to ask a user; and it has no Secure Simple Pairing at all. The whole daemon is 448 lines and its reply code (~120 lines) is trivially reimplemented over the same raw HCI socket. Two processes answering Link_Key_Request on the same adapter would race, so "supervise hcsecd" is not even a safe option (§3).
  3. SSP is possible on FreeBSD's kernel but nothing in base exercises it. The raw HCI hook receives a copy of every event before the kernel processes it (ng_hci_main.c:719ng_hci_misc.c:63-78), the per-socket event filter is a 64-bit bitmap (ng_btsocket.h:71-74) so the SSP events 0x31/0x33/0x36 pass, and ng_hci.h already defines IO_Capability_Request, User_Confirmation_Request, Simple_Pairing_Complete, the reply opcodes (0x2b/0x2c/0x2d/0x34) and Write_Simple_Pairing_Mode (0x0056). But hccontrol has no SSP command, the kernel never sets the controller event mask, and User_Passkey_Request (0x34) / IO_Capability_Response (0x32) are not defined. bluetoothd must send Set_Event_Mask and Write_Simple_Pairing_Mode itself. First real exercise needs hardware (§3.3).
  4. bluetoothd owns the adapter lifecycle the way wland owns the VAP. On FreeBSD the netgraph graph (ubt0ng_hcing_l2capbtsock_*) is built by /etc/rc.d/bluetooth (libexec_rc_rc.d_bluetooth, bluetooth_setup_stack), triggered by devd. NextBSD has neither rc.d nor devd (nextbsd/README.md:31). So bt_devenum() returns nothing until bluetoothd builds the graph itself with libnetgraph (§4).
  5. Nothing in the tree can prompt a logged-in user. No LaunchAgents, no per-user daemons, no CFUserNotification; the only user-facing mechanism is notify_post (hostnamed) and the CLI. So pairing confirmation is pull, not push: bluetoothd parks the pairing in State:/Bluetooth/Pairing/<addr> + notify_post, and the CLI (bluetooth pair, bluetooth confirm) is the agent. Gershwin's pane drives the same two verbs via NSTask (§5.3).
  6. Link keys are plaintext in SCPreferences, a second time, but in a separate prefs file from device metadata, 0600, behind one function (bt_keys_save()) so the keychain migration later touches one file and one function. This is WLAN's Q2 decision (airport.h:40-48) with a one-file improvement (§6).
  7. CI without an adapter is a first-class deliverable. wland today is UNPROBED in the wedge check (wedge-check.sh:153,156) and has no boot-test gate at all. bluetoothd ships with markers (BT-RPC-OK, BT-NOSTACK/BT-NOADAPTER, BT-CLI-OK), a bluetooth status probe in wedge-check.sh, and is automatically covered by daemon-state.sh (§8).

1. How wland is actually built (the template)

1.1 Files
File Lines Role Bluetooth sibling
ul/src/WLAN/wland.c 655 main loop, radio table, spawns/reaps wpa_supplicant, adopt-on-timer bluetoothd.c
ul/src/WLAN/vap.c/.h 249/88 PHY enumeration (net.wlan.devices sysctl), VAP clone/adopt adapter.c/.h (ubt detection, netgraph graph, HCI init)
ul/src/WLAN/wpa_ctrl.c/.h, supplicant.c/.h 263/82, 477/106 engine driver + scan cache hci.c/.h (raw HCI socket, event loop, inquiry cache), security.c/.h (keys, pairing)
ul/src/WLAN/airport.c/.h 471/110 the only CF TU: SCDynamicStore publish + SCPreferences known networks btstore.c/.h
ul/src/WLAN/mach_service.c/.h 136/13 bootstrap_check_in + raw mach_msg receive loop, 500 ms timeout copy verbatim, rename
ul/src/WLAN/wlan.defs, wlan_mig_types.h 144/86 MIG IDL, subsystem 30000, all fixed-size bluetooth.defs, bluetooth_mig_types.h, subsystem 31000
ul/src/WLAN/wland_mig.c 260 routine bodies, every one takes wland_lock() bluetoothd_mig.c
ul/src/WLAN/wlan.c 385 the CLI, pure C, bootstrap_look_up then RPCs bluetooth.c
ul/src/WLAN/wland.8, wlan.8 214/237 NextBSD-original man pages bluetoothd.8, bluetooth.8
ul/src/WLAN/Makefile 91 bsd.prog.mk, MIGOUT=, link line copy
ul/overlay/System/Library/LaunchDaemons/org.nextbsd.wland.plist RunAtLoad+KeepAlive, MachServices, stderr to /var/log/wland.stderr org.nextbsd.bluetoothd.plist
ul/build-userland.sh:740-778 host mig over wlan.defs, make ... MIGOUT=, then hand-link the CLI with $CROSS_CC copy block after the WLAN one
1.2 Rules wland encodes that bluetoothd must inherit
1.3 What wland does not have (and bluetoothd should)

2. FreeBSD's Bluetooth userland, per daemon

Sources under a local releng/15.1 fetch.

Daemon What it owns How it is driven Verdict for bluetoothd
hcsecd(8)usr.sbin_bluetooth_hcsecd_hcsecd.c (448 lines) Answers PIN_Code_Request, Link_Key_Request; stores Link_Key_Notification. Config /etc/bluetooth/hcsecd.conf (bdaddr → name/pin/key); keys cached to /var/db/hcsecd.keys (hcsecd.h, parser.y:300,360-390). Raw HCI socket, filter on 3 events (hcsecd.c:120-131). Static PIN from file; no SSP; keys saved only for pre-declared devices (:392-396), and flushed only on SIGHUP/exit (:202, :423). Replace. bluetoothd opens the same raw socket and answers the same events, plus SSP. See §3.
sdpd(8)sdpd_main.c SDP server (what services we advertise: HID host, PAN, …). Control socket /var/run/sdp (libsdp/sdp.h:550), -d no-detach, -u/-g drop privs. Stateless; clients (bthidd, btpand) register records via the control socket. Supervise (spawn, reap, restart) exactly as wpa_supplicant. Needed before any profile daemon registers.
bthidd(8)bthidd.c HID host: connects to paired HID devices listed in /etc/bluetooth/bthidd.conf, caches descriptors in /var/db/bthidd.hids (bthid_config.h:38-39). Options -a -c -d -H -p -t -u (bthidd.c:79). Config-file driven; bthidcontrol writes the config from SDP queries. Supervise; H area owns its insides. bluetoothd's boundary: it generates bthidd's config from PairedDevices and restarts bthidd. Whether bthidd reloads on SIGHUP is unverified.
btpand(8) PAN/NAP (Bluetooth networking). Per-connection process. Out of scope for E16.
/etc/rc.d/bluetoothlibexec_rc_rc.d_bluetooth Builds the netgraph graph and initialises the controller: ngctl mkpeer ubtN: hci hook drv, name, mkpeer hci: l2cap acl hci, connect to btsock_hci_raw/btsock_l2c_raw/btsock_l2c, then hccontrol reset (×3), read_bd_addr, read_local_supported_features, read_buffer_size, write_scan_enable, write_class_of_device, write_authentication_enable, write_encryption_mode, write_node_role_switch, change_local_name, initialize (bluetooth_setup_stack). KEYWORD: nostart — devd starts it per device. rc.conf + devd. Absorb into bluetoothd (it is the VAP-clone analogue).

The devd hook that starts rc.d/bluetooth was not found in sbin/devd/devd.conf on releng/15.1 (grep for ubt, rc.d, quietstart returned nothing) — location unverified; irrelevant on NextBSD, which has no devd at all (nextbsd/README.md:31: device events are the in-kernel IORegistry + libIOKit).

ng_ubt.c does not create the hci/l2cap nodes itself (no mkpeer/NG_HCI_NODE_TYPE use; ng_ubt.c:2056 only MODULE_DEPENDs on ng_hci). Without the rc script, a plugged-in dongle is a bare ubt0: netgraph node and bt_devenum() (lib_libbluetooth_hci.c:691-720, ioctl SIOC_HCI_RAW_NODE_LIST_NAMES) lists nothing.

Whether NextBSD's base image actually ships libbluetooth, libsdp, libnetgraph, hccontrol, sdpd, bthidd (FreeBSD's MK_BLUETOOTH/MK_NETGRAPH default to yes; no WITHOUT_ knob was found in nextbsd/build.sh or ul/build-userland.sh) is unverifiednextbsd-freebsd-compat's src.conf is not in the scratch clones. Acceptance for D1 includes checking it.


3. Process model: replace hcsecd, supervise sdpd and bthidd, own the graph

                          Gershwin (ObjC, no Mach)
                Bluetooth.prefPane ── NSTask ──▶ /usr/sbin/bluetooth
 ═══════════════════════════════ Mach line ══════════════════════════════
                /usr/sbin/bluetooth  ── MIG org.nextbsd.bluetooth ──▶
                                                        │
                        /usr/sbin/bluetoothd  (C + CF, job org.nextbsd.bluetoothd)
                        ┌─────────────┬──────────────┬──────────────┬─────────────┐
                        │ adapter.c   │ hci.c        │ security.c   │ btstore.c   │
                        │ ubt detect  │ raw HCI sock │ Link_Key_Req │ SCPrefs     │
                        │ libnetgraph │ inquiry      │ PIN / SSP    │ SCDynStore  │
                        │ graph+init  │ conn/disconn │ pairing FSM  │ notify_post │
                        └──────┬──────┴──────┬───────┴──────┬───────┴──────┬──────┘
                               │             │              │              │
      /dev/ioregistry (libIOKit watch)   PF_BLUETOOTH/SOCK_RAW/HCI    /Local/Library/Preferences/
      or 2 s poll of bt_devenum()        (sockaddr_hci "ubt0hci")     SystemConfiguration/
                               │             │                        org.nextbsd.bluetoothd.plist
   ngctl-equivalent: ubt0: ─drv─ ubt0hci ─acl─ ubt0l2cap ─┬─ btsock_l2c   org.nextbsd.bluetoothd.keys.plist (0600)
                                     └─raw─ btsock_hci_raw └─ btsock_l2c_raw
                               │
             children (spawned by bluetoothd, stock, unpatched):
               /usr/sbin/sdpd -d                (after first adapter is up)
               /usr/sbin/bthidd -d -c <generated>  (H area: when a paired HID device exists)
3.1 Why hcsecd is replaced, with the evidence
Requirement hcsecd today Citation
Store the key of a device paired for the first time at runtime Only if the bdaddr is already in hcsecd.conf; otherwise "Could not find entry" and the key is dropped hcsecd.c:381-396 (get_key(&ep->bdaddr, 1), exact match)
Survive a crash without losing keys Keys are written only in sighup() and at exit hcsecd.c:197-202, :418-426
Ask the user for a PIN Static pin per entry or a default entry; negative reply otherwise hcsecd.c:210-235
SSP: IO capability, numeric comparison, passkey Not handled; any other event is LOG_ERR "Received unexpected HCI event" hcsecd.c:124-127, :190-193
Coexist with another security manager Two raw sockets both receive Link_Key_Request (kernel duplicates every event to every matching socket, ng_btsocket_hci_raw.c:500-517); both would reply design fact
Size of what would be reimplemented send_pin_code_reply + send_link_key_reply + the event switch ≈ 120 lines hcsecd.c:154-379

Conclusion: bluetoothd's security.c is hcsecd rewritten with a key store behind SCPreferences and a pairing state machine in front. /etc/bluetooth/hcsecd.conf is never read (NextBSD never shipped one; no migration needed). hcsecd must not be started; if the base image ships it, bluetoothd's acceptance includes "no hcsecd process exists".

3.2 Why sdpd and bthidd are supervised, not absorbed

Same argument as wpa_supplicant (wland.c:41-55, userland#46 "wpa_supplicant stays stock"): they are base FreeBSD, they speak sockets not Mach, and a CVE is a base rebuild. They are spawned by bluetoothd rather than launchd because (a) sdpd needs the L2CAP socket layer connected, which does not exist until bluetoothd has built the graph, and (b) there is no launchd ordering to express that. Spawn with -d (no detach; sdpd_main.c:75 "c:dg:hu:", bthidd.c:79 "a:c:dH:hp:t:u") so waitpid reaps them and the main loop notices death (wland.c:584-601 pattern).

3.3 SSP on FreeBSD's kernel: what exists and what is missing
Piece Status Citation
Raw HCI socket sees every event before kernel processing Yes — ng_hci_mtap() dups the mbuf to the raw hook at the top of ng_hci_drv_rcvdata ng_hci_main.c:706-719, ng_hci_misc.c:63-78
Kernel tolerates SSP events it does not model Yes — 0x31 and 0x36 are in the "no post-processing" list; unknown events return EINVAL after the raw copy was already delivered ng_hci_evnt.c:107-126, :204-208
Per-socket event filter wide enough for 0x31..0x3b Yes — bit_decl(event_mask, NG_HCI_EVENT_MASK_SIZE*8) = 64 bits ng_btsocket.h:71-74; filter test ng_btsocket_hci_raw.c:704-716
Event/reply definitions NG_HCI_EVENT_IO_CAPABILITY_REQUEST 0x31, NG_HCI_EVENT_USER_CONFIRMATION_REQUEST 0x33 (with numeric_value), NG_HCI_EVENT_SIMPLE_PAIRING_COMPLETE 0x36; replies 0x2b, 0x2c, 0x2d, 0x34; NG_HCI_OCF_WRITE_SIMPLE_PAIRING 0x0056; NG_HCI_OCF_SET_EVENT_MASK 0x0001 and the NG_HCI_EVMSK_* SSP bits ng_hci.h:2089-2103, :865-907, :1423-1428, :1015-1022, :275-283
Missing definitions IO_Capability_Response 0x32, User_Passkey_Request 0x34, User_Passkey_Notification 0x3b, Write_Inquiry_Mode (0x0045), Inquiry_Result_with_RSSI 0x22, Extended_Inquiry_Result 0x2f — grep of ng_hci.h finds none bluetoothd carries its own hci_compat.h defines; a K-area ticket should upstream them
Controller event mask The kernel never sends Set_Event_Mask (no hit in ng_hci_*.c, ng_ubt.c); the spec's default mask (NG_HCI_EVENT_MASK_DEFAULT 0x1fffffffffff, ng_hci.h:1021) excludes bits 48+ i.e. all SSP events bluetoothd sends Set_Event_Mask with default ∪ SSP bits during adapter init
Root privilege Non-root sockets are command-filtered (ng_btsocket_hci_raw.c:684-690, :841-880); bluetoothd is root, so any opcode is allowed
Any base tool that has ever done SSP None — hccontrol has no write_simple_pairing_mode, set_event_mask, or user_confirmation command (grep of hccontrol/*.c) Needs hardware. The FreeBSD Handbook's pairing path is legacy PIN.

Legacy PIN pairing (PIN_Code_RequestPIN_Code_Request_Reply) is what hcsecd proves works; bluetoothd keeps it for old devices.


4. Adapter lifecycle (adapter.c)

Mirrors vap.c/adopt_phys():

  1. Presence of the stack. socket(PF_BLUETOOTH, SOCK_RAW, BLUETOOTH_PROTO_HCI) fails with EPROTONOSUPPORT when ng_btsocket is not compiled in. That is the VAP_NO_NET80211 sentinel (vap.h:35-43): log BT-NOSTACK: ng_btsocket absent — idling, stop polling, keep serving RPC. This is exactly the state the CI kernel is in until the K ticket lands (nextbsd-kernel/config/NEXTBSD has no netgraph/ng_* line; grep).
  2. Detect a controller. Two options, decide in D2: - Poll every 2 s: list netgraph nodes of type ubt via libnetgraph (NgMkSockNode, NgSendMsg(NGM_GENERIC_COOKIE, NGM_LISTNODES); netgraph.h:48-50). Simple, matches PHY_RESCAN_INTERVAL_SEC (wland.c:99). Recommended for v0. - Subscribe to /dev/ioregistry with criteria name="ubt" or classname="ubt" (ioregistry.h:54-63; nodes carry device_get_name/devclass_get_name, iokit_registry.c:223-227) via IOServiceAddMatchingNotification, as diskarbitrationd does (da_iokit_subscribe.c:4-20, diskarbitrationd.c:107-114). Event-driven; adds -lIOKit. Do it in a follow-up once D2's poll is proven; DA's file is the template.
  3. Build the graph (rc.d bluetooth_setup_stack in C): mkpeer ubtN: hci hook drv, name ubtN:hook ubtNhci, mkpeer ubtNhci: l2cap acl hci, name … ubtNl2cap, connect ubtNhci: btsock_hci_raw: raw ubtNraw, connect ubtNl2cap: btsock_l2c_raw: ctl ubtNctl, connect ubtNl2cap: btsock_l2c: l2c ubtNl2c. Idempotent: on restart, adopt an existing ubtNhci (the vap_find_existing() rule, vap.h:79-86).
  4. Initialise the controller over the raw socket bound to ubtNhci (bt_devopen(), lib_libbluetooth_hci.c:54-76; bt_devreq() for command/complete pairs): Reset (retry ×3 like rc.d), Read_BD_ADDR, Read_Local_Supported_Features, Read_Buffer_Size, Write_Class_Of_Device (desktop 0x000104), Change_Local_Name (ComputerName from Setup:/System if present, else hostname), Write_Scan_Enable (page scan on, inquiry scan off unless discoverable), Set_Event_Mask (default ∪ SSP), Write_Simple_Pairing_Mode 1 if Read_Local_Supported_Features reports NG_HCI_LMP_SECURE_SIMPLE_PAIRING (ng_hci.h:118), then NGM_HCI_NODE_INIT (what hccontrol initialize sends).
  5. Publish State:/Bluetooth/Adapter/ubtNhci (§7) and log BT-ADAPTER-OK: ubt0hci 00:11:22:33:44:55.
  6. Power. FreeBSD has no rfkill. bluetooth power off = disconnect all, Write_Scan_Enable 0, NGM_HCI_NODE_SHUTDOWN? No: keep the graph; just scan-disable and mark Powered:false in the store, refuse scan/connect RPCs with bt_status_powered_off_e. power on reverses it. Persist the choice in prefs (Adapters/<bdaddr>/Powered).
  7. Removal. Unplugging destroys ubtN: and its peers; the raw socket read returns an error. Mark the slot inactive, remove the store key, reap. (wland deliberately does not handle removal, wland.c:433-435; bluetoothd should, since USB dongles are the common case.)

5. MIG interface (bluetooth.defs, subsystem 31000)

Flat-only, forced by the OOL limitation (§1.2). Sketch:

subsystem bluetooth 31000;
serverprefix _;
import "bluetooth_mig_types.h";

type bt_adapter = array[32] of char ctype: BTAdapterName;  /* ng node name, NG_NODESIZ */
type bt_addr    = array[18] of char ctype: BTAddr;         /* "aa:bb:cc:dd:ee:ff" */
type bt_name    = array[249] of char ctype: BTDeviceName;  /* BT names are <= 248 octets */
type bt_pin = array[17] of char ctype: BTPin;          /* <= 16 ASCII digits */
type bt_status  = MACH_MSG_TYPE_INTEGER_32 ctype: bt_status_t;
type bt_devstate= MACH_MSG_TYPE_INTEGER_32 ctype: bt_devstate_t;
type bt_pairmth = MACH_MSG_TYPE_INTEGER_32 ctype: bt_pair_method_t;
type bt_pairph  = MACH_MSG_TYPE_INTEGER_32 ctype: bt_pair_phase_t;

/* adapters */
routine bt_adapter_count(server: mach_port_t; out count: int);
routine bt_adapter_info(server: mach_port_t; index: int;
        out name: bt_adapter; out addr: bt_addr; out powered: int;
        out discoverable: int; out status: bt_status);
routine bt_adapter_power(server: mach_port_t; name: bt_adapter; on: int;
        out status: bt_status);
routine bt_adapter_discoverable(server: mach_port_t; name: bt_adapter; on: int;
        seconds: int; out status: bt_status);

/* scan: returns when the inquiry is REQUESTED (wlan_scan rule) */
routine bt_scan(server: mach_port_t; name: bt_adapter; seconds: int;
        out status: bt_status);
routine bt_scan_count(server: mach_port_t; name: bt_adapter; out count: int;
        out scanning: int; out status: bt_status);
routine bt_scan_entry(server: mach_port_t; name: bt_adapter; index: int;
        out addr: bt_addr; out devname: bt_name; out cod: int; out rssi: int;
        out paired: int; out status: bt_status);

/* pairing */
routine bt_pair(server: mach_port_t; addr: bt_addr; pin: bt_pin;
        out status: bt_status);
routine bt_pair_status(server: mach_port_t; addr: bt_addr;
        out phase: bt_pairph; out method: bt_pairmth; out passkey: int;
        out status: bt_status);
routine bt_pair_confirm(server: mach_port_t; addr: bt_addr; accept: int;
        passkey: int; out status: bt_status);
routine bt_unpair(server: mach_port_t; addr: bt_addr; out status: bt_status);

/* connections + paired list */
routine bt_connect(server: mach_port_t; addr: bt_addr; out status: bt_status);
routine bt_disconnect(server: mach_port_t; addr: bt_addr; out status: bt_status);
routine bt_paired_count(server: mach_port_t; out count: int);
routine bt_paired_entry(server: mach_port_t; index: int; out addr: bt_addr;
        out devname: bt_name; out cod: int; out state: bt_devstate;
        out autoconnect: int; out status: bt_status);
routine bt_device_status(server: mach_port_t; addr: bt_addr;
        out devname: bt_name; out cod: int; out state: bt_devstate;
        out rssi: int; out status: bt_status);

Enums in bluetooth_mig_types.h (shape of wlan_mig_types.h:47-85):

Notes: - No callbacks. A reverse Mach port to a "UI agent" would need a Mach-speaking client; there is none above the Mach line (§5.3). Pairing is a state the client polls (bt_pair_status), with notify_post as the wake-up. - The 8 KiB receive buffer (mach_service.c:42) is ample; the biggest reply is bt_scan_entry (18+249+scalars). - bt_scan takes seconds because a Classic inquiry length is chosen up front (Inquiry cmd, ng_hci.h:697); the CLI passes 8 (≈10 s) and polls bt_scan_count's scanning flag instead of sleep(3) (the wlan.c:197-202 wart, called out in wlan.8 BUGS).

5.1 Scan (hci.c)

Inquiry (LAP GIAC, length, unlimited responses) on the raw socket; collect Inquiry_Result (0x02) events (ng_hci.h:1871), then Inquiry_Complete. RSSI needs Write_Inquiry_Mode 1 and event 0x22, neither defined in ng_hci.h (§3.3) — carry local defines; fall back to RSSI 0 if the controller rejects the mode. Names: Remote_Name_Request per result (ng_hci.h:838; bt_devremote_name()), sequentially, cached by bdaddr so repeat scans are fast. Cache shape = struct scan_entry (supplicant.h:33-39) with bdaddr, name, cod, rssi, seen.

5.2 Pair / unpair (security.c)

State machine per bdaddr:

idle ─bt_pair─▶ connecting (Create_Connection) ─▶ Authentication_Requested
   ├─ PIN_Code_Request      → pin given? reply : phase=awaiting_user(method=pin)
   ├─ IO_Capability_Request → reply DisplayYesNo (0x01), no OOB, MITM required
   ├─ User_Confirmation_Request(numeric) → phase=awaiting_user(method=numeric_comparison, passkey)
   │        bt_pair_confirm(accept) → User_Confirmation_Request_Reply / _Negative_Reply
   ├─ User_Passkey_Request  → awaiting_user(method=passkey_entry); bt_pair_confirm(passkey)
   ├─ Link_Key_Notification → bt_keys_save(addr, key, type)   (BEFORE Simple_Pairing_Complete)
   ├─ Simple_Pairing_Complete / Authentication_Complete(status) → done|failed
   └─ timeout (no user answer in N s) → negative reply, failed(pairing_timeout)

Link_Key_Request at any time → reply from the key store or negative. bt_unpair = delete from store, Delete_Stored_Link_Key on the controller (ng_hci.h:1090, harmless if none), disconnect if connected, remove State:/Bluetooth/Device/<addr>, regenerate bthidd config (H).

Policy defaults (v0): pairing is only ever initiated by us; an incoming IO_Capability_Request for a device nobody asked to pair is answered with IO_Capability_Request_Negative_Reply (0x34). Just-works (peer NoInputNoOutput) still surfaces a User_Confirmation_Request; auto-accept it only for a pairing we initiated. The HCI/LMP authentication timeout for an unanswered confirmation is controller-defined (spec default ≈ 30 s) — unverified on real hardware; bluetoothd's own awaiting_user timeout is 60 s and it sends the negative reply itself.

5.3 Prompting the user: what the tree offers

Searched for any user-prompt path: no LaunchAgents directory in ul/overlay or nextbsd-overlays; no CFUserNotification; the only "per-user agent" mention is DA's future-iteration note (diskarbitrationd.c:26); the only notify use is hostnamed's notify_post("com.apple.system.hostname") (hostnamed.c:20), and its vendored code skipped notify_register_dispatch because it killed the daemon during bring-up (set-hostname.c:706-716) — so registration-side notify is not battle-tested; posting is.

Therefore: bluetoothd never pushes to a UI. It (1) sets State:/Bluetooth/Pairing/<addr> = {Method, Passkey, Expires}, (2) notify_post("org.nextbsd.bluetooth.pairing"), and (3) waits for bt_pair_confirm. The agent is whoever called bt_pair: the CLI prints Confirm that 123456 is shown on <name> [y/N]; Gershwin's pane calls bluetooth pair --no-prompt <addr> (exit 3 = user answer needed, prints method=numeric_comparison passkey=123456), shows its own dialog, then bluetooth confirm <addr> yes|no. That two-verb split keeps every NSTask invocation short-lived, which is the BSDBackend.m pattern the WLAN plan relies on (wlan.c:32-37).


6. Persistence (btstore.c)

Two SCPreferences application IDs, so that the secret file can move to a keychain later without touching the metadata file:

org.nextbsd.bluetoothd/Local/Library/Preferences/SystemConfiguration/org.nextbsd.bluetoothd.plist (0644, like wland's):

PairedDevices = {
  "aa:bb:cc:dd:ee:ff" = {
    Name        = "Magic Keyboard";      # last Remote_Name
    Class       = 9536;                  # Class_of_Device, int
    Adapter     = "00:11:22:33:44:55";   # local bdaddr that holds the bond
    Paired      = 1789000000;            # unix time
    LastSeen    = 1789000000;
    AutoConnect = true;                  # reconnect at boot / when it pages us
    Profiles    = ( "HID" );             # from SDP at pair time; H/A areas append
    Trusted     = true;                  # accept incoming ACL from it without asking
  };
};
Adapters = {
  "00:11:22:33:44:55" = { Powered = true; Discoverable = false; Name = "..." };
};

org.nextbsd.bluetoothd.keys...SystemConfiguration/org.nextbsd.bluetoothd.keys.plist:

LinkKeys = {
  "aa:bb:cc:dd:ee:ff" = { Key = <16 bytes, CFData>; Type = 4; };   # Type = HCI key_type
};

Rules: - Written immediately on Link_Key_Notification (the hcsecd failure mode, §3.1), via SCPreferencesSetValue + SCPreferencesCommitChanges (atomic temp+rename, airport.c:276-293). - umask(077) in bluetoothd before the first commit and chmod(0600) after every commit of the keys file (a rename produces a new inode, so a one-time chmod does not survive; whether SCPreferences honours the process umask is unverified — the chmod-after-commit covers both cases). - The plaintext decision is WLAN's Q2 repeated (airport.h:40-48; keychain plan §4.G "interim", Q1 "first consumer"). E16 becomes the second consumer arguing for the System keychain; bluetoothd's bt_keys_save/find/forget are the three functions that change, and only the .keys file migrates. - Link keys are 16 bytes; store as CFData, never as a printable string in a log line (hcsecd logs "exists/doesn't exist" only, hcsecd.c:249-253 — keep that habit). - BLE LTKs (B area) get a sibling dictionary LongTermKeys in the same keys file when they exist; the schema reserves the name.


7. SCDynamicStore keys, notify names, launchd plist

Keys (all invented, like AirPort; airport.h:21-25):

Key Dict When
State:/Bluetooth/Adapter/<node> e.g. ubt0hci {Address, Name, Powered, Discoverable, Connectable, Scanning} adapter up/down, power, scan start/stop
State:/Bluetooth/Device/<addr> {Name, Class, Adapter, Paired, Connected, Profiles, RSSI?} pair/unpair/connect/disconnect; one key per paired or connected device only (scan results never)
State:/Bluetooth/Pairing/<addr> {Method, Passkey, Expires} created at awaiting_user, removed on done/failed

Every dict stays far below CONFIG_DATA_MAX (config_types.h:74). No Setup:/Bluetooth in v0 — SCPreferences under the app IDs above is the durable side; a Setup: mirror can come with the pref-pane work.

Notify names (posted with notify_post, -lnotify, as hostnamed/Makefile:82):

org.nextbsd.bluetoothd.plist (ul/overlay/System/Library/LaunchDaemons/): Label org.nextbsd.bluetoothd, ProgramArguments /usr/sbin/bluetoothd, RunAtLoad true, KeepAlive true, MachServices {org.nextbsd.bluetooth = true}, StandardOutPath/StandardErrorPath /var/log/bluetoothd.stderr. "Start only when a controller exists" is not expressible: every plist in the overlay uses plain KeepAlive true (no PathState/OtherJobEnabled variants exist in the tree; grep), there is no device-triggered launch (no devd; IOKit notifications are a userland API, not a launch trigger), and wland's precedent is "always run, idle at 2 s polls, stop polling when the subsystem is provably absent" (wland.c:526-536). Do the same. daemon-state.sh then classifies it as resident and asserts a live PID automatically (its RunAtLoad/KeepAlive rule, daemon-state.sh:29-36).


8. Boot/CI tests with no adapter present

There is no Bluetooth in qemu (#412), and today no netgraph in the kernel either. Both states must pass CI:

Marker Emitted by Meaning Gate
BT-RPC-OK mach_service.c after bootstrap_check_in service registered (WLAN-RPC-OK twin, mach_service.c:76) boot-test.sh expects it (FAIL if absent)
BT-NOSTACK adapter.c socket(PF_BLUETOOTH…)EPROTONOSUPPORT; polling stopped informational until K lands, then must not appear
BT-NOADAPTER adapter.c stack present, no ubt node; polling expected in qemu after K lands
BT-CLI-OK run.sh timeout 15 bluetooth status exits 0 and prints no Bluetooth adapters (or stack: absent) FAIL otherwise
wedge probe wedge-check.sh probe "org.nextbsd.bluetooth" "bluetooth status" bluetooth status; remove from UNPROBED existing WEDGE-CHECK-FAIL logic
daemon state daemon-state.sh plist-driven, automatic existing DAEMON-STATE gate

Pattern to copy: the ipconfig block in run.sh:1512-1520 and its expect in boot-test.sh:986-997. Marker naming follows the <AREA>-<THING>-OK/FAIL convention (run.sh:1105-1298).

While here: wedge-check.sh:153,156 should get a wlan list probe too — the CLI exists and the reason recorded ("no client tool") is stale. Out of area; note for whoever owns E-network.


9. The bluetooth CLI

/usr/sbin/bluetooth, pure C, bootstrap_look_up("org.nextbsd.bluetooth"), links -llaunch -lsystem_kernel only (build-userland.sh:763-776 shape). Verbs and output mirror wlan(8) (wlan.c:4-8, wlan.8 COMMANDS/EXAMPLES):

bluetooth status [adapter]           adapter: ubt0hci  address: 00:11:..  powered: on  discoverable: off
                                     paired devices: 2 (1 connected)   [or: no Bluetooth adapters / stack: absent]
bluetooth list                       ADDRESS            NAME                 CLASS      STATE       AUTO
                                     aa:bb:cc:dd:ee:ff  Magic Keyboard       keyboard   connected   yes
bluetooth scan [-t seconds] [adapter]
                                     ADDRESS            NAME                 CLASS      SIGNAL PAIRED
                                     aa:bb:..           Magic Keyboard       keyboard   ***  -52  yes
bluetooth pair <addr> [pin] [--no-prompt]
                                     interactive: prints passkey prompt, reads y/N or the passkey, calls confirm
                                     --no-prompt: exit 0 done | 3 user answer needed (prints method=… passkey=…) | 1 failed
bluetooth confirm <addr> yes|no|<passkey>
bluetooth unpair <addr>
bluetooth connect <addr>             returns when REQUESTED; watch with `bluetooth list`
bluetooth disconnect <addr>
bluetooth power on|off [adapter]
bluetooth discoverable on|off [-t seconds] [adapter]

Exit codes: 0 ok, 1 error/RPC failure, 2 usage, 3 user answer needed (--no-prompt only). cannot reach org.nextbsd.bluetooth — is bluetoothd running? on lookup failure (wlan.c:360-366). Signal bars as wlan.c:110-122. Class-of-device rendered from the major/minor class bits (keyboard, mouse, audio, phone, computer, …). Man page bluetooth.8 with FILES (/var/log/bluetoothd.stderr, both prefs paths, "link keys are stored in plaintext"), DIAGNOSTICS (no Bluetooth adapters → check ngctl list for ubt; stack: absent → kernel lacks ng_btsocket), and BUGS.


10. Gershwin preference pane: the boundary only

Sibling of nextbsd#396. The contract the pane builds on, nothing more:

Everything ObjC — a BluetoothBackend protocol, the pane, an IOBluetooth-shaped API — lives above the Mach line in Gershwin and is not scoped here.


11. Open questions

  1. Does NextBSD's base image ship libbluetooth/libnetgraph/sdpd/bthidd/hccontrol? Default FreeBSD MK_BLUETOOTH/MK_NETGRAPH = yes and no WITHOUT_ knob was found, but nextbsd-freebsd-compat was not inspected. D1 checks. If absent, bluetoothd vendors libbluetooth's ~700-line hci.c (BSD-2) rather than adding a build knob.
  2. Does bthidd reload its config on SIGHUP, or must bluetoothd restart it after each pairing? Not checked (bthidd.c signal handling). H area's call; D7 exposes a generic "restart child" hook either way.
  3. Poll vs IORegistry watch for ubt arrival (§4.2). Poll for v0; watch as a follow-up.
  4. What does Read_Local_Supported_Features report on the dongles we will test with? Determines whether SSP or legacy PIN is the default path in practice. Needs hardware.
  5. Incoming pairing requests (a phone pairs to the desktop): rejected in v0 (§5.2). Needs a product decision before A2DP-sink/phone use cases.
  6. Second plaintext-secret consumer: does E16 trigger the keychain plan (its Q1), or is the .keys file split (§6) the accepted debt for now?
  7. Set_Event_Mask bits for LE (B area) are set from the same init sequence; agree who owns adapter_init() extensions.

12. Risks

13. Cross-area asks (not D tickets)

HID: keyboards, mice, trackpads (H)

Deep-dive notes, lightly edited. Draft ids map to issues in the index.

Scope: how a paired Bluetooth keyboard/mouse/trackpad turns into keystrokes and pointer motion on a NextBSD desktop (Gershwin on XLibre), what the kernel and image need, how bluetoothd drives FreeBSD's bthidd, pre-login behaviour, Apple/Logitech specifics, HOGP scope, and what CI can test without a radio.

Sources are FreeBSD releng/15.1 (usr.sbin/bluetooth/bthidd, sys/dev/evdev, sys/dev/vkbd, sys/dev/kbdmux, sys/dev/vt), the NextBSD clones (nextbsd, nextbsd-kernel, nextbsd-userland), gershwin-desktop/gershwin-on-nextbsd, gershwin-desktop/gershwin-system, FreeBSD ports (x11-servers/xlibre-server, x11/xlibre-minimal) and FreeBSDDesktop/libudev-devd. Paths below are relative to those trees; file:line numbers are from the copies fetched on 2026-09-20. Anything not verified against source is marked (unverified).

TL;DR

  1. bthidd's sinks are fixed: keyboards always go to vkbd(4) (bthidd/session.c:108-114), mice always go to /dev/consolectlsysmouse (bthidd/hid.c:541-566), and with -u additionally to /dev/uinput per-device evdev nodes (bthidd/btuinput.c:339-413). The uinput writes are gated by kern.evdev.rcpt_mask bits (btuinput.c:474-484, 511-519).
  2. Kernel: EVDEV_SUPPORT, evdev, uinput, kbdmux are in GENERIC on amd64 and arm64 (sys/amd64/conf/GENERIC:199,381-383, sys/arm64/conf/std.dev:49,72-74) and NEXTBSD includes GENERIC (config/NEXTBSD:1). vkbd is a module only (sys/conf/files:3501 optional vkbd; not in GENERIC) and NextBSD ships no .ko tree (ci/assemble-image.sh:168) → device vkbd must be baked or no BT keyboard can ever attach. Ticket H1.
  3. The crux is X hotplug, and it is already known: XLibre on FreeBSD gets input devices through libudev-devd, whose monitor connects to devd's /var/run/devd.pipe (libudev-devd/udev-monitor.c:50,188-213). NextBSD has no devd (nextbsd/README.md:31, PORTING.md:487-489), so X sees only what exists when it starts. Gershwin's own LoginWindow.sh:91-93 says it: "X enumerates input exactly once at startup and XLibre has no hotplug backend on FreeBSD". A uinput node created when a BT mouse connects is therefore invisible to a running X.
  4. There is a hotplug-free route for keyboards and it is the one XLibre's package already recommends: kern.evdev.rcpt_mask bit 1 (EVDEV_RCPT_KBDMUX) makes kbdmux's evdev node "System keyboard multiplexer" (kbdmux.c:455-473, gated kbdmux.c:707) carry every keyboard, including every vkbd bthidd creates (auto-attached by kbd.c:197-231). That node exists from boot, so libinput opens it at X start and BT keyboards work whenever they connect, pre-login included. XLibre's pkg-message.in recommends kern.evdev.rcpt_mask=6 for exactly this class of problem.
  5. Mice have a similar but worse hotplug-free route (consolectlsysmouse → "System mouse" evdev node, vt_consolectl.c:55-59, vt_sysmouse.c:97-118,122-125, needs bit 0) with duplicate-event risk on psm(4) laptops; the clean per-device uinput route needs the input-hotplug interface that belongs to the other epic. Ticket H4 (decision), H5 (sequenced).
  6. bthidd cannot reload: SIGHUP is a shutdown (bthidd.c:138-140,249-253, loop at bthidd.c:167). Adding a paired device means restarting bthidd, which drops every connected BT HID session. Devices not in bthidd.conf are rejected on connect (server.c:244-250). Tickets H2 (bluetoothd drives it), H3 (make it reload).
  7. Apple Magic Mouse 1 (05ac:030d) is special-cased in bthidd (hid.c:73,434-512); Magic Mouse 2 and Magic Trackpad 1/2 are not (no digitizer/multitouch handling anywhere in bthidd). Logitech's current line is BLE-only → HOGP, blocked on the engine decision. Tickets H6, H7.
  8. CI: the whole sink path (vkbd → kbdmux → evdev, uinput → /dev/input/eventN → X) is testable in qemu with synthetic devices, no radio needed; only the L2CAP side is not. Ticket H8.

1. How bthidd delivers input (releng/15.1)

Build and options
Keyboard path (always vkbd; uinput optional)
Mouse path (always consolectl; uinput optional)
What bthidd recognises
rcpt_mask semantics (kernel)

2. Kernel options: what NEXTBSD has today

Needed by bthidd Where declared In GENERIC (amd64 / arm64)? In config/NEXTBSD?
options EVDEV_SUPPORT, device evdev sys/conf/files:1692-1695 yes / yes (GENERIC:381-382, std.dev:72-73) inherited via include GENERIC (NEXTBSD:1) (inferred, not seen in config -x output)
device uinput files:1696 optional evdev uinput yes / yes (GENERIC:383, std.dev:74) inherited (same caveat)
device kbdmux files:2332 yes / yes (GENERIC:199, std.dev:49) inherited
device vkbd files:3501 optional vkbd no / no (module sys/modules/vkbd) no → H1
device hkbd,ukbd,usbhid,hidbus yes yes; touch spike confirmed via config -x
ng_btsocket (+ ng_bluetooth, ng_hci, ng_l2cap, ng_ubt) files:4299-4316, NOTES:813-817 no no (area K)
/dev/consolectl vt_consolectl.c:76-77 (vt) / syscons.c:668 (sc) yes (vt) yes

The comments in config/NEXTBSD:153-159 and the touch spike describe the exact failure class: "a driver that is a module on stock FreeBSD is absent on NextBSD unless it is compiled into the kernel". vkbd is that for BT keyboards: session_run() returns -1 at session.c:110-115 and the keyboard never attaches, with only a syslog line to show for it.

Recommendation (H1): add device vkbd next to the HID block in config/NEXTBSD with the same style of comment, and have the ticket's acceptance criteria include config -x /boot/kernel/kernel | grep -E 'vkbd|uinput|kbdmux|EVDEV_SUPPORT' on the built kernel so the inherited options are verified rather than inferred. ng_btsocket etc. are area K's ticket; H1 depends on it only for end-to-end testing, not for the build.

3. How X gets input on NextBSD, and the hotplug crux

What ships
How libudev-devd finds devices
Would a newly connected BT keyboard appear to X without a restart?

Three routes, per class:

Route Node seen by X Exists at X start? Needs hotplug? Requires rcpt_mask Device identity in libinput
K-A vkbd → kbdmux → "System keyboard multiplexer" evdev one aggregate keyboard yes (kbdmux attaches at boot) no bit1 set, bit3 clear (XLibre's recommended 6) lost (all keyboards are one device)
K-B bthidd -u uinput "Bluetooth Keyboard" per device, named, UNIQ=bdaddr only if the keyboard connected before X yes bit3 set (default) full
K-C xf86-input-keyboard (kbd driver) on the console tty console keyboard yes no n/a (not evdev) lost; legacy driver, not what XLibre picks on an evdev kernel (patch-config_udev.c)
M-A consolectl → sysmouse → "System mouse" evdev one aggregate pointer yes no bit0 set lost; psm(4) also feeds sysmouse → duplicates unless bit2 is cleared, which may silence hms (unverified, §1)
M-B bthidd -u uinput "Bluetooth Mouse" per device only if connected before X yes bit2 set (default) full; also the only route that can ever carry trackpad MT
M-C xf86-input-mouse on /dev/sysmouse legacy yes no n/a XLibre gives sysmouse no driver on evdev kernels (patch-config_udev.c) unless an InputClass forces it

So: keyboards: yes, without restart, via K-A — and that is the configuration XLibre's own pkg-message.in tells FreeBSD users to set (kern.evdev.rcpt_mask=6). Mice/trackpads: no unless we either accept M-A's aggregate/duplicate trade-offs or the input-hotplug interface exists. Because bthidd always writes to both the aggregate sink and (with -u) the uinput sink, and the mask gates only the uinput side (btuinput.c:474-484,511-519) while also gating the aggregate's evdev emission (kbdmux.c:707, vt_sysmouse.c:125), there is no configuration in which bthidd double-delivers to X: bluetoothd can always start bthidd with -u and let the mask decide.

Interface note for the input-management epic (out of scope here)

Whatever that epic builds, BT HID needs exactly one of:

  1. a process speaking devd's notice protocol on /var/run/devd.pipe (line-oriented !system=DEVFS subsystem=CDEV type=CREATE cdev=input/event5, as udev-monitor.c:154-175 parses; exact wire format (unverified) — check sys/kern/subr_bus.c/subr_devctl.c devctl_notify formatting), fed from /dev/devctl or from hwregd/IORegistry notifications; or
  2. a NextBSD libudev-devd whose monitor subscribes to IOKit-style notifications instead of devd; or
  3. the desktop-wide rcpt_mask aggregate policy (K-A/M-A) accepted as the design, with per-device identity given up.

Option 1 is a small shim and would also fix nextbsd-kernel#175's userland half and the LoginWindow.sh waits. This document does not choose; H4 asks the question.

4. bthidd config lifecycle and how bluetoothd drives it

Files and tools
Runtime behaviour bluetoothd must model
Proposed bluetoothd HID driver (ticket H2)
  1. On successful pair of a device whose SDP has the HID service (UUID 0x1124): run the equivalent of bthidcontrol -a <bdaddr> Query (either exec it or link the same sdp.c logic), append the stanza to /etc/bluetooth/bthidd.conf (or a bluetoothd-owned file passed with -c), and make sure the device is not in bthidd.hids so the first connect is host-initiated.
  2. Restart the supervised bthidd (-c -H -p -u, -a = adapter bdaddr for multi-adapter safety). Because that drops other sessions, batch config changes and log the restart; H3 removes this cost.
  3. Unpair: remove the stanza, Forget the hids entry, restart bthidd; also remove the hcsecd key (area D).
  4. Persist the stanza data (bdaddr, name, ids, descriptor, flags) in the same SCPreferences record area D keeps for paired devices, and regenerate bthidd.conf from it at boot, so the conf file is derived state, not a second source of truth.
  5. Publish State:/Bluetooth/Device/<addr> with Connected/Profile=HID by watching bthidd's syslog lines or, better, by polling sessions — bthidd has no control socket. (gap) The only observable connection state is /var/db/bthidd.hids writes and syslog. H3 should add a status/dump facility if we are patching bthidd anyway.
  6. Launchd: org.nextbsd.bluetoothd RunAtLoad, no dependency on the login window; bthidd/hcsecd are children. The Gershwin loginwindow job has no ordering keys (gershwin-on-nextbsd/overlays/.../io.github.gershwin-desktop.loginwindow.plist: RunAtLoad+KeepAlive only), so pre-login availability is purely "bluetoothd is up early enough and does not need the session".

5. Login window

6. Apple, Logitech, and LE-only devices

7. CI without a radio

qemu has no Bluetooth controller and FreeBSD has no virtual HCI, so L2CAP/SDP/pairing cannot run in CI (same as WLAN). Everything from the sink down can:

8. Recommendation

  1. Ship (no external dependency): H1 bake vkbd; H2 bluetoothd HID driver around a supervised bthidd -u; ship with keyboards on route K-A, which is XLibre's recommended FreeBSD configuration anyway, so that a paired keyboard works pre-login and after any reconnect without X hotplug. Make the rcpt_mask value a deliberate, documented decision (H4) since it is desktop-wide.
  2. Harden: H3 teach bthidd to reload on SIGHUP and rescan all devices per tick (carry as a NextBSD patch, upstream); H8 synthetic input-path CI.
  3. Sequence: H5 BT mice/trackpads on per-device uinput nodes, blocked on the input-hotplug interface from the input-management epic; interim evaluation of route M-A.
  4. Parked: H6 Apple Magic Mouse 2 / Magic Trackpad vendor reports; H7 HOGP scoping, blocked on area B.

9. Open questions

10. Risks

Bluetooth audio (A)

Deep-dive notes, lightly edited. Draft ids map to issues in the index.

Research for nextbsd/nextbsd#473 (epic) and #412 (seed). Read-only; nothing filed. Local sources: freebsd-src (releng/15.1 sparse checkout, 88e7371d), nextbsd-kernel, nextbsd-userland, nextbsd, plus raw fetches from freebsd/freebsd-src@releng/15.1, freebsd/freebsd-ports@main, freebsd/virtual_oss@main, hselasky/virtual_oss@main (copies in e16/src/).

TL;DR

  1. A2DP source (PC -> headset/speaker) is not a porting project any more. FreeBSD 15 imported virtual_oss into base (usr.sbin/virtual_oss, lib/virtual_oss, lib/libcuse, private contrib/libsamplerate), and the ports tree marks audio/virtual_oss IGNORE_FreeBSD_15= included in base system. NextBSD's base is a full buildworld of releng/15.1 with MK_BLUETOOTH at its default (yes), so /usr/sbin/virtual_oss, /usr/sbin/virtual_bt_speaker, sdpd, libbluetooth, libsdp, mixer, sndctl already ship. What does not ship: the Bluetooth backend voss_bt.so (base only builds null + oss), the cuse kernel module (not in GENERIC, and NextBSD has no .ko tree), and a working kldload("cuse.ko") call inside virtual_oss (it errors out on NextBSD even with cuse baked). Three small tickets (A1-A3) close that; A2DP playback then works with zero new protocol code.
  2. Codec: SBC only in practice. The backend does SBC encode+decode natively; AAC is a compile-time option that needs libav/ffmpeg from ports (HAVE_LIBAV). No aptX/LDAC anywhere. No AVRCP anywhere.
  3. A2DP sink (phone -> NextBSD) exists: virtual_bt_speaker(8) (also base), registers an AudioSink SDP record via sdpd and pipes decoded SBC to an OSS device. Cheap to supervise (A5).
  4. HFP/HSP is the genuine gap, and it is mostly kernel-shaped. The kernel does carry SCO over USB isochronous (ng_ubt) and a PF_BLUETOOTH/BLUETOOTH_PROTO_SCO socket (ng_btsocket_sco), but: ng_hci only issues legacy Add_SCO_Connection (HV1/HV2/HV3, CVSD) — no Setup_Synchronous_Connection, no eSCO, no mSBC/wideband; the HCI event switch has no Synchronous_Connection_Complete case; ng_ubt picks the largest isoc alt-setting rather than the one matching the SCO bandwidth; and stock rc.d/bluetooth never connects the sco hook, so SCO sockets are dead until someone runs ngctl connect ubt0hci: btsock_sco: sco ubt0sco. Nobody in FreeBSD base or ports implements the HFP AT-command state machine or an SCO<->OSS audio bridge; sdpd has no Handsfree/Headset profile records. NetBSD's btsco(4) is the nearest BSD-licensed prior art. Estimate: XL, and it starts with a kernel patch series. Park it behind a decision (A6).
  5. Orchestration is a virtual_oss_cmd one-liner once virtual_oss owns /dev/dsp. virtual_oss can re-target its playback/record backend at runtime via its control device (virtual_oss_cmd /dev/vdsp.ctl -P /dev/bluetooth/<bdaddr>), so the clean interface with the future audio epic is: audio epic runs the persistent virtual_oss mixer that owns /dev/dsp; bluetoothd only publishes "device is an A2DP sink, reachable as /dev/bluetooth/<addr>" and asks for the switch. If the audio epic has not landed, bluetoothd's interim fallback is to spawn its own virtual_oss ... -P /dev/bluetooth/<addr> -d dsp.bt0 child, exactly the way wland forks wpa_supplicant (ul/src/WLAN/wland.c:262-275).
  6. CI without a radio can prove everything except the air interface: cuse baked (/dev/cuse exists), virtual_oss starts under launchd against the emulated HDA (snd_hda is baked, config/NEXTBSD:240), sndctl shows a from_user device, the backend .so resolves, and bluetoothd's audio state machine runs against a fake /dev/bluetooth/ open failure path. A2DP/SCO over real links needs a dongle.

1. virtual_oss: what it is, where it lives, what it needs

1.1 In base for FreeBSD 15 (verified)
1.2 How the backend is found and loaded (the two NextBSD traps)

usr.sbin/virtual_oss/virtual_oss/main.c (releng/15.1):

1650  snprintf(lpath, sizeof(lpath), "%s/lib/virtual_oss/voss_%s.so", prefix, name);
1671  } else if (strstr(voss_dsp_rx_device, "/dev/bluetooth/") == voss_dsp_rx_device) {
1672          voss_rx_backend = voss_load_backend("/usr/local", "bt", "rec");
1686  } else if (strstr(voss_dsp_tx_device, "/dev/bluetooth/") == voss_dsp_tx_device) {
1687          voss_tx_backend = voss_load_backend("/usr/local", "bt", "play");
...
2554  if (kldload("cuse.ko") < 0 && errno != EEXIST)
2555          err(1, "Failed to load cuse kernel module");
2557  if (cuse_init() != 0)
2558          errx(EX_USAGE, "Could not connect to cuse module");
1.3 The Bluetooth backend itself (lib/virtual_oss/bt/bt.c = upstream backend_bt/backend_bt.c)

2. HFP/HSP — quantified

What exists in sys/netgraph/bluetooth (releng/15.1):

Layer Status Evidence
USB isoc SCO transport present drivers/ubt/ng_ubt.c:281-282, 336-378 (4 isoc xfers, UBT_ISOC_NFRAMES 32), ubt_isoc_read_one_frame :1290-1366 reassembles NG_HCI_SCO_DATA_PKT; sysctl net.bluetooth.usb_isoc_enable=1 :154-157
Isoc alt-setting choice wrong for SCO ng_ubt.c:738-780 picks the alt setting with the largest wMaxPacketSize; BT-USB spec alt settings encode SCO bandwidth (alt 1 = 1 link CVSD ... alt 5), so the controller and host disagree on frame sizes on many dongles
HCI SCO connection legacy only hci/ng_hci_ulpi.c:322-450 builds NG_HCI_OCF_ADD_SCO_CON with HV1/HV2/HV3 pkt types; no Setup_Synchronous_Connection (eSCO), so no mSBC / wideband speech, no transparent air mode
HCI sync-conn events missing hci/ng_hci_evnt.c:107-200 event switch has CON_COMPL (0x03) but no SYNC_CON_COMPL (0x2C) / SYNC_CON_CHANGED (0x2D); the mask bits exist in ng_hci.h:270-271 but nothing consumes them, so a headset that initiates eSCO produces an unhandled event
SCO socket present socket/ng_btsocket_sco.c (SOL_SCO, SO_SCO_MTU, SO_SCO_CONNINFO :1444-1483; listen/accept/connect), node btsock_sco (ng_btsocket.h:363); buffers NG_HCI_BUFF_SCO_SET(unit->buffer, 1, 10, 1) (ng_hci_main.c:147) until read_buffer_size
Stack wiring not wired libexec/rc/rc.d/bluetooth:119-127 connects raw, ctl, l2c hooks only; nothing connects ubt0hci: sco to btsock_sco:; ng_btsocket_sco only learns routes from NGM_HCI_NODE_UP on a connected hook (ng_btsocket_sco.c:821-856). bluetoothd's stack bring-up (K/D tracks) must add ngctl connect ${dev}hci: btsock_sco: sco ${dev}sco
RFCOMM (HFP signalling) present socket/ng_btsocket_rfcomm.c; usr.bin/bluetooth/rfcomm_sppd
SDP records for HS/HF/AG absent usr.sbin/bluetooth/sdpd/profile.c:51-76 lists audio_sink, audio_source, dun, ftrn, irmc, lan, opush, sp, nap, gn, panu only; the UUIDs exist in lib/libsdp/sdp.h:136-159
HFP AG/HF AT state machine absent gh code search: 0 hits for handsfree/HFP in freebsd-src and freebsd-ports (only wireshark's dissector list mentions avrcp)
SCO <-> OSS audio bridge absent nothing consumes BLUETOOTH_PROTO_SCO in base or ports

Prior art: NetBSD sys/dev/bluetooth/btsco.c (Itronix/Iain Hibbert, BSD-2-Clause, "Bluetooth SCO Audio device" attaching as a kernel audio device on top of a SCO PCB) — the only BSD-licensed SCO audio implementation; it depends on NetBSD's own bt stack API, so it is a design reference, not a port.

Cost: kernel patch series (eSCO command + events + alt-setting selection in ng_ubt, all under nextbsd-kernel/patches/ since the nodes are baked) + a userland HFP daemon (RFCOMM AT parser for the AG role: AT+BRSF, +CIND, +CMER, AT+VGS/VGM, AT+BCC, ring/answer/hangup, codec negotiation) + an SCO<->cuse bridge (8 kHz mono CVSD, 48-byte SCO frames every 3.75 ms) that virtual_oss can consume as /dev/dsp.hfp. Reasonably an XL on its own, and CVSD-only narrowband unless eSCO lands. Recommendation: park behind a decision whether narrowband headset mic is worth it in E16 at all (A6, "Needs answer"). Everything users notice first — music/video output to a headset — does not need it.

3. AVRCP

Nothing anywhere in FreeBSD implements AVRCP (AVCTP over L2CAP PSM 0x0017, AVC passthrough commands). Consequence today with virtual_oss: play/pause/next keys on a headset are ignored and the headset cannot report/absorb volume (no absolute-volume). Implementing the target role subset (accept the headset's AVCTP connection on PSM 23, answer UNIT INFO/SUBUNIT INFO, decode PASSTHROUGH PLAY/PAUSE/STOP/ FORWARD/BACKWARD/VOLUME_UP/DOWN) is a userland-only L2CAP listener, no kernel work; it needs a local SDP AV_REMOTE_CONTROL_TARGET (0x110C) record, which sdpd cannot register today (profile.c list above), so either extend sdpd with a profile or let bluetoothd own the SDP server for the records it needs. The key events have no consumer on NextBSD yet (there is no media-key path from a daemon to Gershwin/X); the natural delivery is a notify(3) name (org.nextbsd.bluetooth.avrcp.<key>) plus SCDynamicStore state, and absolute-volume writes routed to the audio epic's volume API. Size L, Sequence after A4 (A7).

4. How NextBSD does sound today

5. Recommended orchestration (bluetoothd side)

Model A — preferred, once the audio epic exists: 1. Audio epic runs org.nextbsd.virtual_oss (persistent, -f /dev/dspN -d dsp -t dsp.ctl, owns /dev/dsp). 2. bluetoothd, on ACL connect + SDP shows AUDIO_SINK: publishes State:/Bluetooth/Device/<addr> {Profiles:[A2DP-sink], AudioDevice:"/dev/bluetooth/<addr>"} and sends the switch request (Mach call into the audio daemon, or interim: exec virtual_oss_cmd /dev/dsp.ctl -P /dev/bluetooth/<addr> and, if the device advertises AUDIO_SOURCE, -R). On disconnect: -P /dev/dspN (the value it recorded before switching). Policy (auto-switch on connect? user preference in SCPreferences) is the audio epic's; bluetoothd only offers.

Model B — interim, no audio epic yet (what A4 ships): - bluetoothd forks virtual_oss -S -C 2 -c 2 -r 48000 -b 16 -s 8ms -R /dev/null -P /dev/bluetooth/<addr> -d dsp.bt0 -t dsp.bt0.ctl as a supervised child (same fork/exec/waitpid shape as wland -> wpa_supplicant, ul/src/WLAN/wland.c:262-275, :356, :586), waits for /dev/dsp.bt0, publishes the node in SCDynamicStore, and kills it on disconnect/unpair. Users (or Gershwin) pick /dev/dsp.bt0 explicitly; the "make it default" step is a mixer/sndctl-shaped hole the audio epic fills. Not a launchd job per device: the child's lifetime is the ACL's, bluetoothd already owns that state, and launchd has no service ordering to express "after the link is up" (wland plist comments). - Sink role (A5): virtual_bt_speaker -B -d /dev/dsp supervised the same way, started when the adapter is discoverable; needs bluetoothd's sdpd (or bluetoothd's own SDP server) running first.

6. Needs from the future audio-device epic (interface only)

7. Open questions

  1. Does the FreeBSD:15 binary package repo actually publish audio/virtual_oss_bluetooth? It is a DISTVERSION=${OSVERSION} src-based port (IGNORE without /usr/src); official builders have src, but I did not confirm the package exists on pkg.FreeBSD.org. If not, A2 falls back to building voss_bt.so in the NextBSD base build (compat repo) or nextbsd-userland.
  2. Where do NextBSD userland base patches live? nextbsd-freebsd-compat has no patches dir; only nextbsd-kernel/patches/. A3's kernel-side fix avoids the question; the modfind() guard should go upstream to freebsd/virtual_oss.
  3. devfs real-node-vs-clone precedence for -d dsp on 15.1 (see 4). Needs a hardware run.
  4. Is narrowband (CVSD, 8 kHz) headset audio worth an XL? Video calls are the only consumer, and without eSCO/mSBC quality is telephone-grade.
  5. AVRCP volume/keys need a consumer; do we file the Gershwin side under E16 or the audio epic?
  6. net.bluetooth.usb_isoc_enable and alt-setting selection: does any tested dongle work with ng_ubt's largest-alt policy? (Only matters for A6.)

8. Risks