Bluez.Gatt (bluez v0.1.0)

Copy Markdown View Source

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 on get_services/1.
  • {:gatt_services_done, address} — service stream terminator.
  • {:gatt_read, address, handle, {:ok, binary} | {:error, code}} — characteristic and descriptor reads (a failed get_services/1 on 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

Types

Packed 48-bit MAC, MSB-first.

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

address()

@type address() :: non_neg_integer()

Packed 48-bit MAC, MSB-first.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear_cache(address)

@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}.

connect(address, opts, subscriber)

@spec connect(address(), keyword(), pid()) :: :ok

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.

connections_free()

@spec connections_free() :: {non_neg_integer(), non_neg_integer()}

Free / total connection slots (the host's connections_free callback).

disconnect(address)

@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.

get_services(address)

@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).

max_connections()

@spec max_connections() :: pos_integer()

Total active-connection slots this GATT client offers.

notify(address, handle, enable?)

@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.

pair(address)

@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.

read(address, handle)

@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.

read_descriptor(address, handle)

@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.

start_link(opts \\ [])

task_supervisor()

@spec task_supervisor() :: module()

Name of the Task.Supervisor all BlueZ calls run under.

unpair(address)

@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.

write(address, handle, data, response?)

@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.

write_descriptor(address, handle, data)

@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, ...}).