Подключение радио-модуля NRF24 к ардуино

Радиомодуль NRF24L01

Радиомодуль NRF24L01

Нередко возникают задачи, связанные с необходимостью получать данные от датчиков, находящихся на значительном удалении от управляющего устройства. Простое решение: протянуть пару десятков метров провода. Но в реальности это не всегда возможно. Длинные провода подвержены наводкам. Кроме того, подача питания по длинным проводам часто невозможна из-за высокого сопротивления проводника. Поэтому, источник питания всё равно приходится размещать в непосредственной близости от датчика. О необходимости прятать провода для минимальной эстетической привлекательности тоже не стоит забывать. Всё это подталкивает нас к идее обойтись вовсе без проводов, а именно построить систему передачи данных по радиоканалу. Существует и недорогое решение для беспроводной связи: радио-модуль на базе чипа NRF24. О подключении и работе с одной из разновидностей такого чипа и пойдёт речь в статье. Модуль стоит всего 1 доллар. По ссылке модуль можно купить с бесплатной доставкой у проверенного мной продавца. Или можно купить сразу десяток модулей. Тогда ещё дешевле.

Обзор радио-модуля NRF24L01

Модуль представляет собой полноценный микроконтроллер. Помимо программы, обеспечивающей работу с радио, в контроллер можно загрузить свою программу с помощью программатора. Однако, о том как это сделать я не буду рассказывать в этой статье. Модуль подключается к Arduino по интерфейсу SPI. Максимальная скорость обмена данными между модулями составляет 2Мбит/сек. Пожалуй, достаточно для любых задач. При подаче питания модули самостоятельно организуются в сеть. Управлять этим процессом мы не можем. Нам доступны команды вроде: «передай байт модулю с адресом 1», или «прочитай содержимое приёмного буфера». Вся низкоуровневая работа по передаче данных осуществляется модулем без участия внешнего контроллера. Каждому модулю должен быть назначен адрес. Адрес может занимать 3, 4 или 5 байт. Существует несколько библиотек для общения с модулем через Arduino. Отличаются они в основном уровнем общения. Где то вы пишете и читаете прямо из регистров контроллера модуля, где то работа с модулем осуществляется не так гибко, но в более привычном для ардуинщика виде на более высоком уровне. Мне нравится высокоуровневые библиотеки. В статье рассмотрена работа с помощью библиотеки Mirf. Но сначала, нужно подключить радиомодуль NRF24l01 к Arduino.

Подключение радио-модуля NRF24L01 к Arduino

Установить модуль на макетную плату невозможно из-за близко расположенных выводов. Для установки на макетную плату я изготовил переходники из кусочка макетного текстолита и пары гребёнок мама и папа. Подробно останавливаться на этом не буду, просто покажу коллаж с фотографиями переходника:

Переходник для установки модуля NRF24l01 на макетную плату

Переходник для установки модуля NRF24l01 на макетную плату

Но чаще я просто использую провода мама-мама и подключаю модуль к Arduino Nano. Для организации радиоканала потребуется как минимум 2 радио-модуля. Каждый радио-модуль подключается к контроллеру Ардуино с помощью интерфейса SPI.

Наиболее распространённые модули окрашены либо в зелёный, либо в чёрный цвет. Распиновка на зелёных модулях нанесена шелкографией, а вот на чёрных нет. Поэтому, вам пригодится картинка. Распиновка контактов выглядит так:

Распиновка модуля NRF24L01

Распиновка модуля NRF24L01

Обратите внимание! Контактов здесь меньше, чем на зелёном модуле за счёт того, что  VCC и GND не дублируются. На зелёном модуле вместо контакта GND расположен дублирующий контакт VCC. Поэтому, при замене чёрного модуля на зелёный без тщательной проверки подключения, можно легко устроить КЗ!

Зелёный модуль NRF24l01

Зелёный модуль NRF24l01

Как вы можете знать, интерфейс SPI на разных платах Arduino разведён по разному. На некоторых моделях (Mega2560, Nano, Uno) SPI пины продублированы на цифровых пинах платы. В таблице ниже приведено соответствие SPI контактам цифровых выводов:

Плата Arduino MOSI MISO SCK
Nano, Uno 11 12 13
Mega2560 51 50 52
Leonardo ICSP-4 ICSP-1 ICSP-3

В Arduino Leonardo пины SPI разъёма не дублируются на цифровые пины и подключить модуль можно только к SPI разъёму по следующей схеме:

ICSP разъём Arduino

ICSP разъём Arduino

Итак, выводы модуля подключаем к выводам платы в соответствии с таблицей. MOSI модуля к MOSI платы и т.д. Выводы модуля CE и CSN можно подключить к любому свободному цифровому выводу платы. Для удобства, подключаем CE к 8 цифровому выводу, а CSN к 7 выводу платы. Для питания модулю необходимо не 5 вольт, а 3,3 вольта. Несмотря на то, что в интернете множество информации о нормальной работе модуля при напряжения питания 5 вольт, я бы не стал запускать модуль с повышенным питанием на постоянной основе. Подавайте питание с разъёма Arduino 3,3V. Если используете arduino mini, на которой нет разъёма 3,3V, придётся использовать регулятор напряжения. Если уж совсем невмоготу, то подавайте 5 вольт на свой страх и риск. Никто вас за это не осудит. Для более стабильной работы модуля рекомендуется так же подключить между выводом +3,3В и землёй как можно ближе к самому модулю конденсатор не менее 10 мкф. Лучше всего припаять его прямо к пинам питания на модуле. Конденсатор не обязателен, но большинство тем на форумах с проблемами передачи данных с помощью радиомодуля заканчиваются сообщением: «я припаял конденсатор и теперь всё хорошо».

Модуль NRF24l01 с припаянным конденсаторомМодуль NRF24l01 с припаянным конденсатором

Модуль NRF24l01 с припаянным конденсатором

Итак, модуль подключен. Теперь самое интересное: программирование. Как отмечалось выше, для работы с модулем существует множество библиотек. В интернете вы можете найти множество споров о том, какая библиотека для работы с радио-модулем подходит больше всего. Мне нравится Mirf. С ней и работаю.

Краткий обзор библиотеки Mirf

Библиотеку можно скачать с официального сайта: playground.arduino.cc/InterfacingWithHardware/Nrf24L01
На всякий случай, я сохранил её у себя: http://uscr.ru/share/Mirf.zip

Распакуйте архив в папку libraries, которая находится в папке со средой программирования Arduino IDE.

Теперь, когда библиотека установлена, вы можете проверить работоспособность ваших модулей. Для этого в одну из плат с подключённым радио-модулем загрузите библиотечный пример ping_server, в другую — пример ping_client.

Или просто скопируйте вот эти скетчи:

ping_server:

/**
 *
 * Подключение:
 * Hardware SPI:
 * MISO -> 12
 * MOSI -> 11
 * SCK -> 13
 *
 * Настраиваемые пины:
 * CE -> 8
 * CSN -> 7
 *
 */

#include <SPI.h>
#include <Mirf.h>
#include <nRF24L01.h>
#include <MirfHardwareSpiDriver.h>

void setup(){
  Serial.begin(9600);
  
  /*
   * Подключение SPI драйвера.
   */

  Mirf.spi = &MirfHardwareSpi;
  
  /*
   * Установка пинов.
   */
   
  Mirf.init();
  
  /*
   * Задаём адрес приёмника.
   */
   
  Mirf.setRADDR((byte *)"serv1");
  
  /*
   * Устанавливаем размер полезной нагрузки (payload)
   * Значение должно совпадать для сервера и для клиента
   */
   
  Mirf.payload = sizeof(unsigned long);
  
  /*
   * Включаем приём
   */
   
  Mirf.config();
  
  Serial.println("Listening..."); 
}

void loop(){
  /*
   * Приёмный буфер
   */
   
  byte data[Mirf.payload];
  
  /*
   * Если пакет получен
   *
   * isSending так же восстанавливает режим прослушивания эфира
   * при переходе от true к false
   */
   
  if(!Mirf.isSending() && Mirf.dataReady()){
    Serial.println("Got packet");
    
    /*
     * Загружаем пакет в буфер
     */
     
    Mirf.getData(data);
    Mirf.setTADDR((byte *)"clie1");
    
    /*
     * Отправляем данные назад клиенту
     */
     
    Mirf.send(data);
    
    /*
     * Ожидаем окончания передачи
     */
      
    Serial.println("Reply sent.");
  }
}

ping_client:

/**
 * Пример, который позволяет определить задержку передачи
 *
 * Подключение:
 * SPI:
 * MISO -> 12
 * MOSI -> 11
 * SCK -> 13
 *
 * Настраиваемые:
 * CE -> 8
 * CSN -> 7
 *
 */

#include <SPI.h>
#include <Mirf.h>
#include <nRF24L01.h>
#include <MirfHardwareSpiDriver.h>

void setup(){

  Serial.begin(9600);
  /* Для указания пинов, к которым подключены
   * пины модуля, можно использовать переменные:
   *
   * Mirf.csnPin = 9;
   * Mirf.cePin = 7;
   */
  //Если переменные не заданы, используются значения по умолчанию (CE->8, CSN->7)

  // Инициализируем SPI интерфейс:
  Mirf.spi = &MirfHardwareSpi;
  // Инициализируем модуль:
  Mirf.init();

  // Устанавливаем адрес модуля

  Mirf.setRADDR((byte *)"clie1");

  // Устанавливаем размер пакета (payload)
  // Здесь устанавливаем payload равным размеру переменной unsigned long
  Mirf.payload = sizeof(unsigned long);

  // Включаем передатчик
  Mirf.config();
  Serial.println("Beginning ... ");
}

void loop(){
  // Получаем количество миллисекунд, прошедших с момента включения платы:
  unsigned long time = millis();
  // Устанавливаем адрес модуля. которому будем передавать пакет:
  Mirf.setTADDR((byte *)"serv1");
  // Передаём значение переменной time в виде массива байт:
  Mirf.send((byte *) &time);

  // Ждём окончания передачи
  while(Mirf.isSending()){
  }

  Serial.println("Finished sending");
  delay(10);
  // Если ничего не получено в ответ
  while(!Mirf.dataReady()){
    Serial.println("Waiting");
    // В течении секунды
    if ( ( millis() - time ) > 1000 ) {
      // Считаем. что пакет потерян
      Serial.println("Timeout on response from server!");
      return;
    }
  }
  // пробуем получить ответный пакет:
  Mirf.getData((byte *) &time);
  // Вычисляем задержку передачи
  // и выводим полученное значение в серийный порт:
  Serial.print("Ping: ");
  Serial.println((millis() - time));

  delay(1000);
}

При загрузке этих скетчей в платы, начнётся обмен данными. В монитор порта при этом будет выводиться информация о времени задержки при обмене пакетами в миллисекундах:

Успешный обмен пакетами между модулями NRF24L01

Успешный обмен пакетами между модулями NRF24L01

Таким образом можно тестировать работоспособность модулей и даже оценить качество связи между модулями. Для передачи полезных данных нужно немного усложнить код.

Передача полезных данных между двумя модулями

Рассмотренные выше примеры прекрасно демонстрируют саму возможность передачи данных, однако, не дают ответа на некоторые практические вопросы. Сейчас мы напишем прошивки для двух ардуин, одна из которых («сервер») будет уметь отвечать на 2 команды: отправить значение millis() и считать значение напряжения на пине A0 и отправить его по радиоканалу другой плате (клиент). На клиенте же мы реализуем таймаут ожидания ответа для того, что бы программа не зависла в случае недоступности сервера. Клиент будет просто в цикле запрашивать обе команды с сервера и ещё одну неподдерживаемую команду для демонстрации превышения таймаута ожидания ответа от сервера. Для понимания происходящего будет не лишним изучить такие понятия, как «указатель» и «ссылка» применительно к языку программирования. Но, по большому счёту, можно обойтись без такого знания. Честно говоря, я очень поверхностно разбираюсь в этом, поэтому некоторые моменты для меня являются магией. И всё равно не плохо живу при этом.

Важно! В скетчах в качестве пина для индикации используется пин 4. К нему можно подключить светодиод. Если захотите переназначить пин индикации на другой, сверьтесь с таблицей выше для того, что бы не занять случайно пин, используемый в SPI.

Код для сервера:

#include <SPI.h>
#include <Mirf.h>
#include <MirfHardwareSpiDriver.h>
#include <nRF24L01.h>

// Адрес модуля
#define ADDR "serv1"
// Размер полезной нагрузки
#define PAYLOAD sizeof(unsigned long)
// Светодиод для индикации - 4 пин
#define StatusLed 4
// Переменная для приёма и передачи данных
unsigned long data=0;
unsigned long command=0;

void setup(){
  Serial.begin(9600);
  // Мигнём светодиодом:
  pinMode(StatusLed, OUTPUT);
  for (byte i=0; i<3; i++) {
    digitalWrite(StatusLed, HIGH);
    delay(100);
    digitalWrite(StatusLed, LOW);
  }

  Mirf.cePin = 8;
  Mirf.csnPin = 7;
  Mirf.spi = &MirfHardwareSpi;
  MirfHardwareSpi;
  Mirf.init();

  Mirf.setRADDR((byte*)ADDR);
  Mirf.payload = sizeof(unsigned long);
  Mirf.config();
  Serial.println("Beginning ... ");
}
void loop() {
  // Обнуляем переменную с данными:
  data=0;
  command=0;
  // Ждём данных
  if(!Mirf.isSending() && Mirf.dataReady()){
    Serial.println("Got packet");
    //Сообщаем коротким миганием светодиода о наличии данных
    digitalWrite(StatusLed, HIGH);
    delay(100);
    digitalWrite(StatusLed, LOW);
    // Принимаем пакет данные в виде массива байт в переменную data:
    Mirf.getData((byte *) &command);
    // Сообщаем длинным миганием светодиода о получении данных
    digitalWrite(StatusLed, HIGH);
    delay(500);
    digitalWrite(StatusLed, LOW);
    // Выводим полученные данные в монитор серийного порта
    Serial.print("Get data: ");
    Serial.println(command);
  }
  // Если переменная не нулевая, формируем ответ:
  if (command!=0) {
    switch(command) {
    case 1:
      // Команда 1 - отправить число милисекунд,
      // прошедших с последней перезагрузки платы
      Serial.println("Command 1. Send millis().");
      data=millis();
      break;
    case 2:
      // команда 2 - отправить значение с пина AnalogPin0
      Serial.println("Command 2. Send A0 reference.");
      data=analogRead(A0);
      break;
    default:
      // Нераспознанная команда. Сердито мигаем светодиодом 10 раз и
      // жалуемся в последовательный порт
      Serial.println("Unknown command");
      for (byte i=0; i<10; i++) {
        digitalWrite(StatusLed, HIGH);
        delay(100);
        digitalWrite(StatusLed, LOW);
      }
      break;
    }
    // Отправляем ответ:
  
    Mirf.setTADDR((byte *)"clie1");
    //Отправляем ответ в виде массива байт:
    Mirf.send((byte *)&data);
  }
  // Экспериментально вычисленная задержка.
  // Позволяет избежать проблем с модулем.
  delay(10);
}

Код для клиента:

#include <SPI.h>
#include <Mirf.h>
#include <MirfHardwareSpiDriver.h>
#include <nRF24L01.h>

// Адрес модуля
#define ADDR "clie1"
#define PAYLOAD sizeof(unsigned long)
// Светодиод для индикации - 4 пин
#define StatusLed 4
// Переменная для приёма и передачи данных
unsigned long data=0;
unsigned long command=0;
// Флаг для определения выхода по таймауту
boolean timeout=false;
// Таймаут ожидания ответа от сервера - 1 секунда
#define TIMEOUT 1000
// Переменная для запоминания времени отправки
unsigned long timestamp=0;

void setup(){
  Serial.begin(9600);
  // Мигнём светодиодом:
  pinMode(StatusLed, OUTPUT);
  for (byte i=0; i<3; i++) {
    digitalWrite(StatusLed, HIGH);
    delay(100);
    digitalWrite(StatusLed, LOW);
  }

  Mirf.cePin = 8;
  Mirf.csnPin = 7;
  Mirf.spi = &MirfHardwareSpi;
  Mirf.init();

  Mirf.setRADDR((byte*)ADDR);
  Mirf.payload = sizeof(unsigned long);
  Mirf.config();
  Serial.println("Beginning ... ");
}

void loop() {
  timeout=false;
  // Устанавливаем адрес передачи
  Mirf.setTADDR((byte *)&"serv1");
  // Запрашиваем число милисекунд,
  // прошедших с последней перезагрузки сервера:
  Serial.println("Request millis()");
  command=1;
  Mirf.send((byte *)&command);
  // Мигнули 1 раз - команда отправлена
  digitalWrite(StatusLed, HIGH);
  delay(100);
  digitalWrite(StatusLed, LOW);
  // Запомнили время отправки:
  timestamp=millis();
  // Запускаем профедуру ожидания ответа
  waitanswer();

  // Запрашиваем число милисекунд,
  // прошедших с последней перезагрузки сервера:
  Serial.println("Request A0 reference");
  command=2;
  Mirf.send((byte *)&command);
  // Мигнули 1 раз - команда отправлена
  digitalWrite(StatusLed, HIGH);
  delay(100);
  digitalWrite(StatusLed, LOW);
  // Запомнили время отправки:
  timestamp=millis();
  // Запускаем профедуру ожидания ответа
  waitanswer();

  // Отправляем невалидную команду
  // прошедших с последней перезагрузки сервера:
  Serial.println("Invalid command");
  command=42;
  Mirf.send((byte *)&command);
  // Мигнули 1 раз - команда отправлена
  digitalWrite(StatusLed, HIGH);
  delay(100);
  digitalWrite(StatusLed, LOW);
  // Запомнили время отправки:
  timestamp=millis();
  // Запускаем профедуру ожидания ответа
  waitanswer();
  // Эксперимаентально вычисленная задержка.
  // Позволяет избежать проблем с модулем.
  delay(10);
  Serial.println("-----------------------------------------");
  delay(1000);
}

void waitanswer() {
  // Немного плохого кода:
  // Устанавливаем timeout в ИСТИНУ
  // Если ответ будет получен, установим переменную в ЛОЖЬ
  // Если ответа не будет - считаем ситуацию выходом по таймауту
  timeout=true;
  // Ждём ответ или таймута ожидания
  while(millis()-timestamp<TIMEOUT && timeout) {
    if(!Mirf.isSending() && Mirf.dataReady()) {
      // Мигнули 2 раза - ответ получен
      for (byte i=0; i<2; i++) {
        digitalWrite(StatusLed, HIGH);
        delay(100);
        digitalWrite(StatusLed, LOW);
      }
      timeout=false;
    
      // Принимаем пакет данные в виде массива байт в переменную data:
      Mirf.getData((byte *)&data);
      // Выводим полученные данные в монитор серийного порта
      Serial.print("Get data: ");
      Serial.println(data);
      data=0;
    }
  }
  if (timeout) {
    // Мигнули 10 раз - ответа не пришло
    for (byte i=0; i<10; i++) {
      digitalWrite(StatusLed, HIGH);
      delay(100);
      digitalWrite(StatusLed, LOW);
    }
    Serial.println("Timeout");
  }
}

После загрузки в мониторе порта клиента будет нечто подобное:

Пример передачи данных с помощью радио модуля

Пример передачи данных с помощью радио модуля

При этом со стороны сервера:

Пример передачи данных с помощью радио модуля

Пример передачи данных с помощью радио модуля

Пришло время обратить внимание на некоторые детали.

Сразу поговорим о «магии»: приём и передачу данных лучше осуществлять через разные переменные. Команду кладёте в одну переменную, а результат принимаете в другую. В примере выше мы могли бы обойтись одной только переменной data, без command. Сначала приравнивать переменную data к единице и отправлять её, а затем в неё же принимать результат. Но тогда при передаче команды на приёмник приходит мусор. Очевидно, что проблема кроется в использовании ссылок, с которыми неплохо бы разобраться. Но я, как упоминал ранее, плохо разбираюсь в работе с ссылками, потому для меня это «магия». Ещё один магический ритуал — задержка (delay(10);). Именно при такой задержке модуль работает стабильно. Поставить задержку больше можно. А вот при меньшей задержке при попытке обратиться к модулю (проверить статус или сменить адрес) сразу после передачи пакета данных, модуль может зависнуть.

Пройдёмся по операторам:

Mirf.payload = PAYLOAD; устанавливает размер буфера для приёма «полезной нагрузки» из пакета данных. Мы его объявили равным размеру переменной типа unsigned long (4 байта) чуть выше: #define PAYLOAD sizeof(unsigned long). Вместо sizeof(unsigned long) можно было просто написать: «4». Но так проще. Payload сообщает модулю, размер пакетов, которые он должен использовать при передаче. При этом, вы не сможете передать больше данных, чем размер payload. Например, если бы мы установили payload равным 2 байтам, а передали 4, то на приёмнике в переменной оказалось бы случайное значение. При этом передать данных меньше, чем размер payload можно, но возможны трудноотлавливаемые баги. Возможно, это особенность библиотеки Mirf, но лучше устанавливать payload равным размеру передаваемых данных. Разумеется, payload должен совпадать для всех модулей, участвующих в передаче. Есть ограничение: payload не может быть больше 32 байт. При установке payload больше 32, вы получите множество забавных проблем. Если вам нужно передавать больше 32 байт данных за раз, то единственный путь — разработка собственного пакетного протокола, который будет «пилить» данные на пакеты по 32 байта и передавать их последовательно.

Mirf.setRADDR((byte*)’serv1′); задаёт адрес приёмника (R—receive, англ. приём). Именно под этим адресом модулю нужно отправлять пакеты от других модулей.

Mirf.setTADDR((byte *)’clie1′); задаёт адрес получателя (T-transmit, англ. передача). Общение между модулями строится по следующему сценарию: первый модуль единожды вызывает процедуру Mirf.setRADDR((byte*)’serv1′) и слушает эфир под адресом «serv1». Второй модуль единожды вызывает процедуру Mirf.setRADDR((byte*)’clie1′) и слушает эфир под адресом «clie1». При передаче пакета, на первом модуле устанавливается адресат Mirf.setTADDR((byte *)’clie1′) и пакет уходит второму модулю. Второй модуль для ответа устанавливает Mirf.setTADDR((byte *)’serv1′) и ответ уходит на первый модуль.
Важно! Адрес модуля должен занимать не менее 3 и не более 5 байт.

Mirf.isSending() проверяет, закончилась ли передача пакета. Буфер отправки и передачи не разделён. Поэтому, при должном везении, вы можете прочитать то, что пытаетесь отправить, если не будете проверять, закончилась ли уже отправка.

Mirf.dataReady() проверяет, что входящий пакет уже получен. Как отмечалось ранее, модули живут своей жизнью. В том числе и пакеты передаются между модулями без участия внешних устройств. Библиотека может лишь прочитать пришедший пакет.

Mirf.getData((byte *) &data); читает пришедший пакет в переменную data

Mirf.send((byte *) &data); отдаёт команду на отправку пакета. Обратите внимание, в вызове процедур указывается ссылка на переменную, а сами данные передаются в виде массива байт. В данном контексте сылку нужно понимать так: мы указываем библиотеке адрес в памяти, в котором лежит значение переменной. Библиотека принимает данные и копирует их в память. В результате, мы получаем доступ к переданному значению через переменную. Это удобно: вы можете передавать абсолютно любые данные. Главное, что бы типы данных совпали на приёмнике и на передатчике. Передавать таким образом можно даже структуры.

Передача структур

В структуру можно упихать много переменных и передать их все за раз. Это удобно, учитывая, что за раз мы можем передать 32 байта. Для начала напишем скетч, в котором научим arduino по команде «1» передавать команду дальше по цепочке. При этом каждый модуль будет знать только свой адрес и адрес следующего модуля в цепочке. Добавим для наглядности отчёт о получении команды (в ответ модуль будет отправлять число 42) и при этом я уберу проверку таймаута передачи, что бы не загромождать код. Для большей правдоподобности построим сеть из 3х модулей. При этом первый модуль будет у нас цепочку инициировать. Далее модули по кругу будут обмениваться командой «1».
Совершенно искусственный пример, как видите. Интерес в этом примере представляет пример передачи структур данных. Ну и поскольку модуль знает только следующего участника цепочки, но не знает предыдущего, нам понадобится передавать адрес отправителя прямо в структуре вместе с командой. Иначе модуль не будет знать, куда отправлять отчёт. Обратите внимание, как просто можно передавать строковые значения адреса модуля внутри структуры.
При работе со структурами очень важно правильно посчитать размер структуры и правильно указать PAYLOAD. Размер структуры равен размеру всех переменных, которые её составляют. Структуры, разумеется, тоже должны быть одинаковыми и на приёмнике и на передатчике.
Итак, вот такой скетч я написал. Скетч для всех модулей одинаков, меняются только некоторые переменные в начале.
Код для первого модуля:

#include <SPI.h>
#include <Mirf.h>
#include <MirfHardwareSpiDriver.h>
#include <nRF24L01.h>

// Адрес модуля
#define ADDR "mod0"
// Адрес следующего в цепочке модуля
#define NEXT "mod1"
//Этот модуль начинает цепочку?
boolean iamfirst=true;
// Размер полезной нагрузки
#define PAYLOAD 6
// Светодиод для индикации - 4 пин
#define StatusLed 4
// Структуры для приёма и передачи данных
typedef struct { //Структура ответа
  byte From[5];//Адрес отправителя
  byte Result;//Результат выполнения команды (в нашем случае всегда 1)
}
AnswerStruct;
typedef struct { //Структура запроса
  byte From[5];//Адрес отправителя
  byte Command;//Команда (в нашем случае всегда 1)
}
RequestStruct;
//Принимаем мы всегда AnswerStruct , а оправляем RequestStruct. Так проще.
//Отличать ответ на наш запрос от нового запроса нужно логикой внутри программы.

//Обнулим структуры на всякий случай:
AnswerStruct Ans={
  "0000", 255}; 
RequestStruct Req={
 "0000", 255};
// Флаг для определения выхода по таймауту
boolean timeout=false;
// Таймаут ожидания ответа - 1 секунда
#define TIMEOUT 1000
// Переменная для запоминания времени отправки
unsigned long timestamp=0;

void setup(){
  Serial.begin(9600);
  // Мигнём светодиодом:
  pinMode(StatusLed, OUTPUT);
  for (byte i=0; i<3; i++) {
    digitalWrite(StatusLed, HIGH);
    delay(100);
    digitalWrite(StatusLed, LOW);
  }

  Mirf.cePin = 8;
  Mirf.csnPin = 7;
  Mirf.spi = &MirfHardwareSpi;
  Mirf.init();

  Mirf.setRADDR((byte*)ADDR);
  Mirf.payload = PAYLOAD;
  Mirf.config();
  Serial.println("Beginning ..");
}

void loop() {
  // Если нужно начинать цепочку, то начинаем:
  if (iamfirst) {
    Serial.println("I am first!!!");
    iamfirst = false;
    sendStruct(1, (byte*)NEXT);
  }
  //Проверяем, не получили мы данные:
  if(!Mirf.isSending() && Mirf.dataReady()) {

    // Принимаем пакет данных в виде массива байт в структуру :
    Mirf.getData((byte *)&Ans);
    // Выводим полученные данные в монитор серийного порта
    Serial.print("Get data from: ");
    Serial.print((char*)Ans.From);
    Serial.print(":");
    Serial.println(Ans.Result);
  }
  //Если что-то пришло, действуем:
  if (Ans.Result==1) {
    //Это запроc на продолжение цепочки. Ждём секунду и отправляем назад отчёт:
    delay(1000);
    Serial.print("Need to send OK to ");
    Serial.println((char*)Ans.From);
    sendStruct(42, (byte*)Ans.From);
    //Ещё секунда и продолжаем цепочку:
    delay(1000);
    sendStruct(1, (byte*)NEXT);

  }
  if (Ans.Result==42) {
    //А это просто подтверждение
    Serial.println("OK");
  }
  //Обнуляем:
  Ans.Result=0;
}

void sendStruct(byte command, byte* addr) {
  memcpy(&Req.From, &ADDR, 4);
  Req.Command=command;
  Serial.print("Send ");
  Serial.print(Req.Command);
  Serial.print(" to ");
  Serial.println((char*)addr);
  // Устанавливаем адрес передачи
  Mirf.setTADDR(addr);
  //Отправляем
  Mirf.send((byte *)&Req);
  // Эксперимаентально вычисленная задержка.
  // Позволяет избежать проблем с модулем.
  delay(10);
}

Все остальные модули получают этот же скетч, меняются только три переменные вверху: ADDR, NEXT, iamfirst.
Для второго модуля будет так:

// Адрес модуля
#define ADDR "mod1"
// Адрес следующего в цепочке модуля
#define NEXT "mod2"
//Этот модуль начинает цепочку?
boolean iamfirst=false;

И, наконец, для третьего:

// Адрес модуля
#define ADDR "mod2"
// Адрес следующего в цепочке модуля
#define NEXT "mod0"
//Этот модуль начинает цепочку?
boolean iamfirst=false;

Третий модуль замкнёт цепочку и всё продолжится по кругу. Вот такой вывод в мониторе серийного порта должен появится (слева направо: первый, второй, третий модули):

Успешный обмен данными между тремя модулями

Успешный обмен данными между тремя модулями

Напоминаю, модуль NRF24L01 можно купить с бесплатной доставкой у проверенного мной продавца по этой ссылке. Ещё здесь можно заказать сразу десяток модулей. При покупке десяти стоимость одной штуки совсем смешная. Не забывайте, что я собираю большой список ссылок на продавцов, у которых я заказывал платы Arduino, модули, инструменты и просто полезные штуки и мне всё понравилось. Полный список здесь: Проверенные товары интернет-магазинов.

Кажется, это всё, что потребуется знать вам для передачи данных с помощью радио модуля NRF24. На все ваши вопросы с постараюсь ответить в комментариях.