Bluetooth with Raspberry Pi and bleno – part 2: GATT

08.01.2018

Marcin Budny
Head of R&D
Marcin Budny

This post is a continuation of the series on Bluetooth with bleno.
In this post I’ll explain how to implement simple functionality on top of GATT protocol.

GATT

GATT (Generic Attribute Profile) specifies a hierarchical data structure, that can be used by a GATT client and GATT server to communicate with each other.

Source: https://www.bluetooth.com/specifications/gatt/generic-attributes-overview

The structure consists of one or more services. Each service has its unique UUID and contains a set of characteristics. Each characteristic also has its own UUID, a value, information about supported operations (like reading or writing) and other metadata. An analogy could be dictionaries (services) containing key – value entries (characteristics).

The GATT defines procedures for a client to discover services and characteristics hosted on a server. Client then can read, write or subscribe to selected characteristics.

Contrary to what might be intuitive, the GATT server is usually a Bluetooth peripheral device like a heartbeat monitor. The client on the other hand is a central device like a smartphone.

There are also some predefined services and characteristics that unify the way of exchaning common types of information. For example, a device may implement the Battery Service which has a mandatory Battery Level characteristic. This way every client can easily discover battery level reporting functionality in a device, regardless of its type and manufacturer.

A Bluetooth calculator

We’ll implement a simple calculator that adds two numbers written to its input characteristics and lets the client read the result. We’re not going to use predefined services or characteristics, but rather use our own custom ones.

Selecting UUIDs

A base Bluetooth UUID is 00000000-0000-1000-8000-00805F9B34FB .All services and attributes using short 16 and 32 bit uuids are actually converted from 128 bit XXXXYYYY-0000-1000-8000-00805F9B34FB  format. When selecting a custom UUID, make sure it is not in that range. Remember that you have no guarantee on the uniqueness of the UUID – somebody else might be also using it.

The code

I’ll assume you know how to setup bleno and write the boilerplate code from the previous post. First, let’s declare the UUIDs.

const CALCULATOR_SERVICE_UUID = "00010000-89BD-43C8-9231-40F6E305F96D";
const ARGUMENT_1_UUID = "00010001-89BD-43C8-9231-40F6E305F96D";
const ARGUMENT_2_UUID = "00010002-89BD-43C8-9231-40F6E305F96D";
const RESULT_UUID = "00010010-89BD-43C8-9231-40F6E305F96D";

Now we need to create a characteristic representing an argument. We’ll have a generic one for both arguments.

class ArgumentCharacteristic extends bleno.Characteristic {
    constructor(uuid, name) {
        super({
            uuid: uuid,
            properties: ["write"],
            value: null,
            descriptors: [
                new bleno.Descriptor({
                    uuid: "2901",
                    value: name
                  })
            ]
        });

        this.argument = 0;
        this.name = name;
    }

    onWriteRequest(data, offset, withoutResponse, callback) {
        try {
            if(data.length != 1) {
                callback(this.RESULT_INVALID_ATTRIBUTE_LENGTH);
                return;
            }

            this.argument = data.readUInt8();
            console.log(`Argument ${this.name} is now ${this.argument}`);
            callback(this.RESULT_SUCCESS);

        } catch (err) {
            console.error(err);
            callback(this.RESULT_UNLIKELY_ERROR);
        }
    }
}

In the constructor, we’re calling the base class and specifying the characteristic’s UUID, access mode (write) and name descriptor (this is optional). Then we need to implement the onWriteRequestmethod that will be called by bleno framework when the client attempts to write a value. The value is available as a  Bufferso we need to parse it. For simplicity’s sake, we just assume the data is a 8 bit unsigned integer, but it can be basically anything.

*Note: There is a limit on the data size in the characteristic. The maximum is 512 bytes, however this data will not be sent all at once, but rather in chunks. The default chunk size is 23 bytes. In many cases, splitting data into chunks will be handled automatically for you by the Bluetooth programming framework you are using. However sending a lot of small packets has negative impact on throughput, so you might want to increase the chunk size by performing MTU (Max Transfer Unit) negotiation. Some client libraries, like the Android one, allow you to do that. Others, like the iOS one, handle this implicitly. Read more on MTU here.

Similar to argument, we also need the result characteristic:

class ResultCharacteristic extends bleno.Characteristic {
    constructor(calcResultFunc) {
        super({
            uuid: RESULT_UUID,
            properties: ["read"],
            value: null,
            descriptors: [
                new bleno.Descriptor({
                    uuid: "2901",
                    value: "Calculation result"
                  })
            ]
        });

        this.calcResultFunc = calcResultFunc;
    }

    onReadRequest(offset, callback) {
        try {
            const result = this.calcResultFunc();
            console.log(`Returning result: ${result}`);

            let data = new Buffer(1);
            data.writeUInt8(result, 0);
            callback(this.RESULT_SUCCESS, data);
        } catch (err) {
            console.error(err);
            callback(this.RESULT_UNLIKELY_ERROR);
        }
    }
}

Here, the client needs to read data, so we need to implement onReadRequestmethod. Again, the data needs to be serialized into a Buffer.

Now, we need to declare a service:

bleno.on("advertisingStart", err => {

    console.log("Configuring services...");
    
    if(err) {
        console.error(err);
        return;
    }

    let argument1 = new ArgumentCharacteristic(ARGUMENT_1_UUID, "Argument 1");
    let argument2 = new ArgumentCharacteristic(ARGUMENT_2_UUID, "Argument 2");
    let result = new ResultCharacteristic(() => argument1.argument + argument2.argument);

    let calculator = new bleno.PrimaryService({
        uuid: CALCULATOR_SERVICE_UUID,
        characteristics: [
            argument1,
            argument2,
            result
        ]
    });

    bleno.setServices([calculator], err => {
        if(err)
            console.log(err);
        else
            console.log("Services configured");
    });
});

bleno.on("stateChange", state => {

    if (state === "poweredOn") {
        
        bleno.startAdvertising("Calculator", [CALCULATOR_SERVICE_UUID], err => {
            if (err) console.log(err);
        });

    } else {
        console.log("Stopping...");
        bleno.stopAdvertising();
    }        
});

The full source code is available here.

When you run the app with sudo node app.js, you can then test it with a generic BLE client app like this one. It’s not ideal, but allows you to do some testing without actually writing client code. Alternatively, you can use  hcitooland gatttoolfrom another Linux machine.


Here’s a sample app’s output. The disconnect in the middle is required due to poor design of the client app – you can’t select another characteristic to write without disconnecting first.

Starting bleno...
Bleno: Adapter changed state to poweredOn
Configuring services...
Bleno: servicesSet
Services configured
Bleno: advertisingStart
Bleno: accept 59:99:a8:5d:d7:d6
Argument Argument 2 is now 34
Bleno: disconnect 59:99:a8:5d:d7:d6
Bleno: accept 59:99:a8:5d:d7:d6
Argument Argument 1 is now 17
Returning result: 51
Bleno: disconnect 59:99:a8:5d:d7:d6

Summary

In this post I explained the GATT protocol and showed how to implement a simple Bluetooth service with bleno. I the next post, I’ll explain how to notify client about changes in the data.