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
- Lilygo T-Dongle (ESP32-S3) Amazon, $16
- USB-C to USB-A Female Cable Amazon, $10
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.
-
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.
-
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.
-
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:
- set up our SPI interface and LED strip
- a basic function to cycle through R,G,B colors
- 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.