Controlling WS2812 LED Strips with SPI on nRF52832 using nRF Connect SDK

Addressable LED strips based on the WS2812 (often called NeoPixels) are a favorite choice for adding colorful lighting effects to embedded projects. They only require a single data line to control an entire chain of LEDs, making them ideal for wearables, IoT devices, and creative lighting installations. However, the communication protocol they use is highly timing-sensitive, which makes them tricky to control directly in software.

The nRF52832, one of Nordic’s most popular Bluetooth SoCs, does not include a dedicated peripheral for generating WS2812 signals. Fortunately, by using the SPI peripheral in an unconventional way, you can reliably generate the waveforms these LEDs require. In this post, we’ll explore how WS2812 LEDs work, the challenges of driving them, and how to implement a working driver in the nRF Connect SDK using Zephyr’s SPI API.

How WS2812 LEDs Communicate

Each WS2812 LED package combines three LEDs (red, green, and blue) with a tiny controller chip. The controller listens to a single input line, latches color values for its LED, and passes the remaining data down the line to the next LED. This creates a shift-register effect, allowing hundreds of LEDs to be daisy-chained while still being individually addressable.

The protocol operates at about 800 kHz. Each bit of color information is represented by a pulse that lasts 1.25 microseconds in total, with the duty cycle of the high period defining whether the bit is a zero or a one. A logical one is a high signal for roughly 0.7 microseconds followed by low for 0.6 microseconds. A logical zero is a high signal for 0.35 microseconds and a low for about 0.8 microseconds. After all data is sent, a reset condition is generated by holding the line low for at least 50 microseconds.

Each LED consumes 24 bits of data, arranged in GRB order: eight bits for green, eight bits for red, and eight bits for blue. Once it has received 24 bits, the LED displays its new color and forwards the rest of the stream to the next device.

This protocol is elegant in its simplicity but extremely unforgiving in timing. A jitter of even a few hundred nanoseconds can cause the LEDs to flicker or ignore commands.


Why SPI Works as a Signal Generator

On the nRF52832, software bit-banging is impractical because the processor can be interrupted by Bluetooth events and other system tasks. To ensure reliable operation, the data line must be driven by a hardware peripheral that guarantees precise timing. Pulse-width modulation (PWM) with DMA is one option, but another powerful and often simpler approach is to use the SPI peripheral.

By configuring SPI to run at 4 MHz, each SPI bit lasts 250 nanoseconds. Since each WS2812 bit requires about 1.25 microseconds, you can map each WS2812 bit onto a sequence of SPI bits that create the correct duty cycle. For example, a logical one can be encoded as 0x70 (high for 0.75 us, low for 1 us), and a logical zero as 0x40 (high for 0.25 us, low for 1.25 us). These approximations fall comfortably within the WS2812 timing tolerances.

This method effectively transforms the WS2812 problem into an encoding problem. Once the data is converted into the appropriate SPI bitstream, the SPI peripheral transmits it with hardware accuracy.

Hardware Setup

For this project, we assume a custom board based on the nRF52832. The WS2812 strip is powered at 5 V. The ground of the LED strip and the nRF52832 must be common. The data input of the strip is connected to pin P0.06 on the nRF52832. Since the SoC operates at 3.3 V logic, some strips accept this directly, while others require a level shifter. For robust operation, a logic-level shifter is recommended.

The strip used in this example has eight LEDs, but the same technique scales to much longer strips.

Project Configuration

The application is built with the nRF Connect SDK using Zephyr. To begin, create a new application and configure it with the following options in prj.conf:

CONFIG_LOG=y
CONFIG_LOG_DEFAULT_LEVEL=3
CONFIG_LOG_BACKEND_UART=y
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_LED_STRIP=y
CONFIG_SPI=y
CONFIG_SOC_NRF52832_ALLOW_SPIM_DESPITE_PAN_58=y

This enables SPI support and sets up logging.

Next, create a dts file or a board overlay file to configure SPI0 for outputting the WS2812 data stream

#include <zephyr/dt-bindings/led/led.h>

ws2812_spi: &spi0 { 
    status = "okay";
    pinctrl-0 = <&spi0_default>;
    pinctrl-1 = <&spi0_sleep>;
    pinctrl-names = "default", "sleep";
    compatible = "nordic,nrf-spim";
    led_strip: ws2812@0 {
        compatible = "worldsemi,ws2812-spi";
        reg = <0>; /* ignored, but necessary for SPI bindings */
        spi-max-frequency = <4000000>;
        chain-length = <8>;
        color-mapping = <LED_COLOR_ID_GREEN
                         LED_COLOR_ID_RED
                         LED_COLOR_ID_BLUE>;
        spi-one-frame = <0x70>;
        spi-zero-frame = <0x40>;
        status = "okay";
    };
};

&pinctrl {
    spi0_default: spi0_default {
        group1 {
            psels = <NRF_PSEL(SPIM_SCK, 0, 13)>,
                    <NRF_PSEL(SPIM_MOSI, 0, 14)>;
        };
    };
    spi0_sleep: spi0_sleep {
        group1 {
            psels = <NRF_PSEL(SPIM_SCK, 0, 13)>,
                    <NRF_PSEL(SPIM_MOSI, 0, 14)>;
            low-power-enable;
        };
    };
};

Even though SPI normally uses both clock and MOSI pins, in this application only MOSI is routed to the LED strip. The clock pin is not physically connected.

Application Example

We take a look at the led_strip example in nRF Connect SDK.

#include <zephyr/kernel.h>
#include <zephyr/drivers/led_strip.h>
#include <zephyr/device.h>
#include <zephyr/drivers/spi.h>
#include <string.h>

#define STRIP_NODE          DT_NODELABEL(led_strip)
#define STRIP_NUM_PIXELS    DT_PROP(DT_ALIAS(led_strip), chain_length)
#define RGB(_r, _g, _b) { .r = (_r), .g = (_g), .b = (_b) }

static const struct led_rgb colors[] = {
    RGB(0x0f, 0x00, 0x00), /* red */
    RGB(0x00, 0x0f, 0x00), /* green */
    RGB(0x00, 0x00, 0x0f), /* blue */
};

static struct led_rgb pixels[STRIP_NUM_PIXELS];
static const struct device *const strip = DEVICE_DT_GET(STRIP_NODE);

int main(void)
{
    size_t color = 0;
    int rc;

    if (device_is_ready(strip)) {
        LOG_INF("Found LED strip device %s", strip->name);
    } else {
        LOG_ERR("LED strip device %s is not ready", strip->name);
        return 0;
    }

    LOG_INF("Displaying pattern on strip");
    while (1) {
        for (size_t cursor = 0; cursor < ARRAY_SIZE(pixels); cursor++) {
            memset(&pixels, 0x00, sizeof(pixels));
            memcpy(&pixels[cursor], &colors[color], sizeof(struct led_rgb));

            rc = led_strip_update_rgb(strip, pixels, STRIP_NUM_PIXELS);
            if (rc) {
                LOG_ERR("couldn't update strip: %d", rc);
            }

            k_sleep(K_MSEC(50));
        }

        color = (color + 1) % ARRAY_SIZE(colors);
    }

    return 0;
}

This program:

  • Loops forever.
  • For each position (cursor) along the strip:
    • Clears all LEDs (memset).
    • Sets one LED to the current color (memcpy).
    • Sends the pixel buffer to the strip using led_strip_update_rgb().
    • Waits for the configured delay.
  • After finishing one pass, it changes to the next color in the colors array.
  • The effect is a moving pixel animation that cycles through red, green, and blue.

Observations and Performance

The SPI method scales well. With eight LEDs, the buffer size is small, but even with hundreds of LEDs, the nRF52832 can drive the strip efficiently. Each LED requires nine bytes of SPI data, so a strip of 100 LEDs needs 900 bytes per update. At 8 MHz, this transmits in under a millisecond, leaving plenty of CPU time for Bluetooth and other tasks.

Since the SPI peripheral uses EasyDMA, large transfers can run without CPU involvement. This means the method is robust against interrupts, unlike software-based approaches. The reset delay is short compared to the transfer time, so it does not limit performance significantly.

Practical Considerations

Driving long LED strips can consume a lot of power. For testing, keep brightness low or use short strips. For larger installations, use a dedicated power supply and inject power at multiple points along the strip.

Another important detail is signal integrity. WS2812 LEDs use a relatively high data rate and are sensitive to noise. Keep the data line short, use a series resistor close to the MCU output pin, and consider a level shifter if your strip does not reliably register 3.3 V signals.

Extensions and Enhancements

Once the basic driver works, it can be extended in several ways. A higher-level library can provide functions for setting colors with HSV values, applying gradients, or running animations such as fades and rainbows. The encoding and transfer routines can be abstracted into a dedicated driver module so that applications simply work with arrays of RGB values.

Integration with Bluetooth opens up exciting possibilities. A smartphone app could control the LED patterns wirelessly, turning the custom board into a smart light controller. With Zephyr’s modular design, it is straightforward to combine the WS2812 driver with Bluetooth services such as Nordic’s UART Service or a custom GATT profile.

Conclusion

The WS2812 LED strip is simple to connect but demands precise timing to control. On the nRF52832, this requirement can be met by creatively using the SPI peripheral as a waveform generator. By encoding each LED bit into a sequence of SPI bits, the data stream matches the WS2812 timing, and the SPI hardware guarantees accurate transmission.

Using the nRF Connect SDK, this solution is clean and maintainable. The code fits naturally into the Zephyr framework. With this foundation, you can build everything from simple color demos to complex Bluetooth-controlled lighting systems.

By understanding the inner workings of WS2812 LEDs and applying the flexibility of the nRF52832, you can unlock the full potential of colorful LED lighting in your embedded projects.