Custom BLE Services with the nRF SDK

It’s very hard to write an introduction to an article about BLE without sounding a little ridiculous. What are you going to say, that it’s all around us today? It’s been all around us for five years. It’s the #1 choice for IoT applications today, owing in no small part to the fact that you can connect to any IoT device with a phone.

Today, I’m going to “talk” you through one of the most common, but also one of the most illustrative tasks that BLE development involves: writing a custom service (or a “vendor-specific service”, in BLE jargon). We’re going to do it from scratch, and we’ll discuss all the background on why we do things as we do — a lengthy discussion but, I hope, a useful one.

Table of Contents

Contents hide

Introduction

Why go through all this trouble? I think this exercise allows you to get a basic, hands-on feeling of the BLE protocol. The Bluetooth consortium standardizes a lot of services, and it’s not uncommon to have devices exposing nothing but standard services. But if you’re just starting out, integrating a bunch of those doesn’t teach you much about Bluetooth.

We’re going to use nRF’s stack because their radio modules are the most popular choice for BLE applications. Their modules are featured in everything, from low-cost hobbyist development modules to low-power medical devices. Sparkfun’s kits, like this one, are readily available to enthusiasts and lend themselves easily to a lot of interesting projects.

What we’ll do today. We’re going to write an application that blinks a LED, at a configurable rate. The application will expose a BLE service for that, and it will allow you to enable or disable the blinking and to configure the blinking rate. It’s a trivial example, but I picked it because it’s a) something we’re all familiar with and b) something that’s self-contained.

Prerequisites. You do need to be a little familiar with BLE, as this is not really a “BLE from grounds up” tutorial. However, knowing how difficult introductory material on BLE is, we will do a pretty extensive recap of the essentials first.

If you are reasonably familiar with BLE, and/or have some basic familiarity with the nRF SDK, you can skip the parts that I labelled “optional”.

Ideally, you should have already gone through an example or two, and should know to use one of the predefined services that the nRF SDK includes. If that’s not the case, I recommend that you at least cover basic information on BLE (a good starting point is here). nRF also has some introductory material that’s pretty terrible but will at least get you up to speed with the terminology: here, here and here. Ideally, though, you might want to take a few hours and skim through something like this.

What you’ll need. You’re going to need an nRF board, the toolchain and the nRF SDK.

The NRF board: in the interest of making this accessible to as wide an audience as possible, I’ve written and tested this on a SparkFun Pro nRF52840 Mini – Bluetooth Development Board. This is a good board. But if you plan to do anything serious with BLE, and especially if you plan to work on something that you’re going to sell to a bunch of people rather than give to your friends, I heartily recommend that you get one of nRF’s development boards. Real debugging and logging capabilities are definitely worth the price tag if you’re doing real-life development.

The toolchain and the SDK. Once again, in the interest of making this accessible to as wide an audience as possible, I’ve only used GCC (which is free) and the nRF SDK (which is also free). If you’re using Sparkfun’s board, they have a good tutorial on how to set it up here. I deviate from it in only one regard (the part called “Adding board files” there), and I’m going to show you how I do that in the section called “nRF SDK: A Minimal Application”. But it’s OK if you follow that tutorial to the letter, because what I do won’t break that setup, either.

A note on how to read this guide. Like my other HOWTOs (I purposely avoid the term “tutorial”), this guide is not meant for copying and pasting. Writing one of those would be quick and efficient but it wouldn’t teach you anything.

This HOWTO deliberately builds things from the ground up, so that I can show you not just how something is done, but — more importantly — why it’s done that way, and the kind of design process that goes into it. That makes it quite verbose but, I hope, also makes it a lot more useful than a 700-word guide on what to click and what snippets to paste in your code.

What I recommend is that you read this from beginning to end, without writing any code. Then implement the application yourself by following this HOWTO and referring to the full code that’s available here.

Our Application

What we’ll do

The application we’re going to write will blink an LED at a rate that can be customized in increments of 100 ms. It will expose two “outlets” in terms of user interface:

  1. A LED blinking enable/disable switch. When LED blinking is enabled, the LED will blink at the user-supplied rate (1 second by default). When LED blinking is disabled, the LED will remain off.
  2. A blinking interval input, allowing the user to customize the rate at which the LED blinks.

Both of these will be exposed over BLE. We’ll do that by implementing a custom service, which will expose two characteristics, one for each of the two outlets above.

Confused? If all this stuff about services and characteristics doesn’t tell you much, don’t worry, I’ll get to it in a bit.

The LED enable characteristic will just take a true/false input. The blinking interval characteristic will take a value between 0 and 255, corresponding to the blinking interval (in 100 ms increments: e.g. a value of 12 will mean 1200 ms, or 1.2 s). By default, the LED will be off.

How we’ll do it

The application we’re going to write will have two parts. One of them will be protocol-independent. The other one will be BLE-specific.

The protocol-independent part will do the actual LED blinking. We’re going to create a one-shot timer that expires at the user-supplied interval. When the time expires, its handler is going to toggle the LED and, if blinking is still enabled (i.e. if the user hasn’t disabled it in the meantime), it will re-program the timer.

The timer will start disabled, since LED blinking is also disabled by default. When the user enables LED blinking, the timer will also be started.

None of what we have so far depends on BLE in any way. It could just as well be exposed over ZigBee or over HTTPS, or it could be controlled with switches and knobs.

We’re going to keep all this state information — the blinking rate and whether it’s enable or not — separate, in their own structure, and we’re going to tie that structure to the BLE service.

How? As you’ll find out later, our BLE service will expose two handlers, associated with each one of the two characteristics. When a characteristic is written to, the handler associated to it will get invoked. We’ll supply those handlers, and they are going to work on our state structure. This way, we’ll be able to keep the application logic separate from the protocol logic.

A note on protocol independence.I warmly recommend that you structure any production program you write so that it’s protocol-independent, as much as it’s feasible. It may not look like a big deal today, but the next big revolution in terms of IoT protocols could be here in five years.

“As much as it’s feasible” is trickier than it looks. There are protocol abstraction layers that abstract protocols as diverse as Z-Wave, BLE and Wi-Fi. Unfortunately, these protocols are very different. Consequently, abstraction layers that try to unify them tend to be very general, and over-engineered to the point of uselessness.

The code I’m going to show you today isn’t “100%” protocol-independent. In particular, the two handlers I mentioned above are, technically-speaking, protocol logic: they take a BLE connection handle and a pointer to a BLE service structure as arguments. The code inside them is protocol-independent though, so “porting” this application would amount to copying the handler code in the right place. Either way, it’s about 20 lines out of more than 650, so less than 5%. That’s not too shabby!

(Optional) Quick Recap: BLE Attributes, Services and Characteristics

If you’re still confused about attributes, services and characteristics, or if all that stuff I said above about characteristics didn’t ring any bell, this section may be for you!

Attributes

The fundamental data unit of BLE is the attribute. An attribute is a piece of labeled, addressable data — pretty much like a record in a flat database. It’s a somewhat unfortunate choice in terms of naming, because everyone who first hears about is invariably asks “wait, an attribute of what?“, and the discussion goes pretty philosophical at this point. Let’s just leave it at “attribute”.

An attribute is defined by three things:

  • A 16-bit handle, which uniquely identifies the attribute (that’s the “addressable” part) so that you can read and write its values.
  • A 128-bit UUID, which describes the attribute’s type (that’s the “labeled” part). Some of these UUIDs are standardized, in a manner similar to IANA’s well-known port numbers list. The BLE standard specifies a bunch of “well-known UUIDs”, for types like “Temperature”, “Battery level”, or “Application name”. Fortunately, 128 bits is a lot. You can define your own types (and, in fact, we will).
  • A variable-length value (that’s, well, the “piece of data” part). An attribute can be up to 512 bytes long, which turns out to be enough for most of what we need to do with BLE (but, of course, you can chain more attributes together if you need more space).

Addressing, reading and writing attributes is all handled by a protocol called ATT. You never really interact with ATT when writing applications, though. You interact with another protocol called GATT (which for some reason stands for General Attribute Profile, not to be confused with GAP, the Generic Access Profile, but okay).

Characteristics

Practical experience with other protocols has shown that working with individual attributes would be cumbersome and detrimental to interoperability. So GATT defines (among other things) characteristics and services as the fundamental elements of inter-device interaction within the BLE protocol.

A characteristic is a value with a known type and format, defined through a characteristic specification. Presumably, the Bluetooth Working Groups did not appreciate the joke about what it takes to understand recursion.

In more readable, if slightly more inexact terms, a characteristic is a set of attributes that fully describe a value. It can contain not only the value itself, but also information about data presentation, about how it’s meant to be shown to the user, information about how that data is to be shared with other devices and many other things.

Why is this necessary? Consider the case of a smart thermometer (not that the world needs one of those but it’s a self-contained example so let’s imagine that’s a thing we need to be concerned about). You’d think one attribute, of type temperature, would be enough, right? But:

  • What scale is used for that measurement? Celsius? Fahrenheit? Kelvin? Or does it say just “cold”, “okay”, “warm”, and “hot”?
  • What format is that temperature in? Float? 8- or 16-bit integer? How is “25.5 degrees Celsius” represented — is it 250, or 0x41bc0000?
  • Which temperature is it? If I buy three thermometers, and put one in the bedroom, one in the living room, and one in the kitchen, how will I know which temperature reading is from which thermometer? (Please don’t say “just look at the address”, I’m not good with MAC addresses, I haven’t had my Bluetooth implant installed).

All these things are embedded in the characteristic, in addition to the temperature  value.

A characteristic describing the temperature would have several attributes: one for the value, one for the scale, one for the format (that’s called the Characteristic presentation format descriptor in BLE jargon), and one — possibly user-writable — containing a human-readable name for that characteristic, such as “Temperature in the living room” (the Characteristic user description descriptor). Plus another attribute that says “here begineth a temperature characteristic” for a total of five attributes

There are a few more things that go into a characteristic’s description: it’s possible to restrict reading that characteristic’s value, or writing to it, for example. But this discussion is already a bit too abstract, so let’s leave these concepts for a little later, when we’ll be able to put them in the proper context.

Services

Characteristics allows standards organisations and vendors to pack and describe data in an unambiguous, portable way. But the data, by itself, doesn’t embody how a device works. In order to fully describe the behaviour of a device or application, we also need a standard, unambiguous way to describe what it does with the data.

A piece of data (i.e. a set of characteristics) along with an unambiguous description of what a device does with it is called a service. Or, in the words of the Bluetooth Core Specification, a service is “a collection of data and associated behaviors to accomplish a particular function or feature“.

Several GATT services have been standardised already. The standard spec definition is pretty readable and straightforward, so I encourage you to have a look at the specifications for a simple service, like the Immediate Alert Service, to see what a service is comprised of.

A service is comprised of one or more characteristics, along with the behavior associated with the values in those characteristics and to how they change. So not just any bunch of characteristics defines a service (or, to put it more bluntly, the often-repeated mantra that “a characteristic is a collection of attributes and a service is a collection of characteristics” is incorrect). Implementing a service doesn’t consist only of exposing the right characteristics: the behavior of the device must also match the specifications of the service. For example, in the case of the Immediate Alert service, a device needs to respect the alert levels defined in the service’s specifications, and needs to present the right type of alert for each alert level.

A service can expose one or more characteristics, and some of these characteristics are optional, meaning that you can still claim to implement that service without exposing them. Generally, these are aspects of a service that are not deemed to be essential.

For example, the Current Time service, which allows a device to expose information about the current local time, has only one essential characteristic: the Current Time characteristic, which tells you, well, it tells you what time it is. There are two other optional characteristics, which allow the device to tell you what timezone it’s configured with, and to tell you what source it derived that time from. Both are useful for some devices, but you can still get a decent clock without them.

The BLE specifications distinguish between Primary and Secondary Services. A Primary Service is a service that embodies what the user would see as essential device functionality; without it, a device wouldn’t be doing what it’s supposed to do. A Secondary Service is a service that’s used by another service to augment itself, or to perform its full range of functions.

In principle, a device can expose any number of primary services and any number of secondary services. It can also expose any number of instances of the same services. For example, a device that has multiple redundant battery banks can choose to instantiate a Battery Service for each one of them.

However, some services do pose restrictions. The specs of the TX Power service, for example, dictate that this service be always instantiated as a primary service. The specs of the Current Time service require that no more than one instance of the Current Time service shall be exposed.

The primary/secondary status requirements are usually a matter of choice (and some specifications go a little overboard, requiring that a service be exposed as primary even though it doesn’t always make sense). The latter, however, tends to be a matter of engineering common-sense more than anything else. For example, a clock that disagrees with itself on what time it is right now is definitely not a clock you want to look at if you have a plane to catch — hence the requirement that no more than one instance of the Current Time service be instantiated.

If you’re working with a vendor-specific service, of course, the choice on whether a service is primary or secondary, and on how many instances it can have, is all yours. It may seem confusing when you read about it, but it’ll make a lot more sense when you’ll be confronted with the full context involved in a design decision. Nine times out of ten, the choice will show itself.

Services, Characteristics and UUIDs

Particularly careful readers may have an interesting question at this point. We’ve mentioned that attributes are pieces of labeled, addressable data. If you want to read an attribute, you issue a read command with that attribute’s handle and you get back its value. (This is really what happens, but I’m really glossing over the details of “issue a read command” and “get back its value”).

But what if you want to read or write to a characteristic? It takes multiple attributes to define one — it would be kind of a pain to still have to work on individual attributes, especially now when there’s a bunch of them for a single value. It would make a lot of sense for characteristics to be labeled and addressable as well, wouldn’t it?

Well, they are — but in order to understand how that’s done, we’re going to have to take a look at how services are identified first.

Services need to be identified and labeled, too, and for the same reasons. First, it would be awkward to have to manipulate them just in terms of attributes, too. Down the protocol stack, that’s what actually happens, but that’s hidden from the developer’s view, and with good reason. Second, you want to have an unambiguous way to identify and address services in order to enable things like service enumeration — asking a device to tell you what it can do.

So each service has a unique, 128-bit UUID. There are a couple of rules about how these UUIDs are allocated, but there is only about which you need to care for the purpose of this explanation: only some services can use UUIDs of the form xxxx-xxxx-0000-1000-8000-00805F9B34FB.

UUIDs in that class are reserved for services registered with the Bluetooth SIG by SIG members. These are referred to as 16-bit UUIDs. For these services, the first 16 bits of the UUIDs are sufficient to identify them. This means, for example, that any packet which includes them — such as advertising packets which announce what services a device supports — will be shorter. Since they’re shorter, it takes less time to transmit them, and less battery power.

These services are still identified by a 128-bit UUID — it’s just that, when only 16 bits are received, the BLE stack will assume that the rest of the UUID is the BLE base UUID. So the Pulse Oximeter Service, for example, which has the 16-bit UUID 0x1822, is actually going to be identified by the UUID 0000-1822-0000-1000-8000-00805F9B34FB.

Characteristics are identified by UUIDs, too, and just as with services, there’s a list of characteristics that get a 16-bit UUID (these ones). As with service, these aren’t “true” 16-bit values — behind the scenes, they’ll get converted to 128-bit UUIDs.

There is no requirement for the UUIDs of characteristics within a service to be derived from that service’s UUID, but there is a common practice to derive characteristic UUIDs from service UUIDs. Most vendor-specific service and characteristic definitions will follow a pattern. For example, a service identified by this UUID:

0000-1f00-bdcb-4385-9ada-88e56d6fb973

is likely to expose characteristics with these UUIDs:

0000-1f01-bdcb-4385-9ada-88e56d6fb973
0000-1f02-bdcb-4385-9ada-88e56d6fb973
0000-1f03-bdcb-4385-9ada-88e56d6fb973
0000-1f04-bdcb-4385-9ada-88e56d6fb973

it’s not a requirement, but it makes debugging easier.

nRF SDK: A Minimal Application

Every great application ever written did nothing but print “Hello, world!” back when it was first compiled. What we’re doing is slightly above that in terms of complexity, so let’s start with the next step: a minimal application that does nothing but boot and twiddle thumbs.

The nRF SDK comes with a “minimal” BLE application template. It’s fairly verbose, and we are going to carve it a bit, too, but I would like to walk you through it to get a full understanding of our application’s scaffolding.

Setting up a minimal application template

You can find a minimal template in your nRF SDK’s directory, under examples/ble_peripheral/ble_app_template/. Let’s start by copying that to our own directory, so that we can work on it as we please.

$ cp -R nRF5_SDK_15.3.0_59ac345/examples/ble_peripheral/ble_app_template/ programs/ble_blink_service

If you look inside this directory, you’ll see a single source file main.c, and a bunch of hardware-specific directories, one for each target device, such as pca10056. We’ll look at these in a minute.

There are three things that we want to do:

  • Set up our board file and linker script
  • Set up the SDK configuration file
  • Remove the things we won’t need in our application

Setting up the board file and linker script

There are more ways to do this. A lot of guides recommend that you copy your boardfile in the nRF SDK structure and add the relevant magic defines in the global boardfile header.

I don’t like that. First, it’s messy: you mess up with the official SDK, which you shouldn’t be doing on principle, and if you want to use a breakout board, it’s very inefficient. In one project you’ll hook up three LEDs, in another project you’ll hook up an LED and two buttons, and you’ll end up editing the same board file for every single project. You’ll lose track of it. Second, it means that, in order to compile your application on a particular machine, you’re going to have to drag the modified SDK along with the application code. That kind of defeats the purpose of having an SDK in the first place.

Instead, we’re going to copy our boardfile to our project tree, and modify the Makefile so that we can include a custom, out-of-the-SDK-tree header.

We’ll start by duplicating the boilerplate of the PCA10056 project:

$ cp -R pca10056 sparkfun_nrf52840_mini

Inside the new sparkfun_nrf52840_mini/directory, you’ll find only one directory, called s140. That’s the SoftDevice that implements the BLE protocol on the nRF chip from your nRF52840 Mini board. And inside it, you’ll find a bunch of directories, one for each supported compiler, plus one config directory that we’ll get to in a minute. The one we care about right now is the armgccdirectory, which holds the Makefile and linker script for our board.

Adding our boardfile

Let’s start by copying this boardfile header to our project’s root folder. Rename it as custom_board.h.

Now, in the Makefile under sparkfun_nrf52840_mini/s140/armgcc, we’ll add the root project directory in the list of include directories, and pass BOARD_CUSTOMas our board’s name. To do so, you want to add $(PROJ_DIR)to the list of include directories:

INC_FOLDERS += \
  $(PROJ_DIR) \
  ...

and replace CFLAGS += -DBOARD_PCA10056with CFLAGS += -DBOARD_CUSTOM.

Why does this work? If you’ll look at the SDK code under components/boards/boards.h, you’ll find the following little snippet that’s extremely common in the world of embedded SDKs:

#if defined(BOARD_NRF6310)
  #include "nrf6310.h"
#elif defined(BOARD_PCA10000)
  #include "pca10000.h"
#elif defined(BOARD_PCA10001)
  #include "pca10001.h"
...
#elif defined(BOARD_CUSTOM)
  #include "custom_board.h"
#else
#error "Board is not defined"

Passing -DBOARD_CUSTOMmakes the preprocessor include custom_board.h, which we can supply ourselves (there’s no such file in the SDK). All we have to do is provide the file and tell the compiler what directory it’s in.

You will also want to change SDK_ROOTto point at your SDK’s directory. And, if you want to use Sparkfun’s nrfutil, you can also add these targets at the end of the Makefile, to make flashing your code a little more touch-and-go:

dfu-package: $(OUTPUT_DIRECTORY)/nrf52840_xxaa.hex
        @echo Packaging $<
        adafruit-nrfutil dfu genpkg --sd-req 0xFFFE --dev-type 0x0052 --application $<  _build/dfu-package.zip

bootload: $(OUTPUT_DIRECTORY)/nrf52840_xxaa.hex dfu-package
        @echo Flashing: $<
        adafruit-nrfutil --verbose dfu serial --package _build/dfu-package.zip -p $(SERIAL_PORT) -b 115200 --singlebank --touch 1200

That’s great! Your Makefile should look like this now, barring the location of the SDK, which may be different on your machine.

Customizing our boardfile

This part depends entirely on your hardware and your breadboard/soldering preferences. You’ll have to adapt these for your own preferences.

On my end, I added two additional LEDs to the board file, although only one of them (LED 3) is used in the code below. This is the LED that we’re going to blink, and it’s connected on GPIO pin 20. Here is what the relevant definitions in my boardfile look like:

#define LEDS_NUMBER    4

#define LED_1          NRF_GPIO_PIN_MAP(0,7)
#define LED_2          NRF_GPIO_PIN_MAP(0,14)
#define LED_3          NRF_GPIO_PIN_MAP(0,19)
#define LED_4          NRF_GPIO_PIN_MAP(0,20)
#define LED_START      LED_1
#define LED_STOP       LED_2

#define LEDS_ACTIVE_STATE 1

#define LEDS_LIST { LED_1, LED_2, LED_3, LED_4 }

I won’t cover the (rather murky) inner workings of the nRF BSP layer. Suffice to say that this is enough to automagically be able to refer to the third led in the LED_LISTarray as BSP_BOARD_LED_3, and to be able to toggle it.

Adding our linker script

Linker scripts are surprisingly obscure today. A linker script controls and configures the link process, by describing how the sections of the input files (that is, object files) map to the sections of the output files (that is, libraries or executable binaries), and what the memory layout of the output file will be. Our code is going to run in an environment where there’s nothing resembling a dynamic loader, so the address of each section (and each symbol, in fact) will need to be known at link time and match the location where it will be loaded in hardware. An in-depth discussion of linker scripts is way outside the scope of this HOWTO, but I encourage you to have a look at the manual and at other resources such as this blog post.

In our case, we don’t have to change anything about the sections configuration, we just need to adjust the size of the Flash region:

MEMORY
{
  FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xce000
  RAM (rwx) :  ORIGIN = 0x200022e0, LENGTH = 0x3ddf0
}

At this point, your linker script should look like this.

You should now be able to compile and load the application, and marvel at the Zen of a system that boots, advertises over BLE, and does nothing.

nRF Connect showing new BLE device.
You can find the device…

 

nRF connect showing BLE device properties.
…and connect to it, but not much else.

Under the hood, there’s a lot of stuff that makes that “nothing” happen, though, and if you’re curious about what precisely is there, the next section will explain it all.

(Optional) A walk through the minimal application template

Before we go ahead and add our new service (and let’s not forget the LED blinking code, every embedded developer’s favourite program!) let’s go through the minimal application template.

The minimal application template covers a lot of ground for something that “minimal”. And I want to cover it because, as you’ll see in a minute, it contains a lot of fundamental BLE scaffolding. It gives you a complete base: it initializes the BLE stack, it initializes, and handles the events of, the advertising process and so on. At the end of the day, all you have to do on the BLE side is plug in your services and you’re done.

That’s pretty comprehensive, and that’s why it’s important to understand it.

The main function looks like this:

int main(void)
{
    bool erase_bonds;

    // Initialize.                                                                                                                                                      
    log_init();
    timers_init();
    buttons_leds_init(&erase_bonds);
    power_management_init();
    ble_stack_init();
    gap_params_init();
    gatt_init();
    advertising_init();
    services_init();
    conn_params_init();
    peer_manager_init();

    // Start execution.                                                                                                                                                 
    NRF_LOG_INFO("Template example started.");
    application_timers_start();

    advertising_start(erase_bonds);

    // Enter main loop.                                                                                                                                                 
    for (;;)
    {
        idle_state_handle();
    }
}

There’s plenty of stuff here but don’t worry, most of it is pretty straightforward.

Logging

The nRF SDK has very comprehensive logging support, and I cannot stress how useful and important this is. Nine times out of ten, the BLE devices you’ll be working with have almost no human-accessible I/O capabilities whatsoever. At best you’ll get a couple of LEDs and a couple of buttons.

If you’re familiar with embedded systems development, this won’t be a surprise for you, but bear with me for everyone else’s sake. Debugging a device like this one is very tricky. If you have a JTAG adapter you can attach a debugger to the firmware, and you can therefore set breakpoints and watches and step through the code. However, that’s completely useless when debugging interrupt handlers, connection timeouts or race conditions. If you set a breakpoint in the interrupt handler, any race condition you’re debugging will have long passed. If you step through the code, your connection will timeout for entirely valid reasons, not because of some bug.

Debugging by logging is the bread and butter of embedded systems debugging. Sometimes, when bug reports come from the field, you’re debugging based on logs from three weeks ago, from a device that’s halfway across the globe, that you have no access to. That’s why logging is a big deal, and deciding what, how, where and when to log is an equally big deal for reliable devices.

The minimal application log_initcode just initializes the default backends. We won’t be using logging in this application because we already have a lot of ground to cover. But I warmly encourage you to read up on the nRF Logger module. User documentation is a little scarce but this is a useful starting point if you’ve never used a logger module before and you don’t know what to expect.

Timers initialization

The nRF SDK has pretty good support for timers. At a minimum, you should initialize the timer module by calling app_timer_init(). The minimal application template also suggests that you initialize your application timers in this function but you will quickly find that, for non-trivial applications, it’s better to initialize application state in separate, application-specific init functions. So we aren’t going to add anything besides what’s already there, namely:

static void timers_init(void)
{
    // Initialize timer module.                                                                                                                                         
    ret_code_t err_code = app_timer_init();
    APP_ERROR_CHECK(err_code);
}

Initializing buttons and LEDs

The nRF SDK is pretty opinionated about the structure of your board support package (BSP), which isn’t necessarily a bad thing, because it makes this step pretty trivial. The whole function looks like this:

static void buttons_leds_init(bool * p_erase_bonds)
{
    ret_code_t err_code;
    bsp_event_t startup_event;

    err_code = bsp_init(BSP_INIT_LEDS | BSP_INIT_BUTTONS, bsp_event_handler);
    APP_ERROR_CHECK(err_code);

    err_code = bsp_btn_ble_init(NULL, &startup_event);
    APP_ERROR_CHECK(err_code);

    *p_erase_bonds = (startup_event == BSP_EVENT_CLEAR_BONDING_DATA);
}

The way this works is you pass a set of flags to bsp_init, telling it what you want to initialize (buttons and LEDs in our case). bsp_initgoes through the list of LEDs and buttons defined in your board header (LEDS_LISTand BUTTONS_LIST, respectively — I encourage you to look through custom_board.hto see how they’re defined) and does the correct GPIO initialization magic. You also pass an event handler to bsp_init, which you use to handle notifications about BSP events such as button presses, among other things.

The situation with BSP events is a little murky because there are some events, like device wake-up events, that can’t be handled inside your application handler (because the application is sleeping and can’t handle anything).

The way folks at nRF have decided to handle this is actually pretty clean, if a little counterintuitive because they decided not to over-engineer it. The events that your application handler can’t process are handled behind the scenes, by the SoftDevice code. If it decides to wake up the device (and, therefore, your application), it will simply fill a user-supplied structure with the reason why it woke up the device. In order to make all that happen, you call bsp_btn_ble_init, passing two arguments to it: an error handler (we’re going to leave it to NULL, as in the minimal application template, but don’t do that in real-life applications!) and a pointer to a bsp_event_tstructure, which the BLE stack will fill with the BSP event that caused it to wake up the device.

The part about bonding data is outside the scope of this HOWTO, so I’ll just tell you a few quick words about it. After you’ve paired two devices, BLE supports this thing called bonding, which enables you to re-pair them very quickly, without going through the whole pairing process again, because pairing is inconvenient and pretty battery-intensive. Of course, you sometimes want to break this bonding, in order to pair with another device, and for efficiency reasons it makes sense to mix “wake up” with “break bonding” into a single event — “wake up in order to break bonding”. That’s why there’s a separate event, called BSP_EVENT_CLEAR_BONDING_DATA, just for that.

Initializing power management

Initializing the power management module is a pretty simple affair in our application: we just call the initialization function of the Power Management Module. Needless to say, power management is a big deal for devices that have to run for years off a CR 2032 battery, but that’s a topic that we’re going to cover on another occasion.

Initializing the BLE stack

This is where the real fun begins. Here’s what the init function looks like:

static void ble_stack_init(void)
{
    ret_code_t err_code;

    err_code = nrf_sdh_enable_request();
    APP_ERROR_CHECK(err_code);

    // Configure the BLE stack using the default settings.                                                                                                              
    // Fetch the start address of the application RAM.                                                                                                                  
    uint32_t ram_start = 0;
    err_code = nrf_sdh_ble_default_cfg_set(APP_BLE_CONN_CFG_TAG, &ram_start);
    APP_ERROR_CHECK(err_code);

    // Enable BLE stack.                                                                                                                                                
    err_code = nrf_sdh_ble_enable(&ram_start);
    APP_ERROR_CHECK(err_code);

    // Register a handler for BLE events.                                                                                                                               
    NRF_SDH_BLE_OBSERVER(m_ble_observer, APP_BLE_OBSERVER_PRIO, ble_evt_handler, NULL);
}

The first thing we do is enable the SoftDevice by calling nrf_sdh_enable_request (API docs here). Without the SoftDevice that implements the BLE stack, we can’t do anything over BLE. Nothing surprising here.

Now that the SoftDevice is initialized, we need to configure the BLE stack parameters. Fortunately, there’s a quick way to do it if the default settings are OK: we can call nrf_sdh_ble_default_cfg_set.

This function takes two parameters. The first one tells it which BLE configuration set to use. As far as I know, at the time of writing that’s easy enough because only one is supported, but nRF might change that in the future. The second parameter is actually an output parameter. nrf_sdh_ble_default_cfg_setwill output the address of the beginning of the application RAM address. That address will then get passed to nrf_sdh_ble_enable (API docs here), which actually enables the BLE stack.

SoftDevice and Memory Requirements

If your first thought is “wait, what, why does it even need to know that adress and why is it an in/out parameter?”, well, here’s how it works.

The SoftDevice firmware needs some RAM memory itself in order to do its whole BLE magic. One way to do that would be to have separate RAM modules for the SoftDevice and the application. Unfortunately, that would be awfully inefficient.

Why? Suppose you set aside 4K for the SoftDevice. If you fill it up, then you put yourself in a pretty bad position in the long run. If your memory requirements ever expand, because later versions of the BLE standard mandate additional features (that actually happened with BLE 4.2), or because you want to increase some buffers, or simply because you need more memory for a bugfix, you have to put out a new chip. That sort of defeats the purpose of having a programmable chip (through the SoftDevice mechanism) in the first place. What you want is to just release a new version of the SoftDevice and be done with it.

On the other hand, if you leave “some” room, it raises the question of just how much room. 2K? 1K? 256 bytes? If that’s not enough, we’re back to square one. If it is, you end up with unused RAM that just sits around wasting die space and power. That’s not a good idea for consumer devices, which get manufactured in the millions or hundreds of millions, with microscopic profit margins and live off batteries that couldn’t shock a flea.

So Nordic opted for a different option which works surprisingly well, but gives you a somewhat convoluted development process. They figured, okay, we’re going to make just one RAM area, and the SoftDevice and the application will have to share it. The application, of course, will have to make sure it doesn’t end up writing over the SoftDevice’s RAM — and, a very straightforward way to ensure that never happens is to put the application after the SoftDevice RAM region, so that deliberately growing (or unintentionally overflowing) a buffer won’t cause application data to spill over the SoftDevice’s RAM.

This looks straightforward but it’s not, because how much RAM the SoftDevice uses depends on the configuration. So you don’t know how much RAM the SoftDevice needs in advance (or, I mean, you could, but you can’t get that information at compile-time today). Instead, when you load the configuration (in our case, by calling nrf_sdh_ble_default_cfg_set), you get a pointer to where the application RAM starts.

That’s fine and dandy but what about the linker script we mentioned above? How are you supposed to write the linker script — which needs to contain the RAM address where the application is loaded — if you don’t know where the application is going to be loaded?

This thread sheds some light and it’s a little surrealistic, but the basic takeaway is that you truly don’t know until you try it. There’s two ways to do it.

No matter how you do it, you begin by starting at the lowest RAM offset in the linker script (in our case, that would be 0x20000000).

One way to do it would be to set a breakpoint after the call nrf_sdh_ble_default_cfg_set, jot down what it returns and put that in the linker script. If you think that’s crazy, just wait until you hear the second one.

There’s also the alternative of using sd_ble_enable(API docs here) or nrf_sdh_ble_enable (API docs here), to which you pass a pointer to where you want your application RAM to start, and you get back a pointer to the minimum address where you application RAM could start. So if the SoftDevice needs 0x3000 bytes of RAM, and you were to pass 0x2000A000 as the start address, you would get 0x20003000 and NRF_SUCCESS in response. If you were to pass 0x20001000, you would get NRF_ERROR_NO_MEM.

What you do in this case is either you start with a minimum guess about how much RAM the SoftDevice will need, and keep increasing it until the init function no longer fails, or you start with a minimum guess about how much RAM your application needs and do the breakpoint-and-jot-down dance we did above.

Could this be handled by pre-computing how much memory is needed based on the configuration you provide — and which you need to know at compile-time anyway? Absolutely, but then what would be the fun in that.

Here there be dragons alert: changing the configuration of the BLE stack can change the amount of memory that the SoftDevice requires. So you need to do this after changing the SDK config!

BLE Observers

OK, there’s one last piece of mystery: what does this do?

NRF_SDH_BLE_OBSERVER(m_ble_observer, APP_BLE_OBSERVER_PRIO, ble_evt_handler, NULL);

NRF_SDH_BLE_OBSERVER registers a handler (in our case, ble_evt_handler) for BLE events with the BLE stack.

We didn’t declare m_ble_observeranywhere — this macro will take care of that, too (the declaration is static, so it does outlive the function, not that it makes all of this any less dirty…).

The second parameter, the observer priority, tells the registration code how far down the queue our obserer is. The lower this number, the higher the priority. nRF has a few more details about how priorities work, but in short, it’s best to take their word about the priority of observers registered by an application.

As for the handler, well, it’s best if we just look at the code, since this will tell us all about what kind of events it handles, too:

static void ble_evt_handler(ble_evt_t const * p_ble_evt, void * p_context)
{
    ret_code_t err_code = NRF_SUCCESS;

    switch (p_ble_evt->header.evt_id)
    {
        case BLE_GAP_EVT_DISCONNECTED:
            NRF_LOG_INFO("Disconnected.");
            // LED indication will be changed when advertising starts.                                                                                                  
            break;

        case BLE_GAP_EVT_CONNECTED:
            NRF_LOG_INFO("Connected.");
            err_code = bsp_indication_set(BSP_INDICATE_CONNECTED);
            APP_ERROR_CHECK(err_code);
            m_conn_handle = p_ble_evt->evt.gap_evt.conn_handle;
            err_code = nrf_ble_qwr_conn_handle_assign(&m_qwr, m_conn_handle);
            APP_ERROR_CHECK(err_code);
            break;

        case BLE_GAP_EVT_PHY_UPDATE_REQUEST:
        ...
        case BLE_GATTC_EVT_TIMEOUT:
        ...
        case BLE_GATTS_EVT_TIMEOUT:
        ...
        default:
        ...
    }
}

I deliberately left out a bunch of code above, since how each event is handled isn’t what we’re concerned with right now: connect events, disconnect events, PHY update requests and so on. In a word, everything that has to do with the BLE stack below the application protocols.

GAP initialization

Next one down the line in the initialization code is GAP initialization. GAP, the Generic Access Profile, specifies the ways devices advertise, discover and connect to each other. TI has a pretty good introduction on the topic, which should also give you an idea about why and how power consumption is relevant in this regard.

There are plenty of parameters that need to be set up. The minimal application template just sets the name that the device advertises itself under, and the minimum set of parameters required to carry out advertisement — minimum connection interval, slave latency and the connection supervision timeout.

static void gap_params_init(void)
{
    ret_code_t              err_code;
    ble_gap_conn_params_t   gap_conn_params;
    ble_gap_conn_sec_mode_t sec_mode;

    BLE_GAP_CONN_SEC_MODE_SET_OPEN(&sec_mode);

    err_code = sd_ble_gap_device_name_set(&sec_mode,
                                          (const uint8_t *)DEVICE_NAME,
                                          strlen(DEVICE_NAME));
    APP_ERROR_CHECK(err_code);

    /* YOUR_JOB: Use an appearance value matching the application's use case.                                                                                           
       err_code = sd_ble_gap_appearance_set(BLE_APPEARANCE_);                                                                                                           
       APP_ERROR_CHECK(err_code); */

    memset(&gap_conn_params, 0, sizeof(gap_conn_params));

    gap_conn_params.min_conn_interval = MIN_CONN_INTERVAL;
    gap_conn_params.max_conn_interval = MAX_CONN_INTERVAL;
    gap_conn_params.slave_latency     = SLAVE_LATENCY;
    gap_conn_params.conn_sup_timeout  = CONN_SUP_TIMEOUT;

    err_code = sd_ble_gap_ppcp_set(&gap_conn_params);
    APP_ERROR_CHECK(err_code);
}

Nothing surprising here.

GATT Initialization

GATT, the Generic Attribute Profile, is the one that handles attributes, characteristics and services, as we discussed above.

The GATT initialization routine doesn’t actually initialize the characteristics and service definitions — that’s pretty verbose, as we’ll see soon, and it tends to make sense to keep them in a separate function. Instead, all we usually need to do is initialize the GATT module.

In order to do that, we first need to declare an instance of the GATT module. That can be a global instance:

RF_BLE_GATT_DEF(m_gatt);

and then we initialize it:

static void gatt_init(void)
{
    ret_code_t err_code = nrf_ble_gatt_init(&m_gatt, NULL);
    APP_ERROR_CHECK(err_code);
}

The second parameter, which is NULL in our call, is a pointer to an event handler that gets invoked when a GATT parameter is changed. That’s outside the scope of our HOWTO but it’s useful to remember that it exists.

Initializing Advertisement Parameters

If you played with the minimal example and a Bluetooth application of your choice, like nRF Connect, or if you looked at the screenshots above, you’ve probably noticed that there’s a lot of data that’s being advertised, even for a device that “does nothing”.

In order for the SoftDevice to know what and how to advertise, we need to se its advertisement parameters. The pattern is one that you’ll encounter in a lot of places in the nRF SDK: you fill in a configuration or initialization structure (in our case, its type is ble_advertising_init_t), and then ask the SDK to upload that configuration to the BLE module by calling a corresponding init function (in our case, that’s ble_advertising_init).

So, what does our configuration look like?

First of all, we’re advertising the device’s name and appearance. We actually filled in the name of the device as part of gap_init, but we also have to tell the BLE module to advertise it. We haven’t filled in any appearance value, but if we wanted do, we’d also do that when initializing the GAP parameters. The meaning of the flags is self-explanatory:

init.advdata.name_type               = BLE_ADVDATA_FULL_NAME;
init.advdata.include_appearance      = true;

Next, we pass the protocol and discoverability flags:

init.advdata.flags                   = BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE;

This one requires a little explanation. We’re actualy passing two flags: LE_ONLYand GENERAL_DISC_MODE.

The first flag, LE_ONLY, means that this device supports only Bluetooth Low Energy. Some devices also support other modes, like Bluetooth BR/EDR, and can pass the corresponding flags.

The other part of this flag, GENERAL_DISC_MODE, causes the device to advertise itself as being in the General Discoverable mode. “Discoverable” means that other devices can discover your device by scanning, but BLE distinguishes between two modes of discoverable operation.

The one we use, the general discoverable mode, is intended to be used when devices want to be discoverable for a long period of time. The standard doesn’t say how long is a “a long period of time”, but general practice is to consider anything longer than 30 seconds “a long period of time”.

But BLE allows you to advertise another mode, too: Limited Discoverable Mode. Limited mode is used when devices want to be discoverable only for a, well, limited period of time.

What’s the point? Advertising takes battery power, and you don’t always have a beefy one to waste by advertising for a long time before someone sees you. But that’s not the only rationale.

The really useful reason for distinguishing between the two is that it enables Bluetooth hosts — such as a laptop or a phone — to make specific user interface choices, too, based on user intent.

The common idiom is to display devices in limited discoverable mode first. In turn, devices will typically advertise themselves as being in limited discoverable mode after actions like a button press, or after the user has changed the batteries and powered up the device again. The reasoning is that, since the user has just interacted with a device in a manner that caused it to go into advertising mode, the next thing they are most likely to do is to try and pair that device with another one.

The next thing we want to do is to advertise the services we support. We do this by passing a list of the UUIDs we support. First we define the list:

static ble_uuid_t m_adv_uuids[] =                                               /**< Universally unique service identifiers. */
{
    {BLE_UUID_DEVICE_INFORMATION_SERVICE, BLE_UUID_TYPE_BLE}
};

and then, in our advertising initialization function, we pass it to the BLE stack:

init.advdata.uuids_complete.uuid_cnt = sizeof(m_adv_uuids) / sizeof(m_adv_uuids[0]);
init.advdata.uuids_complete.p_uuids  = m_adv_uuids;

I will purposely skip the part about fast advertising, because it’s very much outside the scope of this HOWTO, and just say that there are good pointers about it in this thread. This brings us to the last step of our initialization function, setting the advertising event handler.

We pass the BLE stack a pointer to our handler in the evt_handlermember of our init structure. The event handler in question looks like this:

static void on_adv_evt(ble_adv_evt_t ble_adv_evt)
{
    ret_code_t err_code;

    switch (ble_adv_evt)
    {
        case BLE_ADV_EVT_FAST:
            NRF_LOG_INFO("Fast advertising.");
            err_code = bsp_indication_set(BSP_INDICATE_ADVERTISING);
            APP_ERROR_CHECK(err_code);
            break;

        case BLE_ADV_EVT_IDLE:
            sleep_mode_enter();
            break;

        default:
            break;
    }
}

It’s going to be called by the BLE stack whenever an advertising-related event, such as entering advertising mode or going into idle mode occurs. The BLE-specific parts are handled by the BLE stack itself — you aren’t expected to do anything about that. But this gives you a chance to respond to these events in your application, in order to offer indication that the device is advertising and discoverable (e.g. blink a LED in a specific pattern), or to put it to sleep after a period of time.

These are all the parameters that we really, really have to set, so we’re ready to do the actual initialization:

err_code = ble_advertising_init(&m_advertising, &init);
APP_ERROR_CHECK(err_code);

ble_advertising_conn_cfg_tag_set(&m_advertising, APP_BLE_CONN_CFG_TAG);

Services initialization

This part is going to be short: our device doesn’t support any service yet, so we don’t need to initialize any service.

The minimal application template does just one thing here — it initializes the Queued Write module. We don’t need queued writes for our application, but I want to cover it because it’s very relevant for a lot of practical applications. Queued writes allow you to issue multiple write commands, to several characteristics, up to 512 bytes. This enables you to send multiple chunks of data simultaneously, as opposed to writing characteristics, one-by-one. There is some potential for saving power in this, but more importantly, it enables you to synchronise data writes.

Initializing connection parameters configuration

BLE allows some connection parameters to be tweaked, such as the connection interval (how often the central device will ask a peripheral — our device — for new data). The idea is to strike a balance between latency, data rate and power consumption, and the BLE specifications deliberately leave these choices to us because each application has a different idea about where the right balance lies.

You’re already familiar with how this works: we fill in an init structure and pass it to an init function, which hands it down to the BLE stack. If anything went wrong, the BLE stack will tell us about it through the function’s error code.

The flags in the init structure are pretty straightforward, so I won’t cover their meaning here:

cp_init.p_conn_params                  = NULL;
cp_init.first_conn_params_update_delay = FIRST_CONN_PARAMS_UPDATE_DELAY;
cp_init.next_conn_params_update_delay  = NEXT_CONN_PARAMS_UPDATE_DELAY;
cp_init.max_conn_params_update_count   = MAX_CONN_PARAMS_UPDATE_COUNT;
cp_init.start_on_notify_cccd_handle    = BLE_GATT_HANDLE_INVALID;
cp_init.disconnect_on_fail             = false;
cp_init.evt_handler                    = on_conn_params_evt;
cp_init.error_handler                  = conn_params_error_handler;

but I do want to call your attention to the event handler associated with this module. In our case, it looks like this:

static void on_conn_params_evt(ble_conn_params_evt_t * p_evt)
{
    ret_code_t err_code;

    if (p_evt->evt_type == BLE_CONN_PARAMS_EVT_FAILED)
    {
        err_code = sd_ble_gap_disconnect(m_conn_handle, BLE_HCI_CONN_INTERVAL_UNACCEPTABLE);
        APP_ERROR_CHECK(err_code);
    }
}

This event handler is going to be very terse in all applications, because you can only get two types of events: either the connection parameter negotiation has succeeded, or it has failed.

Why is this relevant? Both endpoints — both our devices and the central device we pair it with — have preferences regarding connection parameters. If one of them is flexible enough, it can try to re-negotiate a connection with different parameters. If not, you will at least want to terminate the current link by calling sd_ble_gap_disconnect.

Initializing the Peer Manager module

The last thing we want to initialize is the Peer Manager module. The peer manager module handles encryption, pairing, and bonding. We don’t use it in our application and I don’t want to cover it because it’s a very long topic. But I will just refer you to the documentation because you want to be aware of this module and of what it does.

Starting the main loop

This concludes our initialization sequence. All that’s left now is to start the application timers, begin advertising, and then run the main application loop:

for (;;)
{
    idle_state_handle();
}

Whew!

Adding a New Service

Specifying our BLE Service

Before we start writing up the code, let’s draft a quick specification of our BLE service, just to know what we’re going to implement.

We’re going to do this the old-fashioned, human-readable way, but just so you know, there’s actually an official, formal, standardized way to write GATT service definitions using XML, and you can find the schema here. Yeah, yeah, I know it’s XML, but I can’t overstate how useful this is. I warmly recommend that you use this to keep track of specifications. Not only is it a good and unambiguous reference that everyone in a technical team can refer to, but it’s also a great way to automatically generate validation code and test cases.

Now, without further ado, we are going to implement a service called Blink. The Blink service will expose two characteristics:

  1. A LED on/off characteristic. When this characteristic has a non-zero value, the LED will be blinked at a rate defined by the Blink Rate characteristic. When this characteristic is zero, the LED will be off.
  2. A Blink Rate characteristic. This characteristic describes the rate at which the LED will blink, in units of 100 ms. If the value of this characteristic is 0, the LED will be on at all times.

The Blink service is a vendor-specific service, so it will have a vendor-specific UUID. The recommended way to generate an UUID is to use this tool, but for the purpose of this tutorial I’ve cheated a bit. The base UUID for our service definition will be 0000-0000-4241-0381-2536-9412FD7a5541
, a random value that I have chosen fairly by waiting for my cat to walk over the keyboard a few times and discarding all characters except 0-9 and A-F.

Since we’re celebrating 50 years from the moon landing this year, the service will be identified by the UUID

0000-1969-4241-0381-2536-9412FD7A5541

And the two characteristics will be identified by:

  • LED On/Off: 0000-196A-4241-0381-2536-9412FD7A5541
  • LED Interval: 0000-196B-4241-0381-2536-9412FD7A5541

This service will be advertised as a primary service.

nRF SDK: Anatomy of a Service

For the nRF SDK, a service has a remarkably lightweight presentation. A service consists of nothing more than a BLE observer, which listens for events and delivers them to an event handler, and an user-defined object which retains and caches any information that may be required for that service’s implementation. That’s pretty much it.

Peaking ahead a little, the nRF SDK even encourages a standard manner to instantiate services. We’re going to define a macro that looks like this:

#define BLE_BLINK_DEF(_name)                            \
    static ble_blink_t _name;                           \
    NRF_SDH_BLE_OBSERVER(_name ## _obs,                 \
    BLE_BLINK_OBSERVER_PRIO,                            \
    blink_ble_evt_handler, &_name)

and this will instantiate all the data structures we need for our implementation.

Of course, this doesn’t quite get the job done: note how we haven’t yet said anything about UUIDs, or about characteristics. Clearly, all this needs to be plugged in to the BLE stack in some way — and then we’ll have to plug it in to our application, too.

Plugging our Service into the BLE Stack

First things first: we want to tell the BLE stack about our service. This will enable our device to advertise this service, which is the first step towards doing anything useful.

The way to do that is pretty straightforward. Internally, nRF’s SDK stack holds a list of vendor-specific services, and all we need is to get ourselves in that list. In order to do that, we need to do two things.

First, we register our base UUID with the BLE stack. If that’s successful, the BLE stack will return success, and give us a pointer to the type field of the UUID entry it just registered.

Then, we are then going to ask the BLE stack (more specifically, the GATTS middleware) to associate our service’s UUID — derived from our base UUID — with an entry in the list of vendor-specific services. As part of our request, we’ll supply the service UUID and the UUID type we got from our previous request. If the request is successful, the BLE stack will give us back a pointer to a handle, which can be used to identify events coming from this service.

In order for all this to be useful, we’re going to have to define:

  • An event handler, which we’ll supply to the BLE stack when instantiating our service. The BLE stack will notify us through this event handler when something interesting happens, such as when the value of a characteristic is written.
  • A structure to retain all the information that’s relevant for our service, including the type and handle that we get from the BLE SDK as part of the initialization process.

This will give us an empty service. It won’t have any characteristics, but it will be advertised — enough to tell if we’re making progress or not!

Quick detour I: SDK configuration

Remember when I said that, internally, the nRF SDK holds a list of vendor-defined services? Well, that list has a maximum length of 0 by default, and we need to alter that.

To do so, look for sdk_config.hunder sparkfun_nrf52840_mini/s140/config/, and change this:

#ifndef NRF_SDH_BLE_VS_UUID_COUNT
#define NRF_SDH_BLE_VS_UUID_COUNT 0
#endif

into this:

#ifndef NRF_SDH_BLE_VS_UUID_COUNT
#define NRF_SDH_BLE_VS_UUID_COUNT 1
#endif

to make room for one more vendor-specific UUID.

Quick Detour II: new makefile targets

Also, because we are not unwashed, primitive barbarians, we are going to keep our service implementation in separate files. We’ll create two files called blink.hand blink.cin your project’s root folder, and then add the latter to the list of SRC_FILESin our Makefile.

Our service’s data structures and definitions

Let’s start with our service’s scaffolding: the basic data structures and function definitions, which we’ll tuck away nicely under blink.h.

We mentioned that we are going to have to supply the nRF SDK with a base UUID and a service UUID, derived from the base UUID, which will be assigned to our service. So let’s add these now:

#define BLINK_UUID_BASE        {0x41, 0x55, 0x7A, 0xFD, 0x12, 0x94, 0x36, 0x25, \
                                0x81, 0x03, 0x41, 0x42, 0x00, 0x00, 0x00, 0x00}
#define BLINK_UUID_SERVICE     0x1969
#define BLINK_UUID_LED_ENA     0x196A
#define BLINK_UUID_LED_INT     0x196B

Now, if you’ll recall, we said there are two things that the BLE stack will give us as part of the initialization sequence: a service handle, and a service UUID type. We need to retain these (and a bunch of other data that we’ll get to later), so let’s create a structure for that.

/**
 * BLE Blink Service
 */
struct ble_blink_s {
        /* Service handle assigned by the BLE stack */
        uint16_t service_handler;
        /* Service UUID type assigned by the BLE stack */
        uint8_t  service_type;        
};

typedef struct ble_blink_s ble_blink_t; /* Eww, but nRF likes this */

We also want an event handler for our service, so let’s declare that as well:

/* Event handler for the Blink BLE Service */
void blink_ble_evt_handler(ble_evt_t const *p_ble_evt, void *p_context);

The prototype for service event handlers is the same for all BLE services. We’ll go into the details of event handling later, but you need to understand how interfacing is done at this point, because it’s relevant for service instantiation.

p_ble_evt_tcontains event-specific data, such as the event type (device disconnected, someone just wrote a characteristic etc.) and any useful data that comes with it (such as the value that was written if the event was a characteristic write event). The second argument is a pointer to the instance of the service to which the event notification was destined — that is, we’ll get a pointer to the ble_blink_tinstance that goes with this event.

It’s clear why the first argument is needed, but what about the second one? Remember when we said that it’s possible to run more than one instance of a service? That is the reason why we want this argument.

To create an instance of a service, we’ll create an instance of its associated structure ( ble_blink_tin our case), a BLE observer, and we’ll associate our event handler to that observer. The “canonic” way to do that in the nRF SDK is to define a macro like this one:

#define BLE_BLINK_OBSERVER_PRIO 2

#define BLE_BLINK_DEF(_name)            \
    static ble_blink_t _name;           \
    NRF_SDH_BLE_OBSERVER(_name ## _obs, \
    BLE_BLINK_OBSERVER_PRIO,            \
    blink_ble_evt_handler, &_name)

which we can use to declare “an instance” of our service like this:

BLE_BLINK_DEF(m_blink);

We can define multiple instances in this manner. Each instance will have its own BLE observer, but they all use the same event handler — which is why we want to pass the context pointer, which will tell us which instance the event we’ll handling belongs to.

Service initialization

At this point, you probably have a good idea about what the initialization function of our service will look like. We’re going to write a function that takes the context structure of our service (i.e. a ble_blink_tstructure) and will register our vendor-specific service with the BLE stack. In other words, its prototype will look like this:

/* Initialize the blink service */
ret_code_t blink_init(ble_blink_t *p_blink);

Its implementation is so simple it’s not even fun. We start by registering our base UUID:

ret_code_t blink_init(ble_blink_t *p_blink)
{
        ret_code_t err_code;
        ble_uuid_t ble_uuid;

        ble_uuid128_t base_uuid = {BLINK_UUID_BASE};

        if (!p_blink)
                return NRF_ERROR_INVALID_PARAM;

        /* Add the new service */
        err_code = sd_ble_uuid_vs_add(&base_uuid, &p_blink->service_type);
        VERIFY_SUCCESS(err_code);

And then we register a service with our service UUID, caching the new instance’s handle in our context structure:

        ble_uuid.type = p_blink->service_type;
        ble_uuid.uuid = BLINK_UUID_SERVICE;
        err_code = sd_ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY,
                                            &ble_uuid,
                                            &p_blink->service_handle);

        VERIFY_SUCCESS(err_code);

        return err_code;
}

Easy, wasn’t it?

Minimal event handling

We also want to write a stub event handler for our service. We won’t do anything useful just yet, but we do need to provide an event handling function (and, in the next section, we’re actually going to need it!). So let’s go ahead and stub it for now.

/* Event handler for the Blink BLE Service */
void blink_ble_evt_handler(ble_evt_t const *p_ble_evt, void *p_context)
{
        ble_blink_t *p_blink = (ble_blink_t *)p_context;

        if (!p_ble_evt)
                return;

        switch (p_ble_evt->header.evt_id)
        {
        case BLE_GAP_EVT_CONNECTED:
                break;
        default:
                return;
        }
}

Our service implementation is done so far. Your blink.cshould look like this, and the corresponding header should look like this.

Altering the Linker Script, Again

Remember, earlier in this HOWTO, when we talked about how changing the SDK configuration can change the memory requirements of the SoftDevice?

Well, that just happened. We added an extra vendor-specific service, so we need to adjust the amount of RAM our application uses. We’re going to bump up the low limit of our RAM use, and bump its length down:

MEMORY
{
  FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xce000
  RAM (rwx) :  ORIGIN = 0x200022e0, LENGTH = 0x3dd20
}

How do I know how much to bump the limits? Trial and error, really. I wish there were more to it, but there isn’t. The first lead engineer I worked under had a name for this, and I really want to mention it because it’s a name that deserves more popularity — he called it LOP: Luck-Oriented Programming.

Instantiating and Initializing our New Service

At this point, we have a minimal implementation of our service. What’s left? Well, we need to create an instance of this service and to initialize it.

We’re going to do that in main.c. We need to do three things here:

  1. Instantiate our service (obviously)
  2. Alter services_initto initialize our service’s instance(s)
  3. Alter advertising_initto make the device advertise our service
  4. Alter the initialization sequence so that services_init gets called before advertising_init. You’ll have to trust me on this one for now — the reason why we have to do this will become clear in a minute.

Well, let’s instantiate our service then:

#include "blink.h"
...
/* BLE Blink service */
BLE_BLINK_DEF(m_blink);

And let’s initialize this instance as part ofservices_init:

static void services_init(void)
{
    ret_code_t         err_code;
    nrf_ble_qwr_init_t qwr_init = {0};

    ...

    err_code = blink_init(&m_blink);
    APP_ERROR_CHECK(err_code);
}

Finally, let’s make our device advertise this new service. Fortunately, all the magic is already happening in advertising_init, all we need to do is add our service in the list of UUIDs defined at the beginning of main.c. We’ll replace the first entry (BLE_UUID_DEVICE_INFORMATION_SERVICE) with ours, since we don’t need the Device Information service.

static ble_uuid_t m_adv_uuids[] =                                               /**< Universally unique service identifiers. */
{
 {BLINK_UUID_SERVICE, BLE_UUID_TYPE_VENDOR_BEGIN},
};

We’re almost done. There’s just one catch left. advertising_initgives the BLE stack a list of UUIDs to advertise, but in our code’s current form, our service’s UUID isn’t registered until much later, in services_init. The BLE stack will helpfully refuse to advertise an UUID it doesn’t know about.

That’s why we want to modify the initialization sequence and call services_initfirst, like this:

// Initialize.
log_init();
timers_init();
buttons_leds_init(&erase_bonds);
power_management_init();
ble_stack_init();
gap_params_init();
gatt_init();

services_init();
advertising_init();

conn_params_init();
peer_manager_init();

This is actually what nRF does in their example code, too. Your guess as to why the minimal application template doesn’t do it is as good as mine.

At this point, main.c should look like this.

Testing our Minimal Service

At this point, if we scan for devices, we should find ours in the list. I mean, sort of:

Scanning reveals our BLE device, but with a truncated name.
We can find our device, but why is the name truncated?

Why is the name truncated?

Well, as it turns out, the advertising packet for BLE is very small: only 31 bytes. There’s not a lot of room there, and just our service’s UUID takes up 16 of those bytes! With everything else going on, only 4 bytes are all that’s left, and the name gets truncated to that.

That raises two questions though.

First — surely that can’t be right, no? 31 bytes isn’t enough to fit two vendor-specific UUIDs, and there are devices that definitely advertise more than that, aren’t there?

Sort of — devices can send extra advertising data, but not in the advertising packet. They can expose this data on-demand, by answering to a Scan Request. The corresponding packet is known as a Scan Response, and the nRF SDK does support it, but we’re not going to cover it here because it’s really not essential.

Second — isn’t that a really bad idea? Surely advertising one vendor-specific UUID and a name longer than four characters is a legitimate use case — can’t we have a longer name?

We can — and actually, in this case, we do! If you connect to the device and check out the Device Name characteristinc under the Generic Access services, you’ll see that it does list the full name, as in the screenshot below:

BLE device name shown by Device Name characteristic.
Our device’s full name is nonetheless exposed by the Device Name characteristic

You’ll also find our service listed there. It’s just that it’s empty:

Our BLE service is listed, but empty
Our device’s description

But we’ll take care of that soon!

Adding a New Characteristic

nRF SDK: Anatomy of a Characteristic

As with services, the nRF SDK identifies characteristics based on a handle that it assigns when requested by the developer. This handle is sufficient for the BLE stack to identify a characteristic, but it doesn’t describe it entirely.

When adding a characteristic, we’re expected to provide:

  1. Its UUID (or, rather, 16 bits of UUID, which will be mixed with the parent service’s UUID to get the full, 128-bit UUID of our characteristic)
  2. The parent service’s type (i.e. the one that sd_ble_uuid_vs_addreturned when we registered our vendor-speific service’s UUID)
  3. The initial and maximum length of the our characteristic’s value
  4. The initial value of our characteristic
  5. Whether the characteristic is readable and/or writeable, and
  6. The security permissions required to read and/or to write the characteristic’s value

We provide all these things and, in return, the BLE stack will give us a handle for our characteristic — which we’ll have to retain somewhere, just like we did with the service

#1, #2 and #4 are pretty straightforward — let’s talk about the other three for a bit.

We speak of “initial” and “maximum” lengths for a characteristic because the BLE standard allows for characteristics to have a non-fixed length. Why? The point of this feature is to only send as many bytes as needed — which, in turn, saves power, because every byte you send is extra time you spend awake and extra power spent on making electrons move.

It may not look like much, but it can be, because some characteristic values are human-readable and user-writeable. For instance, you may expose the name of a device’s owner through a characteristic, in which case you’ll want to be generous with the maximum space and allow for, say, 128 characters. But if you had to transmit all of those bytes, no matter what, and the user’s name were Joe Holt, more than 93% of the power used when transmitting it would be wasted.

What about #5 and #6?

The BLE standard allows you to define read-only, write-only, and read-write characteristics. This is independent of any security feature. There are things that you want to disallow, under all circumstances, even for authenticated users. You don’t want to allow anyone to change a device’s serial number, for example.

But BLE also supports a number of authentication and authorization features. In addition to a characteristic’s “native” attributes, you can further restrict the right to read or write characteristics based on the current security level. For example, a medical device could present some characteristics, like the device’s name, under any circumstances — but would only allow reading potentially sensitive data over encrypted, authenticated connections.

In addition to this compulsory data, we’re also going to provide something else: a Characteristic User Description. The BLE standard allows each characteristic to have a developer-specified, human-readable description. This description is defined by its size and value (and can be variable-length, too). And, as with the characteristic itself, reading and writing to it is subject to security restrictions (which aren’t necessarily the same as those of the characteristic to which it belongs).

Well, enough talking, let’s add our two characteristics!

Adding a Characteristic with the nRF SDK

Adding a characteristic is very straightforward. You populate a structure of type ble_add_char_params_twith the description’s parameters, and a structure of type ble_add_char_user_desc_tfor the Characteristic User Description. You point the p_user_descrmember of the ble_add_char_params_tstructure to the Characteristic User Description, and hand the ble_add_char_params_tstructure to characteristic_add.

characteristic_addreturns an error code, and outputs a handle through its third parameter. You want to retain this handle somewhere, because we’re going to need it when we plug the service into our application in the next section.

Well, let’s get to the code, shall we? Let’s add the LED Enable/Disable characteristic.

First of all, we want to amend the definition of our service’s structure, so that we can retain the handle of this characteristic:

 /**
  * BLE Blink Service
  */
 struct ble_blink_s {
         /* Service handle assigned by the BLE stack */
         uint16_t service_handle;
         /* Service UUID type assigned by the BLE stack */
         uint8_t  service_type;
         /* Handle for enable/disable LED characteristic */
         ble_gatts_char_handles_t led_ena_handle;
 };

and let’s add our characteristic’s UUID:

#define BLINK_UUID_LED_ENA     0x196A

Next, let’s write a function that creates this new characteristic.

Our function needs to know what instance of the service to add the characteristic to, so we’ll pass a pointer to our ble_blink_tstructure. We’ll use a uint8_tto hold the characteristic’s value, with 0 meaning Disabled and any non-zero value meaning Enabled. By default, the characteristic’s value will be 0, and it will be readable and writable:

static ret_code_t add_led_ena_char(ble_blink_t *p_blink)
{
    ret_code_t err_code;
    ble_add_char_params_t add_char_params;
    ble_add_char_user_desc_t add_char_user_desc;
    static char *user_desc_text = "LED Enable";
    uint8_t initial_ena_val = 0;

    /* Add the LED enable characteristic */
    memset(&add_char_params, 0, sizeof add_char_params);
    add_char_params.uuid = BLINK_UUID_LED_ENA;
    add_char_params.uuid_type = p_blink->service_type;
    add_char_params.init_len = sizeof(uint8_t);
    add_char_params.p_init_value = &initial_ena_val;
    add_char_params.max_len = sizeof(uint8_t);
    add_char_params.char_props.read = 1;
    add_char_params.char_props.write = 1;

For the security permissions, let’s leave this characteristic open to everyone:

add_char_params.read_access = SEC_OPEN;
add_char_params.write_access = SEC_OPEN;

Next, let’s initialize our Characteristic User Description:

memset(&add_char_user_desc, 0, sizeof add_char_user_desc);
add_char_user_desc.max_size = strlen(user_desc_text);
add_char_user_desc.size = strlen(user_desc_text);
add_char_user_desc.p_char_user_desc = (uint8_t *)user_desc_text;
add_char_user_desc.is_var_len = false;
add_char_user_desc.read_access = SEC_OPEN;
add_char_user_desc.write_access = SEC_NO_ACCESS;

and we’ll have it referred to by our ble_add_char_params_tinstance:

add_char_params.p_user_descr = &add_char_user_desc;

And, finally, let’s register this characteristic with the BLE stack, retaining the handle that it returns to us in the led_ena_handlemember of our service structure:

    err_code = characteristic_add(p_blink->service_handle,
                                  &add_char_params,
                                  &p_blink->led_ena_handle);
    VERIFY_SUCCESS(err_code);

    return err_code;
}

All that’s left now is to call this function in our initialization function:

ret_code_t blinky_init(ble_blinky_t *p_blinky,
                       const ble_blinky_init_t *p_blinky_init)
{
        ...
  
        err_code = add_led_ena_char(p_blink);
        VERIFY_SUCCESS(err_code);

        return err_code;
 }

 

That’s it! At this point, if you try to connect to our device, you’ll find the new characteristic. You’ll be able to read and write its value, and you’ll be able to get its description, too.

nRF Connect showing new BLE characteristic
We have a brand new characteristic!

The other characteristic should be straightforward to add now: add a member for the characteristic’s handle in the service structure (we’ll call it led_int_handlefor the rest of this HOWTO), and add a function that does, well, pretty much what the previous one did.

This is left as an exercise to the reader, but if you want to cheat, here’s what you should end up with: header file, implementation.

Plugging the Service into our Application

There’s only one thing left to do now: we need to teach our device to blink an LED, and we need to tie that into our BLE service.

The nRF SDK has a pretty clever way of handling buttons and LEDs through a BSP package. That package is pretty complex, though, and I won’t cover it here. Presumably, if you’re at the point where you’re worrying about writing a custom BLE service, you’ve already gone through the ritual blinking of an LED upon pressing a button. If you haven’t, the nRF SDK includes a reasonable example under examples/peripheral/blinky/, and there’s a sort of a tutorial here.

Our Application, sans Bluetooth

Remember when we said that, ideally, this part should be protocol-independent? Well, we’re going to do exactly that — we’re going to write the application without any Bluetooth support at first. To keep things simple and make interfacing a little less complicated, we’re going to do this in main.c, but a large, real-life codebase would likely hold the application interface and implementation in separate files.

Our application’s state consists of two elements:

  1. Whether or not blinking is enabled, and
  2. How often the LED should be turned on and off

We’re going to encode this information in a simple structure, like this:

struct blink_state {
    /* Blinking enabled/disabled */
    bool enabled;
    /* Blinking interval in 100 ms increments */
    uint8_t interval;
};

The way our application works is entirely trivial: upon enabling blinking, we’ll schedule a one-shot timer that goes off after interval * 100ms. Upon expiry, the handler will toggle the LED and, if LED blinking is still enabled at that point, it will re-arm the timer. If not, the timer will remain unarmed.

Of course, without a BLE interface, there won’t be any (easy) way to enable LED blinking — but if you want to prototype this functionality, you can simulate it in any way that feels comfortable to you. The easiest route is probably to toggle the enabledvalue upon pressing a button. We won’t cover that here but it shouldn’t be too hard.

To implement this behaviour, we’re going to need:

  • An instance of the state structure, of course
  • A reference to the LED that we’re going to toggle
  • An application timer

In larger applications, it might be a good idea to combine all these in a single structure. I’m not going to do that because the idiomatic way to instantiate application timers with the nRF SDK precludes us from (concisely) declaring timers inside structures, and the end result would be even harder to explain. But we will declare these as static, so that at least we don’t polute the global namespace:

/* Application state */
static struct blink_state state;
/* LED blink timer */
APP_TIMER_DEF(led_timer_id);
/* Application LED */
static uint32_t app_led_idx;

We want to start from a well-defined state, so we’ll write our own init function for the application state. It won’t have to do much: it will initialize everything to well-known values and create the timer:

static ret_code_t application_init(void)
{
        ret_code_t err_code;

        app_led_idx = BSP_BOARD_LED_3;
    
        app_state.enabled = false; /* LED disabled by default */
        app_state.interval = 10;   /* Blink every 1 second */

        err_code = app_timer_create(&app_led_timer_id,
                                    APP_TIMER_MODE_SINGLE_SHOT,
                                    app_led_timer_handler);

        APP_ERROR_CHECK(err_code);

        if (app_state.enabled)
                app_timer_start(app_led_timer_id,
                                APP_TIMER_TICKS(app_state.interval * 100),
                                NULL);

        return NRF_SUCCESS;
}

The timer handler, app_led_timer_handler, is equally trivial:

static void app_led_timer_handler(void *p_context)
{
    bool led_state;

    led_state = bsp_board_led_state_get(app_led_idx);
    if (led_state)
        bsp_board_led_off(app_led_idx);
    else
        bsp_board_led_on(app_led_idx);

    if (app_state.enabled)
        app_timer_start(app_led_timer_id,
                        APP_TIMER_TICKS(app_state.interval * 100),
                        NULL);
}

All that’s left now is to call the initialization routine as part of the initialization sequence:

 int main(void)
 {
    ret_code_t err_code;
    bool erase_bonds;

    // Initialize.
    log_init();
    ...
    // Start execution.
    NRF_LOG_INFO("Template example started.");
    application_timers_start();

    err_code = application_init();
    if (err_code != NRF_SUCCESS)
    {
        bsp_board_led_on(BSP_BOARD_LED_2);
        for(;;);
        /* Panic here */
    }

    advertising_start(erase_bonds);
    ...
}

The application compiles and runs now — if it doesn’t, you can look here to see what I did. But it’s not much to look at. In fact, since we’re starting with LED blinking disabled, it doesn’t even do anything, because there’s no way to enable it. If you do enable it by default, all it does is blink the LED.

Time to add the BLE bits!

Plugging the BLE Service into Our Application

BLE and State

The characteristics-based structure of BLE maps naturally to a very common idiom for the sort of applications you’d implement on top of the BLE protocol. BLE characteristics expose the application’s state. You read them to glimpse at the application state, and you can write to them in order to alter the application’s state.

So what we want to do in order to plug our BLE service into our application is to:

  1. Initialize our service’s initial state so that it matches the application’s state
  2. Change the application’s state according to changes in our service’s characteristics

A straightforward way to do these things is as follows:

  1. In order to initialize our service’s state to a known, specific value, we won’t hardcode the initial values of our characteristics. Instead, we’ll copy our application state in a service-specific init structure, the way we did it with advertising parameters, when we passed an advertising_initstructure.
  2. In order to change application state when our characteristics’ values change, we’re going to write a GATT write event handler for each characteristic. The event handler is going to be invoked whenever the characteristic is writen to, and will have access to the new value — giving us a chance to reflect these values in our application’s state. We’re going to pass these handlers to the service initialization routine as part of the same init structure that we’ll define for #1.

Initial Service State

This is the easy part. Let’s start by defining our new structure in the service header file:

typedef struct {
        struct blink_state initial_state;
} ble_blink_init_t;

We’ve already defined blink_stateearlier — and we’re already using it to encode our application’s state! This state information is shared by the service implementation and the application implementation. It’s the bridge between them. It’s not the only architectural choice, but it’s the most straightforward one.

(Of course, the compiler will now yell at you because it doesn’t know where the state structure’s declaration is. It’s OK to move it out of main.c and into blink.h).

Now, let’s rewrite our service init routine to use this structure instead. We’ll pass it a pointer to our structure, and the init routine will, in turn, pass it to the characteristic init routines, where the information is actually needed:

ret_code_t blink_init(ble_blink_t *p_blink, const ble_blink_init_t *p_blink_init)
{
...
        err_code = add_led_ena_char(p_blink, p_blink_init);
        VERIFY_SUCCESS(err_code);

        err_code = add_led_int_char(p_blink, p_blink_init);
        VERIFY_SUCCESS(err_code);
...
}

And now the LED enable characteristic’s init routine will look like this:

static ret_code_t add_led_ena_char(ble_blinky_t *p_blinky,
                                   const ble_blinky_init_t *p_blinky_init)
{
    ret_code_t err_code;
    ble_add_char_params_t add_char_params;
    ble_add_char_user_desc_t add_char_user_desc;
    static char *user_desc_text = "LED Enable";
    uint8_t initial_ena_val = p_blinky_init->state.enabled;

    /* Add the LED enable characteristic */
    memset(&add_char_params, 0, sizeof add_char_params);
    add_char_params.uuid = BLINKY_UUID_LED_ENA;
    add_char_params.uuid_type = p_blinky->service_uuid;
    add_char_params.init_len = sizeof(uint8_t);
    add_char_params.p_init_value = &initial_ena_val;
...
    err_code = characteristic_add(p_blinky->service_handle,
                                  &add_char_params,
                                  &p_blinky->led_ena_handle);
    VERIFY_SUCCESS(err_code);
    p_blinky->led_ena_wr_handler = p_blinky_init->led_ena_wr_handler;

    return err_code;
}

The other characteristic’s initialization routine needs the same treatment, of course.

Now, at the other end of our application, let’s call the service init routine with the right data. We’ll start by statically initializing state information:

/* Application state */
static struct blink_state app_state = {
    .enabled = 0,
    .interval = 5,
};

It doesn’t need to be statically-initialized, it just needs to be initialized before the service is initialized. On some devices, state values get initialized dynamically, based on things like whether or not a button is being held down when the device boots up. And since we’re initializing these values statically, we no longer need the hardcoded initializers in application_init, so you can take those out.

We also want to pass this initial state to the service init routine. Therefore, inside services_init, we’ll locally declare an instance of our init struct, and pass the initial state to the service init routine through this struct. Like this:

static void services_init(void)
{
    ret_code_t         err_code;
    nrf_ble_qwr_init_t qwr_init = {0};
    ble_blink_init_t   m_blink_init;

    // Initialize Queued Write Module.
    qwr_init.error_handler = nrf_qwr_error_handler;

    err_code = nrf_ble_qwr_init(&m_qwr, &qwr_init);
    APP_ERROR_CHECK(err_code);

    m_blink_init.state = app_state;

    err_code = blink_init(&m_blink, &m_blink_init);
    APP_ERROR_CHECK(err_code);
}

This is what you should end up with. At this point, you should see the initial values reflected in the service’s characteristics when you pair with the device.

Characteristic Write Handlers

We’re almost at the end of the road here! All we need to do now is add write handlers for our two characteristics, and we’re done.

First, let’s understand how write handlers work with the nRF SDK.

Remember that, when we register a characteristic with the BLE stack, the BLE stack gives us back a characteristic handle. This is where this handle will be useful.

When a characteristic’s value is written, a GATT write event is generated. The service to which that characteristic belongs is notified. In our case, that’s blink_ble_evt_handler, which we registered as the even thandler through the BLE_BLINK_DEFmacro.

The event is described through a specialized event structure (e.g. ble_gatts_evt_write_tfor write events — see documentation here). This structure’s handlemember is set to the handle of the characteristic that the event corresponds to.

This is where the automagic part ends, and where our — fortunately simple — task begins. In our event handler, we need to look at the event’s handle and match it to our characteristics’ handles. If we detect a match, we can take whatever action we want — and we have access to the data that was written, too.

Now, the idiomatic way to keep “whatever action we want” organized is to create a write handler for each characteristic, and invoke that handler from the service’s event handler. Peeking ahead a little, we’ll take our service’s event handler stub, and turn it into the real deal — like this:

/* Event handler for the Blink BLE Service */
void blink_ble_evt_handler(ble_evt_t const *p_ble_evt, void *p_context)
{
        ble_blink_t *p_blink = (ble_blink_t *)p_context;

        if (!p_ble_evt || !p_blink)
                return;

        switch (p_ble_evt->header.evt_id)
        {
        case BLE_GAP_EVT_CONNECTED:
                break;
        case BLE_GATTS_EVT_WRITE:
        {
                ble_gatts_evt_write_t const *p_evt_write = &p_ble_evt->evt.gatts_evt.params.write;
                if (p_evt_write->handle == p_blink->led_int_handle.value_handle &&
                    p_blink->led_interval_wr_handler)
                        p_blink->led_interval_wr_handler(p_ble_evt->evt.gap_evt.conn_handle,
                                                          p_blink,
                                                          p_evt_write->data[0]);

                if (p_evt_write->handle == p_blink->led_ena_handle.value_handle &&
                    p_blink->led_ena_wr_handler)
                        p_blink->led_ena_wr_handler(p_ble_evt->evt.gap_evt.conn_handle,
                                                     p_blink,
                                                     p_evt_write->data[0]);
                break;
        }
        default:
                return;
        }
}

Based on the code above, you can probably guess what we need to do. We need to amend our service initialization code to register the handlers that we provide. We can’t just harcode them: the handlers are inevitably going to be application-specific, so we must be able to provide our own.

Unsurprisingly, we’re going to provide these handlers through the ble_blink_initstructure, the same way we did with the initial state. The handlers will be defined in the application code, and we’ll pass pointers to them inside services_init.So let’s extend our init structure as follows:

typedef struct {
        /* Service state */
        struct blink_state state;
        /* LED enable write handler */
        ble_blink_evt_handler_t led_ena_wr_handler;
        /* Interval enable write handler */
        ble_blink_evt_handler_t led_interval_wr_handler;
} ble_blink_init_t;

You don’t need to define a special type for your write handlers but it’s idiomatic to do so in the nRF SDK. Here’s what the definition looks like:

typedef void (*ble_blink_evt_handler_t)(uint16_t conn_handle,
                                        ble_blink_t *p_blink,
                                        uint8_t data);

Now, providing a handler when initializing the service isn’t enough. If we need to call that handler later, we need to store a pointer to it in the service’s instance, so let’s extend our service’s structure as well:

struct ble_blink_s {
        /* Service handle assigned by the BLE stack */
        uint16_t service_handle;
        /* Service UUID type assigned by the BLE stack */
        uint8_t  service_type;
        /* Handle for enable/disable LED characteristic */
        ble_gatts_char_handles_t led_ena_handle;
        /* Handle for enable/disable interval characteristic */
        ble_gatts_char_handles_t led_int_handle;
        /* State information currently associated with this service */
        struct blink_state state;
        /* LED enable write handler */
        ble_blink_evt_handler_t led_ena_wr_handler;
        /* Interval enable write handler */
        ble_blink_evt_handler_t led_interval_wr_handler;
};

It’s pointless to save a handler for a characteristic that couldn’t be added, so the best way to go about this is to defer saving these handlers until after the characteristics have been successfully added. That is, we’ll save these pointers as part of the characteristics’ initialization code, like this:

static ret_code_t add_led_ena_char(ble_blink_t *p_blink,
                                   const ble_blink_init_t *p_blink_init)
{
...
    err_code = characteristic_add(p_blink->service_handle,
                                  &add_char_params,
                                  &p_blink->led_ena_handle);
    VERIFY_SUCCESS(err_code);
    p_blink->led_ena_wr_handler = p_blink_init->led_ena_wr_handler;

    return err_code;
}

Saving a reference to the other characteristic’s write handler is left as an exercise to the reader. It’s pretty much a copy-paste job.

All that’s left on the service side is to amend out event handler to invoke the characteristics’ write handlers. That’s what the code snippet we peeked at earlier did.

Passing the handlers is trivial: just define the handler functions (let’s call them on_led_ena_writeand on_led_interval_write), and pass them in the init structure as part of services_init:

static void services_init(void)
{
    ...
    m_blink_init.state = app_state;
    m_blink_init.led_ena_wr_handler = on_led_ena_write;
    m_blink_init.led_interval_wr_handler = on_led_interval_write;

    err_code = blink_init(&m_blink, &m_blink_init);
    APP_ERROR_CHECK(err_code);
}

You can already do some rudimentary testing at this point: have the handlers light up an LED and go into an infinite loop, or log something over the serial line if you have access to that.

All that’s left now is to have the handlers act upon our application’s state. This is the most straightforward part:

static void on_led_ena_write(uint16_t conn_handle, ble_blink_t *p_blink,
                             uint8_t data)
{
    app_state.enabled = data;

    if (app_state.enabled) {
        app_timer_stop(app_led_timer_id);
        app_timer_start(app_led_timer_id,
                        APP_TIMER_TICKS(app_state.interval * 100),
                        NULL);
    } else {
        app_timer_stop(app_led_timer_id);
        bsp_board_led_off(app_led_idx);
    }
}

static void on_led_interval_write(uint16_t conn_handle,
                                  ble_blink_t *p_blink,
                                  uint8_t data)
{
    app_state.interval = data;
}

That’s it! You should end up with code that looks like this — or you can look at the full repo here. Go ahead and play with your application now. You should be able to enable and disable blinking, and adjust the interval, over BLE.

Where to go from here?

Whew! This was a lot of stuff to cover, wasn’t it? But, hey, if you made it this far, aren’t you glad you understand why things work just a little better?

Here’s what else you can do to practice your hand and your understanding of the nRF SDK just little more:

  1. Extend the service initialization code so that the security level requirements for each characeristics are also passed through the init structure.
  2. Every time you reset the device, the characteristic’s values are reverted to their default settings. That’s not very nice. Extend the application so that they’re saved somewhere, and erased when you bond with a new device.
  3. Add a set of pre-defined blinking rates: 0.1, 0.5, 1, 2, 5 and 10 seconds. Add a characteristic which allows the user to select one of these profiles or an arbitrary, user-defined interval.

Have fun!

Author’s Notes

Hey! I’m glad you’ve read all this, and I hope it was useful!

Did you spot an error? Look — this is a lot of material, and although I’ve done my best to keep it accurate and review it thoroughly, something may have slipped. If you found an error, please let me know!

Stuck? It may not be your fault, maybe I missed a critical step in my explanation, or something just wasn’t clear enough! If you’re stuck on this tutorial, drop me a line! I can’t promise I’ll answer right away, but I’ll write back as soon as I can.

Leave a Reply

Your email address will not be published. Required fields are marked *