# Exchange data over L2CAP



An L2CAP channel is a connection-oriented stream between two Bluetooth devices. Once the channel is open, both sides get an [`L2CAPChannel`](/bare/reference/bare/modules/bare-bluetooth#l2capchannel): a [`Duplex`](/bare/reference/bare/modules/bare-stream#duplex) stream from [`bare-stream`](/bare/reference/bare/modules/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](/bare/how-to/connect-over-bluetooth/read-write-and-subscribe-with-gatt).

This guide follows [`example/l2cappong.js`](https://github.com/holepunchto/bare-bluetooth/blob/d1ef34487f57cc183b5d7a0d6c588d95447eb420/example/l2cappong.js) from the [`bare-bluetooth`](https://github.com/holepunchto/bare-bluetooth) repository, in which two machines bounce `ping` and `pong` over a channel. It then shows how [`bare-mobile-doctor`](https://github.com/holepunchto/bare-mobile-doctor) removes the one manual step that the example has.

<Callout type="tip">
  For how the pieces fit together, read [Bluetooth in Bare](/bare/explanation/bluetooth) first. For the rules that keep L2CAP code reliable across platforms, read [Bluetooth best practices](/bare/explanation/bluetooth/best-practices).
</Callout>

## Before you begin [#before-you-begin]

* Two machines with Bluetooth: macOS, or Linux with `bare-bluetooth` 0.6.3 or later. On Linux, `bluetoothd` (the [BlueZ](https://github.com/bluez/bluez) daemon) must be running and the adapter powered on (`bluetoothctl power on`).
* [Bare](https://github.com/holepunchto/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.3` tag, which is the version that this guide's code comes from:

```bash skip="install-instruction"
git clone --branch v0.6.3 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](/bare/how-to/connect-over-bluetooth/set-up-bluetooth-in-a-mobile-app).

## Run the example [#run-the-example]

On the first machine, start the listener. It prints the PSM that its channel was given:

```bash skip="needs-bluetooth-radio"
bare example/l2cappong.js listen
```

On the second machine, connect with that PSM:

```bash skip="needs-bluetooth-radio"
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 [#publish-a-channel-and-advertise]

The listening side is a [`Server`](/bare/reference/bare/modules/bare-bluetooth#server). Once the radio is on, it takes these steps:

1. It publishes a channel with [`server.publishChannel()`](/bare/reference/bare/modules/bare-bluetooth#serverpublishchanneloptions). The operating system picks the channel's PSM and reports it in the [`channelPublish`](/bare/reference/bare/modules/bare-bluetooth#server) event.
2. It advertises a service UUID with [`server.startAdvertising()`](/bare/reference/bare/modules/bare-bluetooth#serverstartadvertisingoptions), so the other side can find it.

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-bluetooth/example/l2cappong.js#L22-L45 title="example/l2cappong.js" skip="example-import"
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`](/bare/reference/bare/modules/bare-bluetooth#server) event hands the server its end of the stream. The [`channel.peer`](/bare/reference/bare/modules/bare-bluetooth#l2capchannel) 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:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-bluetooth/example/l2cappong.js#L5-L9 title="example/l2cappong.js" skip="example-import"
// 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 [#find-the-server-and-open-the-channel]

The connecting side is a [`Central`](/bare/reference/bare/modules/bare-bluetooth#central). It takes these steps:

1. It scans for the service UUID with [`central.startScan()`](/bare/reference/bare/modules/bare-bluetooth#centralstartscanserviceuuids-options).
2. It connects to the first match with [`central.connect()`](/bare/reference/bare/modules/bare-bluetooth#centralconnectperipheral).
3. It opens the channel on the PSM it was given with [`peripheral.openL2CAPChannel()`](/bare/reference/bare/modules/bare-bluetooth#peripheralopenl2capchannelpsm).

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-bluetooth/example/l2cappong.js#L47-L93 title="example/l2cappong.js" skip="example-import"
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()`](/bare/reference/bare/modules/bare-bluetooth#centralstopscan) 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`](/bare/reference/bare/modules/bare-bluetooth#discoveredperipheral) is `null`. BlueZ replays devices that it remembers from earlier scans, and those devices may not be in range now.
* It listens for [`error`](/bare/reference/bare/modules/bare-bluetooth#bluetootherror) on the central and on the [`Peripheral`](/bare/reference/bare/modules/bare-bluetooth#peripheral). A failed connection or channel arrives there, and an `error` event that nobody listens for throws.

<Callout type="warn">
  The example has no deadline on opening the channel, and opening a channel can hang without an `error`. `ble-swarm` reports that, historically, an iOS peripheral never answered a channel that a macOS central opened, and gave no error. In an app, start a timer when you call `openL2CAPChannel()`, and give up if no [`channelOpen`](/bare/reference/bare/modules/bare-bluetooth#peripheral) event arrives. See [Put a deadline on opening a channel](/bare/explanation/bluetooth/best-practices#put-a-deadline-on-opening-a-channel).
</Callout>

## Use the channel as a stream [#use-the-channel-as-a-stream]

Both ends now hold an [`L2CAPChannel`](/bare/reference/bare/modules/bare-bluetooth#l2capchannel) and use it the same way:

* Call [`write()`](/bare/reference/bare/modules/bare-stream#writablewritedata-unknown-encoding-bufferencoding-cb-streamcallback-boolean) to send.
* Listen for [`data`](/bare/reference/bare/modules/bare-stream#duplexevents) to receive.
* Listen for [`close`](/bare/reference/bare/modules/bare-stream#duplexevents) and [`error`](/bare/reference/bare/modules/bare-stream#duplexevents) to learn that the other side has gone.

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-bluetooth/example/l2cappong.js#L95-L114 title="example/l2cappong.js" skip="example-import"
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`](https://github.com/holepunchto/framed-stream), so each `write()` arrives as one `data` event on the other side. See [Chat over the channel](/bare/how-to/connect-over-bluetooth/build-a-bluetooth-chat-for-ios-and-android#6-chat-over-the-channel).

On Linux, the channel also has an [`mtu`](/bare/reference/bare/modules/bare-bluetooth#l2capchannel) property, and a single write larger than it fails. Keep writes to at most `channel.mtu` bytes there.

## Hand the PSM over automatically [#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`](/bare/reference/bare/modules/bare-bluetooth#serverpublishchanneloptions) 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 creates one readable [`Characteristic`](/bare/reference/bare/modules/bare-bluetooth#const-characteristic--new-characteristicuuid-options) and adds it in a [`Service`](/bare/reference/bare/modules/bare-bluetooth#const-service--new-serviceuuid-characteristics-options). The code below is the part that answers every read of that characteristic with the PSM, as text:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L315-L335 title="bare/bluetooth.js" skip="example-import"
    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 the characteristic with [`peripheral.discoverCharacteristics()`](/bare/reference/bare/modules/bare-bluetooth#peripheraldiscovercharacteristicsservice-characteristicuuids). The code below handles the result: it reads the characteristic with [`peripheral.read()`](/bare/reference/bare/modules/bare-bluetooth#peripheralreadcharacteristic) and opens the channel on the number it reads:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L185-L214 title="bare/bluetooth.js" skip="example-import"
    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 [`serviceAdd`](/bare/reference/bare/modules/bare-bluetooth#server) and [`channelPublish`](/bare/reference/bare/modules/bare-bluetooth#server) events have fired finds no characteristic, or reads an empty value. [Advertise once both are ready](/bare/how-to/connect-over-bluetooth/build-a-bluetooth-chat-for-ios-and-android#3-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](/bare/explanation/bluetooth/best-practices#normalize-uuids-before-comparing-them).

## Encrypt the channel [#encrypt-the-channel]

[`server.publishChannel()`](/bare/reference/bare/modules/bare-bluetooth#serverpublishchanneloptions) publishes an unencrypted channel by default. You have two ways to encrypt the traffic:

* **Pair the devices.** [`publishChannel({ encrypted: true })`](/bare/reference/bare/modules/bare-bluetooth#serverpublishchanneloptions) 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 the `pairRequest` event with a [`PairRequest`](/bare/reference/bare/modules/bare-bluetooth#pairrequest).
* **Encrypt on top of the link.** [`ble-swarm`](/bare/reference/bare/modules/ble-swarm) publishes unencrypted channels and runs a [Noise](https://noiseprotocol.org/) handshake over them instead, which keeps pairing out of the picture.

[Bluetooth in Bare](/bare/explanation/bluetooth#security) compares the two.

## Where to go next [#where-to-go-next]

* [Build a Bluetooth chat for iOS and Android](/bare/how-to/connect-over-bluetooth/build-a-bluetooth-chat-for-ios-and-android): this pattern in a complete mobile app.
* [Read, write and subscribe with GATT](/bare/how-to/connect-over-bluetooth/read-write-and-subscribe-with-gatt): the other way to exchange data.
* [`L2CAPChannel` reference](/bare/reference/bare/modules/bare-bluetooth#l2capchannel)
* [Bluetooth best practices](/bare/explanation/bluetooth/best-practices)
