Skip to content

Instantly share code, notes, and snippets.

@michalhosna
Last active November 12, 2025 11:00
Show Gist options
  • Select an option

  • Save michalhosna/03d212e7a75f237caf50187e7b0bb681 to your computer and use it in GitHub Desktop.

Select an option

Save michalhosna/03d212e7a75f237caf50187e7b0bb681 to your computer and use it in GitHub Desktop.
Nanlite nRF24 Intesity+CCT control Rust minimal example

Nanlite nRF24 Intesity+CCT control

Minimal rust example for (not only) RasberryPi with nRF24 connected over SPI.

Original reverse engeneering of the protocol done by https://gist.github.com/vmedea.

See https://gist.github.com/vmedea/434694c11092261fcac401b7a4b9a741 for more details about the protocol including other modes than only intensity+cct.

Using

How to run on RasberryPi

  1. You need to enable SPI in /boot/config.txt, that can be done using raspi-config tui.
  2. Wire up nRF24 module
nRF24 rPI
GND pin 20
VCC pin 17
CE pin 22 (GPIO 25)
SPI CSN pin 24
SPI SC(L)K pin 23
SPI MOSI pin 19
SPI MISO pin 21
IRQ Not connected

See https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#gpio for rPi pinout documenatation.

  1. Download this project
    • Create a project directory
    • Put Cargo.toml there
    • Put main.rs into src subdir, so it is at src/main.rs
    • You need to have rust installed on the rpi (quickest way is using https://rustup.rs), or cross-compile
    • cargo run
[package]
name = "nanlite-nrf24"
version = "0.1.0"
edition = "2021"
[dependencies]
rf24-rs = { version = "0.3.1" , features = ["std"]}
linux-embedded-hal = { version = "0.4.0", features = ["spi", "gpio-cdev"]}
use std::path::Path;
use linux_embedded_hal::{
CdevPin,
Delay,
SpidevDevice,
gpio_cdev::{Chip, LineRequestFlags},
spidev::{SpiModeFlags, Spidev, SpidevOptions},
};
use rf24::{
CrcLength,
DataRate,
PaLevel,
radio::{
RF24,
prelude::{
EsbAutoAck,
EsbChannel,
EsbCrcLength,
EsbDataRate,
EsbInit,
EsbPaLevel,
EsbPayloadLength,
EsbPipe,
EsbRadio,
RadioErrorType,
},
},
};
fn main() {
let nrf24_ce_gpio = 25;
let mut rf24 = rf24_init("/dev/spidev0.0", nrf24_ce_gpio).unwrap();
let addr = 1; // 1 to 512
let intesity = 50; // 0..100
let cct = 50; // 0..100, maps to 2700K .. 6000K
set_intensity_and_cct(&mut rf24, addr, intesity, cct).unwrap()
}
pub fn rf24_init(
spi_dev: impl AsRef<Path>,
ce_gpio_offset: u32,
) -> Result<
RF24<SpidevDevice, CdevPin, Delay>,
<RF24<SpidevDevice, CdevPin, Delay> as RadioErrorType>::Error,
> {
let mut spi = Spidev::open(spi_dev).unwrap();
spi.configure(&SpidevOptions {
bits_per_word: None,
max_speed_hz: Some(8_000_000),
lsb_first: None,
spi_mode: Some(SpiModeFlags::SPI_MODE_0),
})
.unwrap();
let spi_device = SpidevDevice(spi);
let mut chip = Chip::new("/dev/gpiochip0").unwrap();
let ce_line = chip.get_line(ce_gpio_offset).unwrap();
let ce_pin = CdevPin::new(
ce_line
.request(LineRequestFlags::OUTPUT, 0, "rust-nanlite-nrf24")
.unwrap(),
)
.unwrap();
let mut rf24 = RF24::new(ce_pin, spi_device, Delay);
rf24.init().unwrap();
rf24.set_crc_length(CrcLength::Bit16)?;
rf24.set_channel(0x73)?;
rf24.set_pa_level(PaLevel::Max)?;
rf24.set_dynamic_payloads(false)?;
rf24.set_payload_length(4)?;
rf24.set_auto_ack(true)?;
rf24.set_data_rate(DataRate::Mbps1)?;
rf24.set_address_length(5)?;
Ok(rf24)
}
pub fn set_intensity_and_cct<Radio: EsbRadio>(
rf24: &mut Radio,
addr: u16,
intensity: u8,
cct: u8,
) -> Result<(), Radio::Error> {
let addr_bytes = addr.to_be_bytes();
rf24.as_tx(Some(&[0x00, 0x00, 0x00, addr_bytes[0], addr_bytes[1]]))?;
let intensity = intensity.min(100);
let cct = cct.min(100);
let check1 = intensity.overflowing_add(cct).0;
let check2 = check1 ^ 0xff;
rf24.send(&[intensity, cct, check1, check2], false)?;
rf24.send(&[intensity, cct, check2, check1], false)?;
rf24.send(&[intensity, cct, check1, check2], false)?;
rf24.send(&[intensity, cct, check2, check1], false)?;
Ok(())
}
@vmedea
Copy link

vmedea commented Nov 7, 2025

i wired it up like this (did decide to also connect IRQ to GPIO 24, because i had an extra wire anyway) and i have tested this with one of my lights, and it worked as-is!

(and zero pairing overhead like with bluetooth)

Thank you!

@vmedea
Copy link

vmedea commented Nov 12, 2025

This works for colors:

pub fn set_hue_sat_intensity<Radio: EsbRadio>(
    rf24: &mut Radio,
    addr: u16,
    hue: u16,
    sat: u8,
    intensity: u8,
) -> Result<(), Radio::Error> {
    let addr_bytes = addr.to_be_bytes();
    rf24.as_tx(Some(&[0x00, 0x00, 0x00, addr_bytes[0], addr_bytes[1]]))?;

    let hue = hue.min(360);
    let sat = sat.min(100);
    let intensity = intensity.min(100);

    rf24.send(&[0xf0 | ((hue >> 8) as u8), intensity, (hue & 0xff) as u8, sat], false)?;

    Ok(())
}

For intensity+cct+gm:

pub fn set_intensity_cct_gm<Radio: EsbRadio>(
    rf24: &mut Radio,
    addr: u16,
    intensity: u8,
    cct: u8,
    gm: u8,
) -> Result<(), Radio::Error> {
    let addr_bytes = addr.to_be_bytes();
    rf24.as_tx(Some(&[0x00, 0x00, 0x00, addr_bytes[0], addr_bytes[1]]))?;

    let intensity = intensity.min(100);
    let cct = cct.min(100);
    let gm = gm.min(100);
    let check = intensity.overflowing_add(cct).0;

    // Handle check != gm ^ 0xff requirement by fudging one of the values and recomputing.
    let (cct, check) = if check == gm ^ 0xff {
        let cct_new = if cct == 100 { 99 } else { cct + 1 };
        let check_new = intensity.overflowing_add(cct_new).0;
        (cct_new, check_new)
    } else {
        (cct, check)
    };

    rf24.send(&[intensity, cct, gm, check], false)?;

    Ok(())
}

No idea if it's supposed to send multiple variants here, as well. We don't have a remote that supports this, to check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment