macchina.io EDGE

BtLE Programming Guide

Introduction

The IoT::BtLE classes in macchina.io EDGE provide an API for communicating with Bluetooth LE (BLE) devices. Bluetooth™️ is a wireless technology used to build Personal Area Networks (PAN) or piconets. Since it’s original development by Ericsson in 1989, it has grown in popularity to the point where today it has become a standard feature in many segments of the consumer electronic market.

The use of Bluetooth in industrial IoT is also growing, mostly due to Bluetooth LE, a newer variant of Bluetooth optimized for minimum power usage, as is required for wireless, battery-powered sensor devices.

See this blog post for a general introduction to Bluetooth LE technology.

The current implementation of the BtLE APIs in macchina.io EDGE is based on the BlueZ protocol stack, although alternative implementations would be possible as well.

Prerequisites

To enable the BtLE APIs in macchina.io EDGE, additional software components must be installed.

Bluetooth Protocol Support

First, on the Linux device running macchina.io EDGE with the BtLE APIs, Bluetooth support must be installed. The following instructions are for Debian-based systems (including Raspberry Pi OS and Ubuntu).

$ sudo apt-get install -y bluetooth

BlueZ Helper

Furthermore, a helper executable, bluez-helper, is required. This acts as a command-line interface to the BlueZ protocol stack and is used by the BtLE API implementation.

bluez-helper includes source code from the BlueZ project, which is made available under Version 2 of the GNU General Public License. Therefore, bluez-helper is also made available under Version 2 of the GNU General Public License (GPLv2).

Furthermore, bluez-helper also includes source code (bluepy-helper.c) from the bluepy project by Ian Harvey, also licensed under GPLv2.

bluez-helper serves two purposes. First, the command-line interface provided by bluez-helper is easier to use by clients than using the BlueZ APIs directly. Second, since BlueZ is licensed under the GPLv2, any code directly linking the BlueZ libraries would also have to be licensed under the same license. Having a process boundary between the BtLE implementation and BlueZ avoids this requirement.

NOTE: The "official" way if using the BlueZ functionality is via D-Bus. It would also be possible to implement the BtLE APIs using D-Bus, but the current implementation uses the helper application approach to avoid having a dependency on D-Bus for the sole purpose of using BlueZ.

bluez-helper requires a Linux system with glib-2.0 and pkg-config installed, as well as GNU Make and GCC.

To install bluez-helper on a Debian-based system, the following dependencies must be installed:

$ sudo apt-get install -y build-essential libglib2.0-dev

To get the bluez-helper source code and build it:

$ git clone git@github.com:macchina-io/bluez-helper.git
$ cd bluez-helper
$ make -s

It is also possible to cross-compile bluez-helper by setting the CC Make variable to the cross-compiler.

To install bluez-helper to /usr/local/bin, run:

$ sudo make install

The install directory can be changed by setting the INSTALL_PREFIX Make variable.

In addition to copying the executable to the install directory, the install target will also set the cap_net_raw and cap_net_admin+eip capabilities on the executable. This allows the executable to be used from a non-privileged user account. Without this capability, certain operations (e.g., device discovery) can only be done as root.

Enabling BtLE in macchina.io EDGE

BtLE support must be enabled in macchina.io EDGE by adding the following configuration property to the main configuration file (macchina.properties).

btle.bluez.helper = /usr/local/bin/bluepy-helper

This configuration setting tells the BtLE API the location of the helper executable.

Enabling Bluetooth

As final step, the Bluetooth adapter on the Linux system must be enabled with:

$ sudo hciconfig hci0 up

To verify Bluetooth LE is working, a scan for peripheral devices can be performed:

$ sudo hcitool lescan
LE Scan ...
57:5E:1F:02:DF:A8 (unknown)
D8:F5:FC:71:E8:50 Ruuvi E850
F9:D4:EC:6A:1C:49 Ruuvi 1C49
...

Alternatively, the newer bluetoothctl command can also be used, if it is available.

$ bluetoothctl scan le
Discovery started
[CHG] Controller 2C:CF:67:19:96:E7 Discovering: yes
[CHG] Device 56:D4:7F:33:2B:FB RSSI: -61
...

Using BtLE

Scanning for Peripherals Using the Web User Interface

After restarting macchina.io EDGE, the BtLE Peripherals app in the web user interface can be used to scan for any Bluetooth LE devices in range.

BtLE Peripherals App
BtLE Peripherals App

It will show a table with the names, MAC address, address type, signal strength and connectable flag of all devices found. Table rows can be filtered by name, as in the example below.

BtLE Peripherals Scan
BtLE Peripherals Scan

Scanning for Peripherals Programmatically

To perform the same scan for peripherals programmatically, the IoT::BtLE::PeripheralBrowser service can be used. The service is registered under the service name io.macchina.btle.peripheralbrowser. A scan can be initiated by calling the browse() method. The method will initiate the scan and then return immediately. Any devices found will be reported asynchronously via the peripheralFound event.

A scan can be active or passive, depending on the boolean parameter passed to browse(). Full advertising data will only be provided in an active scan (true). Advertising data for a passive scan (false, default) is limited to at most 31 bytes.

The following JavaScript example code will initiate a passive scan for five seconds.

const pbRef = serviceRegistry.findByName('io.macchina.btle.peripheralbrowser');
var pb;
if (pbRef)
{
    pb = pbRef.instance();

    pb.on('peripheralFound', ev => {
        console.log('peripheralFound: %O', ev.data);
    });
    pb.browse();
    setTimeout(() => pb.cancelBrowse(), 5000);
}

Note that a passive scan will go on until explicitly terminated by calling cancelBrowse(), where as an active scan will automatically end after a few seconds.

The peripheralFound event will provide event data of type IoT::BtLE::PeripheralInfo. This includes the device name, address, signal strength (rssi) and advertising data.

Handling Advertising Data

Some devices, e.g. Ruuvi Tags, include useful information like sensor data in their advertising data.

Here is an example for reading and interpreting the advertising data of a Ruuvi Tag. The advertising data format (Ruuvi data format 5, RAWv2) is described here.

function mac2string(mac)
{
    let result = '';
    for (let i = 0; i < mac.length; i++)
    {
        if (i > 0) result += ':';
        result += mac[i].toString(16).padStart(2, '0');
    }
    return result;
}

const pbRef = serviceRegistry.findByName('io.macchina.btle.peripheralbrowser');
var pb;
if (pbRef)
{
    pb = pbRef.instance();

    pb.on('peripheralFound', ev => {
        for (let d of ev.data.data)
        {
            if (d.type === 0xff)
            {
                const buffer = d.data;
                if (buffer[0] === 0x99 && buffer[1] === 0x04)
                {
                    // RUUVI advertisement
                    if (buffer[2] === 0x05)
                    {
                        // Data format 5 (RAWv2)
                        const payload = buffer.slice(2);
                        const values = payload.unpack('!BhhH');
                        const temperature = values[1]*0.005;
                        const humidity = values[2]*0.0025;
                        const pressure = (values[3]+50000)/100;
                        const mac = mac2string(payload.slice(18, 24));
                        console.log('temp=%f hum=%f press=%f mac=%s', temperature, humidity, pressure, mac);
                    }
                }
            }
        }
    });

    pb.browse(true);
    setTimeout(() => pb.cancelBrowse(), 5000);
}

Upon receiving an advertisement, the code goes through all advertisement data elements (in the data array) and looks for element data type 0xff, manufacturer specific data. Within that element, it checks for the manufacturer code in the first two bytes (0x9904, Ruuvi Innovations) and the data format ID (0x05) in the third byte. The rest of the data is the payload, consisting of temperature, humidity, etc, which has to be converted and scaled from single byte values to usable SI unit values.

Accessing Peripherals with the GATT Protocol

Bluetooth LE uses a data model based on services, characteristics and attributes. The GATT (Generic Attribute Profile) protocol is used to discover, read and modify these.

A characteristic is a data value transferred between client and server. The data value can be a sensor value or a configuration setting. A characteristic consists of one or more attributes containing the actual value, as well as configuration options and meta data. Values can be read by actively reading them, or the device can send notifications when it changes. Attribute values are an opaque sequence of bytes from a protocol point of view. Many attributes contain 8 or 16-bit (little endian) integer values or ASCII/UTF-8 strings.

Pretty much everything in the GATT protocol is identified by UUIDs. Since a UUID takes up 16 bytes, which is quite much for a protocol optimized for really small data sizes, two special variants of UUIDs are also used. One is a 16-bit UUID, the other a 32-bit UUID. These UUIDs are basically short hand notations for full UUIDs, where the value is used to form the first 32 bits of a full UUID. The rest of the UUID is fixed to the special value "-0000-1000-8000-00805f9b34fb". Therefore, the shorthand 16-bit UUID 0x2902 would correspond to the full UUID 00002902-0000-1000-8000-00805f9b34fb. The Poco::UUID class in C++ and UUID objects in JavaScript are used to represent GATT UUIDs.

The IoT::BtLE::Peripheral interface provides a generic interface for interacting with peripheral devices.

In order to communicate with a device, an instance of IoT::BtLE::Peripheral service must be obtained. This is done via the IoT::BtLE::PeripheralManager service, registered under the name io.macchina.btle.peripheralmanager. This service has one method, findPeripheral(), which takes a 6-byte MAC address in string format (xx:xx:xx:xx:xx:xx) as parameter. The method returns the name of the Peripheral service instance representing the device with the given address.

The following JavaScript code example shows how to obtain a Peripheral object.

const pmRef = serviceRegistry.findByName('io.macchina.btle.peripheralmanager');
let pm;
if (pmRef)
{
    pm = pmRef.instance();
    const peripheralName = pm.findPeripheral('80:6F:B0:F0:25:91');
    const peripheralRef = serviceRegistry.findByName(peripheralName);
    const peripheral = peripheralRef.instance();
    console.log(peripheral.address());
    // ...
}

First the PeripheralManager service is obtained via the service registry. The PeripheralManager is then asked to return the name of the Peripheral service for that device with the given address. The PeripheralManager will check whether such a service already exists. If not, it will create a new Peripheral instance and register it with the service registry. The name of the service will be returned, which can then be used to find the service in the registry.

After the peripheral instance has been obtained, a connection with the peripheral must be established. With the connection established, requests can be sent to the peripheral device, and it's also possible to receive notifications or indications from the device. The Peripheral object reports the connection status via events.

peripheral.on('connected', ev => {
    console.log('Peripheral connected: %s', ev.source.address());
});

peripheral.on('disconnected', ev => {
    console.log('Peripheral disconnected: %s', ev.source.address());
});

peripheral.connectAsync();

Note that we use the connectAsync() method to initiate the connection. There is also a connect() method, but this method will block until either the connection has been established, or the connection request has timed out.

In the event handler function for any peripheral event, the peripheral object is available via the source property of the event object.

Service Discovery

The first step after connecting to a device is learning about its services. This can be done by calling the services() method. This method returns a list of UUIDs representing the services supported by the peripheral.

To check whether a device supports one of the standard services identified by a 16-bit or 32-bit UUID, the serviceUUIDForAssignedNumber() method can be used. This will return a full UUID if the service is supported, otherwise a null UUID.

Basic Services

There are some services that must be supported by every device. For example, the Generic Access Service (UUID 0x1800) provides the device name and appearance information. The Device Information Service (UUID 0x180A) provides the manufacturer name, model number, hardware and firmware/software revision. The Peripheral service provides methods for obtaining this information.

peripheral.on('connected', ev => {
    console.log('Peripheral connected: %s', peripheral.address());
    console.log('  Device Name       : %s', peripheral.deviceName());
    console.log('  Manufacturer Name : %s', peripheral.manufacturerName());
    console.log('  Model Number.     : %s', peripheral.modelNumber());
    console.log('  Hardware Revision : %s', peripheral.hardwareRevision());
    console.log('  Firmware Revision : %s', peripheral.firmwareRevision());
    console.log('  Software Revision : %s', peripheral.softwareRevision());
    console.log('  Services:\n%O', peripheral.services());
});

Discovering Characteristics of a Service

The characteristics of a service can be discovered with the characteristics() method. The UUID of the service must be passed as parameter. The method returns an array of UUIDs representing the supported characteristics of the service.

Reading and Writing Characteristics

For accessing a Characteristic's attributes, a so-called handle is used. A handle is a 16-bit integer that identifies an attribute in a device. For a specific device (and version), handles are usually constant. However, different devices usually use different handle values for the same attribute.

Characteristics can have different properties, which are represented as bits in a 16-bit value. Properties give information about supported access modes (e.g., read-only, read/write, notification, etc.).

GATT_PROP_BROADCAST     = 0x01,
GATT_PROP_READ          = 0x02,
GATT_PROP_WRITE_NO_RESP = 0x04,
GATT_PROP_WRITE         = 0x08,
GATT_PROP_NOTIFY        = 0x10,
GATT_PROP_INDICATE      = 0x20,
GATT_PROP_WRITE_SIGNED  = 0x40,
GATT_PROP_EXTENDED      = 0x80

The properties and value handle of a characteristic can be obtained with the characteristic() method, taking the service UUID and requested characteristic UUID as parameters. Alternatively, the characteristicForAssignedNumber() method can be used. This one takes a service UUID and a 16-bit or 32-bit UUID. Both method return a IoT::BtLE::Characteristic structure containing properties and valueHandle.

To read or write a characteristic's value, its valueHandle is used. Note that some characteristics may have more than one handle, e.g. there's a separate handle for enabling or disabling notifications (client configuration, 0x2902).

The IoT::BtLE::Peripheral interface provides various methods for reading and writing attribute values of different types, by their handle, e.g. readUInt8(), readInt32(), readString(), etc.

For example, to read the device name (what peripheral.deviceName() does), first we check if the peripheral supports the Generic Access service (0x1800). If so, we find the characteristic containing the name (0x2A00).

const gaUUID = peripheral.serviceUUIDForAssignedNumber(0x1800);
if (!gaUUID.isNull())
{
    const dnChar = peripheral.characteristicForAssignedNumber(gaUUID, 0x2a00);
    const deviceName = peripheral.readString0(dnChar.valueHandle);
    console.log('Device Name: %s', deviceName);
}

The Battery service (0x180F) will provide the battery level in percent (characteristic 0x2A19) for battery-powered devices.

const batUUID = peripheral.serviceUUIDForAssignedNumber(0x180f);
if (!batUUID.isNull())
{
    const batChar = peripheral.characteristicForAssignedNumber(batUUID, 0x2a19);
    const level = peripheral.readUInt8(batChar.valueHandle);
    console.log('Battery Level: %d %', level);
}

Notifications

It is possible to receive a notification when the battery level changes. To enable notifications, first an event handler must be registered for the notificationReceived event. Then, notifications for the battery level must be enabled by writing the value 1 to the battery level's client characteristic configuration (0x2902).

peripheral.on('notificationReceived', ev => {
    console.log('Notification received: %O', ev);
    if (ev.data.handle === batChar.valueHandle)
    {
        const level = ev.data.data.readUInt8(0);
        console.log('Battery Level Update: %d', level);
    }
});

const batClientCfgHandle = peripheral.handleForDescriptor(batUUID, peripheral.expandUUID(0x2a19), peripheral.expandUUID(0x2902));
peripheral.writeUInt16(batClientCfgHandle, 1);

Note that the handle for the client configuration attribute is found by calling the handleForDescriptor() method. This method takes three UUIDs, the service UUID, the characteristic UUID and the descriptor UUID. The descriptor UUID for the client characteristic configuration is 0x2902, which has to be expanded into a full UUID by calling the expandUUID() method.

The event handler receives the value as a raw byte buffer (data). The actual value must be read from that buffer. In JavaScript, the various Buffer methods (readUInt16(), unpack(), etc.) can be used. In C++, the best way to read the buffer is via a Poco::BinaryReader and a Poco::MemoryInputStream.

The event handler also should check the value handle, especially if the peripheral can send notifications for multiple characteristics. The value handle reported in the event has the same value as the valueHandle of the characteristic.

C++ Example Code

C++ example code for working with the BtLE interfaces can be found in the devices/SensorTag and devices/XDK directories in the macchina.io EDGE source code.

SensorTag implements various devices (based on the IoT::Devices::Sensor and IoT::Devices::Accelerometer interfaces) to the built-in sensors (temperature, humidity, light, accelerometer, etc.) of the TI SensorTag family of evaluation kits (CC2541, CC2650, CC1352).

XDK implements the same for the XDK evaluation kit from LEGIC (previously Bosch).

Securely control IoT edge devices from anywhere   Connect a Device