I Conquered The NRF24L01!

Published by Dan on

It took many years but the ability to bother AI to help me finally let me face my greatest challenge – getting a NRF24L01+ to communicate with another one – but without using the standard Arduino Uno (clone) that every single successful tutorial seems to be using! The secret seems to have been bitbanging SPI to slow it down considerably so that the NRF could keep up.

Features:

  • Uses RP2040-Zero (clone) for its compact size
  • Both boards (TX and RX) are identical – get both with ONE order to a PCB manufacturer!
  • Transmitter has an OLED implemented so you can monitor the analog inputs
  • Support for both the through hole and tiny smd NRF module, again, same board!
  • 3 analog inputs
  • 3 servo outputs
  • Stable power from an AMS1117 3.3

Here is what you need:

Note: some of these links are affiliate links and support the creation of content without costing you any extra.

Please note – I am aware of a bit of an error on the board – my servo pinout is wrong – but they are easy to de-pin and re-pin to put the signal wire in the center.

Code

To get the most updated code, it’s best to check my GitHub – as I do plan on transcribe it into Python (undecided on Micropython or CircuitPython at this time) but it does work as you see it here. I also feel it is unkind to send people over to GitHub, a site that isn’t very user-friendly, instead of just pasting it here.

You will need the U8g2 (by Olikraus) for the transmitter code – but everything else should work with the default Arduino libraries.

GitHub Link

Transmitter Code

/*
 * ======================================================================================
 * PROJECT: RP2040 NRF24L01 TRANSMITTER WITH OLED VISUALIZER
 * ======================================================================================
 * * DESCRIPTION:
 * Reads 3 potentiometers and sends their positions wirelessly to a receiver.
 * Simultaneously displays the values as a bar graph on an OLED screen.
 * * KEY FEATURES:
 * 1. Non-Blocking: Uses internal timers (millis) instead of delay() so the radio
 * and screen can run at different speeds without stopping each other.
 * 2. Hybrid Communication: 
 * - Radio uses "Bit-Banging" (manual pin control) for maximum compatibility.
 * - Screen uses "Hardware I2C" (slowed to 100kHz) for stability.
 * * WIRING (RP2040-Zero):
 * - GP26, GP27, GP28 -> Potentiometer Wipers (Middle Pins)
 * - GP4  -> NRF24L01 CSN  (Chip Select Not)
 * - GP3  -> NRF24L01 CE   (Chip Enable)
 * - GP7  -> NRF24L01 SCK  (Clock)
 * - GP5  -> NRF24L01 MOSI (Master Out Slave In)
 * - GP6  -> NRF24L01 MISO (Master In Slave Out)
 * - GP14 -> OLED SDA
 * - GP15 -> OLED SCL
 * * LIBRARIES REQUIRED:
 * - U8g2 (by olikraus)
 */

#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>

// --- PIN DEFINITIONS ---
// These constants make it easy to change pins later if you rebuild the circuit.
const int CSN_PIN  = 4;   // NRF24L01 Chip Select
const int CE_PIN   = 3;   // NRF24L01 Radio Enable
const int SCK_PIN  = 7;   // NRF24L01 Clock
const int MOSI_PIN = 5;   // NRF24L01 Data OUT
const int MISO_PIN = 6;   // NRF24L01 Data IN
const int POT1     = 26;  // Analog Input 1
const int POT2     = 27;  // Analog Input 2
const int POT3     = 28;  // Analog Input 3

// --- OLED SCREEN SETUP ---
// We use the U8g2 library because it handles clone screens better than Adafruit.
// "2ND_HW_I2C" tells it to use the RP2040's secondary I2C hardware (Wire1).
// U8X8_PIN_NONE means we don't have a reset pin connected.
U8G2_SSD1306_128X64_NONAME_F_2ND_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

// --- RADIO CONFIGURATION ---
// This 5-byte code is the "Frequency Address". 
// The Receiver MUST have the exact same address to hear us.
byte address[5] = {0xAB, 0xCD, 0x12, 0x34, 0x56};

// --- TIMING VARIABLES (NON-BLOCKING) ---
// We use these to track "when was the last time we did X?"
unsigned long previousRadioMillis = 0;
unsigned long previousOledMillis = 0;

// Update Rates:
// Radio: 20ms = 50 times per second (Fast, for smooth servo movement)
// Screen: 200ms = 5 times per second (Slow, to save processor power)
const long radioInterval = 20; 
const long oledInterval = 200; 

// Global variables to store pot values so both Radio and Screen can see them
byte val1, val2, val3;

// ======================================================================================
// SECTION: BIT-BANG SPI FUNCTIONS
// These functions manually toggle pins to talk to the radio. 
// We do this because Hardware SPI was unstable on this specific build.
// ======================================================================================

// Sends/Receives one byte of data
byte bitbangSPI(byte data) {
  byte incoming = 0;
  // Loop 8 times (once for each bit in the byte)
  for (int i = 7; i >= 0; i--) {
    // 1. Set the Output Pin (MOSI) to the correct bit (0 or 1)
    digitalWrite(MOSI_PIN, (data >> i) & 0x01);
    delayMicroseconds(2); // Short wait for signal stability
    
    // 2. Pulse the Clock (SCK) HIGH
    digitalWrite(SCK_PIN, HIGH);
    delayMicroseconds(2); 
    
    // 3. Read the Input Pin (MISO)
    if (digitalRead(MISO_PIN)) incoming |= (1 << i);
    
    // 4. Pulse the Clock (SCK) LOW
    digitalWrite(SCK_PIN, LOW);
    delayMicroseconds(2); 
  }
  return incoming; // Return the byte we received
}

// Helper to write a command to a specific NRF24L01 register
void nrfWriteReg(byte reg, byte value) {
  digitalWrite(CSN_PIN, LOW);  // Select the radio (Wake up)
  bitbangSPI(0x20 | reg);      // Send Command: "Write to Register" + Register Number
  bitbangSPI(value);           // Send the Value
  digitalWrite(CSN_PIN, HIGH); // Deselect (Sleep)
}

// ======================================================================================
// SECTION: SETUP (Runs once on power up)
// ======================================================================================
void setup() {
  Serial.begin(115200);

  // --- 1. HARDWARE I2C OLED CONFIGURATION ---
  // The RP2040 needs to be told which pins to use for I2C Wire1
  Wire1.setSDA(14);
  Wire1.setSCL(15);
  Wire1.begin();
  // CRITICAL FIX: Force speed to 100kHz. 
  // Clone OLEDs often glitch or turn to static if this is too fast.
  Wire1.setClock(100000); 

  // Initialize the screen driver
  u8g2.begin();
  u8g2.setBusClock(100000); // Tell the library to respect the slow speed
  u8g2.clearBuffer();       // Clear video memory
  u8g2.setFont(u8g2_font_ncenB08_tr); // Set a nice font
  u8g2.drawStr(0, 10, "Radio Init...");
  u8g2.sendBuffer();        // Push to screen

  // --- 2. RADIO PIN CONFIGURATION ---
  pinMode(CE_PIN, OUTPUT);
  pinMode(CSN_PIN, OUTPUT);
  pinMode(SCK_PIN, OUTPUT);
  pinMode(MOSI_PIN, OUTPUT);
  pinMode(MISO_PIN, INPUT);

  // Default states
  digitalWrite(CSN_PIN, HIGH); // CSN is active LOW, so start HIGH (off)
  digitalWrite(CE_PIN, LOW);   // CE is active HIGH, so start LOW (off)

  Serial.println("--- TX STARTING ---");
  delay(100); // Tiny pause to let power stabilize

  // --- 3. RADIO REGISTRY CONFIGURATION ---
  // 0x00 CONFIG: Power Up, Enable CRC, Transmitter Mode
  nrfWriteReg(0x00, 0x0E); 
  delay(5); // Required wait after Power Up

  // 0x03 SETUP_AW: Set Address Width to 5 bytes
  nrfWriteReg(0x03, 0x03); 
  
  // Set the "Transmit To" Address
  digitalWrite(CSN_PIN, LOW);
  bitbangSPI(0x20 | 0x10); // Write to TX_ADDR register
  for(int i=0; i<5; i++) bitbangSPI(address[i]);
  digitalWrite(CSN_PIN, HIGH);

  // Set the "Receive From" Address (Pipe 0)
  // Essential for Auto-Acknowledgment (The RX replies "I got it!")
  digitalWrite(CSN_PIN, LOW);
  bitbangSPI(0x20 | 0x0A); // Write to RX_ADDR_P0 register
  for(int i=0; i<5; i++) bitbangSPI(address[i]);
  digitalWrite(CSN_PIN, HIGH);

  // Radio Tuning
  nrfWriteReg(0x01, 0x3F); // Enable Auto-Ack on all pipes
  nrfWriteReg(0x05, 0x4C); // RF Channel 76 (2.476 GHz)
  nrfWriteReg(0x06, 0x07); // Data Rate 1Mbps, Power 0dBm (Max)

  Serial.println("Hardware Initialized.");
  
  // Update Screen to show we are ready
  u8g2.clearBuffer();
  u8g2.drawStr(0, 10, "System Ready");
  u8g2.sendBuffer();
}

// ======================================================================================
// SECTION: MAIN LOOP (Runs forever)
// ======================================================================================
void loop() {
  unsigned long currentMillis = millis(); // Get current time

  // --- TASK 1: READ SENSORS ---
  // We read these every single loop because it is very fast.
  // map() converts the 0-1023 (Analog) to 0-255 (Byte) for sending.
  val1 = map(analogRead(POT1), 0, 1023, 0, 255);
  val2 = map(analogRead(POT2), 0, 1023, 0, 255);
  val3 = map(analogRead(POT3), 0, 1023, 0, 255);

  // --- TASK 2: SEND RADIO PACKET (Every 20ms) ---
  if (currentMillis - previousRadioMillis >= radioInterval) {
    previousRadioMillis = currentMillis; // Reset timer
    
    // 1. Clear Interrupt Flags (Reset "Sent" status)
    nrfWriteReg(0x07, 0x70); 
    
    // 2. Flush TX Buffer (Delete old unsent data)
    digitalWrite(CSN_PIN, LOW); 
    bitbangSPI(0xE1); 
    digitalWrite(CSN_PIN, HIGH);

    // 3. Load Payload into Radio
    digitalWrite(CSN_PIN, LOW);
    bitbangSPI(0xA0); // Command: Write TX Payload
    bitbangSPI(val1); // Byte 1
    bitbangSPI(val2); // Byte 2
    bitbangSPI(val3); // Byte 3
    digitalWrite(CSN_PIN, HIGH);

    // 4. Pulse CE Pin to Fire the transmission
    digitalWrite(CE_PIN, HIGH);
    delayMicroseconds(15); // Radio needs at least 10us
    digitalWrite(CE_PIN, LOW);
  }

  // --- TASK 3: UPDATE OLED SCREEN (Every 200ms) ---
  if (currentMillis - previousOledMillis >= oledInterval) {
    previousOledMillis = currentMillis; // Reset timer

    u8g2.clearBuffer(); // Start with blank canvas
    
    // Draw Title
    u8g2.drawStr(0, 10, "TX Status: Active");

    // Math: Convert 0-255 value to 0-100 pixel width
    int w1 = map(val1, 0, 255, 0, 100);
    int w2 = map(val2, 0, 255, 0, 100);
    int w3 = map(val3, 0, 255, 0, 100);

    // Draw Bar 1
    u8g2.drawStr(0, 25, "S1");     // Label
    u8g2.drawFrame(20, 16, 102, 10); // Empty Box (x, y, width, height)
    u8g2.drawBox(22, 18, w1, 6);     // Filled Bar inside the box

    // Draw Bar 2
    u8g2.drawStr(0, 40, "S2");
    u8g2.drawFrame(20, 31, 102, 10);
    u8g2.drawBox(22, 33, w2, 6);

    // Draw Bar 3
    u8g2.drawStr(0, 55, "S3");
    u8g2.drawFrame(20, 46, 102, 10);
    u8g2.drawBox(22, 48, w3, 6);

    u8g2.sendBuffer(); // Actually update the pixels on the screen
  }
}

Receiver Code

/*
 * ======================================================================================
 * PROJECT: RP2040 NRF24L01 RECEIVER (RX)
 * ======================================================================================
 * * DESCRIPTION:
 * Listens for wireless packets containing 3 byte values.
 * Maps those values to 3 Servos (0-180 degrees).
 * * KEY FEATURES:
 * 1. Polling Method: Checks the radio constantly for "Data Ready" signal.
 * 2. Instant Response: No delays in the loop means servos move instantly.
 * * WIRING:
 * - GP0 -> Servo 1 Signal
 * - GP1 -> Servo 2 Signal
 * - GP2 -> Servo 3 Signal
 * - GP4 -> NRF24L01 CSN
 * - GP3 -> NRF24L01 CE
 * - GP7 -> NRF24L01 SCK
 * - GP5 -> NRF24L01 MOSI
 * - GP6 -> NRF24L01 MISO
 */

#include <Arduino.h>
#include <Servo.h>

// --- PIN ASSIGNMENTS ---
const int CSN_PIN  = 4;
const int CE_PIN   = 3;
const int SCK_PIN  = 7;
const int MOSI_PIN = 5;
const int MISO_PIN = 6;

// Create Servo Objects
Servo s1, s2, s3;

// Address must match Transmitter exactly
byte address[5] = {0xAB, 0xCD, 0x12, 0x34, 0x56};

// ======================================================================================
// SECTION: BIT-BANG SPI FUNCTIONS
// Identical to TX side. Manually toggles pins for radio communication.
// ======================================================================================
byte bitbangSPI(byte data) {
  byte incoming = 0;
  for (int i = 7; i >= 0; i--) {
    digitalWrite(MOSI_PIN, (data >> i) & 0x01);
    delayMicroseconds(2);
    digitalWrite(SCK_PIN, HIGH);
    delayMicroseconds(2);
    if (digitalRead(MISO_PIN)) incoming |= (1 << i);
    digitalWrite(SCK_PIN, LOW);
    delayMicroseconds(2);
  }
  return incoming;
}

void nrfWriteReg(byte reg, byte value) {
  digitalWrite(CSN_PIN, LOW);
  bitbangSPI(0x20 | reg);
  bitbangSPI(value);
  digitalWrite(CSN_PIN, HIGH);
}

// Reads a value from a register
byte nrfReadReg(byte reg) {
  digitalWrite(CSN_PIN, LOW);
  bitbangSPI(0x00 | reg);
  byte val = bitbangSPI(0xFF); // Send dummy byte to read result
  digitalWrite(CSN_PIN, HIGH);
  return val;
}

// ======================================================================================
// SECTION: SETUP
// ======================================================================================
void setup() {
  Serial.begin(115200);
  
  // Wait a max of 3 seconds for serial monitor to open, then proceed anyway
  long startWait = millis();
  while (!Serial && (millis() - startWait < 3000));

  // Attach Servos to Pins
  s1.attach(0); 
  s2.attach(1); 
  s3.attach(2);
  
  // Setup Radio Pins
  pinMode(CE_PIN, OUTPUT);
  pinMode(CSN_PIN, OUTPUT);
  pinMode(SCK_PIN, OUTPUT);
  pinMode(MOSI_PIN, OUTPUT);
  pinMode(MISO_PIN, INPUT);

  digitalWrite(CSN_PIN, HIGH);
  digitalWrite(CE_PIN, LOW);

  Serial.println("--- RX STARTING ---");

  // --- RADIO CONFIGURATION ---
  // 0x00 CONFIG: Power Up, Enable CRC, RX (Receiver) Mode
  nrfWriteReg(0x00, 0x0F); 
  delay(5); 

  // 0x03 SETUP_AW: 5 Byte Address
  nrfWriteReg(0x03, 0x03); 

  // Set the Receive Address on Pipe 0
  digitalWrite(CSN_PIN, LOW);
  bitbangSPI(0x20 | 0x0A); // RX_ADDR_P0
  for(int i=0; i<5; i++) bitbangSPI(address[i]);
  digitalWrite(CSN_PIN, HIGH);

  // Payload Size (How many bytes are we expecting per packet?)
  // We are sending 3 bytes (Pot1, Pot2, Pot3)
  nrfWriteReg(0x11, 0x03); 

  // Tuning (Must match TX)
  nrfWriteReg(0x01, 0x3F); // Enable Auto-Ack
  nrfWriteReg(0x05, 0x4C); // Channel 76
  nrfWriteReg(0x06, 0x07); // 1Mbps, Max Power

  // Clear any existing interrupt flags
  nrfWriteReg(0x07, 0x70);
  
  // Set CE HIGH to start listening endlessly
  digitalWrite(CE_PIN, HIGH);
  Serial.println("Listening for TX...");
}

// ======================================================================================
// SECTION: MAIN LOOP
// ======================================================================================
void loop() {
  // 1. Check Radio Status
  // We read the STATUS register (0x07) to see if new data arrived.
  byte status = nrfReadReg(0x07);

  // 2. Check Bit 6 (RX_DR = Data Ready)
  // if (status & 0x40) means "Is the 6th bit a 1?"
  if (status & 0x40) { 
    
    // 3. Read the Data
    digitalWrite(CSN_PIN, LOW);
    bitbangSPI(0x61); // Command: Read RX Payload
    byte p1 = bitbangSPI(0xFF); // Read Byte 1
    byte p2 = bitbangSPI(0xFF); // Read Byte 2
    byte p3 = bitbangSPI(0xFF); // Read Byte 3
    digitalWrite(CSN_PIN, HIGH);

    // 4. Move Servos
    // Map the 0-255 radio byte to 0-180 servo degrees
    s1.write(map(p1, 0, 255, 0, 180));
    s2.write(map(p2, 0, 255, 0, 180));
    s3.write(map(p3, 0, 255, 0, 180));

    // Debugging (Optional - comment out if it slows things down too much)
    // Serial.print("RX: "); Serial.print(p1); Serial.print(" "); Serial.println(p3);

    // 5. Reset the Flag
    // Tell the radio "I have read the data, clear the alert."
    nrfWriteReg(0x07, 0x40);
  }
}