Any day now, my nRF52 based development board will arrive, while some nRF51 boards have been around already for some while, waiting to finally get into use. And yes, one day I'll really move towards the ARM Cortex-M world. But until then, I'll remain in 8bit AVR territory, and be glad for Nordic's nRF8001 BLE connectivity chip, which simply provides the Bluetooth LE stack and can be hooked up to and used by anything with an SPI connection.
Olimex was once again my choice of source, so this article is focusing on their nRF8001 module, but I don't think there are much differences to other boards such as the one from Adafruit. The Olimex board has an additional Bluetooth test interface via UART (which won't be used here) but requires 3.3V as input voltage, while the Adafruit board comes equipped with a 3.3V regulator and will also work with e.g. 5V. Other than that, they both use SPI with some additional chip select sync logic for communication, that's all that matters for our purpose.
Setting up the nRF8001
Since the nRF8001 is just a radio connectivity chip with SPI, the setup will happen via SPI as well. In this case, setup means both the Bluetooth radio setup as well as BLE services and characteristics. Providing all these options can obviously be a bit complex to handle all manually, so Nordic provides nRFgoStudio to click your configuration together and generate a C header file containing all the information as raw (and rather undocumented) setup commands. Once the nRF8001 comes out of reset, the setup commands can be sent as-is via SPI, and everything is ready to go.
Unfortunately, nRFgo Studio is a Windows-only program, and there don't seem to be any plans to port it to other platforms. But good news, it will work with Wine / PlayOnLinux. Personally, I had a smoother experience installing it through PlayOnLinux though.
nRFgo Studio has some additional features than setting up the nRF8001 chip, but those won't matter to us. And to actually use the program, I recommend having a look at the built-in help on "nRF8001 Configuration". They do a better job than I could do myself as part of this article, so I won't even try.
A simple example
To keep things simple, the example system will be a non-secured (i.e. no bonding required) Bluetooth device with a single LED and a button.
- the LED will be connected to the AVR's PWM output and the duty cycle is controlled via BLE characteristic.
- the button is connected to the AVR's external interrupt pin
INT0
and any level change is sent to the remote BLE device if it enabled notifications for the button state characteristic.
For this, there is one custom service defined with those two characteristics, i.e one write without response and one notify characteristic. Both will require some additional interaction, so to start off easier, a simple read characteristic is also added, that just replies with a pre-defined (as part of the configuration in nRFgo Studio) serial number.
UUIDs, services and characteristics
To uniquely define the custom services and characteristics, the universally unique identifier (UUID) needs to be exactly that: universally unique. No other BLE service or characteristic should use it. It's not that relevant for a test system like this, but I wouldn't want to take the official Bluetooth UUID space for it either. So might as well take a random UUID space.
nRFgo Studio lets you create such a random UUID space, which in case of the example system is 4215xxxx-C696-4C43-AB71-20B33874F581
, meaning all but the two bytes marked with xxxx
will be static, and those two bytes can be used to uniquely specify each service and characteristic.
In our case, there is:
UUID | Type | Name | Comment |
---|---|---|---|
0xa001 |
Service | Example Service | the BLE service for our system |
0xc001 |
Characteristic | PWM Duty Cycle | Write without response |
0xc002 |
Characteristic | Button State | Notification |
0xcf00 |
Characteristic | Serial Number | Read-only, specified in nRFgo Studio |
All of this can be found and imported to nRFgo Studio from the XML configuration file and results in a generated services.h
file that is then directly used in the code.
The code
The generated services.h
file is maybe worth to have a first look at before anything else. It probably won't make much sense on first glance, and most of it is anyway just some hex data (the setup data). Other than that, the word pipe can be seen rather often.
Tell me about Pipes
The pipes are the communication channels between the nRF8001 module and the application connected to it (i.e. what's running on the AVR in this case). Every communication between the application and remote side will happen through those pipes.
In this example, there are two of them: one for the PWM duty cycle, and one for the button state. As you can see, there is no pipe for the serial number characteristic, because this is a read-only value without any modification option configured, so nRF8001 will take care of that automatically whenever the remote side wants to read it.
Also, the RX and TX names seem reversed, as they are again from the application's point of view, not the remote side's. And from application point of view, we really do send the button state and receive the PWM duty cycle value.
Other nRF8001 related header files
There are two additional header files in the nrf/
directory, hal_platform.h
and aci.h
. The first one is empty and is meant for hardware specific implementations. It could be just removed along with its inclusion in services.h
, but since that file is auto-generated, it will override the modifications next time it's getting generated. Plus, if done properly, the AVR related SPI communication should be actually separated in that hal_platform.h
file.
The aci.h
file is part of Nordic's own SDK and was copied from there. There's for example Nordic's SDK for Arduino containing the file. Yes, using that SDK is also an option, it seems quite extensive and possibly handles every case there is to handle. Including those never needed in this system.
Everything else
The rest is what is required to put all the pieces together:
uart.c
anduart.h
for debug output and debug interfacespi.c
andspi.h
for SPI handlingnrf.c
andnrf.h
for everything related to Bluetooth LE communication through the nRF8001 modulemain.c
where everything is starting from
There's nothing too exciting in the first two (well, four with header) files, so the full focus is given to the nrf.c
and main.c
files.
The real code
Well, okay, the main.c
isn't much of a thrill either, the usual system setup and main loop. The main loop takes care of three main parts:
- Button interrupt handling
- UART debug interface with very limited functionality
- everything nRF8001 related
The actual INT0
interrupt handler is only setting an internal flag that is then checked during each loop iteration. Sending the button state information through the BLE module can take quite some time (from a CPU point of view), so taking care of this inside the interrupt handler itself is not the best idea.
The UART debug interface felt like a nice to have feature during development and testing, and for now only has a reset and temperature command which either resets and re-initializes the nRF8001, or reads the module's on-chip temperature.
nrf.c
At last, reaching the centerpiece.
Now is a good time to get the nRF8001 Product Specification to make sense of the contents in nrf.c
. While there's lots if useful information in it, chapter 23 Protocol reference and everything following that will be most useful when reading the code. And of course, the source file itself
Breaking down the file in its main parts, we have:
nrf_setup()
for setting up nRF8001 with the generated data fromservices.h
nrf_advertise()
to start advertising and set the module to wait for connectionsnrf_transmit()
and its derivative macros (nrf_send()
,nrf_receive()
andnrf_txrx()
) to send and receive data to and from the nRF8001 modulenrf_parse()
to handle received events, including connection state and PWM duty cycle characteristic write commandsnrf_send_button_data()
to send the button state
Again, the nRF8001 Product Specification and example system source code next to each other are the best documentation here. I could explain more details about the code, but I kinda hope I did a well enough job with the code readability and comments.
Show me what you got
Setting up the example system is one thing, but actually communicating with it, getting the button state changes and set the LED brightness, and ultimately verifying it actually works is even better. The easiest way is to use a Bluetooth dongle (or a Raspberry Pi and the like with BLE) and hcitool
andgatttool
from BlueZ.
hcitool
is mostly needed to scan for our device and make sure it's actually advertising and also to get its IP address (we could also read that from the module itself, but we'd still want to see if we can scan for it)
$ sudo hcitool lescan
LE Scan ...
C5:9D:03:34:03:C8 AVR nRF8001
So here we have the MAC address and it shows the name defined in nRFgo Studio's GAP settings. Let's get the available BLE services from it by calling gatttool
with our device's MAC address, using a random MAC address as our own and the --primary
parameter:
$ gatttool -b C5:9D:03:34:03:C8 -t random --primary
attr handle = 0x0001, end grp handle = 0x0007 uuid: 00001800-0000-1000-8000-00805f9b34fb
attr handle = 0x0008, end grp handle = 0x0008 uuid: 00001801-0000-1000-8000-00805f9b34fb
attr handle = 0x0009, end grp handle = 0xffff uuid: 4215a001-c696-4c43-ab71-20b33874f581
The first two services are Bluetooth specific BLE services (Generic Access and Generic Attribute services) using the official Bluetooth base UUID, while the third one is our custom Example Service as specified above. Great.
Let's try the same for the characteristics:
$ gatttool -b C5:9D:03:34:03:C8 -t random --characteristics
handle = 0x0002, char properties = 0x02, char value handle = 0x0003, uuid = 00002a00-0000-1000-8000-00805f9b34fb
handle = 0x0004, char properties = 0x02, char value handle = 0x0005, uuid = 00002a01-0000-1000-8000-00805f9b34fb
handle = 0x0006, char properties = 0x02, char value handle = 0x0007, uuid = 00002a04-0000-1000-8000-00805f9b34fb
handle = 0x000a, char properties = 0x06, char value handle = 0x000b, uuid = 4215c001-c696-4c43-ab71-20b33874f581
handle = 0x000c, char properties = 0x10, char value handle = 0x000d, uuid = 4215c002-c696-4c43-ab71-20b33874f581
handle = 0x000f, char properties = 0x02, char value handle = 0x0010, uuid = 4215cf00-c696-4c43-ab71-20b33874f581
Again starting with official Bluetooth characteristics, followed by our own three characteristics.
Now, I added the serial number characteristic as simple read-only value to have a basic test of reading a value from the nRF8001 module. Calling gatttool --char-read
will do that, but it will require a handle parameter. Looking at the last output, there is a handle and a char value handle in each line. In this case, we'll need the char value handle. The handle for our 0xcf00
serial number characteristic is therefore 0x0010
$ gatttool -b C5:9D:03:34:03:C8 -t random --char-read -a 0x0010
Characteristic value/descriptor: b0 0b fa ce
The same concept works for writing the PWM duty cycle characteristic 0xc001
with char value handle of 0x000b
. The duty cycle parameter is given directly to the AVR's 8bit timer register and therefore a value between 0 and 255 instead of 0% and 100%.
$ gatttool -b C5:9D:03:34:03:C8 -t random --char-write -a 0x000b -n 7f
$ gatttool -b C5:9D:03:34:03:C8 -t random --char-write -a 0x000b -n ff
$ gatttool -b C5:9D:03:34:03:C8 -t random --char-write -a 0x000b -n 00
This will set the LED brightness to half, full and off again. Note that -n
parameter (characteristic write value) doesn't have the 0x
prefix. Not sure if this is/was a bug since the help output shows it with prefix. If one won't work, try the other.
What's now left is the button state. For this, we need to look into characteristic descriptors. Mainly, our custom button state characteristic needs to be configured to enable notifications on it, using its Client Characteristic Configuration. Again, gatttool
gives us all what we need.
$ gatttool -b C5:9D:03:34:03:C8 -t random --char-desc
handle = 0x0001, uuid = 00002800-0000-1000-8000-00805f9b34fb
handle = 0x0002, uuid = 00002803-0000-1000-8000-00805f9b34fb
handle = 0x0003, uuid = 00002a00-0000-1000-8000-00805f9b34fb
handle = 0x0004, uuid = 00002803-0000-1000-8000-00805f9b34fb
handle = 0x0005, uuid = 00002a01-0000-1000-8000-00805f9b34fb
handle = 0x0006, uuid = 00002803-0000-1000-8000-00805f9b34fb
handle = 0x0007, uuid = 00002a04-0000-1000-8000-00805f9b34fb
handle = 0x0008, uuid = 00002800-0000-1000-8000-00805f9b34fb
handle = 0x0009, uuid = 00002800-0000-1000-8000-00805f9b34fb
handle = 0x000a, uuid = 00002803-0000-1000-8000-00805f9b34fb
handle = 0x000b, uuid = 4215c001-c696-4c43-ab71-20b33874f581
handle = 0x000c, uuid = 00002803-0000-1000-8000-00805f9b34fb
handle = 0x000d, uuid = 4215c002-c696-4c43-ab71-20b33874f581
handle = 0x000e, uuid = 00002902-0000-1000-8000-00805f9b34fb
handle = 0x000f, uuid = 00002803-0000-1000-8000-00805f9b34fb
handle = 0x0010, uuid = 4215cf00-c696-4c43-ab71-20b33874f581
So we see our 0xc002
button state characteristic in line 14, and right afterwards the Bluetooth 0x2902
client characteristic configuration descriptor in line 15, with the handle 0x000e
. Based on its documentation, the first bit needs to be set to enable notifications. In theory, this means writing 0x0001
to handle 0x000e
will do that. In practice, byte order is somewhat different and 0x0100
(i.e. LSB first) is needed.
$ gatttool -b C5:9D:03:34:03:C8 -t random --char-write-req -a 0x000e -n 0100 --listen
Characteristic value was written successfully
Unlike the other commands, this time gatttool
doesn't instantly return back to the shell but waits for the data. So pressing and releasing the button will show
Notification handle = 0x000d value: 01
Notification handle = 0x000d value: 00
and continue displaying the button states until interrupted with CTRL+C.
There we go, everything appears to work nicely.
Getting interactive
Calling gatttool
from the console as described above works all well, but for every command called, the connection is established and dropped afterwards. This is okay in general, but there's an alternative by using its interactive mode which provides its own command line interface, in which connections can persist.
Instead of adding any command as parameter, -I
will run gatttool
in interactive mode.
$ gatttool -b C5:9D:03:34:03:C8 -t random -I
[C5:9D:03:34:03:C8][LE]>
By default, no connection is active yet but has to be established explicitly
[C5:9D:03:34:03:C8][LE]> connect
Attempting to connect to C5:9D:03:34:03:C8
Connection successful
[C5:9D:03:34:03:C8][LE]>
If your terminal supports colors, the MAC address should turn from grey to blue after the connection was successful.
Inside the interactive mode's CLI, things work very much the same as on the terminal - plus tab completion.
[C5:9D:03:34:03:C8][LE]> char-read-hnd 0x0010
Characteristic value/descriptor: b0 0b fa ce
[C5:9D:03:34:03:C8][LE]> char-write-
char-write-cmd char-write-req
[C5:9D:03:34:03:C8][LE]> char-write-cmd 0x000b 7f
[C5:9D:03:34:03:C8][LE]> char-write-cmd 0x000b ff
[C5:9D:03:34:03:C8][LE]> char-write-cmd 0x000b 00
[C5:9D:03:34:03:C8][LE]> char-write-req 0x000e 0100
Characteristic value was written successfully
Notification handle = 0x000d value: 01
Notification handle = 0x000d value: 00
[C5:9D:03:34:03:C8][LE]>
Moving on from here
One obvious next step would be to control the BLE device from a mobile app. However, the functionality is so little and its usefulness is even less, it feels pointless to write an app for it. Some generic BLE app that allows you to read and write characteristics is probably fine enough.