Current section
Files
Jump to
Current section
Files
lib/bluez.ex
defmodule Bluez do
@moduledoc """
Brings up the Linux BlueZ stack — `dbus-daemon`, `bluetoothd`, and
(optionally) bluez-alsa — under one supervisor, with Elixir clients for
BLE scanning (`Bluez.Client`), active GATT connections (`Bluez.Gatt`),
pairing (`Bluez.Agent`), and A2DP audio PCM discovery (`Bluez.BlueAlsa`).
Built for Nerves devices (read-only rootfs, `MuonTrap`-supervised
daemons) but has no Nerves dependency; any Linux host where the calling
application may own the system D-Bus instance works.
Children, started `:rest_for_one`:
1. `dbus-daemon --system` (`MuonTrap.Daemon`) — owns the system bus at
`/run/dbus/system_bus_socket`.
2. `Bluez.BusReady` — a one-line gate that blocks until the bus socket
exists, so `bluetoothd` never races the bus. `:rest_for_one` re-runs
it (and `bluetoothd`) if `dbus-daemon` restarts.
3. `bluetoothd` (`MuonTrap.Daemon`) — claims `org.bluez`, drives the
adapter via the kernel mgmt socket. Run with `-E` (experimental) —
required for `AdvertisementMonitorManager1` passive scanning and the
GATT `MTU` property.
4. `Bluez.Client` — persistent `rebus` client owning the discovery
session; adverts fan out through its `on_advertisement:` fun.
5. `Bluez.Agent` — default NoInputNoOutput pairing agent.
6. `Bluez.Gatt` (+ its `Task.Supervisor`) — active connections + GATT
client; results flow through its `on_gatt_event:` fun.
7. With `audio: true` (the default): `bluealsad` (`MuonTrap.Daemon`,
A2DP source) and `Bluez.BlueAlsa` (org.bluealsa client). Placed
after the scanning/GATT clients so an audio-daemon fault never
restarts the scanning stack — the children that follow *do* restart
with it under `:rest_for_one`, which is intended (same audio path).
8. `extra_children:` — host-supplied child specs, appended last.
## Options
All are optional; pass them to `start_link/1` (usually via a child spec
`{Bluez, opts}`):
* `client:` — keyword opts for `Bluez.Client` (`on_advertisement:`,
`pubsub:`).
* `gatt:` — keyword opts for `Bluez.Gatt` (`on_gatt_event:`,
`on_connections_changed:`).
* `audio:` — boolean (default `true`): start the `bluealsad` daemon +
`Bluez.BlueAlsa` client. Requires bluez-alsa (v4 recommended) on the
device.
* `blue_alsa:` — keyword opts for `Bluez.BlueAlsa` (`pubsub:`).
* `extra_children:` — host child specs appended at the end of the tree.
Under `:rest_for_one` they restart with the audio path, and a fault
there never disturbs the scanning/GATT stack above them. Ordering
within the slot is the caller's contract.
* `desired_adapter:` — MAC string (`"AA:BB:CC:DD:EE:FF"`) of the radio
to drive, or `nil` (auto: lowest-index adapter). Written to
`:persistent_term` before the children start. Hosts that switch
radios at runtime may instead publish the term themselves under
`Bluez.DevicePath.desired_adapter_key/0` BEFORE (re)starting this
supervisor — the opt and the pre-published term coexist (the opt,
when present, wins by writing last).
* `dbus_daemon_path:` — dbus-daemon binary (default
`/usr/bin/dbus-daemon`).
* `bluetoothd_path:` — bluetoothd binary (default
`/usr/libexec/bluetooth/bluetoothd`).
* `bluealsad_paths:` — candidate bluez-alsa daemon binaries, first
existing wins (default `["/usr/bin/bluealsad", "/usr/bin/bluealsa"]`
— the daemon was renamed in bluez-alsa v4).
## Runtime requirements
* BlueZ ≥ 5.66 (`bluetoothd -E`), dbus, and — for `audio: true` —
bluez-alsa v4.
* Writable `/run/dbus` and `/data/bluetooth` (`prepare_runtime/0`
creates both and a machine-id before the daemons launch; on a
read-only rootfs, point `/var/lib/bluetooth` at `/data/bluetooth`
via an overlay symlink so `bluetoothd` can persist adapter state).
* This supervisor OWNS the system bus: don't run it next to a distro
dbus/bluetoothd.
## Public-API `catch :exit` idiom
The synchronous read APIs (`Bluez.Client.adapters_info/0`,
`Bluez.BlueAlsa.pcms/0`, `Bluez.Gatt.connections_free/0`, …) are meant
to be wrapped by hosts in `catch :exit` so callers work while the stack
is down. Be aware what that swallows: it converts BOTH the
process-not-running exit AND a call timeout into the same "subsystem
off" default — a wedged server renders as a disabled subsystem rather
than raising. Accept that tradeoff knowingly, or catch only
`:exit, {:timeout, _}` separately where the distinction matters.
"""
use Supervisor
@default_dbus_daemon "/usr/bin/dbus-daemon"
@default_bluetoothd "/usr/libexec/bluetooth/bluetoothd"
# bluez-alsa renamed the daemon `bluealsa` -> `bluealsad` in v4.0; resolve
# at start, preferring the v4 name.
@default_bluealsad_candidates ["/usr/bin/bluealsad", "/usr/bin/bluealsa"]
@run_dir "/run/dbus"
@socket_path "/run/dbus/system_bus_socket"
@machine_id_path "/run/dbus/machine-id"
# On a read-only rootfs, point /var/lib/bluetooth here via an overlay
# symlink (see the moduledoc).
@bluetooth_state_dir "/data/bluetooth"
@doc "System-bus socket path the rebus clients connect to."
@spec socket_path() :: String.t()
def socket_path, do: @socket_path
def start_link(opts \\ []) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl Supervisor
def init(opts) do
prepare_runtime()
# Publish the desired radio before any child can resolve it (see the
# `desired_adapter:` opt doc; Keyword.fetch so an explicit nil = auto
# is also written, clearing a stale earlier selection).
case Keyword.fetch(opts, :desired_adapter) do
{:ok, mac} -> :persistent_term.put(Bluez.DevicePath.desired_adapter_key(), mac)
:error -> :ok
end
# Explicit restart budget so the documented "benign endless retry
# loop" (Client `{:stop, :no_adapter}` while the controller is absent)
# is benign by configuration, not by cycle timing: an observed ~10 s
# :no_adapter cycle (6/min) and a faster :dbus_connect_failed cycle
# both fit in 10-per-60 s, while a genuinely hot crash loop still
# escalates to the host's supervisor within a minute.
Supervisor.init(children(opts), strategy: :rest_for_one, max_restarts: 10, max_seconds: 60)
end
@doc """
The child list `init/1` supervises, exposed for child-order tests. See
the moduledoc for the supported opts.
"""
@spec children(keyword()) :: [Supervisor.child_spec() | {module(), term()} | module()]
def children(opts) do
[
# Vendored rebus runtime (see lib/bluez/rebus/VENDORED.md): the
# gen_event signal router and the DynamicSupervisor every bus
# connection runs under. First so all clients below can connect, and
# so a fault here rebuilds the whole stack (every connection rides
# on these two).
{Bluez.Rebus.SignalHandler, []},
{DynamicSupervisor, strategy: :one_for_one, name: Bluez.Rebus.ConnectionSupervisor},
# System bus. --nofork so MuonTrap's port owns the process; --nopidfile
# because /var/run may be read-only and we don't read a pidfile anyway.
# The daemons are all MuonTrap.Daemon, so each needs a distinct child
# :id (the default id is the module, which collides).
Supervisor.child_spec(
{MuonTrap.Daemon,
[
Keyword.get(opts, :dbus_daemon_path, @default_dbus_daemon),
["--system", "--nofork", "--nopidfile"],
[name: __MODULE__.Dbus, log_output: :info, log_prefix: "dbus-daemon: "]
]},
id: :dbus_daemon
),
# Gate: bluetoothd must not start before the bus socket exists.
__MODULE__.BusReady,
# The BlueZ daemon. -n keeps it in the foreground (MuonTrap tracks it);
# -E enables experimental interfaces, required for
# org.bluez.AdvertisementMonitorManager1 (passive scanning). stderr
# carries its logs.
Supervisor.child_spec(
{MuonTrap.Daemon,
[
Keyword.get(opts, :bluetoothd_path, @default_bluetoothd),
["-n", "-E"],
[
name: __MODULE__.Bluetoothd,
env: [{"DBUS_SYSTEM_BUS_ADDRESS", "unix:path=#{@socket_path}"}],
stderr_to_stdout: true,
log_output: :info,
log_prefix: "bluetoothd: "
]
]},
id: :bluetoothd
),
# Persistent rebus client: owns the discovery session and turns BlueZ
# device signals into advertisements, fanned out via its
# `on_advertisement:` opt. After bluetoothd in rest_for_one order so
# it (re)connects only once bluetoothd is up.
{__MODULE__.Client, Keyword.get(opts, :client, [])},
# Default org.bluez pairing agent: bluetoothd routes the IO for
# Gatt's Device1.Pair() calls here. Before Gatt in rest_for_one
# order — Gatt depends on it (weakly: its casts no-op when the Agent
# is down), never the other way around.
__MODULE__.Agent,
# Every BlueZ call the GATT client makes runs under this
# Task.Supervisor so its GenServer loop never blocks on D-Bus
# (Device1.Connect alone can take ~25 s).
{Task.Supervisor, name: __MODULE__.Gatt.task_supervisor()},
# The GATT client itself (its own rebus connection, separate from
# Client). Last so a bluetoothd/Client restart also rebuilds it —
# its device objects and connection state die with bluetoothd.
{__MODULE__.Gatt, Keyword.get(opts, :gatt, [])}
] ++
audio_children(opts) ++
Keyword.get(opts, :extra_children, [])
end
# bluez-alsa A2DP-source daemon + the org.bluealsa client, gated on the
# `audio:` opt. No `-i`: bluealsad manages all controllers; role
# separation (which adapter may pair/connect headsets) is the host's
# concern. Placed after the scanning/GATT clients: a crash here never
# restarts the scanning stack (only the children that follow restart
# with it under :rest_for_one). See the moduledoc.
defp audio_children(opts) do
if Keyword.get(opts, :audio, true) do
[
Supervisor.child_spec(
{MuonTrap.Daemon,
[
bluealsad_path(opts),
["-p", "a2dp-source"],
[
name: __MODULE__.BlueAlsad,
env: [{"DBUS_SYSTEM_BUS_ADDRESS", "unix:path=#{@socket_path}"}],
stderr_to_stdout: true,
log_output: :info,
log_prefix: "bluealsad: "
]
]},
id: :bluealsad
),
# org.bluealsa D-Bus client: learns which A2DP-playback PCMs are
# ready to open. Connects to the system bus (dbus-daemon), not to
# bluealsad, so it tolerates bluealsad being down.
{__MODULE__.BlueAlsa, Keyword.get(opts, :blue_alsa, [])}
]
else
[]
end
end
@doc """
Create the writable dirs the daemons need before they launch. Idempotent;
safe to call on every (re)start of this supervisor (`init/1` does).
"""
@spec prepare_runtime() :: :ok
def prepare_runtime do
File.mkdir_p!(@run_dir)
# A previous incarnation's socket file survives its dbus-daemon
# (runtime stop/start by the host) — and a stale file makes BusReady
# wave clients through to ECONNREFUSED before the NEW daemon has bound
# it. Hardware-found on the first enable→disable→enable round-trip.
# Remove it so socket existence again implies a listener.
_ = File.rm(@socket_path)
File.mkdir_p!(@bluetooth_state_dir)
# bluetoothd stores pairing/link keys + the adapter identity here; keep it
# owner-only rather than the default world-readable 0755.
File.chmod!(@bluetooth_state_dir, 0o700)
ensure_machine_id()
:ok
end
# dbus tolerates a generated/ephemeral machine-id, but write a stable one to
# the writable run dir so every component on the bus agrees. 32 lowercase hex
# chars, no dashes — the D-Bus machine-id format.
defp ensure_machine_id do
unless File.exists?(@machine_id_path) do
id = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
File.write!(@machine_id_path, id <> "\n")
end
end
# Resolve the bluez-alsa daemon binary from the candidates: first existing
# wins; when none exists the first candidate is returned so the child spec
# is still well-formed — MuonTrap surfaces the missing-binary error at
# start.
defp bluealsad_path(opts) do
candidates = Keyword.get(opts, :bluealsad_paths, @default_bluealsad_candidates)
Enum.find(candidates, &File.exists?/1) || hd(candidates)
end
end