Exchange data over L2CAP
Open an L2CAP channel between two devices with bare-bluetooth, use it as a duplex stream, and hand its PSM to the other side through GATT.
An L2CAP channel is a connection-oriented stream between two Bluetooth devices. Once the channel is open, both sides get an L2CAPChannel: a Duplex stream from bare-stream that you read and write like a socket.
Use an L2CAP channel when you want to send more than a few small values, such as:
- Messages.
- Files.
- A replication stream.
For small values that other devices may also want to read, see Read, write and subscribe with GATT.
This guide follows example/l2cappong.js from the bare-bluetooth repository, in which two machines bounce ping and pong over a channel. It then shows how bare-mobile-doctor removes the one manual step that the example has.
For how the pieces fit together, read Bluetooth in Bare first. For the rules that keep L2CAP code reliable across platforms, read Bluetooth best practices.
Before you begin
- Two machines with Bluetooth: macOS, or Linux with
bare-bluetooth0.6.3 or later. On Linux,bluetoothd(the BlueZ daemon) must be running and the adapter powered on (bluetoothctl power on). - Bare on both machines:
npm i -g bare. On macOS, the terminal app that you run it from needs Bluetooth access under System Settings, Privacy & Security. - The example scripts, which live in the repository rather than the npm package. Clone the
v0.6.3tag, which is the version that this guide's code comes from:
git clone --branch v0.6.3 https://github.com/holepunchto/bare-bluetooth
cd bare-bluetooth
npm installOn iOS and Android, the same code runs inside an app's worklet. See Set up Bluetooth in a mobile app.
Run the example
On the first machine, start the listener. It prints the PSM that its channel was given:
bare example/l2cappong.js listenOn the second machine, connect with that PSM:
bare example/l2cappong.js connect <psm>The two sides then trade ping and pong once a second until you stop one of them.
Publish a channel and advertise
The listening side is a Server. Once the radio is on, it takes these steps:
- It publishes a channel with
server.publishChannel(). The operating system picks the channel's PSM and reports it in thechannelPublishevent. - It advertises a service UUID with
server.startAdvertising(), so the other side can find it.
function listen() {
const server = new Server()
server.on('stateChange', (state) => {
console.log('state:', state)
if (state !== 'poweredOn') return
server.publishChannel()
server.startAdvertising({ name: NAME, serviceUUIDs: [SERVICE_UUID] })
console.log('advertising', SERVICE_UUID)
})
server.on('channelPublish', (psm) => {
console.log(`listening on psm ${psm}, advertising as "${NAME}"`)
console.log(`run: bare example/l2cappong.js connect ${psm}`)
})
server.on('channelOpen', (channel) => {
console.log('channel open:', channel.peer)
rally(channel, false)
})
server.on('error', (err) => console.error('error:', err.message))
}When a central opens the channel, the channelOpen event hands the server its end of the stream. The channel.peer property identifies the device on the other end: a per-host UUID on Apple, and the Bluetooth address on Android and Linux.
The example picks the service UUID from the Bluetooth base range, so it takes 2 bytes of the advertisement instead of 16:
// Peers are matched on this rather than on NAME: a name only reaches the other
// side reliably once connected, but an advertised service UUID always does.
// Kept in the Bluetooth base range so it costs 2 bytes of advertising budget
// instead of 16.
const SERVICE_UUID = '0000f00d-0000-1000-8000-00805f9b34fb'Find the server and open the channel
The connecting side is a Central. It takes these steps:
- It scans for the service UUID with
central.startScan(). - It connects to the first match with
central.connect(). - It opens the channel on the PSM it was given with
peripheral.openL2CAPChannel().
function connect(psm) {
const central = new Central()
let connecting = false
// Nothing advertising the service looks exactly like a hung scan, so say so
// rather than sit there quietly.
let searching = null
central.on('stateChange', (state) => {
console.log('state:', state)
if (state !== 'poweredOn') return
console.log('scanning for', SERVICE_UUID)
central.startScan([SERVICE_UUID])
searching = setTimeout(() => {
console.error('nothing advertising that service after 20s')
console.error('is "bare example/l2cappong.js listen" still running on the other machine?')
}, 20000)
})
// BlueZ replays devices cached from earlier scans too; no RSSI means this
// scan has not actually heard it.
central.on('discover', (discovered) => {
if (connecting || discovered.rssi === null) return
connecting = true
clearTimeout(searching)
console.log('found:', discovered.id, discovered.rssi)
central.stopScan()
central.connect(discovered)
})
central.on('connect', (peripheral) => {
console.log('connected:', peripheral.id)
peripheral.on('channelOpen', (channel) => {
console.log('channel open:', channel.psm)
rally(channel, true)
})
peripheral.on('error', (err) => console.error('peripheral error:', err.message))
peripheral.openL2CAPChannel(psm)
})
central.on('error', (err) => console.error('error:', err.message))
}Three details matter here:
- It stops scanning with
central.stopScan()once it has found the server. A scan keeps the radio busy and costs battery for as long as it runs. - It ignores discoveries whose
rssiisnull. BlueZ replays devices that it remembers from earlier scans, and those devices may not be in range now. - It listens for
erroron the central and on thePeripheral. A failed connection or channel arrives there, and anerrorevent that nobody listens for throws.
The example has no deadline on opening the channel, and opening a channel can hang without an error. ble-swarm found that an iOS peripheral never answers a channel that a macOS central opens. In an app, start a timer when you call openL2CAPChannel(), and give up if no channelOpen event arrives. See Put a deadline on opening a channel.
Use the channel as a stream
Both ends now hold an L2CAPChannel and use it the same way:
- Call
write()to send. - Listen for
datato receive. - Listen for
closeanderrorto learn that the other side has gone.
function rally(channel, serves) {
channel.on('data', (data) => {
const received = Buffer.from(data).toString()
console.log('<', received)
const reply = received === 'ping' ? 'pong' : 'ping'
setTimeout(() => {
console.log('>', reply)
channel.write(Buffer.from(reply))
}, 1000)
})
channel.on('close', () => console.log('channel closed'))
channel.on('error', (err) => console.error('channel error:', err.message))
if (serves) {
console.log('>', 'ping')
channel.write(Buffer.from('ping'))
}
}The channel delivers bytes in order, but not necessarily in the chunks that you wrote them in. To send messages rather than bytes, frame them. bare-mobile-doctor wraps the channel in framed-stream, so each write() arrives as one data event on the other side. See Chat over the channel.
On Linux, the channel also has an mtu property, and a single write larger than it fails. Keep writes to at most channel.mtu bytes there.
Hand the PSM over automatically
The example has one manual step: you copy the PSM from one machine to the other. CoreBluetooth and Android don't let you choose the number. Linux accepts a psm option, but Apple and Android ignore it. So code that runs on every platform has to let the central learn the PSM from the server.
bare-mobile-doctor does this with GATT. The server adds a Service with one readable Characteristic, and answers every read with the PSM, as text:
this._server.on('channelPublish', (psm) => {
dbg('Server: channelPublish psm=', psm)
this._psm = psm
this.emit('log', `L2CAP channel published psm=${psm}`)
if (this.advertising === ToggleState.REQUESTED) this.startAdvertising()
})
this._server.on('channelOpen', (channel) => {
dbg('Server: channelOpen (invitee side)')
this.emit('log', 'L2CAP channel open')
this.emit('link', new PeerLink(channel))
})
// A central reads this to discover the PSM before opening the channel. The
// PSM is a plain number, so send it as raw text (not JSON) - the central
// parses it with Number().
this._server.on('readRequest', (req) => {
dbg('Server: readRequest (PSM) psm=', this._psm)
const value = this._psm != null ? Buffer.from(String(this._psm)) : Buffer.alloc(0)
this._server.respondToRequest(req, Server.ATT_SUCCESS, value)
})After connecting, the central discovers that characteristic with peripheral.discoverCharacteristics(), reads it with peripheral.read(), and opens the channel on the number it reads:
peripheral.on('characteristicsDiscover', (service, chars) => {
dbg('Central: characteristicsDiscover', chars && chars.length)
const psmChar = findByUUID(chars, this.psmUUID)
if (!psmChar) {
this.emit('error', 'PSM characteristic not found')
this.emit('connectFailed')
return
}
this.emit('log', 'Reading L2CAP PSM')
peripheral.read(psmChar)
})
peripheral.on('read', (char, data) => {
const text = Buffer.from(data).toString()
dbg('Central: read PSM char ->', JSON.stringify(text))
const psm = Number(text)
if (!psm) {
this.emit('error', `Invalid PSM: ${JSON.stringify(text)}`)
this.emit('connectFailed')
return
}
this.emit('log', `Opening L2CAP channel psm=${psm}`)
peripheral.openL2CAPChannel(psm)
})
peripheral.on('channelOpen', (channel) => {
dbg('Central: channelOpen (inviter side)')
this.emit('log', 'L2CAP channel open')
this.emit('link', new PeerLink(channel))
})Two rules make this reliable:
- Advertise only once both are ready. A central that connects before the
serviceAddandchannelPublishevents have fired finds no characteristic, or reads an empty value. Advertise once both are ready shows howbare-mobile-doctorwaits for both. - Compare UUIDs normalized. The discovered characteristic's UUID may come back uppercase, lowercase or shortened, depending on the platform. See Normalize UUIDs before comparing them.
Encrypt the channel
server.publishChannel() publishes an unencrypted channel by default. You have two ways to encrypt the traffic:
- Pair the devices.
publishChannel({ encrypted: true })asks for an encrypted link, which requires the two devices to be paired. The user may see a pairing prompt. On Linux, your app has to answer thepairRequestevent with aPairRequest. - Encrypt on top of the link.
ble-swarmpublishes unencrypted channels and runs a Noise handshake over them instead, which keeps pairing out of the picture.
Bluetooth in Bare compares the two.
Where to go next
- Build a Bluetooth chat for iOS and Android: this pattern in a complete mobile app.
- Read, write and subscribe with GATT: the other way to exchange data.
L2CAPChannelreference- Bluetooth best practices
Last updated on