Enter the Multi-NUS: A Simple Wireless UART Network

If you’re thinking about building a network of sensors and you begin to survey the options, you’re likely to encounter a frothing sea of acronyms representing a variety of standards and protocols clamoring for your attention, begging you to pick them out of the crowd for your next generation product. Maybe you’re familiar with basic Bluetooth LE connections but need to network the devices or with Wi-Fi networking but want your sensors to run for years and years without new batteries or charging. You could certainly look at Thread, which is excellent for cloud-connected devices, but requires a Thread Border Router, maybe not be something you want to deal with. Zigbee is another option. It’s got a long history, but has its own complications. So which will it be?

Allow me to offer yet another option for your decision matrix. If you want a simple, tiny footprint network, I recommend creating a star-topology network of BLE connections. Nordic devices can each have up to 20 connections with any mix of central or peripheral roles for those connections. If you can live with 20 devices, you could have one central as a hub for 20 devices. To expand, you could have more hubs or have the peripherals act as hubs for sub-networks. Each of the peripherals could be a central to 19 more peripherals and so on. These data connections can operate with bandwidth up to the 2Mb/s BLE limit which is great. Data can really fly around this little network if that’s your aim. This star network is relatively easy to build, fast, and very lightweight from a code perspective. So, I thought I’d put together a bit of code to build just such a miniature star-topology network and to do it, I used the Nordic UART Service (NUS). I expanded the NUS Central Role example to accept up to 20 peripherals and to be able to send individual messages, broadcast them, and provide a mechanism for the peripherals to send messages to each other. I call it the Multi-NUS! I’ll show you the software libraries I used to extend the NUS example and how to use them, and how to set up and test your own Multi-NUS.

The code for this tutorial is hosted on Github as the Multi-NUS. Download it here.

If you’re new to the Nordic UART Service, check out my last blog on the subject here, NUS on nRF Tutorial.

And if none if this nRF Connect SDK business rings a bell, please start from the beginning here, nRF Connect SDK Tutorial Series

This guide was written using nRF Connect SDK tagged version v1.4.1. There may be differences between this tutorial using v1.4.1 and your code using other versions of NCS but the concepts will still apply.

Design Goals

The goal of this tutorial, as it should be with any tutorial in my opinion, is not so much to teach you how to build a multi-NUS, but to provide a basis for the you to extend your own application with networking, sharing data among devices, or just to handle multiple connections. To that end, I want to use standard functionality as much as possible and keep the result as elegant as the code I start with. Also, I want to restrict the modifications to the central application. The peripheral application will be bone stock to minimize the variables and to keep it simple.

The specific design goals for the Multi-NUS are:

  1. One central device connects to up to 20 peripheral devices.
  2. The central device can broadcast messages to all peripheral devices.
  3. The central device can individually send messages to each peripheral device.
  4. Each peripheral device can send messages to the central device.
  5. The central device can route messages from one peripheral to another or from one peripheral to all others.

These goals achieve the basic functionality I’m after. You can see that there are huge opportunities to extend this feature set and I encourage you to take those steps. Let’s look at how we can implement these design goals.

Connection Context Library

In the nRF Connect SDK (NCS), as a fork of the Zephyr Project, Bluetooth connections are managed using an abstraction called a “bt_conn”. This is a data structure containing all the information pertaining to a Bluetooth connection and an API with the functionality to manage connections. Most of the examples in NCS demonstrate that it’s relatively easy to establish a single connection between two nRF devices, but what if you want to have multiple connections like we do? Well, NCS contains a library called the Bluetooth Connection Context Library, which provides functionality for managing multiple connections and data associated with each connection, aka, the “connection context”.  We’ll see that the application can store any data of any variety and tie that data to a specific connection. The context in this case is the NUS data structure that defines the NUS being run on a particular connection. By associating the NUS information with the connection, we can retrieve the context information just by having the connection information. If we know which connection we are dealing with, we can find the information about the associated NUS. And vice versa, if we know which NUS we are interacting with, we can retrieve the connection information, kinda. We’ll see the details further on.

We can also use the Connection Context Library to iterate through the connections to perform an action like broadcasting data to all the peripherals. The Connection Context Library instance contains an array of slots for connections. These slots are populated as connections are formed beginning with zero and counting up to the maximum. The Multi-NUS default maximum number of connections is set to 20 because that’s the most the Soft Device Controller is designed to handle. Although if you were to use the Open Source Zephyr Controller, I do not believe there is a limit. (Go for it reader!). When the connection context library instance is instantiated, we must provide the size of the connection context and the maximum number of connections the library will manage. In the Multi-NUS, the definition statement looks like this:

BT_CONN_CTX_DEF(conns, CONFIG_BT_MAX_CONN, sizeof(struct bt_nus_client));

So, that makes a Connection Context Library (CCL) instance. Now what can we do with it? The API is quite simple, in fact. We can assign available connection context slots to connections, dynamically allocating the memory for them. We can delete connections under management and free the memory, and then we can search the library for a connection either by having a pointer to the connection or by the index, or ID, of the connection in the library and the library will return the connection information and connection context for us to use. Let’s look at how we employ the Connection Context Library to achieve the design goals for the multi-NUS.

Mutexes: An Important Note

The Connection Context Library makes use of RTOS functionality to make sure that the library is not modified by one part of an application while being used by another. To accomplish this, the library uses a mutex, which is a RTOS feature that acts like a simple lock. The application can lock it, unlock it, check its state, or sit waiting, blocked until the Mutex is unlocked. It’s important in your application to interact with the library in such a way that you don’t get unexpected blocking behavior and the way to do that is to make the appropriate exit calls when you finish with an interaction with the CCL. At the end of every piece of code that accesses a slot in the CCL whether initialized or not, there must be a call to release the slot that looks something like this: 

bt_conn_ctx_release(&conns_ctx_lib, (void *)nus_client);

If you look at this function in the CCL source, you’ll see that it releases the mutex, in fact one mutex per slot in the library. If you don’t make this release call, you will almost certainly see the application become unresponsive. It may appear locked because it is waiting in a blocking call for the mutex to be released and it never is. So, we’ll see this release call throughout the application and you’ll want to check that you uniformly call it before leaving a function that uses the CCL.

Scanning and Forming Connections

In the stock NUS example, scanning is only necessary to find an NUS peripheral to connect to. Scanning starts at the beginning, and stops after finding a suitable peer, starting again if the connection drops. But in the multi-NUS, we want to scan until we hit the maximum number of peers, so you’ll see that throughout the application, scanning is almost always running. Scanning is configured, as it is in the standard NUS, to look for the UUID of a NUS peripheral, and if it sees it, to automatically initiate a connection. There’s no work for the application to do until the connection has successfully been established. Once it is, the application calls the “connected” callback function and it is here we see the first use of the CCL.

First, the application needs to allocate memory on the heap for storing the UART Service context data. The first step is calling the bt_conn_ctx_alloc() function which grabs a chunk of memory from the heap and grabs a slot in the Connection Context Library and points that slot’s pointers at the current connection, the one being configured here, and at the allocated data buffer. And then the memset call initializes this newly allocated memory.  In order to get the size of the context data, we use another CCL library function, bt_conn_ctx_block_size_get(), and use this size to set the memory allocation.

struct bt_nus_client *nus_client = bt_conn_ctx_alloc(&conns_ctx_lib, conn);

memset(nus_client, 0, bt_conn_ctx_block_size_get(&conns_ctx_lib));

Now we’ve got a chunk of memory to use for the context data, it’s time to populate it with real data. But, there’s no difference in the Multi-NUS example from the original because the Connection Context Library is just about where data are stored and providing a mechanism to find them later; it doesn’t change the fundamental application. Note also, as mentioned above, we call the release function to take care of that mutex.

err = bt_nus_client_init(nus_client, &init);

bt_conn_ctx_release(&conns_ctx_lib, (void *)nus_client);

And that’s it for adding the connection to the CCL. The same function runs every time a new NUS is found and it’s added to the pile. It’s all done dynamically and all the management of the data arrays for all the connections is done for us. This helps toward that simple design goal.

The next step after connection formation is GATT Service Discovery where we’ll use the same data memory and populate it with information we obtain from the discovery process. At the end of the connected() function, we see the call:

gatt_discover(conn);

Let’s see what’s going on in gatt_discover().

Performing Service Discovery

Now that the connected() function has populated the connection context, we can make use of the memory we initialized in the pursuit of the design goals. In the gatt_discover() function, we see how to retrieve the memory location of the connection context by knowing the connection’s memory address, aka the value of the pointer to it.

static void gatt_discover(struct bt_conn *conn)

{

    int err;

    struct bt_nus_client *nus_client = bt_conn_ctx_get(&conns_ctx_lib, conn);

    if (!nus_client) {
        return;
    }

    err = bt_gatt_dm_start( conn,
                            BT_UUID_NUS_SERVICE,
                            &discovery_cb,
                            nus_client);
                            
    if (err) {
        LOG_ERR("could not start the discovery procedure, error " "code: %d", err);
    }

    bt_conn_ctx_release(&conns_ctx_lib, (void *) nus_client);
}
 

Here we see the initialization of a bt_nus_client data structure pointer with the value returned by calling bt_conn_ctx_get(). This function requires, as its argument, the pointer to the connection which it uses to find the matching CCL slot. If you wander into the CCL library source, you’ll see that the CCL stores the pointer value and later looks for a match to locate the connection context. It’s not terribly complicated, but it’s nice that it’s there and we can use it. In the middle of the function, we see the nus_client pointer being used to kick off the Discovery Manager. And at the end of the function, we see the always important release call. This releases the mutex for this slot in the CCL. Now the BLE stack will go off and go through the discovery process. When the process completes, the discovery_complete() callback will be called. This function contains the first actual Multi-NUS behavior. But, before we look at it, we should take a look at message routing in the Multi-NUS.

Multi-NUS Message Routing

Routed Message Character

Peripheral ID

Message

*

##

yyyyyyyyyyyyyyyyy….

I created a simple messaging protocol to send messages around the Multi-NUS. All routed messages must start with a single character, *. Every peripheral is assigned a slot with an index of an array by the CCL when it joins, so this is used as the peripheral ID and serves as that peripheral’s address. To route a message then, one must start the message with * and then specify a two digit address. To send a message to peripheral 4, I’d start the message with *04 and follow that with whatever data I wanted to send. Address 99 is reserved as the broadcast address. If *99 is sent by any peripheral, that message is broadcast to all peripherals. If a peripheral sends a message without the routing character, *, then it is a message to the central. If the central receives data over its local UART without the routing character, then that message is broadcast from the central to all peripherals. Any peripheral can send a message to any other peripheral by using the correct address. That message doesn’t pass directly. When the central receives it, it checks for the routing header and then transmits it to the appropriate peripheral. Let’s see it in action in the discover_complete() callback.

Finalizing the Connection…What’s My Number?

For the message routing scheme to work, a peripheral should know its address. In the discovery_complete() function, we need to get the ID of the connection from the CCL and then send it to the peripheral. After the ordinary Discovery Manager functions which are the same as in the original NUS, we see the following:

size_t num_nus_conns = bt_conn_ctx_count(&conns_ctx_lib);

size_t nus_index = 99;

for (size_t i = 0; i < num_nus_conns; i++) {

    const struct bt_conn_ctx *ctx = bt_conn_ctx_get_by_id(&conns_ctx_lib, i);

    if (ctx) {
        if (ctx->data == nus) {
                
            nus_index = i;
            char message[3];
            sprintf(message, "%d", nus_index);
            message[2] = '\r';

            int length = 3;
            err = bt_nus_client_send(nus, message, length);

            if (err) {
                LOG_WRN("Failed to send data over BLE connection"
                        "(err %d)", err);
            } else {
            LOG_INF("Sent to server %d: %s",
                nus_index, log_strdup(message));
            }

            bt_conn_ctx_release(&conns_ctx_lib, (void *)ctx->data);

            break;

        } else {

            bt_conn_ctx_release(&conns_ctx_lib, (void *)ctx->data); 
        }
    }
}

In this function, we see that we know the context address, not from looking in the CCL, but as an argument to the callback function. Unfortunately, we cannot search for a connection in the CCL just by knowing the address of the context. Perhaps this capability will be an extension we can make in the future, but for now we have to do a bit of work to find the appropriate entry in the CCL that matches this NUS.

To find the NUS in the library, we make use of some of the other functions in the CCL API. We use bt_conn_ctx_count() at the top. This function returns the size of the CCL instance in terms of the number of slots. We can use this to know over how many slots we need to iterate to perform a complete search in the for loop.

Then, in the for loop, we use another CCL function, bt_conn_ctx_get_by_id(), which returns the CCL slot, a bt_conn_ctx data structure with two members: a pointer called data, which is “the context”, and a pointer to a bt_conn, which is “the connection”. First, we check to see if the returned data structure has been initialized at all, which it will not be if the slot is unused. If it’s uninitialized, we jump to the else and release this slot. If the slot is used, we check to see if the context pointer that was passed to the callback function as an argument is the same context pointer (remember, it’s a memory address) as the one stored in the CCL slot. If it does, Yahtzee! We’ve found the right context in the CCL and we know it’s ID. We transmit the ID to this peripheral using a new function, one that actually sends data, bt_nus_client_send(). Let’s take a look at it next.

Send It To the Multi-NUS

To implement the multi-NUS message routing, we’ll need a bit of code. I kept everything as simple as I felt I could while meeting the fundamental design requirements, but it’s still long enough that I don’t want to copy it all here. Let’s look at some of it. Here’s how we parse the address header:

if (messageStart) {

    messageStart = false;

    /*Check if it's a routed message*/

    if (message[0] == ROUTED_MESSAGE_CHAR){
        routedMessage = true;

        /*Determine who the intended recipient is*/
        char str[2];
        str[0] = message[1];
        str[1] = message[2];
        nus_index = atoi(str);

        *Is this a number that makes sense?*/
        if ((nus_index >= 0) && (nus_index < num_nus_conns)){
            broadcast = false;

            /*Move the data buffer pointer to after the recipient info 
            and shorten the length*/

            message = &message[3];
            length = length - 3;

        }else if (nus_index == BROADCAST_INDEX) {
            broadcast = true;
            message = &message[3];
            length = length - 3;
        }

    } else {
        broadcast = true;
    }
}

If it’s the beginning of the message, we check for the special routing character. If we don’t find it, we broadcast the message. If we do find it, the parsing continues. We grab the address bytes and do some bounds checking and look for the broadcast mask, 99. This gathers all the info we’ll need to send the message as intended.

The next portions of the function transmit the data according the address parsing. If the message is to be sent to a single peripheral, we see just one transmit, which calls the original NUS transmit function, bt_nus_client_send(). If it’s a broadcast message, we see the same use of the CCL library functions as above to iterate through the connections and send a message to each, which we see in the code below:

for (int i = 0; i < num_nus_conns; i++) {

    const struct bt_conn_ctx *ctx = bt_conn_ctx_get_by_id(&conns_ctx_lib, i);

    if (ctx) {

        struct bt_nus_client *nus_client = ctx->data;

        if (nus_client) {

            err = bt_nus_client_send(nus_client, message, length);

            if (err) {

                LOG_WRN("Failed to send data over BLE connection"
                                "(err %d)",err);

            }else{
                LOG_INF("Sent to server %d: %s", i, log_strdup(message));
            }

            bt_conn_ctx_release(&conns_ctx_lib, (void *)ctx->data);
            err = k_sem_take(&nus_write_sem, NUS_WRITE_TIMEOUT);

            if (err) {
                LOG_WRN("NUS send timeout");
            }
        }
    }
}
 

Well, that’s the bulk of the code. There are additions and modifications elsewhere in the project to check received messages for routing information, but they’re just riffs on the same ideas we’ve gone through. I’ll leave it up to you to further explore the code.

Testing

To test the Multi-NUS properly, you should have at least three nRF52 or nRF53 Development Kits. I haven’t tested with Thingys personally, but they should work as well in theory. Two of the boards should be flashed with the stock “peripheral_uart” from NCS in the nrf/samples/bluetooth/peripheral_uart folder. You’ll clone the Multi-NUS project repository from Github to your local folder. I like to put it next to the peripheral_uart folder for ease. Then build the multi-NUS project for the board you intend to use as the central and flash it. If you’ve got a serial terminal hooked up to each board, you’ll see the system start up and see the central transmit each peripheral’s ID to it after they’ve formed a connection. From there you can send messages, try sending *99Hello from the central or one of the peripherals and see what happens.  

Final Thoughts

I hope you take this project and make it your own. There is much that can be done to extend it. As I said, I didn’t modify the peripheral code at all. So, the project could be extended with parsing at the peripheral, or the whole network could be extended, so that peripherals in the system could also act as centrals for their own network. If you did that, you could come up with all kinds of different routing arrangements. Things like routing tables become necessary and processes to update them. Self-healing network?!? Don’t get altogether too crazy. If it starts to look a lot like Thread or Zigbee or some such similar jobber, it’s probably best that you go with the standard. This design is intended to be super light and super simple, but it isn’t a solution to all problems. So, do check in with your local FAE and let them know what you’re doing so we can offer our unlimited wisdom. Until then, cheers!

Useful Links:

Bluetooth Connection Context Library

Nordic UART Service (NUS)

Multi-NUS Project Repository

nRF Connect SDK Tutorial Series

nRF Connect for Desktop