Active BLE connections + GATT client over BlueZ D-Bus — the engine an ESPHome-proxy-style host puts behind its Bluetooth-proxy adapter.
Owns its own rebus connection, separate from Bluez.Client
(the passive scanner): independent match rules, independent failure domain,
and zero changes to the hardware-verified advert path. Concurrent method
calls on one rebus connection don't serialize (replies are correlated by
serial), but every call still blocks its calling process — so all BlueZ
calls here run in Tasks under Bluez.Gatt.Tasks, never in
this GenServer's own loop. Device1.Connect alone can take ~25 s.
Event contract
Results flow asynchronously to the subscriber pid captured at
connect/3, through the on_gatt_event: fun (fn subscriber, event;
default: send(subscriber, event)). Hosts inject a translator to reshape
events for their own wire protocol. The full event set:
{:gatt_connection, address, {:ok, mtu} | {:error, code}}— connect result, post-remove teardown, or an unexpected disconnect.{:gatt_service, address, %Bluez.Gatt.Service{}}— one per service, streamed onget_services/1.{:gatt_services_done, address}— service stream terminator.{:gatt_read, address, handle, {:ok, binary} | {:error, code}}— characteristic and descriptor reads (a failedget_services/1on a not-ready link also answers here with handle 0, the ESPHome convention).{:gatt_write, address, handle, {:ok, :done} | {:error, code}}— characteristic and descriptor writes.{:gatt_notify, address, handle, {:ok, :done} | {:error, code}}— Start/StopNotify result.{:gatt_notify_data, address, handle, binary}— a notification value.{:gatt_pair, address, success? :: boolean(), code :: integer()}{:gatt_unpair, address, success?, code}{:gatt_clear_cache, address, success?, code}
Error codes follow the ESPHome BLE convention this stack was built
against: -1 generic, -2 not connected.
Options
on_gatt_event:— see above.on_connections_changed:— zero-arity fun invoked whenever a connection slot is taken or freed (default: no-op) — e.g. a stats ticker.
Connection lifecycle
flowchart LR
connect["connect cast"] --> dev1["Device1.Connect (Task)"]
dev1 --> resolved{"ServicesResolved?"}
resolved -- true --> gmo["GetManagedObjects"]
resolved -- false --> wait["wait for signal<br/>(resolve timeout)"]
wait --> gmo
gmo --> tree["GattTree.build"]
tree --> host(["{:gatt_connection, addr, {:ok, mtu}} → host"])The connection reply is deliberately deferred until BlueZ has resolved
services: every subsequent GATT request is handle-keyed, and the
handle ↔ object-path map only exists once the GATT objects are visible.
MTU comes from the experimental MTU characteristic property
(bluetoothd -E), falling back to the BLE minimum (23).
Unexpected disconnects surface as Device1.PropertiesChanged Connected=false; the subscriber is told via the same connection envelope
with an error code. Requested disconnects remove state immediately and
need no follow-up message.
Notifications
StartNotify makes BlueZ emit PropertiesChanged with Value on the
characteristic path — the same signal mechanism as adverts. The
char-path → {address, handle} route is registered before the
StartNotify call returns (and rolled back on error) so no early value
can race past us.
The host owns cross-client address locking; this
module trusts that connect arrives at most once per address per
ownership cycle, but stays defensive (a stale entry is torn down and
replaced).
Pairing and cache clearing (Phase 2)
pair/1 calls Device1.Pair() — IO is negotiated through
Bluez.Agent (the default NoInputNoOutput agent), and the
Pair Task brackets the call with expect_pairing/1/pairing_done/1 so
the agent only authorizes pairings we initiated. unpair/1 and
clear_cache/1 both map to Adapter1.RemoveDevice — BlueZ's only
bond-removal API, and the only D-Bus way to drop a device's cached GATT
database (they differ only in the reply envelope; the bond, if any,
goes too — same observable semantics as ESP32's
esp_ble_remove_bond_device). RemoveDevice destroys the device object —
and the live link with it.
Hardware-observed ordering hazard: BlueZ disconnects the device (and
emits Connected=false) while processing RemoveDevice, before the
method returns — and a failed Pair() can likewise drop the link before
its error reply lands. Either way the signal path tears the entry down
first, so pair/remove Task messages carry the subscriber pid themselves:
the op reply is always delivered, and entry teardown happens via
whichever of the two paths (signal or result) still finds the entry.
All three require a live connection entry: replies route to the
subscriber captured at connect/3, so for an unknown address there
is no one to answer — those requests are logged and dropped (HA only
issues them on connected devices).
Summary
Functions
Returns a specification to start this module under a supervisor.
Drop BlueZ's cached GATT database for the device. Same underlying
operation as unpair/1 (RemoveDevice is the only D-Bus API for it —
the bond, if any, goes too; same observable semantics as ESP32's
esp_ble_remove_bond_device), differing only in the reply envelope:
{:gatt_clear_cache, address, success?, code}.
Open a BLE connection to address and capture subscriber as the pid
all of this connection's events route to (via the on_gatt_event: fun).
Free / total connection slots (the host's connections_free callback).
Tear down address's connection. Requested disconnects emit no
follow-up event (the entry is dropped before BlueZ reports the link
down); unknown addresses are a no-op.
Stream the connected device's GATT database to the subscriber: one
{:gatt_service, address, %Bluez.Gatt.Service{}} per service, then
{:gatt_services_done, address}. On a not-ready link the failure is
reported as {:gatt_read, address, 0, {:error, -2}} (the ESPHome
convention for a failed service listing).
Total active-connection slots this GATT client offers.
Start (enable?: true) or stop notifications/indications on the
characteristic at handle. The call result arrives as
{:gatt_notify, address, handle, {:ok, :done} | {:error, code}};
subsequent values arrive as {:gatt_notify_data, address, handle, binary}. The value route is registered before StartNotify returns so
no early notification can be lost.
Bond with the connected device (Device1.Pair()), with IO negotiated
through Bluez.Agent — only pairings initiated here are authorized.
Result: {:gatt_pair, address, success? :: boolean(), code}. A failed
pairing can drop the link (hardware-observed), in which case a
{:gatt_connection, address, {:error, _}} teardown event follows.
Read the characteristic at handle (a value handle, as reported in
the service stream). Result: {:gatt_read, address, handle, {:ok, binary} | {:error, code}}. Falls back to a descriptor read when the
handle names a descriptor.
Read the descriptor at handle. Same result envelope as read/2
({:gatt_read, ...}); falls back to a characteristic read when the
handle names one.
Name of the Task.Supervisor all BlueZ calls run under.
Remove the device's bond via Adapter1.RemoveDevice — BlueZ's only
bond-removal API, which also destroys the device object and any live
link. Result: {:gatt_unpair, address, success?, code}, followed by a
{:gatt_connection, address, {:error, -2}} teardown event when a
connection was up.
Write data to the characteristic at handle. response? selects
Write-With-Response (true) vs Write-Without-Response. Result:
{:gatt_write, address, handle, {:ok, :done} | {:error, code}}.
Writes larger than the 512-byte ATT attribute limit are refused
up front.
Write data to the descriptor at handle (always Write-With-Response).
Same result envelope as write/4 ({:gatt_write, ...}).
Types
@type address() :: non_neg_integer()
Packed 48-bit MAC, MSB-first.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear_cache(address()) :: :ok
Drop BlueZ's cached GATT database for the device. Same underlying
operation as unpair/1 (RemoveDevice is the only D-Bus API for it —
the bond, if any, goes too; same observable semantics as ESP32's
esp_ble_remove_bond_device), differing only in the reply envelope:
{:gatt_clear_cache, address, success?, code}.
Open a BLE connection to address and capture subscriber as the pid
all of this connection's events route to (via the on_gatt_event: fun).
Cast-style: returns :ok immediately. The outcome arrives as
{:gatt_connection, address, {:ok, mtu} | {:error, code}} — deferred
until BlueZ has resolved services, so every handle-keyed request below
is valid the moment the success event lands. opts are accepted for
host-side compatibility and currently unused.
Refused (with an error event) when address is not a 48-bit MAC or all
max_connections/0 slots are taken.
@spec connections_free() :: {non_neg_integer(), non_neg_integer()}
Free / total connection slots (the host's connections_free callback).
@spec disconnect(address()) :: :ok
Tear down address's connection. Requested disconnects emit no
follow-up event (the entry is dropped before BlueZ reports the link
down); unknown addresses are a no-op.
@spec get_services(address()) :: :ok
Stream the connected device's GATT database to the subscriber: one
{:gatt_service, address, %Bluez.Gatt.Service{}} per service, then
{:gatt_services_done, address}. On a not-ready link the failure is
reported as {:gatt_read, address, 0, {:error, -2}} (the ESPHome
convention for a failed service listing).
@spec max_connections() :: pos_integer()
Total active-connection slots this GATT client offers.
@spec notify(address(), non_neg_integer(), boolean()) :: :ok
Start (enable?: true) or stop notifications/indications on the
characteristic at handle. The call result arrives as
{:gatt_notify, address, handle, {:ok, :done} | {:error, code}};
subsequent values arrive as {:gatt_notify_data, address, handle, binary}. The value route is registered before StartNotify returns so
no early notification can be lost.
@spec pair(address()) :: :ok
Bond with the connected device (Device1.Pair()), with IO negotiated
through Bluez.Agent — only pairings initiated here are authorized.
Result: {:gatt_pair, address, success? :: boolean(), code}. A failed
pairing can drop the link (hardware-observed), in which case a
{:gatt_connection, address, {:error, _}} teardown event follows.
@spec read(address(), non_neg_integer()) :: :ok
Read the characteristic at handle (a value handle, as reported in
the service stream). Result: {:gatt_read, address, handle, {:ok, binary} | {:error, code}}. Falls back to a descriptor read when the
handle names a descriptor.
@spec read_descriptor(address(), non_neg_integer()) :: :ok
Read the descriptor at handle. Same result envelope as read/2
({:gatt_read, ...}); falls back to a characteristic read when the
handle names one.
@spec task_supervisor() :: module()
Name of the Task.Supervisor all BlueZ calls run under.
@spec unpair(address()) :: :ok
Remove the device's bond via Adapter1.RemoveDevice — BlueZ's only
bond-removal API, which also destroys the device object and any live
link. Result: {:gatt_unpair, address, success?, code}, followed by a
{:gatt_connection, address, {:error, -2}} teardown event when a
connection was up.
@spec write(address(), non_neg_integer(), binary(), boolean()) :: :ok
Write data to the characteristic at handle. response? selects
Write-With-Response (true) vs Write-Without-Response. Result:
{:gatt_write, address, handle, {:ok, :done} | {:error, code}}.
Writes larger than the 512-byte ATT attribute limit are refused
up front.
@spec write_descriptor(address(), non_neg_integer(), binary()) :: :ok
Write data to the descriptor at handle (always Write-With-Response).
Same result envelope as write/4 ({:gatt_write, ...}).