Arduino NRF24L01+ Communications

Ben Fraser
11 min readJun 27, 2017

--

For this walkthrough, we’ll be looking at a more advanced example of using the Nordic Semiconductor NRF24l01+ transceivers for reliable communications. If you’re new to these devices, I recommend the extensive tutorial available here to brush up on the basics. Most importantly, ensure the modules are working with a basic example, prior to moving on to multi-device communications.

The transceivers will be using a packet structure known as Enhanced ShockBurst. This simple packet structure is broken down into 5 different fields, which is illustrated below.

Figure 1. Enhanced ShockBurst packet structure.

The original ShockBurst structure consisted only of Preamble, Address, Payload and the Cyclic Redundancy Check (CRC) fields. Enhanced ShockBurst brought about greater functionality for more enhanced communications using a newly introduced Packet Control Field (PCF).

This new structure is great for a number of reasons. Firstly, it allows for variable length payloads with a payload length specifier, meaning payloads can vary from 1 to 32 bytes. Secondly, it provides each sent packet with a packet ID, which allows the receiving device to determine whether a message is new or whether it has been retransmitted (and thus can be ignored).

Finally, and most importantly, each message can request an acknowledgement to be sent when it is received by another device. This acknowledgement message can contain a preloaded payload, meaning a system of bi-directional communications can be established entirely using a master device that transmits data requests to each slave device in succession. The nRF24L01+ does this by storing a preloaded message into its transmit First-In-First-Out (FIFO) buffer, and sending this message on the listening pipe address as soon as a message with ack payload requested is received.

We will be creating a simple program that can be expanded to almost any use-case, using this acknowledge payload functionality of Enhanced ShockBurst.

Hardware Setup

First of all, ensure you have at least two Arduino micro-controller devices (preferably UNOs), and two NRF24L01 radio transceivers. Preferably, you also have two NRF24L01+ breakout power regulator modules. You can pick these up easily on eBay for about £1.50 each. They effectively step down an input voltage of 5V and regulate it at precisely 3.3V for powering each radio. If you don’t have one of these, and are using the direct 3.3V output of your Arduino (or Rasp. Pi or anything other device), then its likely you’ll experience power problems at some point during use. I’d advise using at least a capacitor of between 1 to 10 uF between the Vcc and Ground terminals of the nRF24L01 module, which will help regulate the input voltage and reduce any fluctuations.

Figure 2. nRF24L01+ power adaptor breakout module — regulated 3.3V output from 5V input.

Connect each transceiver to the micro-controller as shown in Figure 2 below. Please note: the input power to the transceiver is shown direct from 3.3V, but if you are using one of the power regulator breakout adaptors, ensure you use the 5V output.

Figure 3. Interfacing each NRF24L01+ transceiver with the Arduino UNO microcontroller.

You can also use the In-Circuit Serial Programming (ICSP) pins if you have a lot of hardware connected to your Arduino and want to conserve digital pins. The top-left pin serves as the Master-Input Slave-Output (MISO), the centre-left the Serial Clock (SCK) and the centre-right the Master-Output Slave-Input (MOSI), as shown in Figure 4. However, you’ll still need to choose two pins for Chip Enable (CE) and Chip-Select Not (CSN).

Figure 4. Overview of the Arduino UNO, particularly the ICSP pins for interfacing with SPI compatible devices, such as the nRF24L01+ transceiver.

Software — Master to single slave device

For this example, we’ll be using the optimised TMRh20, which you can download here. Ensure you remove any old RF24 libraries if you previously had them loaded into your Arduino libraries.

The first program will be a simple master-to-one-slave communication using the acknowledge payload feature of the nRF24L01+ radios.

We’ll start with the master device program, which will effectively be the controller of communications within our system. The master should send out a generic message with acknowledge payload selected to each slave device. Each slave should then receive this message from the master, and instantly reply with its preloaded acknowledgement message.

Before we can use the radio transceiver device, we must set our program up to include the required libraries and to configure the device appropriately. Since we are using an Arduino UNO, I have chosen to use digital pins 9 and 10 for Chip-Enable (CE) and Chip-Select-Not (CSN), however you can choose any digital pins that you like, depending on how you have connected your nRF24L01.

// include external libs for nrf24l01+ radio 
#include <RF24.h>
#include <SPI.h>
#include <nRF24l01.h>
#include <printf.h>
// set Chip-Enable (CE) and Chip-Select-Not (CSN) pins (for UNO)
#define CE_PIN 9
#define CSN_PIN 10
// create RF24 radio object using selected CE and CSN pins
RF24 radio(CE_PIN,CSN_PIN);
// setup radio pipe address for remote sensor node
const byte nodeAddress[5] = {'N','O','D','E','1'};
// integer array for slave node data:[node_id, returned_count]
int remoteNodeData[2] = {1, 1,};

Notice that we have created a 5-byte radio pipe address for our node, which uses the ASCII chars N, O, D, E and 1. ASCII chars take up 1 byte each, and so five ASCII characters give a 5-byte address. You could equally have created this address using hex or binary values too — I chose ASCII for its improved readability, and also so that for the advanced program later on we can label each node simply as ‘NODE1’, ‘NODE2’, ‘NODE3’ etc.

We also created an integer array variable remoteNodeData, which is used to store the data sent from our slave device. For this example, we have a node_id to identify the slave, which will just be ‘1’. We also have a returned_count that we will increment with each transmission, so that the data changes over time and we can visualise this in the serial monitor.

void setup() {// begin radio object
radio.begin();

// set power level of the radio
radio.setPALevel(RF24_PA_LOW);
// set RF datarate - lowest rate for longest range capability
radio.setDataRate(RF24_250KBPS);
// set radio channel to use - ensure all slaves match this
radio.setChannel(0x66);
// set time between retries and max no. of retries
radio.setRetries(4, 10);
// enable ackpayload - enables each slave to reply with data
radio.enableAckPayload();
// setup write pipe to remote node - must match node listen address
radio.openWritingPipe(nodeAddress);
}

We set up the main configuration settings for the radio object in the Arduino setup() function, prior to starting the main loop. There are a variety of different settings you can play with here, including the power and data rate parameters. The maximum power will get you greater range, as will the lowest data rate setting (250KBPS as we used). Conversely, a lower power setting and higher data rate will yield a lower achievable range — tinker with the settings as you see fit, but if you encounter problems during use ensure you adjust the power to a lower level to see if this rectifies the issue.

The setRetries parameter allows us to specify the time to wait between radio transmission attempts (4 ms in this case), and the number of retries we shall make to communicate (10 selected for this example).

Following this is the radio.enableAckPayload(), which is the crux of our program. This changes the ShockBurst No_Ack field of the Packet Control Field (as discussed earlier) to zero for each message sent, which means that our receiving devices know they are to send a preloaded payload as soon as they receive this message.

Finally, we open a writing pipe address on the ASCII ‘NODE1’ 5-byte address defined earlier. We’re now ready to write the main program.

void loop() {  Serial.println("[*] Attempting to transmit data to remote node.");

// boolean to indicate if radio.write() tx was successful
bool tx_sent;
tx_sent = radio.write( &masterSendCount, sizeof(masterSendCount));
// if tx success - receive and read smart-post ack reply
if (tx_sent) {
if (radio.isAckPayloadAvailable()) {
// read ack payload and copy message to remoteNodeData
radio.read(&remoteNodeData, sizeof(remoteNodeData));
Serial.print("[+] Successfully received data from node.");
Serial.print(" ---- The received count was: ");
Serial.println(remoteNodeData[1]);
}
}
else {
Serial.print("[-] The transmission to the node failed.");
}
Serial.println("------------------------------------------");
}

By piecing these code snippets together, we have the code for our master device. I have provided the full version of this below:

Slave device code. With our master device program formed, we need to create a receiving slave program that responds to our master. The configuration and setup is mostly the same as the master, but with several small changes.

// open a reading pipe on the chosen address - matches the master tx
radio.openReadingPipe(1, nodeAddress);
// enable ack payload - slave replies with data using this feature
radio.enableAckPayload();
// preload payload with initial data
radio.writeAckPayload(1, &remoteNodeData, sizeof(remoteNodeData));

Rather than setting up a writing pipe, we open a reading pipe, since we want our slave to listen out for an incoming message from the master. For this we use the same 5-byte address that the master transmits on — ‘NODE1’.

Additionally, we preload the Ack Payload with sample initial data, using the radio.writeAckPayload function. Each time we carry this out, we update the preloaded information within the payload, which is sent to the master as soon as we read an incoming request message. For a real system that deals with more complexity than our example, we would typically gather a variety of sensor readings, followed by routinely updating this preloaded ack-payload so that it contains fresh data for our master.

Moving on to the main function, we must create a simple process that continually checks for a received message on the listening pipe address. If a message is received, we then read the data, which immediately transmits our preloaded ack-payload back to the master. After doing this, we will change our preloaded data so that it is dynamic, and thus can be seen to change on the master device serial monitor.

void loop(void) {
// check for radio message and send sensor data using auto-ack
if ( radio.available() ) {
radio.read( &dataFromMaster, sizeof(dataFromMaster) );
Serial.println("Received request from master - sending data.");
// update data count after sending data - allows our data to change so we can visualise it on the serial monitor
updateNodeData();
}
}
void updateNodeData(void)
{
// increment node count - reset to 1 if exceeds 500
if (remoteNodeData[1] < 500) {
remoteNodeData[1]++;
} else {
remoteNodeData[1] = 1;
}
// set ack-payload ready for next sending to master
radio.writeAckPayload(1, &remoteNodeData, sizeof(remoteNodeData));
}

Combining these sections together, along with a few tweaks and enhancements, and we obtain the full slave device code, as provided below.

Software — Master to multiple slave device

Okay, now for the magic. With a working system of a master and slave communicating using nRF24L01+ radios and the ack payload feature, we can adapt the program to work with multiple slave devices. For this example, we will write a program to support up to 6 slave devices (although we will only use 3) that send data back to a master after receiving a request message.

The first change needed to the master device program is to create additional address pipes for the extra two slave devices.

// setup radio pipe addresses for each slave
const byte nodeAddresses[3][5] = {
{'N','O','D','E','1'},
{'N','O','D','E','2'},
{'N','O','D','E','3'}
};

We also need to store two extra sets of data from each slave, so we need to update our data structure to hold incoming data:

// simple integer array for each remote node data, in the form { node_id, returned_count }
int remoteNodeData[3][3] = {{1, 1,}, {2, 1}, {3, 1}};

Since we’re sending to a total of three slaves, rather than sending just one transmission to one slave like previously, we need to adopt the following process:

  • Set write pipe to first slave device
  • Send ack-request message
  • Receive follow-up ack-payload from slave
  • Move to next slave address — repeat

With our two-dimensional array of byte addresses created two paragraphs up, we can carry out this process easily using a for loop, like so:

// make a call for data to each node in turn
for (byte node = 0; node < 3; node++) {
// setup write pipe to current slave
radio.openWritingPipe(nodeAddresses[node]);
Serial.print("[*] Attempting to transmit data to node ");
Serial.println(node + 1);
// boolean to indicate if radio.write() tx was successful
bool tx_sent;
tx_sent = radio.write(&masterSendCount, sizeof(masterSendCount));
// if tx success - receive and read slave ack-reply
if (tx_sent) {
if (radio.isAckPayloadAvailable()) {
// read ack payload and copy to remoteNodeData array
radio.read(&remoteNodeData[node], sizeof(remoteNodeData[node]));
Serial.print("[+] Successfully received data from slave: ");
Serial.println(node);
Serial.print(" ---- The node count received was: ");
Serial.println(remoteNodeData[node][1]);
// iterate master count - provides changing data to slave
if (masterSendCount < 500) {
masterSendCount++;
} else {
masterSendCount = 1;
}
}
}
else {
Serial.print("[-] The transmission to slave failed.");
}
}

Notice that we use exactly the same process as with a single slave — just three times using a loop. Each time we successfully transmit and receive an ack-reply we also increment the master count (which is sent to the slaves), so that we have continually changing data that we can observe in the serial monitor for this example. With exception to a few minor tweaks, thats the only changes that we need to make to the master! The fully updated master program is provided below if you require it.

The slave program is actually very nearly complete as it is from the first version we made previously. The only changes needed are the correct listening pipe address and node_id loaded into each slave device. To save you from tedious changes to the code, a version is provided below, whereby the only change needed is to the macro defined at the top: NODE_ID. Simply change this to ‘0’ for slave 1, ‘1’ for slave 2, and ‘3’ for slave 3. Depending on the value given, the program will select the appropriate listening address and node data as required.

And thats it! I know the program is relatively pointless as it is currently — but it provides the foundation for providing a much greater and versatile system of reliable communications between a set of remote slaves and central master unit. By the addition of several sensing modules or control interfaces, we can quickly expand this system to carry out a limitless number of useful tasks.

An example of such a system that uses the structure of these communications to perform a variety of tasks is an intrusion monitoring system I will be covering in various future posts. This system integrates X-Band Radar Doppler sensing, Passive Infrared sensing, Radio communications and various forms of display and control to create a fully fledged intrusion monitoring system.

I hope you enjoyed, and, most importantly, learned how to make use of the cheap and readily available nRF24L01+ transceiver modules to create a reliable system of communications between multiple devices.

--

--

Ben Fraser

Aerospace and electronic systems engineer. Ministry of Defence, United Kingdom.