Bare Docs

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 it is open, both sides get an L2CAPChannel, a bare-stream Duplex you read and write like a socket. Use it when you want to send more than a few small values, such as messages, files or 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, then shows how bare-mobile-doctor removes the one manual step it has.

Before you begin

  • Two machines with Bluetooth: macOS, or Linux with bare-bluetooth 0.6.3-0 or later. On Linux, bluetoothd must be running and the adapter powered on (bluetoothctl power on).
  • Bare on both: npm i -g bare. On macOS, the terminal app 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.3-0 tag, which is the version this guide's code comes from:
git clone --branch v0.6.3-0 https://github.com/holepunchto/bare-bluetooth
cd bare-bluetooth
npm install

On 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 its channel was given:

bare example/l2cappong.js listen

On the second, 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 publishes a channel and advertises a service UUID so the other side can find it. The operating system picks the channel's PSM and reports it in channelPublish:

example/l2cappong.js
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, channelOpen hands the server its end of the stream. channel.peer identifies the device on the other end: a per-host UUID on Apple, the Bluetooth address on Android and Linux.

The service UUID is picked from the Bluetooth base range, so it takes 2 bytes of the advertisement instead of 16:

example/l2cappong.js
// 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 scans for the service UUID, connects to the first match, and opens the channel on the PSM it was given:

example/l2cappong.js
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 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 rssi is null. BlueZ replays devices it remembers from earlier scans, and those may not be in range now.
  • It listens for error on the central and the peripheral. A failed connection or channel arrives there, and an error event nobody listens for throws.

The example has no deadline on opening the channel, and opening one can hang without an error: ble-swarm found that an iOS peripheral never answers a channel opened by a macOS central. In an app, start a timer when you call openL2CAPChannel() and give up if no channelOpen 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: write() to send, data to receive, and close and error to learn that the other side has gone:

example/l2cappong.js
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 they were written 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, and a single write larger than it fails, so keep writes to at most channel.mtu bytes there.

Hand the PSM over automatically

The example's one manual step is copying 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 it from the server. bare-mobile-doctor does that with GATT: the server adds a service with one readable characteristic and answers every read with the PSM, as text:

bare/bluetooth.js
    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, reads it, and opens the channel on the number it reads:

bare/bluetooth.js
    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 serviceAdd and channelPublish have fired finds no characteristic, or reads an empty value. Advertise once both are ready shows how bare-mobile-doctor waits 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. publishChannel({ encrypted: true }) asks for an encrypted link, which requires the two devices to be paired, so the user may see a pairing prompt, and on Linux your app has to answer pairRequest. To keep pairing out of the picture, ble-swarm publishes unencrypted channels and runs a Noise handshake over them instead. Bluetooth in Bare compares the two.

Where to go next

Last updated on

Was this helpful?

On this page