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. It has two roles:
- A server publishes services, each holding characteristics.
- A central connects to the server, then reads and writes the characteristics, or subscribes to be notified when they change.
Use GATT for small values and for talking to existing devices, such as a heart-rate monitor. For a stream of data between two of your own apps, use an L2CAP channel.
This guide follows two scripts from the bare-bluetooth repository:
scan.jslists what's nearby.gattpong.jshas a central writepingto a characteristic, and the server answers with apongnotification.
For how the pieces fit together, read Bluetooth in Bare first. For the rules that keep GATT code reliable across platforms, read Bluetooth best practices.
Before you begin
- For
scan.js, one machine with Bluetooth. Forgattpong.js, two. macOS works. Linux works withbare-bluetooth0.6.3 or later, withbluetoothd(the BlueZ daemon) running and the adapter powered on (bluetoothctl power on). - Bare on each machine:
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 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)central.startScan() takes an optional array of service UUIDs:
- With no arguments, it reports every device in range.
- With an array of service UUIDs, it reports only devices that advertise one of them.
Keep these points in mind:
- The
discoverevent fires for advertisements rather than devices, so the same device turns up many times. Key what you find by theperipheral.idproperty, and keep the latest. startScan()throws unless the state is'poweredOn'. That is why the call sits in thestateChangelistener.
Run the 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 machines. 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. Once the radio is on, it adds the characteristic in a Service with server.addService(). Once the service has been added, it starts advertising with server.startAdvertising():
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))
}The server handles three kinds of traffic:
- Writes arrive as a
writeRequestevent with an array ofWriteRequestobjects. If a request'sresponseNeededis set, answer it withserver.respondToRequest()and an ATT result code such asServer.ATT_SUCCESS. The central's write doesn't complete until you do. Answer before you do any slow work with the data. - Notifications go out with
server.updateValue(characteristic, data), which reaches every central that is subscribed to that characteristic. Thesubscribeandunsubscribeevents tell you when a central starts or stops listening. - Reads arrive as a
readRequestevent with aReadRequest. This example doesn't use them. Answer a read withrespondToRequest(request, Server.ATT_SUCCESS, data), and userequest.offsetif a central reads a long value in parts. On Android, every read reaches your listener.
For a value that never changes, you can skip the readRequest listener. Create the characteristic with a value instead. Apple then serves the value itself, and so does Linux as long as nothing listens for readRequest.
value belongs to characteristics that you create. From bare-bluetooth 0.6.3, assigning value on a characteristic that the central discovered throws on every platform. To change a remote value, use peripheral.write().
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 with central.stopScan(), and connects to the first device it hears with central.connect():
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 with
peripheral.discoverServices(). - Discover its characteristics with
peripheral.discoverCharacteristics(). - Subscribe with
peripheral.subscribe(). - Write with
peripheral.write()once thenotifyStateevent confirms the subscription.
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])
})Two details about writing and reading:
peripheral.write()waits for the server to confirm by default. To write without a response, passfalseas the third argument, on a characteristic created withwriteWithoutResponse: true.- To read a value once instead of subscribing, call
peripheral.read(characteristic)and listen for thereadevent.
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
}The platforms report UUIDs differently:
- CoreBluetooth reports UUIDs uppercase. It reports those in the Bluetooth base range in their short 16-bit form.
- Android and BlueZ report UUIDs long and lowercase.
Whichever form you write your UUIDs in, a plain === fails on at least one platform. 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, call
peripheral.requestMtu()after connecting, and wait for themtuChangedevent. See Use platform-specific Bluetooth features.
Move anything bigger than a few hundred bytes to 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,Service, andCharacteristicin full.
Last updated on