Bare Docs

Read, write and subscribe with GATT

Scan for nearby devices with bare-bluetooth, expose a GATT service from a Server, and write to a characteristic and get notifications from a Central.

GATT is how Bluetooth Low Energy devices expose small values: a server publishes services, each holding characteristics, and a central connects to read them, write them, or subscribe to be notified when they change. Use GATT for small values and for interoperating with existing devices, such as a heart-rate monitor's measurement. For a stream of data between two of your own apps, an L2CAP channel is usually the better fit.

This guide follows two scripts from the bare-bluetooth repository: scan.js, which lists what's nearby, and gattpong.js, in which a central writes ping to a characteristic and the server answers with a pong notification.

Before you begin

  • For scan.js, one machine with Bluetooth; for gattpong.js, two. macOS works, and Linux with bare-bluetooth 0.6.3-0 or later, with bluetoothd running and the adapter powered on (bluetoothctl power on).
  • Bare on each: 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

Scan for nearby devices

scan.js scans for ten seconds and prints every advertisement it hears. It's the quickest way to check that Bluetooth works on a machine at all:

bare example/scan.js
example/scan.js
const { Central } = require('..')

const central = new Central()

central.on('stateChange', (state) => {
  console.log('state:', state)
  if (state === 'poweredOn') central.startScan()
})

central.on('discover', (peripheral) => {
  console.log('found:', peripheral.id, peripheral.name, peripheral.rssi)
})

central.on('error', (err) => console.error('error:', err.message))

setTimeout(() => {
  central.stopScan()
  central.destroy()
}, 10000)

startScan() with no arguments reports every device in range. Pass an array of service UUIDs to report only devices advertising one of them. Either way, discover fires for advertisements rather than devices, so the same device turns up many times: key what you find by peripheral.id and keep the latest. startScan() throws unless the state is 'poweredOn', which is why the call sits in the stateChange listener.

Run the GATT example

On one machine, start the server:

bare example/gattpong.js listen

On the other, start the central:

bare example/gattpong.js connect

Unlike the L2CAP example, there's nothing to copy between the two: the central finds the server by its advertised service UUID.

Expose a characteristic from the server

The server defines one characteristic that centrals can write to and subscribe to, adds it in a service once the radio is on, and starts advertising once the service has been added:

example/gattpong.js
function listen() {
  const server = new Server()
  const characteristic = new Characteristic(CHARACTERISTIC_UUID, { write: true, notify: true })

  server.on('stateChange', (state) => {
    console.log('state:', state)
    if (state !== 'poweredOn') return

    server.addService(new Service(SERVICE_UUID, [characteristic]))
  })

  server.on('serviceAdd', () => {
    server.startAdvertising({ name: NAME, serviceUUIDs: [SERVICE_UUID] })
    console.log(`advertising as "${NAME}"`)
    console.log('run: bare example/gattpong.js connect')
  })

  server.on('subscribe', (peer, uuid) => {
    if (same(uuid, CHARACTERISTIC_UUID)) console.log('central subscribed')
  })

  // The central writes, the server answers with a notification.
  const reply = rally((message) => server.updateValue(characteristic, Buffer.from(message)))

  server.on('writeRequest', (requests) => {
    for (const request of requests) {
      if (request.responseNeeded) server.respondToRequest(request, Server.ATT_SUCCESS)
      reply(Buffer.from(request.data).toString())
    }
  })

  server.on('error', (err) => console.error('error:', err.message))
}
  • Writes arrive as writeRequest, with an array of requests. If a request's responseNeeded is set, answer it with respondToRequest() and an ATT result code such as Server.ATT_SUCCESS; the central's write doesn't complete until you do. Answer before doing any slow work with the data.
  • Notifications go out with server.updateValue(characteristic, data), which reaches every central subscribed to that characteristic. subscribe and unsubscribe tell you when a central starts or stops listening.
  • Reads, which this example doesn't use, arrive as readRequest. Answer them with respondToRequest(request, Server.ATT_SUCCESS, data), using request.offset if a central reads a long value in parts. On Android every read reaches your listener. For a value that never changes, you can instead create the characteristic with a value: Apple then serves it itself, and so does Linux as long as nothing listens for readRequest.

bare-mobile-doctor answers reads to hand out an L2CAP channel's PSM; see Hand the PSM over automatically.

Connect and subscribe from the central

The central scans for the service, stops scanning and connects to the first device it hears:

example/gattpong.js
  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/gattpong.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)
  })

Once connected, everything happens through events on the Peripheral, one step at a time: discover the service, discover its characteristics, subscribe, and write once the subscription is confirmed by notifyState:

example/gattpong.js
  central.on('connect', (peripheral) => {
    console.log('connected:', peripheral.id)

    let characteristic = null

    // The server notifies, the central answers with a write.
    const reply = rally((message) => peripheral.write(characteristic, Buffer.from(message)))

    peripheral.on('servicesDiscover', (services) => {
      for (const service of services) {
        if (same(service.uuid, SERVICE_UUID)) peripheral.discoverCharacteristics(service)
      }
    })

    peripheral.on('characteristicsDiscover', (service, characteristics) => {
      for (const found of characteristics) {
        if (!same(found.uuid, CHARACTERISTIC_UUID)) continue
        characteristic = found
        peripheral.subscribe(characteristic)
      }
    })

    peripheral.on('notifyState', (subscribed, isNotifying) => {
      if (!isNotifying) return
      console.log('subscribed')
      console.log('>', 'ping')
      peripheral.write(characteristic, Buffer.from('ping'))
    })

    peripheral.on('notify', (notified, data) => reply(Buffer.from(data).toString()))

    peripheral.on('disconnect', () => console.log('disconnected'))
    peripheral.on('error', (err) => console.error('peripheral error:', err.message))

    peripheral.discoverServices([SERVICE_UUID])
  })

peripheral.write() waits for the server to confirm by default. Pass false as its third argument to write without a response, on a characteristic created with writeWithoutResponse: true. To read a value once instead of subscribing, call peripheral.read(characteristic) and listen for read.

Compare UUIDs the way the example does

Every UUID comparison in gattpong.js goes through same(), never ===:

example/gattpong.js
// CoreBluetooth reports UUIDs uppercase, and Bluetooth-base ones in their
// 16-bit short form; BlueZ and Android report them long and lowercase.
function same(uuid, other) {
  return expand(uuid) === expand(other)
}

function expand(uuid) {
  uuid = uuid.toLowerCase()
  if (uuid.length === 4) return `0000${uuid}-0000-1000-8000-00805f9b34fb`
  if (uuid.length === 8) return `${uuid}-0000-1000-8000-00805f9b34fb`
  return uuid
}

CoreBluetooth reports UUIDs uppercase, and reports those in the Bluetooth base range in their short 16-bit form. Android and BlueZ report them long and lowercase. Whichever form you write your UUIDs in, a plain === fails on at least one platform, so copy this helper into any code that matches services or characteristics.

Keep values small

A single write or notification carries at most the connection's ATT MTU minus 3 bytes. Apple and Linux negotiate the MTU themselves. On Android, ask for a larger one with peripheral.requestMtu() after connecting and wait for mtuChanged; see Use platform-specific Bluetooth features. Anything bigger than a few hundred bytes belongs on an L2CAP channel.

Where to go next

Last updated on

Was this helpful?

On this page