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; forgattpong.js, two. macOS works, and Linux withbare-bluetooth0.6.3-0 or later, withbluetoothdrunning 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-0tag, 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 installScan 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.jsconst { 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 listenOn the other, start the central:
bare example/gattpong.js connectUnlike 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:
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'sresponseNeededis set, answer it withrespondToRequest()and an ATT result code such asServer.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.subscribeandunsubscribetell you when a central starts or stops listening. - Reads, which this example doesn't use, arrive as
readRequest. Answer them withrespondToRequest(request, Server.ATT_SUCCESS, data), usingrequest.offsetif 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 avalue: Apple then serves it itself, and so does Linux as long as nothing listens forreadRequest.
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:
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:
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 ===:
// 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
- Exchange data over L2CAP: streams instead of single values.
- Bluetooth best practices: flow control for notifications, caching, and other details of running GATT across platforms.
bare-bluetoothreference:Server,Central,Peripheral,ServiceandCharacteristicin full.
Last updated on