Bluez
BlueZ-over-D-Bus client library for Elixir: BLE scanning (passive and active), active GATT connections, pairing, and bluez-alsa A2DP audio — the pieces an ESPHome-style Bluetooth proxy or any BLE-consuming Elixir application needs on embedded Linux.
Built for Nerves devices (read-only rootfs,
MuonTrap-supervised daemons) but has no
Nerves dependency: it runs on any Linux host where your application may own
the system D-Bus instance.
Status
Early extraction from
universal_proxy, where this
code shipped and was hardware-validated as an ESPHome Bluetooth proxy on
Raspberry Pis (passive/active scanning into Home Assistant, GATT
connect/read/write/notify/pair against real peripherals, BT-headphone
audio). Still being iterated on; not a final API.
Documentation
Start here once you're ready to go beyond the quickstart below:
- Architecture guide — the supervision tree and its restart semantics, the two rebus connections, scan-mode transitions, advert reconstruction, the GATT connection lifecycle, adapter selection, and the benign failure loops.
- Host integration guide — the integration
cookbook: every
Bluez.start_link/1option, the full GATT event contract with a translator example, audio PCM discovery, and the runtime radio-switching pattern. - Nerves system guide — the customized Nerves
system this library requires: buildroot packages, the kernel fragment
(and its silent-
olddefconfiggotcha), radio firmware blobs, and the app-side rootfs overlay.
The core modules carry the reference detail:
Bluez— the supervisor and every start optionBluez.Client— scanning, mode switching, adapter enumerationBluez.Gatt— the GATT request API and event contractBluez.Agent— pairing authorizationBluez.BlueAlsa— A2DP playback PCM discoveryBluez.Mgmt— LE advertising with verbatim AD bytes (kernel MGMT)Bluez.Radio— per-controller ownership for advertisingBluez.AdvertReceiver— verbatim advertising reports off the HCI socket
Features
- One supervisor that brings up
dbus-daemon,bluetoothd -E, and (optionally)bluealsad, with crash-isolation and restart ordering (:rest_for_one) already worked out — an audio fault never restarts scanning, a bus restart rebuilds everything above it. - Passive and active BLE scanning, runtime-switchable. Passive mode
uses BlueZ's
AdvertisementMonitor1(no scan requests — peripherals don't burn battery answering); active mode collects SCAN_RSP data. - Advertisement reconstruction — BlueZ only exposes parsed properties;
they're re-serialized into AD byte structures, emit-gated (first
sighting / payload change / RSSI heartbeat, configurable via
Bluez.Client'srssi_heartbeat_ms:option with default 10 s) and LRU-capped, then fanned out through youron_advertisement:fun. - Handle-keyed GATT client — connect / service discovery / read /
write / notify / pair / unpair / clear-cache, with a documented,
host-agnostic event contract delivered through your
on_gatt_event:fun. Generation-stamped so late replies can't corrupt replaced connections. - Pairing agent that authorizes exactly the pairings your code initiated.
- LE advertising (opt-in) —
Bluez.Mgmtputs verbatim AD bytes on air through the kernel MGMT socket, including layouts BlueZ's D-Bus API refuses (Flags, names, 128-bit UUID lists, ordered 16-bit UUID lists with duplicates), with a settable interval and millisecond-precise durations, on any controller the kernel drives. It sends under aBluez.Radiolease: one owner per controller, and everything it sent goes off air when the lease ends. - Verbatim advert receive (opt-in) —
Bluez.AdvertReceiverreads the controller's legacy and extended advertising reports off an HCI raw socket, AD bytes untouched, for protocols that BlueZ's per-device property merging would destroy. Pure:socket, no NIF or port. - bluez-alsa integration (optional) — enumerates ready-to-open A2DP playback PCMs and signals your app when the set changes.
- No host coupling — configuration flows exclusively through
start_link/1opts (funs, a PubSub, child specs); noApplication.get_env, no callbacks into named host modules.
Installation
def deps do
[
{:bluez, "~> 0.5"}
]
end
A note on the D-Bus layer: exporting objects (which BlueZ's passive
scanning requires) needs a service-side API that isn't in any released
D-Bus client, so this library ships a vendored, namespaced copy of
rebus as Bluez.Rebus.* (MIT, with
the service-side additions proposed upstream in
ausimian/rebus#9 — see
lib/bluez/rebus/VENDORED.md). The namespacing means this can never
collide with a rebus dependency of your own.
Usage
children = [
{Bluez,
client: [on_advertisement: &MyApp.Scanner.on_advertisement/1],
gatt: [on_gatt_event: &MyApp.BLEProxy.gatt_event/2],
desired_adapter: nil, # nil = auto (lowest-index adapter)
exclude_adapters: [], # MACs of radios this stack must never drive
audio: true} # bluealsad + Bluez.BlueAlsa
]
Adverts then arrive at your scanner fun as maps
(%{address:, rss:, address_type:, raw_data:}), and GATT requests are
cast-style with results delivered as events:
Bluez.Gatt.connect(0xAABBCCDDEEFF, [], self())
receive do
{:gatt_connection, addr, {:ok, mtu}} -> Bluez.Gatt.get_services(addr)
{:gatt_connection, _addr, {:error, code}} -> {:error, code}
end
See the host integration guide for the full option surface and event contract.
Runtime requirements
- BlueZ ≥ 5.66, started by this supervisor with
-E(experimental — needed for passive scanning and the GATTMTUproperty). - dbus (this supervisor owns the system bus — don't run it next to a distro dbus/bluetoothd).
- Optional: bluez-alsa v4 for
audio: true. - Optional:
CAP_NET_RAW/CAP_NET_ADMIN(root on Nerves) forBluez.AdvertReceiver/Bluez.Mgmt; Linux ≥ 5.17 forBluez.Mgmt(extended advertising commands, queued by the kernel instead of refused as busy while another is pending). - Writable
/run/dbusand/data/bluetooth; on a read-only rootfs point/var/lib/bluetoothat/data/bluetoothwith an overlay symlink.
On Nerves none of this is in the official systems — see the Nerves system guide for the full customization list (buildroot packages, kernel fragment, firmware blobs, overlay).
License
Apache-2.0 — see the LICENSE file in the repository.