Electronics, RF

Measuring the Range of nRF24L01 Modules with CircuitPython

I became really interested in the capabilities of the nRF24L01 radio modules due to their insanely low price ~$2 in quantity, compared to something like a LoRa module ~$25. So you can basically have 10 for the price of one. Now I am not saying these are comparable devices, since the LoRa devices can be used for extremely low power long-range communications (upwards of 10km). The cheapest nRF24 modules state a range of “100m” and the higher power versions state “1000m”, so in a very different ballpark, but what if we could bridge that gap a bit by using special antennas? Since they are so cheap and readily available I think it’s worth a shot! They also are capable of much higher data rates than LoRa so that would be a nice cherry on top for something like a medium-range style network.

I decided to use the PA and LNA nRF24L01+ version with 2.45GHz patch or directional antennas and see how far they can communicate. I found this nice reference that helped me get started and get familiar with the different modes of operation the radios can operate in.

nRF24L01+ Reference

Hardware

I purchased a set of three modules off of Amazon that also came with voltage regulator modules that help provide enough power for the PA and LNA device. I would suggest doing a bit of research before purchasing as the modules that I received are using a cloned version of the nRF24 chip.

They still technically work but if you spend a bit more or shop around you might have better luck. I will go into the details of using the ones I purchased since I assume all the really cheap ones use the same kind, or similar, cloned and rebadged device. You can see the module connected to one of the patch antennas below.

I also needed a device to interact with the radios I decided to use some CircuitPython oriented development boards from Adafruit. Mostly because they were what I had on hand, but also because they met all of my requirements, battery-powered, buttons for selecting options, and a screen to provide radio information back to the user.

The first one is called the Adafruit PyGamer, which as its name denotes is more geared toward being used as a hand-held gaming device for prototyping various small games built on the CircuitPython software or things like MakeCode.

Adafruit: Pygamer

The second device is in the same vane but is geared toward running small Ai applications on the edge using EdgeImpulse etc. It uses the same form factor as the PyBadge whose main purpose is to be used as an electronics conference badge, to show the participant’s name, website, etc that introduces them into the CircuitPython ecosystem.

Adafruit: Edge badge

One of the cool things, and there are many, is that these boards have a standard prototyping header called Feather. So I was able to create a single Feather Wing prototype design that could integrate the nRF24 module and voltage regulator onto the back of the badges. Below you can see both wings and how it connects onto the back of the PyGamer badge.

I only needed SPI and power for these radio modules making the wiring super simple, below is the pinout of the Feather interface and the red words denote the connections that I used to hook up the microcontroller to the module. I decided to use the battery power pin “Bat” instead of the 3.3V since I am using the regulator that came with the module to provide a clean power source to the radio.

This is because if the onboard voltage regulator could not provide a sufficient and clean 3.3v voltage to the radio it would impact its reception and transmission performance.

The finished Feather prototypes are shown below connected up to their respective circuit python badges. The foil shielding on each module seen in the photograph was necessary to produce good reception and is discussed in the next section.

Software

For the software, it was a pretty easy choice since I specifically choose the Adafruit PyGamer and Adafruit Edge Badge specifically for their support of CircuitPython. I went through the task of updating both boards bootloaders and the latest CircuitPython version. You can learn more about the wonders of CircuitPython on their website.

CircuitPython.org

The long and the short of it though is that it is a simple and easy-to-use programming language for learning and experimenting with microcontrollers. For this project, I was able to find a library made to interact with these radio modules. This simplified my task greatly and allowed me to quickly get things working.

NRF24L01 Circuit Python Library

The codebase has several examples to go along with the library one of them being a simple scanning demo. I decided to try and run this one first since I only needed to use one board and I could test if I had soldered the hardware correctly.

When you plug in a circuit python board to your computer a USB drive should show up called CIRCUITPY. This is how we will be programming the board, adding the necessary libraries, and iterating on our design. First, we need to copy the nrf24 library into the “lib” folder. Once that is done we can copy over the scanning demo python file “examples/nrf24l01_scanner_test.py“ into the base directory of the CIRCUITPY drive. We then need to rename it to “code.py” for circuit python on the board to recognize it as our desired program to run. Now we should see the badge reboot and start printing stuff to the screen. If you get a message that the radio module cannot be found you may need to modify the pin definitions based on the names on the pin diagram shown earlier.

We can also use Mu to display circuit python terminal on Mac this will come in handy once we run the next demo for simple communication between two radios.

codewith.mu

The next step was to run the simple example that sends a packet of data between two radio modules. This is located at “examples/nrf24l01_simple_test.py” in the library source. Copy this over to the CIRCUITPY drive and rename it to code.py replacing the old one.

This program requires the user to interact over the serial console via your computer terminal, in my case I used mu as my serial interface. You need to give each radio a unique ID like 0 or 1then tell it to transmit or receive and the associated duration. This command-line structure is simple and powerful but does not take full advantage of the interfaces on the badges like the buttons or the analog stick. you also need two computers to interact with the serial commands of each board or swap between the two which can become quite tedious.

Unfortunately, my radios were not able to communicate right out of the box. The first issue I came across was the auto ack feature that was enabled in the library was not properly functioning on my radios so I had to disable it. After that, I could get a packet in here and there but was inconsistent at best. This is where I found out that the nRF24 modules with the PA/LNAs are very susceptible to interference and on 2.4GHz there is plenty to go around. So I used a layer of electrical tape and a few layers of aluminum foil to shield the components. This greatly improved the performance and could get consistent reception around my house.

In order to take advantage of the CircuitPython badges, I developed a simple ranging program to test the communication distance. It is extremely simple, using a state machine to control whether the badge is in transmission or reception mode based on pressing the buttons on the badge and printing some status to the console. That’s it basically!

#NRF24L01 CircuitPython Badge Test
import board
import neopixel
import time
import struct
from adafruit_pybadger import pybadger
from digitalio import DigitalInOut
from circuitpython_nrf24l01.rf24 import RF24
# set this to 0 or 1 for the two badges
radio_number = 0
# addresses needs to be in a buffer protocol object (bytearray)
address = [b"1Node", b"2Node"]
# using the python keyword global is bad practice. Instead we'll use a 1 item
# list to store our float number for the payloads sent
payload = [0.0, 5]
SPI_BUS = board.SPI() # init spi bus object
CE_PIN = DigitalInOut(board.D6)
CSN_PIN = DigitalInOut(board.D5)
try:
# initialize the nRF24L01 on the spi bus object
nrf = RF24(SPI_BUS, CSN_PIN, CE_PIN)
# set the Power Amplifier level to -12 dBm since this test example is
# usually run with nRF24L01 transceivers in close proximity
nrf.pa_level = 0
# set TX address of RX node into the TX pipe
nrf.open_tx_pipe(address[radio_number]) # always uses pipe 0
# set RX address of TX node into an RX pipe
nrf.open_rx_pipe(1, address[not radio_number]) # using pipe 1
# uncomment the following 3 lines for compatibility with TMRh20 library
# nrf.allow_ask_no_ack = False
# nrf.dynamic_payloads = False
# nrf.payload_length = 4
# nrf.power = True
nrf.auto_ack = False
except:
print("Cannot find NRF24")
pybadger.show_terminal()
state = "init"
while True:
if pybadger.button.a:
if state != "tx":
print("Button A")
print("State TX")
state = "tx"
try:
nrf.listen = False # ensures the nRF24L01 is in TX mode
except:
print("Failed nrf listen")
#state = "idle"
count = 0
elif pybadger.button.b:
if state != "rx":
print("Button B")
print("State RX")
state = "rx"
try:
nrf.listen = True # put radio into RX mode and power up
except:
print("Failed nrf listen")
#state = "idle"
elif pybadger.button.start:
if state != "idle":
print("Button start")
print("State idle")
state = "idle"
elif pybadger.button.select:
if state != "set":
print("Button select")
print("State settings")
state = "set"
try:
nrf.print_details()
except:
print("Failed nrf settings")
#state = "idle"
if state == "tx":
try:
# use struct.pack to packetize your data
# into a usable payload
buffer = struct.pack("<f", payload[0])
print(count)
# "<f" means a single little endian (4 byte) float value.
start_timer = time.monotonic_ns() # start timer
result = nrf.send(buffer)
end_timer = time.monotonic_ns() # end timer
if not result:
print("send() failed or timed out")
state = "idle"
else:
print(
"Tx successful! Time to Tx:",
f"{(end_timer – start_timer) / 1000} us. Sent: {payload[0]}"
)
payload[0] += 0.01
time.sleep(1)
count += 1
except:
print("Failed TX")
count = 0
#state = "idle"
elif state == "rx":
try:
if nrf.available():
# grab information about the received payload
payload_size, pipe_number = (nrf.any(), nrf.pipe)
# fetch 1 payload from RX FIFO
buffer = nrf.read() # also clears nrf.irq_dr status flag
# expecting a little endian float, thus the format string "<f"
# buffer[:4] truncates padded 0s if dynamic payloads are disabled
payload[0] = struct.unpack("<f", buffer[:4])[0]
# print details about the received packet
print(f"Rx {payload_size} bytes on pipe {pipe_number}: {payload[0]}")
except:
print("Failed RX")
#state = "idle"
elif state == "set":
if pybadger.button.up:
print("Button up")
elif pybadger.button.down:
print("Button down")

I used the pybadger library for easy access to the buttons and integrated the NRF24 simple test I used earlier into the state machine. The other thing I changed was that as long as the badge is in transmitter “TX” or receiver “RX” state it either sends a packet once a second or looks for a packet continuously. I compiled a simple GitHub repo to contain all of the information you would need to set up your own badge for testing.

GitHub: nRF24-CircuitPython-Badge

Testing Range

I wanted to test both the stock antennas as well as my Vivaldi antenna design to see if I could boost the range of the modules. I first measured the antennas on my NanoVNA V2 to get an idea of how they compared to each other.

The stock 2.4GHz antenna responses are both shown below and both show that they should be fairly well matched for the frequency range and are also consistent between the two.

For the actual range test, I decided to just use the Vivaldi as a fixed base station antenna to transmit a signal out. This allowed me to mount the antenna on a tripod since I already had a 3d printed mount that I created to test the antenna’s beam pattern. This setup is shown in the image below.

I then started walking from my house in the direction of the base station antenna beam direction with the stock antenna installed. I was able to pick up the signal for ~350ft not line-of-sight. There were several houses blocking the beam so I could have definitely gone farther if not for that.

I then tried my patch antennas but did not have any improvement for some reason. I need to investigate this further in subsequent tests! But then I switched to the Vivaldi on the receiver as well. pointing back toward the base station transmitter and wow did it improve the reception. I was able to continue to ~1000ft not line-of-sight with more than a few houses in the beam path.

Conclusion

While impressed with the range so far the need for shielding and the prevalence of fake modules makes using these modules inherently finicky. But the ubiquity of them is also what makes them interesting and has the potential to add radio comms to many DIY projects.

My next step is to try and increase the range, I should be able to get more range just by finding an area where I can get a true line of sight with no interfering structures. The other thing that I want to do is to improve the software, playing with the power levels and the data rates. This would allow me to get a better idea of signal quality in an area vs just some reception. I am also curious if there is an RSSI or received signal strength metric that the radio reports that I could build into my metrics. The other thing I want to do is to get some of the lower power modules that can be found for ~$2 apiece. I want to see how they match up to the PA/LNA modules for range.

There is a lot of things I want to accomplish with these radios and potentially want to try my hand at making my own mesh network in the future. But look forward to more content using these radios for various experiments.