Bluetooth in Bare
How Bare apps talk over Bluetooth Low Energy: the bare-bluetooth module family, central and server roles, GATT versus L2CAP, and where encryption fits.
Most peer-to-peer connections in the Pear stack start on a network: Hyperswarm finds peers through the DHT and holepunches a path between them. Bluetooth Low Energy (BLE) needs no network at all. Two devices within radio range can find each other and exchange data with no Wi-Fi, no internet and no server, which makes it the transport for places with no connectivity, such as a plane or a crowded venue, and a useful second path when the network is there.
This page explains how Bluetooth is put together in Bare. For working code, see the Connect over Bluetooth guides.
One API over three platform bindings
Every operating system exposes Bluetooth differently: CoreBluetooth on Apple platforms, the Android Bluetooth API on Android, the BlueZ daemon on Linux. Bare has one native binding per platform, and one module that puts a common API over them:
bare-bluetoothpicks the binding through an import map keyed on the platform, sorequire('bare-bluetooth')loads only the one for the platform it runs on. It unifies class names, state strings and constants, so consumer code needs no platform conditionals for the common cases. There is no entry for Windows, where therequire()fails.bare-bluetooth-applewraps CoreBluetooth on macOS and iOS. Its peripheral class is CoreBluetooth'sPeripheralManager.bare-bluetooth-androidcalls the Android Bluetooth API through JNI, plus Java helper classes it ships alongside the addon. It needs the app's Java VM, so it only runs inside an app that hosts Bare, such as a Bare Kit worklet.bare-bluetooth-linuxtalks tobluetoothdover D-Bus, and handles L2CAP sockets itself.bare-bluetoothwraps it from 0.6.3-0.
The platform bindings stay available underneath. Their APIs follow each platform's own shape, and expose things bare-bluetooth leaves out; see Use platform-specific Bluetooth features.
Higher up, ble-swarm builds a Hyperswarm-shaped transport on bare-bluetooth; see Security below.
Two roles: central and server
Bluetooth Low Energy is asymmetric. One side, the peripheral, advertises and waits; the other, the central, scans for advertisements and connects. bare-bluetooth names them after what your code does with them:
| Role | bare-bluetooth class | Does |
|---|---|---|
| Peripheral | Server | Advertises, holds GATT services, answers reads and writes, publishes L2CAP channels |
| Central | Central | Scans, connects, and talks to the connected device through a Peripheral |
A device can play both roles at once, and for peer-to-peer apps it usually does: each phone advertises so others can find it, and scans so it can find others. Whoever connects first becomes the central for that connection. bare-mobile-doctor's chat runs a Central and a Server side by side on every phone for this reason.
Two ways to move data: GATT and L2CAP
Once connected, there are two ways to exchange data, and they suit different jobs:
| GATT | L2CAP channel | |
|---|---|---|
| Shape | Named values: services holding characteristics, each read, written or subscribed to | A byte stream, a Duplex you read and write |
| Size per operation | At most the ATT MTU minus 3 bytes: 20 before the MTU is negotiated up, a few hundred after | Limited only by the stream (and mtu per write on Linux) |
| How the other side finds it | By service and characteristic UUID | By a PSM number the operating system assigns |
| Talks to | Any BLE device, including standard ones like heart-rate monitors | Your own app on the other end |
GATT is the lingua franca of BLE: every device speaks it, and standard services have well-known UUIDs. But every value is small, and every operation is a round trip. L2CAP connection-oriented channels give you a real stream with flow control, which is what you want for messages, files or replication.
The catch with L2CAP is the PSM. CoreBluetooth and Android don't let an app choose the number its channel listens on; the operating system assigns one when you call publishChannel(). (On Linux, publishChannel({ psm }) asks for a specific one, but code that also runs on Apple and Android can't rely on that.) So the other side can't know it in advance. The usual answer combines both mechanisms: advertise a service UUID so the central can find you, put the PSM in a readable GATT characteristic, and open the channel once the central has read it. bare-mobile-doctor works this way, and Hand the PSM over automatically shows the code. ble-swarm also sends the PSM over GATT, but inside a HELLO frame it notifies on its data characteristic.
Everything is an event
bare-bluetooth has no promises. Each step, from the radio turning on to a value arriving, is an event on one of its objects, and each call starts an operation whose result comes back as an event:
Most failures follow the same rule: a failed operation arrives as an error event with a code, not as an exception. That includes a failed connection attempt and, on Apple and Android, a disconnect caused by an error, which never shows up as disconnect. A few failures are thrown instead: startScan() throws when Bluetooth isn't powered on, the Android binding throws the Java exception when a call itself fails, and the platform packages throw from calls such as retrievePeripherals() and getBondedDevices(). So the shape of a Bluetooth module in Bare is a small state machine driven by events, with try/catch around the calls, as in bare-mobile-doctor's BLECentral and BLEServer classes. Bluetooth best practices collects the rules that keep that state machine honest.
Who is on the other end
A device's id means something different on each platform:
- On Apple, it's a UUID the system assigns per host. CoreBluetooth never reveals the device's Bluetooth address, so the same phone can show up under different ids on different hosts.
- On Android and Linux, it's the Bluetooth address, such as
AA:BB:CC:DD:EE:FF.
Names aren't much better: on Android, startAdvertising({ name }) can only switch the phone's own system Bluetooth name on or off, and on every platform a name may arrive late or not at all. So apps identify what a device is by an advertised service UUID, and who it is by something exchanged after connecting: a name in the first message, as in bare-mobile-doctor's invite, or a public key, as in ble-swarm.
Security
Bluetooth offers link-level encryption, but it comes with pairing. publishChannel({ encrypted: true }) asks for an encrypted L2CAP channel, which requires the two devices to be paired first. On Apple and Android the operating system runs the pairing and may prompt the user; on Linux there is no system prompt, so bare-bluetooth emits pairRequest for your app to accept or reject, and only supports "just works" pairing. Pairing also authenticates devices, not people or apps: it says nothing about which key the app on the other end holds.
The alternative is to leave the link unencrypted and encrypt above it, the way the rest of the Pear stack does. ble-swarm publishes plain channels and runs a Noise handshake over each one, keyed by the same kind of key pair Hyperswarm uses, so each connection comes out as an encrypted, authenticated stream to a known public key, with no pairing prompt. (That is its default l2cap pipe; its gatt pipe runs the same handshake over a characteristic instead.) Its connections are the same kind Hyperswarm emits, so they plug straight into corestore.replicate(). ble-swarm is experimental upstream, like bare-bluetooth. It runs on macOS 13 and later, iOS and Android; it doesn't yet load the Linux binding.
Common questions
See also
- Connect over Bluetooth—guides built on code that ships upstream.
- Bluetooth best practices—the rules behind those guides.
bare-bluetoothreference—the cross-platform API.- One core, many platforms—where the Bluetooth code sits in a mobile app.
- Hyperswarm—peer discovery over the network.
Last updated on