ESP-IDF: LilyGo T-Dongle RGB LED


LilyGo makes some very nice ESP32-based boards, many of them have great little screens and other peripherals built in. I’m using the Lilygo T-Dongle (ESP32-S3) as a diagnostic tool for my ESP-NOW network project. With its 80 X 160 IPS LCD (ST7735) and built in RGB LED, I can quickly see raw data going through the network and monitor some basic status while hooking things up. With my new approach (using ESP-IDF and neovim + tmux toolchain) getting the light to blink is pretty much step 1 for everything else.

The hardware

The T-Dongle has a built-in APA102 addressable LED, which is also referred to as a SK9822 and you control it via the SPI interface.

Pin Function
40 APA102 / SK9822 LED Data Input
39 SPI Clock

The S3 has multiple SPI Hosts that allow communication with external devices such as LEDs, displays, and sensors. 

  1. SPI1 (Internal, Reserved for Flash & PSRAM)

    • This bus is exclusively used for communicating with onboard Flash memory and optional PSRAM.
    • Not available for general-purpose use.
  2. SPI2 (HSPI) - General Purpose SPI

    • Fully supported for external SPI devices.
    • Capable of full-duplex communication.
    • Supports DMA (Direct Memory Access) for efficient data transfers.
    • Recommended for most SPI peripherals when high throughput is needed.
  3. SPI3 (VSPI) - General Purpose SPI

    • Similar to SPI2, can be used for external devices.
    • Also supports DMA for better efficiency.
    • Often used when multiple SPI peripherals need to be connected.

Why Use SPI2 for the APA102 / SK9822 LED?

In this project, SPI2 (HSPI) was chosen because:

  • SPI1 is unavailable for anything other than internal flash.
  • SPI2 is fully capable and works well for single external devices like the APA102.
  • The LED requires fast SPI clocking, and SPI2 is designed for high-speed transactions.

Installation & Dependencies

If you use a VS-Code based editor, you can install ESP-IDF extensions. It takes quite awhile to download everything and configure, but once finished, you get a nice sidebar with ESP-IDF tools and shortcuts. If you want more control, you can follow the ESP-IDF Installation guide

Dependencies are handled a bit differently in an ESP-IDF project. You basically manually install and link libraries into your CMakeLists.txt file. In this example, we’re using led_strip_spi from the core ESP-IDF library, so I cloned the full esp-idf-lib package into the components/ directory, then added a reference to led_strip_spi in my CMakeLists.txt file.

When building, the led_strip_spi library is automatically linked and available.

Build, flash, and monitor

If you’re using VS Code with the extension, you’ll be able to just click esp-idf commands to build, flash, and monitor your project. If at some point you end up in a regular terminal, you can source the ESP-IDF environment by running the following command:

Your path may be different, but it's usually something like this:
source ~/esp/esp-idf/export.sh && \
source ~/esp/esp-idf/python_env/idf5.4_py3.13_env/bin/activate

idf.py is your main entrypoint for building and flashing your project.

idf.py set-target esp32s3
idf.py build
idf.py flash
idf.py monitor

Some projects / examples will reference “menuconfig” – run idf.py menuconfig and you’ll get a curses-based configurator that lets you enable and disable features. Interestingly, you can easily add your own customizations to the menuconfig. For example, I’ll be using it to set Node IDs for my ESP-NOW network.

Project setup

If this is your first time seriously looking at ESP-IDF, think about it as a fully separate path from Arduino-based and PlatformIO-based frameworks/libraries. I didn’t really know what I was doing at first, so I mixed stuff together in ways that were quite frustrating.

To create the initial project structure:

idf.py create-project espidf-lilygo-t-dongle-rgb
mv main/espidf-lilygo-t-dongle-rgb.c main/main.cpp

Then edit main/CMakeLists.txt to use your main.cpp file and dependencies:

idf_component_register(SRCS "main.cpp"
                    PRIV_REQUIRES spi_flash
                    INCLUDE_DIRS ""
                    REQUIRES "led_strip_spi"
                    )

The code

Full project on Github

#include "driver/spi_master.h"
#include "esp_log.h"
#include "freertos/task.h"
#include "led_strip_spi.h"

#include <esp_err.h>
#include <led_strip_spi_sk9822.h>

#define TAG "SPI-LED"

#define LED_STRIP_DATA_PIN GPIO_NUM_40
#define LED_STRIP_CLOCK_PIN GPIO_NUM_39
#define LED_SPI_HOST SPI2_HOST
#define LED_BRIGHTNESS 32 // 0-255

static spi_device_handle_t led_spi;
static led_strip_spi_t strip;

void setup_led_strip() {
  ESP_LOGI(TAG, "Initializing SPI bus for LED strip...");

  spi_bus_config_t buscfg = {
      .mosi_io_num = LED_STRIP_DATA_PIN,
      .miso_io_num = -1,
      .sclk_io_num = LED_STRIP_CLOCK_PIN,
      .quadwp_io_num = -1,
      .quadhd_io_num = -1,
      .max_transfer_sz = LED_STRIP_SPI_BUFFER_SIZE(1),
  };

  esp_err_t err = spi_bus_initialize(LED_SPI_HOST, &buscfg, SPI_DMA_CH_AUTO);
  if (err == ESP_ERR_INVALID_STATE) {
    ESP_LOGW(TAG, "SPI bus already initialized, skipping...");
  } else if (err != ESP_OK) {
    ESP_LOGE(TAG, "Failed to initialize SPI bus: %s", esp_err_to_name(err));
    return;
  }

  spi_device_interface_config_t devcfg = {
      .mode = 3,
      .clock_speed_hz = 10000000, // 10MHz
      .spics_io_num = -1,         // No CS pin needed
      .queue_size = 1,
  };

  err = spi_bus_add_device(LED_SPI_HOST, &devcfg, &led_spi);
  if (err != ESP_OK) {
    ESP_LOGE(TAG, "Failed to add LED SPI device: %s", esp_err_to_name(err));
    return;
  }

  ESP_LOGI(TAG, "LED SPI device added.");

  strip = LED_STRIP_SPI_DEFAULT();
  strip.device_handle = led_spi;
  strip.length = 1; // Single LED
  strip.max_transfer_sz = LED_STRIP_SPI_BUFFER_SIZE(1);
  strip.clock_speed_hz = 10000000; // 10MHz

  err = led_strip_spi_init(&strip);
  if (err != ESP_OK) {
    ESP_LOGE(TAG, "Failed to initialize LED strip: %s", esp_err_to_name(err));
  }

  // Clear LED on startup
  led_strip_spi_flush(&strip);
}

void blink_rgb_led_task(void *pvParameter) {
  while (1) {
    static uint8_t counter = 0;
    uint32_t c;
    rgb_t color;
    esp_err_t err;

    // note: this is correct RGB order.
    c = 0x0000a0 << ((counter % 3) * 8);
    color.r = (c >> 16) & 0xff;
    color.g = (c >> 8) & 0xff;
    color.b = c & 0xff;
    color = rgb_scale(color, LED_BRIGHTNESS);
    ESP_LOGI(TAG, "r: 0x%02x g: 0x%02x b: 0x%02x", color.r, color.g, color.b);

    err = led_strip_spi_fill(&strip, 0, strip.length, color);
    if (err != ESP_OK) {
      ESP_LOGE(TAG, "led_strip_spi_fill(): %s", esp_err_to_name(err));
    }
    led_strip_spi_flush(&strip);
    counter += 1;

    vTaskDelay(pdMS_TO_TICKS(50)); // Adjust delay for smoother/slower transition
  }
}

extern "C" void app_main(void) {
  ESP_LOGI(TAG, "Device started, waiting 1 second before initializing LED");

  vTaskDelay(pdMS_TO_TICKS(1000));

  ESP_LOGI(TAG, "Installing LED Driver");
  esp_err_t err = led_strip_spi_install();
  if (err != ESP_OK) {
    ESP_LOGE(TAG, "led_strip_spi_install(): %s", esp_err_to_name(err));
  }

  ESP_LOGI(TAG, "Waiting 1 second before setting up LED strip");
  vTaskDelay(pdMS_TO_TICKS(1000));

  setup_led_strip();
  ESP_LOGI(TAG, "LED setup complete, waiting 1 second before starting blink task");
  vTaskDelay(pdMS_TO_TICKS(1000));

  xTaskCreate(blink_rgb_led_task, "blink_rgb_led_task", 4096, NULL, 5, NULL);
}

This code is quite a bit different from your average Arduino FastLED rgb cycle example, but stepping through it, things make a lot of sense:

  1. set up our SPI interface and LED strip
  2. a basic function to cycle through R,G,B colors
  3. our app_main() function waits a few seconds, installs the LED driver, sets up the LED strip, and starts our blinking task.

Overall, this is a pretty simple example and being able to quickly start and run a project like this is a good sanity check / starting point for a project. I’ll generally start with RGB LED control and use it as a debugging tool by making it blink, change color, etc., as a program / network / etc., status indicator.