Elixir Nrf24 library
2025-12-20
The nRF24L01+ is a handy, small, low-power transceiver module (a transmitter/receiver) that operates in the 2.4 GHz ISM band. Being compatible with Arduino and Raspberry Pi, it’s a great choice for projects like remote control, sensor data transmission, and more.
The module already has support for the Arduino platform, but support for Nerves and Elixir was missing. Hence the nrf24 library.
Communicating with and configuring the device
The device is configured and controlled through a set of 26 registers. Communication with the nRF24L01+ (reading and writing registers) is performed over SPI (Serial Peripheral Interface), and the heavy lifting is handled by the Circuits.SPI library.
Before communicating with the device, you must open the SPI
bus. Circuits.SPI.bus_names/0 returns a list of available buses. On
Linux-based single-board computers such as the Raspberry Pi, common
bus names are spidev0.0 and spidev0.1, depending on which
chip-select line (CS0 or CS1) the device is wired to. After opening
the bus:
{:ok, spi} = Circuits.SPI.open("spidev0.0")
you can configure the device by writing to its registers. A helper function might look like this:
def write_register(spi = %Circuits.SPI.SPIDev{}, reg, value) when is_integer(value) do
full_cmd = command(:w_register) ||| register(reg)
Circuits.SPI.transfer(spi, <<full_cmd, value>>)
end
def write_register(spi = %Circuits.SPI.SPIDev{}, reg, value) when is_binary(value) do
full_cmd = command(:w_register) ||| register(reg)
Circuits.SPI.transfer(spi, <<full_cmd>> <> value)
end
Writing to a register is performed by sending a binary composed of a
command plus the register address, followed by the value. The command
and register address share the first byte via a bitwise OR.
For example, setting the RF channel is just writing the channel value
to the RF_CH register:
iex> write_register(spi, :rf_ch, 45)
{:ok, <<0x40, 0x00>>}
The device always returns as many bytes as you sent. Here, two bytes
are returned: the first is STATUS (0x40), and the second is a “don’t
care” byte (often 0x00).
The library also provides a convenience function, set_channel/2, but
the example above highlights an important fact about SPI communication
with this device.
Communication with an nRF24L01+ over SPI is always full-duplex: for
every byte you send, you receive one byte. In the example, we sent two
bytes—the command (W_REGISTER OR register address) and the value —
so we received two bytes in return: STATUS plus one more byte.
Reading from registers follows the same pattern:
def read_register(spi = %Circuits.SPI.SPIDev{}, reg, bytes_no) do
full_cmd =
for _ <- 1..bytes_no, reduce: <<command(:r_register) ||| register(reg)>> do
acc -> acc <> <<0xFF>>
end
if bytes_no == 1 do
{:ok, <<_status::8, res>>} = Circuits.SPI.transfer(spi, full_cmd)
res
else
{:ok, <<_status::8, res::bitstring>>} = Circuits.SPI.transfer(spi, full_cmd)
res
end
end
Because the device returns the same number of bytes you clock in, to
read N bytes you send N “dummy” bytes plus the 1-byte
command/address. You’ll receive N+1 bytes; discard the first
(STATUS) and keep the next N bytes as the register contents.
Elixir’s binary pattern matching makes this convenient: we immediately
drop the STATUS byte and bind the rest to the desired data.
Receiving data
To receive data, the receiver’s RF channel, data rate, and addressing must match the transmitter. The nRF24L01+ can simultaneously listen on up to six logical “pipes”, each with its own address (P2–P5 share the high address bytes with P1 but still form distinct logical pipes).
Configuration with the library is straightforward:
{:ok, nrf} =
Nrf24.start_link(
bus_name: "spidev0.0",
ce_pin: 17,
csn_pin: 0,
channel: 0x4C,
crc_length: 2,
speed: :medium,
pipes: [[pipe_no: 0, address: "Node1", payload_size: 4, auto_ack: true]]
)
The library keeps the opened SPI handle in the GenServer state, so clients don’t have to manage it.
Once configured, start listening and receive data:
Nrf24.start_listening(nrf)
{:ok, %{data: <<value::float-little-size(32)>>}} = Nrf24.receive(nrf, 4, 20)
IO.puts("Received: #{value}")
In the second line, a 4-byte payload is received and immediately
unpacked using Elixir’s binary pattern matching. The device uses
little endian which means least significant byte is always sent first
so we are using float-little pattern to extract transmitted float
value.
Transmitting data
Similarly, the transmitter’s configuration must match the receiver’s. One practical difference is that you can omit the address in the initial pipe configuration because library lets you pass it when sending:
{:ok, nrf} =
Nrf24.start_link(
bus_name: "spidev0.0",
ce_pin: 17,
csn_pin: 0,
channel: 0x4C,
crc_length: 2,
speed: :medium,
pipes: [[pipe_no: 0, address: "", payload_size: 4, auto_ack: true]]
)
Then send a payload to a chosen address:
Nrf24.send(nrf, "Node1", <<1.09::float-little-size(32)>>)
Payloads must be provided as binaries, as in the example above.
Conclusion
The datasheet covers many more details—dynamic payloads, pipe addressing nuances, and other features. Here we focused on the basics of device configuration and SPI communication, plus core library usage.
For more, see the library source code or the documentation: